Skip to content

Commit 2c08f53

Browse files
authored
feat(a11y): improve keyboard and screen-reader support across composer, lists, search, and dialogs (#3230)
1 parent 044d635 commit 2c08f53

157 files changed

Lines changed: 11466 additions & 1549 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cursor/skills/accessibility/SKILL.md

Lines changed: 78 additions & 5 deletions
Large diffs are not rendered by default.

.cursor/skills/dev-patterns/SKILL.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,12 @@ When importing from 'stream-chat' library, always import by library name (from '
5050
## React components
5151

5252
Try to avoid inline `style` attribute and prefer adding styles to `.scss` files.
53+
54+
## How a plugin works in stream-chat-react
55+
56+
A "plugin" is just a separate build entry point that ships as a subpath export (e.g. stream-chat-react/emojis, stream-chat-react/mp3-encoder). Concretely, each plugin is wired in 4 places:
57+
58+
- Source folder — src/plugins/<Name>/ with an index.ts re-exporting its public API (see src/plugins/Emojis/index.ts).
59+
- Vite entry — a new key in build.lib.entry in vite.config.ts:20.
60+
- package.json — an exports subpath + a typesVersions entry (package.json:18,44).
61+
- Styling (optional) — a styling/index.scss added as a separate sass target in the build-styling script (package.json:185), producing its own CSS file like dist/css/emoji-picker.css.

examples/vite/src/AccessibilityNavigation/ChatSkipNavigation.tsx

Lines changed: 88 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,85 @@ import {
55
CHANNEL_LIST_TARGET_ID,
66
CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID,
77
CHANNELS_SELECTOR_BUTTON_TARGET_QUERY,
8+
THREAD_LIST_TARGET_ID,
9+
THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID,
810
} from '../ChatLayout/Panels.tsx';
911

1012
export const CHAT_SKIP_NAVIGATION_TARGET_ID = 'app-chat-skip-navigation';
1113

14+
// The list skip-link should land on the FIRST item (a focusable role="option" button with a visible
15+
// focus ring), not on the listbox container — focusing a container gives no visual focus cue.
16+
// Returns whether an option was actually focused: an empty/loading list has no `role="option"`, in
17+
// which case the caller must NOT preventDefault, letting SkipNavigation fall back to focusing the
18+
// list container (otherwise the link would be a dead end).
19+
const focusFirstListOption = (listTargetId: string): boolean => {
20+
const option = document.querySelector<HTMLElement>(`#${listTargetId} [role="option"]`);
21+
option?.focus();
22+
return !!option;
23+
};
24+
25+
const isLinkFor = (target: EventTarget | null, targetId: string) =>
26+
target instanceof HTMLAnchorElement && target.getAttribute('href') === `#${targetId}`;
27+
28+
const isActivationKey = (key: string) =>
29+
key === 'Enter' || key === ' ' || key === 'Spacebar';
30+
1231
const getSkipNavigationLinkLabel = (targetId: string) => {
13-
if (targetId === CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID) {
32+
if (
33+
targetId === CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID ||
34+
targetId === THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID
35+
) {
1436
return 'Skip to message composer';
1537
}
1638
if (targetId === CHANNEL_LIST_TARGET_ID) {
1739
return 'Skip to channel list';
1840
}
41+
if (targetId === THREAD_LIST_TARGET_ID) {
42+
return 'Skip to thread list';
43+
}
1944

2045
return 'Skip to sidebar';
2146
};
2247

2348
export const ChatSkipNavigation = () => {
24-
const [channelsSelectorButtonTargetId, setChannelsSelectorButtonTargetId] = useState<
25-
string | null
26-
>(null);
49+
// Skip targets are tagged imperatively onto SDK-owned DOM and depend on the active view (the
50+
// channel list shows in the channels view, the thread list in the threads view) and the layout, so
51+
// resolve them from the live DOM and keep them in sync via a MutationObserver. Only links whose
52+
// target currently exists are rendered, so there are never dead skip links.
53+
const [composerTargetId, setComposerTargetId] = useState<string | null>(null);
54+
const [listTargetId, setListTargetId] = useState<string | null>(null);
55+
const [selectorButtonTargetId, setSelectorButtonTargetId] = useState<string | null>(
56+
null,
57+
);
2758

2859
useEffect(() => {
29-
const syncChannelsSelectorButtonTargetId = () => {
30-
const channelsSelectorButton = document.querySelector<HTMLElement>(
31-
CHANNELS_SELECTOR_BUTTON_TARGET_QUERY,
60+
const syncTargets = () => {
61+
// The channel composer (channels view) and the thread composer (threads view) carry different
62+
// ids; whichever is currently mounted is the composer skip target.
63+
setComposerTargetId(
64+
document.getElementById(CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID)
65+
? CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID
66+
: document.getElementById(THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID)
67+
? THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID
68+
: null,
69+
);
70+
// Exactly one of the two lists is mounted at a time, depending on the active view.
71+
setListTargetId(
72+
document.getElementById(THREAD_LIST_TARGET_ID)
73+
? THREAD_LIST_TARGET_ID
74+
: document.getElementById(CHANNEL_LIST_TARGET_ID)
75+
? CHANNEL_LIST_TARGET_ID
76+
: null,
77+
);
78+
setSelectorButtonTargetId(
79+
document.querySelector<HTMLElement>(CHANNELS_SELECTOR_BUTTON_TARGET_QUERY)?.id ??
80+
null,
3281
);
33-
setChannelsSelectorButtonTargetId(channelsSelectorButton?.id ?? null);
3482
};
3583

36-
syncChannelsSelectorButtonTargetId();
84+
syncTargets();
3785

38-
const observer = new MutationObserver(syncChannelsSelectorButtonTargetId);
86+
const observer = new MutationObserver(syncTargets);
3987
observer.observe(document.body, {
4088
attributes: true,
4189
attributeFilter: ['id'],
@@ -46,22 +94,39 @@ export const ChatSkipNavigation = () => {
4694
return () => observer.disconnect();
4795
}, []);
4896

49-
const targetIds = useMemo(() => {
50-
const skipTargets = [
51-
CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID,
52-
CHANNEL_LIST_TARGET_ID,
53-
];
54-
55-
if (channelsSelectorButtonTargetId) {
56-
skipTargets.push(channelsSelectorButtonTargetId);
57-
}
58-
59-
return skipTargets;
60-
}, [channelsSelectorButtonTargetId]);
97+
// Ordered by keyboard priority: composer, then the active list, then the sidebar selector.
98+
const targetIds = useMemo(
99+
() =>
100+
[composerTargetId, listTargetId, selectorButtonTargetId].filter(
101+
(id): id is string => !!id,
102+
),
103+
[composerTargetId, listTargetId, selectorButtonTargetId],
104+
);
61105

62106
return (
63107
<nav aria-label='Chat quick navigation' id={CHAT_SKIP_NAVIGATION_TARGET_ID}>
64-
<SkipNavigation getLinkLabel={getSkipNavigationLinkLabel} targetIds={targetIds} />
108+
<SkipNavigation
109+
getLinkLabel={getSkipNavigationLinkLabel}
110+
// Intercept the active list link (SkipNavigation's documented `onClick`/`onKeyDown` +
111+
// `preventDefault` escape hatch) to focus the first item instead of the listbox container.
112+
// Other links fall through to SkipNavigation's default focus handling.
113+
onClick={(event) => {
114+
if (!listTargetId || !isLinkFor(event.currentTarget, listTargetId)) return;
115+
// Only take over when there is an option to focus; otherwise let SkipNavigation focus the
116+
// list container so the link is never a dead end.
117+
if (focusFirstListOption(listTargetId)) event.preventDefault();
118+
}}
119+
onKeyDown={(event) => {
120+
if (
121+
!listTargetId ||
122+
!isLinkFor(event.currentTarget, listTargetId) ||
123+
!isActivationKey(event.key)
124+
)
125+
return;
126+
if (focusFirstListOption(listTargetId)) event.preventDefault();
127+
}}
128+
targetIds={targetIds}
129+
/>
65130
</nav>
66131
);
67132
};

examples/vite/src/App.tsx

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import {
6969
} from './CustomMessageUi';
7070
import { ConfigurableMessageActions } from './CustomMessageActions';
7171
import { SidebarToggle } from './Sidebar/SidebarToggle.tsx';
72+
import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx';
7273

7374
const PUBLIC_VITE_EXAMPLE_API_KEY = 'xzwhhgtazy6h';
7475

@@ -97,6 +98,12 @@ if (!apiKey) {
9798
throw new Error('VITE_STREAM_API_KEY is not defined');
9899
}
99100

101+
const options: ChannelOptions = {
102+
presence: true,
103+
state: true,
104+
limit: 10,
105+
};
106+
100107
const sort: ChannelSort = { last_message_at: -1, updated_at: -1 };
101108

102109
// @ts-expect-error ai_generated isn't on LocalMessage's public type yet
@@ -225,18 +232,6 @@ const App = () => {
225232
() => new URLSearchParams(window.location.search),
226233
[],
227234
);
228-
const options = useMemo<ChannelOptions>(
229-
() => ({
230-
// filter_values: {
231-
// user_id: userId,
232-
// },
233-
// predefined_filter: 'livestreams_channels',
234-
presence: true,
235-
state: true,
236-
limit: 10,
237-
}),
238-
[],
239-
);
240235
const initialChannelId = useMemo(() => getSelectedChannelIdFromUrl(), []);
241236
const initialChatView = useMemo(() => getSelectedChatViewFromUrl(), []);
242237
const initialThreadId = useMemo(
@@ -428,6 +423,7 @@ const App = () => {
428423
HeaderEndContent: SidebarToggle,
429424
HeaderStartContent: SidebarToggle,
430425
MessageActions: ConfigurableMessageActions,
426+
AttachmentSelector: CommandModeAttachmentSelector,
431427
...messageUiOverrides,
432428
}}
433429
>

examples/vite/src/ChannelPreviewOverlay/ChannelPreviewOverlay.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export const ChannelPreviewOverlay = () => {
6969
{canJoin && (
7070
<Button
7171
appearance='solid'
72+
autoFocus
7273
className='app-channel-preview-overlay__join-button'
7374
disabled={joining}
7475
onClick={handleJoin}

examples/vite/src/ChatLayout/Panels.tsx

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ChannelList,
1010
ChatView,
1111
type ChatViewSelectorEntry,
12+
MessageComposerUI as DefaultMessageComposerUI,
1213
MessageComposer,
1314
MessageList,
1415
Thread,
@@ -34,10 +35,33 @@ import { ThreadStateSync } from './Sync.tsx';
3435

3536
export const CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID =
3637
'app-channel-message-composer-textarea';
38+
export const THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID =
39+
'app-thread-message-composer-textarea';
3740
export const CHANNEL_LIST_TARGET_ID = 'app-channel-list';
41+
export const THREAD_LIST_TARGET_ID = 'app-thread-list';
3842
export const CHANNELS_SELECTOR_BUTTON_TARGET_QUERY =
3943
'[id^="str-chat__chat-view-"][id$="-tab-channels"]';
4044

45+
// Tag the list's `[role="listbox"]` with a stable id so the skip-navigation links can target it. The
46+
// listbox unmounts/remounts (search mode, view switches, reloads) and its view may start inactive, so
47+
// observe `document.body` and re-tag whenever it (re)appears. The selector is globally unambiguous —
48+
// only one view's sidebar is mounted at a time.
49+
const useTagSkipNavigationListbox = (listboxSelector: string, targetId: string) => {
50+
useEffect(() => {
51+
const tagListbox = () => {
52+
const listbox = document.querySelector<HTMLElement>(listboxSelector);
53+
if (listbox && listbox.id !== targetId) {
54+
listbox.id = targetId;
55+
}
56+
};
57+
58+
tagListbox();
59+
const observer = new MutationObserver(tagListbox);
60+
observer.observe(document.body, { childList: true, subtree: true });
61+
return () => observer.disconnect();
62+
}, [listboxSelector, targetId]);
63+
};
64+
4165
const ChannelThreadPanel = () => {
4266
const { thread } = useChannelStateContext('ChannelThreadPanel');
4367
const isOpen = !!thread;
@@ -92,6 +116,7 @@ const ResponsiveChannelPanels = () => {
92116
asyncMessagesMultiSendEnabled
93117
audioRecordingEnabled
94118
maxRows={10}
119+
focus
95120
/>
96121
<ChannelPreviewOverlay />
97122
</div>
@@ -127,14 +152,10 @@ export const ChannelsPanels = ({
127152
closeSidebar();
128153
}, [channel?.id, closeSidebar]);
129154

130-
useEffect(() => {
131-
const channelListElement = channelsLayoutRef.current?.querySelector<HTMLElement>(
132-
'.app-chat-sidebar-overlay > .str-chat__channel-list',
133-
);
134-
if (!channelListElement) return;
135-
136-
channelListElement.id = CHANNEL_LIST_TARGET_ID;
137-
}, [channel?.id, sidebarOpen]);
155+
useTagSkipNavigationListbox(
156+
'.app-chat-sidebar-overlay .str-chat__channel-list-inner__main',
157+
CHANNEL_LIST_TARGET_ID,
158+
);
138159

139160
return (
140161
<ChatView.Channels>
@@ -173,6 +194,16 @@ export const ChannelsPanels = ({
173194
);
174195
};
175196

197+
// Renders the "Back to quick navigation" return link above the thread's composer (the SDK renders
198+
// the thread composer internally, so we inject via the MessageComposerUI slot) — matching the
199+
// channel view, where the link sits right above <MessageComposer />.
200+
const ThreadMessageComposerUI = () => (
201+
<>
202+
<ReturnToSkipNavigation />
203+
<DefaultMessageComposerUI />
204+
</>
205+
);
206+
176207
export const ThreadsPanels = ({
177208
iconOnly,
178209
itemSet,
@@ -184,6 +215,11 @@ export const ThreadsPanels = ({
184215
const { activeThread } = useThreadsViewContext();
185216
const threadsLayoutRef = useRef<HTMLDivElement | null>(null);
186217

218+
useTagSkipNavigationListbox(
219+
'.app-chat-sidebar-overlay .str-chat__thread-list',
220+
THREAD_LIST_TARGET_ID,
221+
);
222+
187223
return (
188224
<ChatView.Threads>
189225
<ThreadStateSync />
@@ -202,12 +238,21 @@ export const ThreadsPanels = ({
202238
<div className='app-chat-view__threads-main'>
203239
<ChatView.ThreadAdapter>
204240
<WithDragAndDropUpload className='str-chat__dropzone-root--thread'>
205-
<WithComponents overrides={{ TypingIndicator }}>
206-
<ReturnToSkipNavigation />
241+
<WithComponents
242+
overrides={{
243+
MessageComposerUI: ThreadMessageComposerUI,
244+
TypingIndicator,
245+
}}
246+
>
207247
<Thread
208248
additionalMessageComposerProps={{
249+
additionalTextareaProps: {
250+
id: THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID,
251+
},
209252
audioRecordingEnabled: true,
210253
asyncMessagesMultiSendEnabled: true,
254+
// Auto-focus the thread composer on mount, matching the channels-view composer.
255+
focus: true,
211256
}}
212257
virtualized
213258
/>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { TextComposerState } from 'stream-chat';
2+
import {
3+
type AttachmentSelectorProps,
4+
AttachmentSelector as DefaultAttachmentSelector,
5+
useMessageComposerController,
6+
useStateStore,
7+
} from 'stream-chat-react';
8+
9+
const textComposerCommandSelector = ({ command }: TextComposerState) => ({ command });
10+
11+
export const CommandModeAttachmentSelector = (props: AttachmentSelectorProps) => {
12+
const messageComposer = useMessageComposerController();
13+
const { command } = useStateStore(
14+
messageComposer.textComposer.state,
15+
textComposerCommandSelector,
16+
);
17+
18+
return (
19+
<DefaultAttachmentSelector
20+
{...props}
21+
// In command mode, keyboard flow should stay in command controls + textarea.
22+
// Removing the attachment trigger from tab order avoids an irrelevant stop.
23+
// Spread any incoming buttonProps first so ARIA/data props aren't dropped when this override
24+
// is wired globally via WithComponents.
25+
buttonProps={{ ...props.buttonProps, tabIndex: command ? -1 : undefined }}
26+
/>
27+
);
28+
};

examples/vite/src/PublicChannelOverlay/PublicChannelOverlay.tsx

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export const PublicChannelOverlay = () => {
6666
</div>
6767
<Button
6868
appearance='solid'
69+
autoFocus
6970
className='app-public-channel-overlay__join-button'
7071
disabled={joining}
7172
onClick={handleJoin}
@@ -78,17 +79,3 @@ export const PublicChannelOverlay = () => {
7879
</div>
7980
);
8081
};
81-
82-
export const PublicChannelComposerBanner = () => {
83-
const { canJoin, isMember } = usePublicChannelState();
84-
85-
if (isMember || canJoin) return null;
86-
87-
return (
88-
<div className='app-public-channel-composer-banner'>
89-
<p className='app-public-channel-composer-banner__text'>
90-
You can only view this conversation
91-
</p>
92-
</div>
93-
);
94-
};

0 commit comments

Comments
 (0)