Skip to content

Commit 0b6e3b5

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 de85913 commit 0b6e3b5

File tree

3 files changed

+93
-33
lines changed

3 files changed

+93
-33
lines changed

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

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -160,19 +160,26 @@ const Message = React.memo<
160160
)
161161
})
162162

163-
export const PlaceholderChatListItem = React.memo(_ => {
164-
return <div className={classNames('chat-list-item', 'skeleton')} />
165-
})
163+
export const PlaceholderChatListItem = React.memo(
164+
(props: React.HTMLAttributes<HTMLDivElement>) => {
165+
return (
166+
<div {...props} className={classNames('chat-list-item', 'skeleton')} />
167+
)
168+
}
169+
)
166170

167171
function ChatListItemArchiveLink({
168172
onClick,
169173
chatListItem,
174+
...rest
170175
}: {
171176
onClick: () => void
172177
chatListItem: Type.ChatListItemFetchResult & {
173178
kind: 'ArchiveLink'
174179
}
175-
}) {
180+
} & Required<
181+
Pick<React.HTMLAttributes<HTMLDivElement>, 'aria-setsize' | 'aria-posinset'>
182+
>) {
176183
const tx = window.static_translate
177184
const { onContextMenu, isContextMenuActive } = useContextMenuWithActiveState([
178185
{
@@ -198,6 +205,7 @@ function ChatListItemArchiveLink({
198205
return (
199206
<button
200207
ref={ref}
208+
{...rest}
201209
tabIndex={tabIndex}
202210
onClick={onClick}
203211
onKeyDown={tabindexOnKeydown}
@@ -224,6 +232,7 @@ function ChatListItemError({
224232
roleTab,
225233
isSelected,
226234
onContextMenu,
235+
...rest
227236
}: {
228237
chatListItem: Type.ChatListItemFetchResult & {
229238
kind: 'Error'
@@ -234,7 +243,9 @@ function ChatListItemError({
234243
) => void
235244
roleTab?: boolean
236245
isSelected?: boolean
237-
}) {
246+
} & Required<
247+
Pick<React.HTMLAttributes<HTMLDivElement>, 'aria-setsize' | 'aria-posinset'>
248+
>) {
238249
log.info('Error Loading Chatlistitem ' + chatListItem.id, chatListItem.error)
239250

240251
const ref = useRef<HTMLButtonElement>(null)
@@ -249,6 +260,7 @@ function ChatListItemError({
249260
return (
250261
<button
251262
ref={ref}
263+
{...rest}
252264
tabIndex={tabIndex}
253265
onClick={onClick}
254266
onKeyDown={tabindexOnKeydown}
@@ -292,6 +304,7 @@ function ChatListItemNormal({
292304
onContextMenu,
293305
isContextMenuActive,
294306
hover,
307+
...rest
295308
}: {
296309
chatListItem: Type.ChatListItemFetchResult & {
297310
kind: 'ChatListItem'
@@ -304,7 +317,9 @@ function ChatListItemNormal({
304317
roleTab?: boolean
305318
isSelected?: boolean
306319
hover?: boolean
307-
}) {
320+
} & Required<
321+
Pick<React.HTMLAttributes<HTMLDivElement>, 'aria-setsize' | 'aria-posinset'>
322+
>) {
308323
const ref = useRef<HTMLButtonElement>(null)
309324

310325
const {
@@ -318,6 +333,7 @@ function ChatListItemNormal({
318333
return (
319334
<button
320335
ref={ref}
336+
{...rest}
321337
tabIndex={tabIndex}
322338
onClick={onClick}
323339
onKeyDown={tabindexOnKeydown}
@@ -387,14 +403,22 @@ type ChatListItemProps = {
387403
roleTab?: boolean
388404
isSelected?: boolean
389405
hover?: boolean
390-
}
406+
} & Required<
407+
Pick<React.HTMLAttributes<HTMLDivElement>, 'aria-setsize' | 'aria-posinset'>
408+
>
391409

392410
const ChatListItem = React.memo<ChatListItemProps>(
393411
props => {
394412
const { chatListItem } = props
395413

396414
// if not loaded by virtual list yet
397-
if (typeof chatListItem === 'undefined') return <PlaceholderChatListItem />
415+
if (typeof chatListItem === 'undefined')
416+
return (
417+
<PlaceholderChatListItem
418+
aria-posinset={props['aria-posinset']}
419+
aria-setsize={props['aria-setsize']}
420+
/>
421+
)
398422

399423
if (chatListItem.kind == 'ChatListItem') {
400424
return <ChatListItemNormal {...props} chatListItem={chatListItem} />
@@ -405,10 +429,17 @@ const ChatListItem = React.memo<ChatListItemProps>(
405429
<ChatListItemArchiveLink
406430
chatListItem={chatListItem}
407431
onClick={props.onClick}
432+
aria-posinset={props['aria-posinset']}
433+
aria-setsize={props['aria-setsize']}
408434
/>
409435
)
410436
} else {
411-
return <PlaceholderChatListItem />
437+
return (
438+
<PlaceholderChatListItem
439+
aria-posinset={props['aria-posinset']}
440+
aria-setsize={props['aria-setsize']}
441+
/>
442+
)
412443
}
413444
},
414445
(prevProps, nextProps) => {
@@ -422,15 +453,19 @@ const ChatListItem = React.memo<ChatListItemProps>(
422453

423454
export default ChatListItem
424455

425-
export const ChatListItemMessageResult = React.memo<{
426-
msr: T.MessageSearchResult
427-
onClick: () => void
428-
queryStr: string
429-
/**
430-
* Whether the user is searching for messages in just a single chat.
431-
*/
432-
isSingleChatSearch: boolean
433-
}>(props => {
456+
export const ChatListItemMessageResult = React.memo<
457+
{
458+
msr: T.MessageSearchResult
459+
onClick: () => void
460+
queryStr: string
461+
/**
462+
* Whether the user is searching for messages in just a single chat.
463+
*/
464+
isSingleChatSearch: boolean
465+
} & Required<
466+
Pick<React.HTMLAttributes<HTMLDivElement>, 'aria-setsize' | 'aria-posinset'>
467+
>
468+
>(props => {
434469
const {
435470
msr,
436471
onClick,
@@ -440,6 +475,7 @@ export const ChatListItemMessageResult = React.memo<{
440475
* we don't need to specify here which chat it belongs to.
441476
*/
442477
isSingleChatSearch,
478+
...rest
443479
} = props
444480

445481
const ref = useRef<HTMLButtonElement>(null)
@@ -456,6 +492,7 @@ export const ChatListItemMessageResult = React.memo<{
456492
return (
457493
<button
458494
ref={ref}
495+
{...rest}
459496
tabIndex={tabIndex}
460497
onClick={onClick}
461498
onKeyDown={tabindexOnKeydown}

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export const ChatListItemRowChat = React.memo<{
8484
}
8585
}}
8686
isContextMenuActive={activeContextMenuChatId === chatId}
87+
aria-setsize={chatListIds.length}
88+
aria-posinset={index + 1}
8789
/>
8890
</li>
8991
)
@@ -111,10 +113,15 @@ export const ChatListItemRowContact = React.memo<{
111113
onClick={async _ => {
112114
openViewProfileDialog(accountId, contactId)
113115
}}
116+
aria-setsize={contactIds.length}
117+
aria-posinset={index + 1}
114118
/>
115119
) : (
116120
<li style={style}>
117-
<PlaceholderChatListItem />
121+
<PlaceholderChatListItem
122+
aria-setsize={contactIds.length}
123+
aria-posinset={index + 1}
124+
/>
118125
</li>
119126
)
120127
}, areEqual)
@@ -146,9 +153,15 @@ export const ChatListItemRowMessage = React.memo<{
146153
scrollIntoViewArg: { block: 'center' },
147154
})
148155
}}
156+
aria-setsize={messageResultIds.length}
157+
aria-posinset={index + 1}
149158
/>
150159
) : (
151-
<div className='pseudo-chat-list-item skeleton' />
160+
<div
161+
className='pseudo-chat-list-item skeleton'
162+
aria-setsize={messageResultIds.length}
163+
aria-posinset={index + 1}
164+
/>
152165
)}
153166
</li>
154167
)

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)