Skip to content

Commit da53305

Browse files
committed
fix: adapt the merged code
1 parent 3ac1a92 commit da53305

21 files changed

Lines changed: 130 additions & 87 deletions

File tree

src/components/Accessibility/hooks/__tests__/useInteractionAnnouncements.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,34 @@ describe('useInteractionAnnouncements', () => {
5656
expect(announceMock).toHaveBeenCalledWith('Poll sent', { priority: 'polite' });
5757
});
5858

59+
it('announces a removed poll option by name with polite priority', () => {
60+
const { result } = renderHook(() => useInteractionAnnouncements());
61+
result.current.announceInteraction('poll.optionRemoved', { option: 'First option' });
62+
expect(announceMock).toHaveBeenCalledWith('Removed option First option', {
63+
priority: 'polite',
64+
});
65+
});
66+
67+
it('announces picking up a poll option assertively', () => {
68+
const { result } = renderHook(() => useInteractionAnnouncements());
69+
result.current.announceInteraction('poll.optionPickedUp', { option: 'First option' });
70+
expect(announceMock).toHaveBeenCalledWith(
71+
'Picked up "First option". Use arrow keys to reorder. Press Space or Tab to drop.',
72+
{ priority: 'assertive' },
73+
);
74+
});
75+
76+
it('announces dropping a poll option at its new position assertively', () => {
77+
const { result } = renderHook(() => useInteractionAnnouncements());
78+
result.current.announceInteraction('poll.optionDropped', {
79+
option: 'First option',
80+
position: 2,
81+
});
82+
expect(announceMock).toHaveBeenCalledWith('Dropped "First option" at position 2.', {
83+
priority: 'assertive',
84+
});
85+
});
86+
5987
it('announces the composed poll dialog open message — assertive and deduped', () => {
6088
const { result } = renderHook(() => useInteractionAnnouncements());
6189
result.current.announceInteraction('poll.dialogOpened');

src/components/Accessibility/hooks/useInteractionAnnouncements.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ export type InteractionAnnouncementParams = {
4040
'giphy.sent': undefined;
4141
'giphy.shuffled': InteractionDeliveryOptions & { title?: string };
4242
'poll.dialogOpened': undefined;
43+
/** A poll option was dropped at a new position during keyboard reorder. */
44+
'poll.optionDropped': InteractionDeliveryOptions & {
45+
option: string;
46+
position: number;
47+
};
48+
/** A poll option was picked up to start a keyboard reorder. `option` is its text (or a fallback). */
49+
'poll.optionPickedUp': InteractionDeliveryOptions & { option: string };
50+
'poll.optionRemoved': InteractionDeliveryOptions & { option: string };
4351
'poll.sent': undefined;
4452
'search.cleared': undefined;
4553
'search.resultCount': InteractionDeliveryOptions & {
@@ -102,6 +110,22 @@ const INTERACTION_MESSAGES: {
102110
`${t('aria/Poll dialog opened')}. ${t(
103111
'Create a question, add options, and configure poll settings',
104112
)}. ${t('aria/Press Enter to start typing')}.`,
113+
// Keyboard reorder pickup/drop. Assertive (see INTERACTION_PRIORITIES) — immediate drag feedback
114+
// that must not be queued behind other polite messages.
115+
'poll.optionDropped': (t, params) =>
116+
t('aria/Dropped "{{ option }}" at position {{ position }}.', {
117+
option: params.option,
118+
position: params.position,
119+
}),
120+
'poll.optionPickedUp': (t, params) =>
121+
t(
122+
'aria/Picked up "{{ option }}". Use arrow keys to reorder. Press Space or Tab to drop.',
123+
{ option: params.option },
124+
),
125+
// Confirms a poll option was removed, naming it by its text (or positional fallback). Polite so it
126+
// queues behind the focus move to the next option field instead of interrupting it.
127+
'poll.optionRemoved': (t, params) =>
128+
t('aria/Removed option {{ option }}', { option: params.option }),
105129
'poll.sent': (t) => t('aria/Poll sent'),
106130
'search.cleared': (t) => t('aria/Search cleared'),
107131
// The number of items currently listed in the search results; an empty result set is spelled out
@@ -142,6 +166,9 @@ const INTERACTION_PRIORITIES: Partial<
142166
// Assertive on open: a polite update fired around the dialog's focus-entry announcement is
143167
// dropped; assertive interrupts and is spoken (VoiceOver demotes the aria-describedby hint).
144168
'poll.dialogOpened': 'assertive',
169+
// Keyboard reorder feedback must be immediate (interrupt), not queued behind polite messages.
170+
'poll.optionDropped': 'assertive',
171+
'poll.optionPickedUp': 'assertive',
145172
};
146173

147174
/**

src/components/Dialog/components/Prompt.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,10 @@ const PromptHeader = ({
8383
{close && (
8484
<Button
8585
appearance='ghost'
86-
aria-describedby={hasDescription ? resolvedDescriptionId : undefined}
87-
aria-label={
88-
typeof title === 'string'
89-
? t('Close prompt: {{ title }}', { title })
90-
: t('Close')
91-
}
86+
// Concise "Close": the dialog itself is named by its title (aria-labelledby) and
87+
// described by its description (aria-describedby on the dialog surface), so the close
88+
// button carries neither — repeating them here is redundant noise on focus.
89+
aria-label={t('Close')}
9290
circular
9391
className='str-chat__prompt__header__close-button'
9492
onClick={close}

src/components/MessageComposer/__tests__/MessageInput.test.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,10 +1313,11 @@ describe(`MessageInputFlat`, () => {
13131313
});
13141314

13151315
await waitFor(() => {
1316-
expect(container.querySelector('.str-chat__suggestion-list')).toHaveAttribute(
1317-
'aria-label',
1318-
'aria/Mention Suggestions',
1319-
);
1316+
// The accessible name lives on the listbox container (role="listbox"), not the inner
1317+
// presentation element.
1318+
expect(
1319+
container.querySelector('.str-chat__suggestion-list-container'),
1320+
).toHaveAttribute('aria-label', 'aria/Mention Suggestions');
13201321
expect(
13211322
container.querySelectorAll('.str-chat__suggestion-list-item').length,
13221323
).toBeGreaterThanOrEqual(3);

src/components/Poll/PollCreationDialog/OptionFieldSet.tsx

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,25 @@ import { Button, type ButtonProps } from '../../Button';
1010
import { TextInputFieldSet } from '../../Form/TextInputFieldSet';
1111
import { VisuallyHidden } from '../../VisuallyHidden';
1212
import { useStableId } from '../../UtilityComponents/useStableId';
13-
import { useAriaLiveAnnouncer, useSettledAnnouncement } from '../../Accessibility';
13+
import {
14+
useAriaLiveAnnouncer,
15+
useInteractionAnnouncements,
16+
useSettledAnnouncement,
17+
} from '../../Accessibility';
1418
import { PollOptionReorderHandle } from './PollOptionReorderHandle';
1519

1620
const pollComposerStateSelector = (state: PollComposerState) => ({
1721
errors: state.errors.options,
1822
options: state.data.options,
1923
});
2024

21-
// Reorder pickup/move/drop announcements are emitted via `useAriaLiveAnnouncer`
22-
// and rendered by the modal {@link AriaLiveOutlet} that the poll-creation dialog
23-
// mounts (the Modal/Dialog system renders an outlet inside the active
24-
// `aria-modal` subtree). Screen readers suppress live regions outside an active
25-
// aria-modal container, so the modal outlet keeps these announcements in scope.
25+
// Option pickup/drop/removed confirmations go through the shared {@link useInteractionAnnouncements}
26+
// registry (poll.optionPickedUp / poll.optionDropped / poll.optionRemoved), while the one-shot
27+
// "controls are now available" cue uses {@link useSettledAnnouncement}. Both ultimately deliver via
28+
// `useAriaLiveAnnouncer`, which routes to the modal {@link AriaLiveOutlet} the poll-creation dialog
29+
// mounts (the Modal/Dialog system renders an outlet inside the active `aria-modal` subtree). Screen
30+
// readers suppress live regions outside an active aria-modal container, so the modal outlet keeps
31+
// these announcements in scope.
2632
export const OptionFieldSet = () => {
2733
const { pollComposer } = useMessageComposerController();
2834
const { errors, options } = useStateStore(
@@ -31,6 +37,7 @@ export const OptionFieldSet = () => {
3137
);
3238
const { t } = useTranslationContext('OptionFieldSet');
3339
const announce = useAriaLiveAnnouncer();
40+
const { announceInteraction } = useInteractionAnnouncements();
3441
const optionInputRefs = useRef<Array<HTMLInputElement | null>>([]);
3542
const handleRefs = useRef<Array<HTMLButtonElement | null>>([]);
3643
const pendingFocusIndexRef = useRef<number | null>(null);
@@ -81,14 +88,12 @@ export const OptionFieldSet = () => {
8188
options: nextOptions,
8289
});
8390

84-
// Confirm the removal, naming the option by its text (or its positional fallback). Polite so
85-
// it queues behind the focus move to the next field (below) instead of interrupting it.
86-
announce(
87-
t('aria/Removed option {{ option }}', {
88-
option: labelForOption(removedOption, removedOptionIndex + 1),
89-
}),
90-
{ priority: 'polite' },
91-
);
91+
// Confirm the removal, naming the option by its text (or its positional fallback). The
92+
// registry maps this to a polite message so it queues behind the focus move to the next field
93+
// (below) instead of interrupting it.
94+
announceInteraction('poll.optionRemoved', {
95+
option: labelForOption(removedOption, removedOptionIndex + 1),
96+
});
9297

9398
if (activeOptionId === removedOptionId) {
9499
setActiveOptionId(null);
@@ -100,7 +105,7 @@ export const OptionFieldSet = () => {
100105
optionInputRefs.current[nextFocusedOptionIndex]?.focus();
101106
});
102107
},
103-
[activeOptionId, announce, labelForOption, pollComposer, t],
108+
[activeOptionId, announceInteraction, labelForOption, pollComposer],
104109
);
105110

106111
const handleActivate = useCallback(
@@ -109,15 +114,11 @@ export const OptionFieldSet = () => {
109114
if (idx === -1) return;
110115
const option = pollComposer.options[idx];
111116
setActiveOptionId(optionId);
112-
announce(
113-
t(
114-
'aria/Picked up "{{ option }}". Use arrow keys to reorder. Press Space or Tab to drop.',
115-
{ option: labelForOption(option, idx + 1) },
116-
),
117-
{ priority: 'assertive' },
118-
);
117+
announceInteraction('poll.optionPickedUp', {
118+
option: labelForOption(option, idx + 1),
119+
});
119120
},
120-
[announce, labelForOption, pollComposer, t],
121+
[announceInteraction, labelForOption, pollComposer],
121122
);
122123

123124
const handleDeselect = useCallback(() => {
@@ -129,14 +130,11 @@ export const OptionFieldSet = () => {
129130

130131
if (!option) return;
131132

132-
announce(
133-
t('aria/Dropped "{{ option }}" at position {{ position }}.', {
134-
option: labelForOption(option, idx + 1),
135-
position: idx + 1,
136-
}),
137-
{ priority: 'assertive' },
138-
);
139-
}, [activeOptionId, announce, labelForOption, pollComposer, t]);
133+
announceInteraction('poll.optionDropped', {
134+
option: labelForOption(option, idx + 1),
135+
position: idx + 1,
136+
});
137+
}, [activeOptionId, announceInteraction, labelForOption, pollComposer]);
140138

141139
const handleMove = useCallback(
142140
(direction: -1 | 1) => {

src/components/TextareaComposer/SuggestionList/MentionItem/UserItem.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,16 @@ export const UserItem = ({ entity, focused, ...buttonProps }: UserItemProps) =>
3131
const LeadingSlot = useMemo(
3232
() =>
3333
function UserItemAvatar() {
34-
return <Avatar imageUrl={entity.image} size='md' userName={titleAttribute} />;
34+
// Decorative: the option's accessible name already carries the user's name (title +
35+
// tokenized display name), so the avatar's fallback initials/role would only add noise.
36+
return (
37+
<Avatar
38+
aria-hidden
39+
imageUrl={entity.image}
40+
size='md'
41+
userName={titleAttribute}
42+
/>
43+
);
3544
},
3645
[entity.image, titleAttribute],
3746
);

src/components/TextareaComposer/SuggestionList/SuggestionList.tsx

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ export const SuggestionList = ({
256256
component &&
257257
commandSuggestionsEnabled
258258
);
259+
259260
// A single localized label per suggestion type, used BOTH as the listbox's aria-label and
260261
// in the count announcement ("5 Command Suggestions") — one source of truth, no second key.
261262
// NOTE: the type strings must match the SDK search-source `type` values exactly — `commands`
@@ -268,7 +269,7 @@ export const SuggestionList = ({
268269
case 'emoji':
269270
return t('aria/Emoji Suggestions');
270271
case 'mentions':
271-
return t('aria/User Suggestions');
272+
return t('aria/Mention Suggestions');
272273
default:
273274
return t('aria/Suggestions');
274275
}
@@ -292,16 +293,6 @@ export const SuggestionList = ({
292293

293294
if (!showSuggestionsList) return null;
294295

295-
// todo: remove the legacyUserSuggestionsLabel check with the next major release. It was introduced for backwards compatibility.
296-
const suggestionMenuLabel =
297-
suggestions.searchSource.type === 'commands'
298-
? t('aria/Command Suggestions')
299-
: suggestions.searchSource.type === 'emojis'
300-
? t('aria/Emoji Suggestions')
301-
: suggestions.searchSource.type === 'mentions'
302-
? t('aria/Mention Suggestions')
303-
: t('aria/Suggestions');
304-
305296
return (
306297
<div
307298
aria-label={suggestionMenuLabel}

src/components/TextareaComposer/__tests__/SuggestionList.test.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ vi.mock('../../Dialog/hooks/usePopoverPosition', () => ({
122122
const buildState = (count: number, type = 'mentions') => {
123123
const items = Array.from({ length: count }, (_, i) => ({
124124
id: `id-${i}`,
125+
// MentionItem dispatches on `mentionType` (user/role/user_group/channel/here) to its
126+
// subcomponent; a user mention renders the option row. Without it the item falls through to
127+
// SpecialMentionItem and no `[role="option"]` is emitted.
128+
mentionType: 'user',
125129
name: `name-${i}`,
126130
tokenizedDisplayName: { parts: [`name-${i}`], token: 'name' },
127131
}));
@@ -148,7 +152,7 @@ describe('SuggestionList', () => {
148152

149153
expect(announceInteractionMock).toHaveBeenCalledWith('suggestions.count', {
150154
count: 3,
151-
suggestionsLabel: 'aria/User Suggestions', // mentions → localized type label (t mock returns the key)
155+
suggestionsLabel: 'aria/Mention Suggestions', // mentions → localized type label (t mock returns the key)
152156
});
153157
});
154158

@@ -157,7 +161,7 @@ describe('SuggestionList', () => {
157161

158162
expect(announceInteractionMock).toHaveBeenLastCalledWith('suggestions.count', {
159163
count: 3,
160-
suggestionsLabel: 'aria/User Suggestions',
164+
suggestionsLabel: 'aria/Mention Suggestions',
161165
});
162166

163167
fakeComposerState = buildState(1);
@@ -169,7 +173,7 @@ describe('SuggestionList', () => {
169173

170174
expect(announceInteractionMock).toHaveBeenLastCalledWith('suggestions.count', {
171175
count: 1,
172-
suggestionsLabel: 'aria/User Suggestions',
176+
suggestionsLabel: 'aria/Mention Suggestions',
173177
});
174178
});
175179

@@ -191,7 +195,7 @@ describe('SuggestionList', () => {
191195
expect(announceInteractionMock).toHaveBeenCalledTimes(2);
192196
expect(announceInteractionMock).toHaveBeenLastCalledWith('suggestions.count', {
193197
count: 3,
194-
suggestionsLabel: 'aria/User Suggestions',
198+
suggestionsLabel: 'aria/Mention Suggestions',
195199
});
196200
});
197201

@@ -227,7 +231,7 @@ describe('SuggestionList', () => {
227231
const listbox = container.querySelector('[role="listbox"]');
228232
expect(listbox).toBeInTheDocument();
229233
// The listbox carries the localized type label as its accessible name.
230-
expect(listbox).toHaveAttribute('aria-label', 'aria/User Suggestions');
234+
expect(listbox).toHaveAttribute('aria-label', 'aria/Mention Suggestions');
231235

232236
const options = container.querySelectorAll('[role="option"]');
233237
expect(options.length).toBe(3);

src/i18n/de.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,8 @@
155155
"aria/Open Attachment Selector": "Anhang-Auswahl öffnen",
156156
"aria/Open Channel Actions Menu": "Kanalaktionsmenü öffnen",
157157
"aria/Open channel details": "Kanaldetails öffnen",
158-
"aria/Open image shared by {{ name }}": "Von {{ name }} geteiltes Bild öffnen",
159158
"aria/Open channels view": "Kanalansicht öffnen",
159+
"aria/Open image shared by {{ name }}": "Von {{ name }} geteiltes Bild öffnen",
160160
"aria/Open Message Actions Menu": "Nachrichtenaktionsmenü öffnen",
161161
"aria/Open Reaction Selector": "Reaktionsauswahl öffnen",
162162
"aria/Open Thread": "Thread öffnen",
@@ -253,7 +253,6 @@
253253
"Close": "Schließen",
254254
"Close dialog": "Dialog schließen",
255255
"Close emoji picker": "Emoji-Auswahl schließen",
256-
"Close prompt: {{ title }}": "Eingabeaufforderung schließen: {{ title }}",
257256
"Command not available while editing": "Befehl beim Bearbeiten nicht verfügbar",
258257
"Command not available while replying": "Befehl beim Antworten nicht verfügbar",
259258
"Commands": "Befehle",

src/i18n/en.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,8 @@
155155
"aria/Open Attachment Selector": "Open Attachment Selector",
156156
"aria/Open Channel Actions Menu": "Open Channel Actions Menu",
157157
"aria/Open channel details": "Open channel details",
158-
"aria/Open image shared by {{ name }}": "Open image shared by {{ name }}",
159158
"aria/Open channels view": "Open channels view",
159+
"aria/Open image shared by {{ name }}": "Open image shared by {{ name }}",
160160
"aria/Open Message Actions Menu": "Open Message Actions Menu",
161161
"aria/Open Reaction Selector": "Open Reaction Selector",
162162
"aria/Open Thread": "Open Thread",
@@ -253,7 +253,6 @@
253253
"Close": "Close",
254254
"Close dialog": "Close dialog",
255255
"Close emoji picker": "Close emoji picker",
256-
"Close prompt: {{ title }}": "Close prompt: {{ title }}",
257256
"Command not available while editing": "Command not available while editing",
258257
"Command not available while replying": "Command not available while replying",
259258
"Commands": "Commands",

0 commit comments

Comments
 (0)