Skip to content

Commit 6ceaeaf

Browse files
committed
fix: a11y: correct aria-posinset for chat list
Due to the usage of a virtualized list, screen readers would announce an incorrect number of chats in the chat list, and the positions of individual chats in the list. Explicitly setting `aria-posinset` and `aria-setsize` should work. This only handles the chat list and not all the virtualized lists that we have. Related: - bvaughn/react-window#808. - #4660. - #5025.
1 parent a387576 commit 6ceaeaf

File tree

4 files changed

+94
-33
lines changed

4 files changed

+94
-33
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
### Fixed
99
- fix "Recent 3 apps" in the chat header showing apps from another chat sometimes #5265
1010
- accessibility: don't re-announce message input (composer) after sending every message #5049
11+
- accessibility: correct `aria-posinset` for chat list #5044
1112
- don't close context menues on window resize #5418
1213

1314
<a id="2_11_1"></a>

packages/frontend/src/components/chat/ChatListItem.tsx

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -154,21 +154,28 @@ const Message = React.memo<
154154
)
155155
})
156156

157-
export const PlaceholderChatListItem = React.memo(_ => {
158-
return <div className={classNames('chat-list-item', 'skeleton')} />
159-
})
157+
export const PlaceholderChatListItem = React.memo(
158+
(props: React.HTMLAttributes<HTMLDivElement>) => {
159+
return (
160+
<div {...props} className={classNames('chat-list-item', 'skeleton')} />
161+
)
162+
}
163+
)
160164

161165
function ChatListItemArchiveLink({
162166
onClick,
163167
onFocus,
164168
chatListItem,
169+
...rest
165170
}: {
166171
onClick: (event: React.MouseEvent) => void
167172
onFocus?: (event: React.FocusEvent) => void
168173
chatListItem: Type.ChatListItemFetchResult & {
169174
kind: 'ArchiveLink'
170175
}
171-
}) {
176+
} & Required<
177+
Pick<React.HTMLAttributes<HTMLDivElement>, 'aria-setsize' | 'aria-posinset'>
178+
>) {
172179
const tx = window.static_translate
173180
const { onContextMenu, isContextMenuActive } = useContextMenuWithActiveState([
174181
{
@@ -194,6 +201,7 @@ function ChatListItemArchiveLink({
194201
return (
195202
<button
196203
ref={ref}
204+
{...rest}
197205
tabIndex={tabIndex}
198206
onClick={onClick}
199207
onKeyDown={tabindexOnKeydown}
@@ -225,6 +233,7 @@ function ChatListItemError({
225233
onFocus,
226234
isSelected,
227235
onContextMenu,
236+
...rest
228237
}: {
229238
chatListItem: Type.ChatListItemFetchResult & {
230239
kind: 'Error'
@@ -236,7 +245,9 @@ function ChatListItemError({
236245
) => void
237246
roleTab?: boolean
238247
isSelected?: boolean
239-
}) {
248+
} & Required<
249+
Pick<React.HTMLAttributes<HTMLDivElement>, 'aria-setsize' | 'aria-posinset'>
250+
>) {
240251
log.info('Error Loading Chatlistitem ' + chatListItem.id, chatListItem.error)
241252

242253
const ref = useRef<HTMLButtonElement>(null)
@@ -251,6 +262,7 @@ function ChatListItemError({
251262
return (
252263
<button
253264
ref={ref}
265+
{...rest}
254266
tabIndex={tabIndex}
255267
onClick={onClick}
256268
onKeyDown={tabindexOnKeydown}
@@ -298,6 +310,7 @@ function ChatListItemNormal({
298310
roleTab,
299311
onContextMenu,
300312
isContextMenuActive,
313+
...rest
301314
}: {
302315
chatListItem: Type.ChatListItemFetchResult & {
303316
kind: 'ChatListItem'
@@ -310,7 +323,9 @@ function ChatListItemNormal({
310323
isContextMenuActive?: boolean
311324
roleTab?: boolean
312325
isSelected?: boolean
313-
}) {
326+
} & Required<
327+
Pick<React.HTMLAttributes<HTMLDivElement>, 'aria-setsize' | 'aria-posinset'>
328+
>) {
314329
const ref = useRef<HTMLButtonElement>(null)
315330

316331
const {
@@ -330,6 +345,7 @@ function ChatListItemNormal({
330345
return (
331346
<button
332347
ref={ref}
348+
{...rest}
333349
tabIndex={tabIndex}
334350
onClick={onClick}
335351
onKeyDown={tabindexOnKeydown}
@@ -402,13 +418,21 @@ type ChatListItemProps = {
402418
*/
403419
roleTab?: boolean
404420
isSelected?: boolean
405-
}
421+
} & Required<
422+
Pick<React.HTMLAttributes<HTMLDivElement>, 'aria-setsize' | 'aria-posinset'>
423+
>
406424

407425
const ChatListItem = React.memo<ChatListItemProps>(props => {
408426
const { chatListItem } = props
409427

410428
// if not loaded by virtual list yet
411-
if (typeof chatListItem === 'undefined') return <PlaceholderChatListItem />
429+
if (typeof chatListItem === 'undefined')
430+
return (
431+
<PlaceholderChatListItem
432+
aria-posinset={props['aria-posinset']}
433+
aria-setsize={props['aria-setsize']}
434+
/>
435+
)
412436

413437
if (chatListItem.kind == 'ChatListItem') {
414438
return <ChatListItemNormal {...props} chatListItem={chatListItem} />
@@ -420,24 +444,35 @@ const ChatListItem = React.memo<ChatListItemProps>(props => {
420444
chatListItem={chatListItem}
421445
onClick={props.onClick}
422446
onFocus={props.onFocus}
447+
aria-posinset={props['aria-posinset']}
448+
aria-setsize={props['aria-setsize']}
423449
/>
424450
)
425451
} else {
426-
return <PlaceholderChatListItem />
452+
return (
453+
<PlaceholderChatListItem
454+
aria-posinset={props['aria-posinset']}
455+
aria-setsize={props['aria-setsize']}
456+
/>
457+
)
427458
}
428459
})
429460

430461
export default ChatListItem
431462

432-
export const ChatListItemMessageResult = React.memo<{
433-
msr: T.MessageSearchResult
434-
onClick: () => void
435-
queryStr: string
436-
/**
437-
* Whether the user is searching for messages in just a single chat.
438-
*/
439-
isSingleChatSearch: boolean
440-
}>(props => {
463+
export const ChatListItemMessageResult = React.memo<
464+
{
465+
msr: T.MessageSearchResult
466+
onClick: () => void
467+
queryStr: string
468+
/**
469+
* Whether the user is searching for messages in just a single chat.
470+
*/
471+
isSingleChatSearch: boolean
472+
} & Required<
473+
Pick<React.HTMLAttributes<HTMLDivElement>, 'aria-setsize' | 'aria-posinset'>
474+
>
475+
>(props => {
441476
const {
442477
msr,
443478
onClick,
@@ -447,6 +482,7 @@ export const ChatListItemMessageResult = React.memo<{
447482
* we don't need to specify here which chat it belongs to.
448483
*/
449484
isSingleChatSearch,
485+
...rest
450486
} = props
451487

452488
const ref = useRef<HTMLButtonElement>(null)
@@ -463,6 +499,7 @@ export const ChatListItemMessageResult = React.memo<{
463499
return (
464500
<button
465501
ref={ref}
502+
{...rest}
466503
tabIndex={tabIndex}
467504
onClick={onClick}
468505
onKeyDown={tabindexOnKeydown}

packages/frontend/src/components/chat/ChatListItemRow.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ export const ChatListItemRowChat = React.memo<{
242242
[]
243243
)}
244244
isContextMenuActive={activeContextMenuChatIds.includes(chatId)}
245+
aria-setsize={chatListIds.length}
246+
aria-posinset={index + 1}
245247
/>
246248
</li>
247249
)
@@ -269,10 +271,15 @@ export const ChatListItemRowContact = React.memo<{
269271
onClick={async _ => {
270272
openViewProfileDialog(accountId, contactId)
271273
}}
274+
aria-setsize={contactIds.length}
275+
aria-posinset={index + 1}
272276
/>
273277
) : (
274278
<li style={style}>
275-
<PlaceholderChatListItem />
279+
<PlaceholderChatListItem
280+
aria-setsize={contactIds.length}
281+
aria-posinset={index + 1}
282+
/>
276283
</li>
277284
)
278285
}, areEqual)
@@ -304,9 +311,15 @@ export const ChatListItemRowMessage = React.memo<{
304311
scrollIntoViewArg: { block: 'center' },
305312
})
306313
}}
314+
aria-setsize={messageResultIds.length}
315+
aria-posinset={index + 1}
307316
/>
308317
) : (
309-
<div className='pseudo-chat-list-item skeleton' />
318+
<div
319+
className='pseudo-chat-list-item skeleton'
320+
aria-setsize={messageResultIds.length}
321+
aria-posinset={index + 1}
322+
/>
310323
)}
311324
</li>
312325
)

packages/frontend/src/components/contact/ContactListItem.tsx

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,24 @@ export const DeltaCheckbox = (props: {
3939
</div>
4040
)
4141
}
42-
export function ContactListItem(props: {
43-
tagName: 'li' | 'div'
44-
style?: React.CSSProperties
45-
contact: Type.Contact
46-
onClick?: (contact: Type.Contact) => void
47-
showCheckbox: boolean
48-
checked: boolean
49-
showRemove: boolean
50-
onCheckboxClick?: (contact: Type.Contact) => void
51-
onRemoveClick?: (contact: Type.Contact) => void
52-
disabled?: boolean
53-
onContextMenu?: MouseEventHandler<HTMLButtonElement>
54-
}) {
42+
export function ContactListItem(
43+
props: {
44+
tagName: 'li' | 'div'
45+
style?: React.CSSProperties
46+
contact: Type.Contact
47+
onClick?: (contact: Type.Contact) => void
48+
showCheckbox: boolean
49+
checked: boolean
50+
showRemove: boolean
51+
onCheckboxClick?: (contact: Type.Contact) => void
52+
onRemoveClick?: (contact: Type.Contact) => void
53+
disabled?: boolean
54+
onContextMenu?: MouseEventHandler<HTMLButtonElement>
55+
} & Pick<
56+
React.HTMLAttributes<HTMLDivElement>,
57+
'aria-setsize' | 'aria-posinset'
58+
>
59+
) {
5560
const tx = useTranslationFunction()
5661

5762
const {
@@ -94,6 +99,11 @@ export function ContactListItem(props: {
9499
// because there may be several interactive elements in this component.
95100
onKeyDown={rovingTabindex.onKeydown}
96101
onFocus={rovingTabindex.setAsActiveElement}
102+
// FYI NVDA doesn't announce these, as of 2025-04.
103+
// They probably need to be on the focusable item
104+
// in order for it to work.
105+
aria-setsize={props['aria-setsize']}
106+
aria-posinset={props['aria-posinset']}
97107
>
98108
<button
99109
ref={refMain}

0 commit comments

Comments
 (0)