Skip to content

Commit 69d33e9

Browse files
committed
Accessibility: Fix VO on email composer token inputs (to, cc, etc)
1 parent 71e9278 commit 69d33e9

File tree

5 files changed

+130
-14
lines changed

5 files changed

+130
-14
lines changed

app/src/components/list-tabular-item.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type ListTabularItemProps = {
1313
itemProps?: {
1414
className?: string;
1515
role?: string;
16+
id?: string;
1617
ariaSelected?: boolean;
1718
ariaLabel?: string;
1819
};
@@ -58,8 +59,8 @@ export class ListTabularItem extends React.Component<ListTabularItemProps> {
5859
render() {
5960
const itemProps = this.props.itemProps || {};
6061
const className = `list-item list-tabular-item ${itemProps.className}`;
61-
const { role, ariaSelected, ariaLabel } = itemProps;
62-
const props = Utils.fastOmit(itemProps, ['className', 'role', 'ariaSelected', 'ariaLabel']);
62+
const { role, id, ariaSelected, ariaLabel } = itemProps;
63+
const props = Utils.fastOmit(itemProps, ['className', 'role', 'id', 'ariaSelected', 'ariaLabel']);
6364

6465
// It's expensive to compute the contents of columns (format timestamps, etc.)
6566
// We only do it if the item prop has changed.
@@ -83,6 +84,7 @@ export class ListTabularItem extends React.Component<ListTabularItemProps> {
8384
className={className}
8485
style={{ height: this.props.metrics.height }}
8586
role={role}
87+
id={id}
8688
aria-selected={ariaSelected}
8789
aria-label={ariaLabel}
8890
>

app/src/components/list-tabular.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ type ListTabularRowsProps = {
4646
role?: string;
4747
ariaLabel?: string;
4848
ariaMultiselectable?: boolean;
49+
tabIndex?: number;
50+
ariaActiveDescendant?: string;
51+
domRef?: (el: HTMLElement | null) => void;
4952
onSelect?: (...args: any[]) => any;
5053
onClick?: (...args: any[]) => any;
5154
onDoubleClick?: (...args: any[]) => any;
@@ -101,9 +104,10 @@ export class ListTabularRows extends Component<ListTabularRowsProps> {
101104
}
102105

103106
render() {
104-
const { rows, innerStyles, draggable, role, ariaLabel, ariaMultiselectable, onDragStart, onDragEnd } = this.props;
107+
const { rows, innerStyles, draggable, role, ariaLabel, ariaMultiselectable, tabIndex, ariaActiveDescendant, domRef, onDragStart, onDragEnd } = this.props;
105108
return (
106109
<div
110+
ref={domRef}
107111
className="list-rows"
108112
style={innerStyles}
109113
onDragStart={onDragStart}
@@ -112,6 +116,8 @@ export class ListTabularRows extends Component<ListTabularRowsProps> {
112116
role={role}
113117
aria-label={ariaLabel}
114118
aria-multiselectable={ariaMultiselectable}
119+
tabIndex={tabIndex}
120+
aria-activedescendant={ariaActiveDescendant}
115121
>
116122
{rows.map(r => this.renderRow(r))}
117123
</div>
@@ -131,6 +137,8 @@ export interface ListTabularProps extends ScrollRegionProps {
131137
role?: string;
132138
ariaLabel?: string;
133139
ariaMultiselectable?: boolean;
140+
tabIndex?: number;
141+
ariaActiveDescendant?: string;
134142
onClick?: (...args: any[]) => any;
135143
onSelect?: (...args: any[]) => any;
136144
onDoubleClick?: (...args: any[]) => any;
@@ -191,6 +199,11 @@ export class ListTabular extends Component<ListTabularProps, ListTabularState> {
191199
_cleanupAnimationTimeout?: number;
192200
_onWindowResize?: any;
193201
_scrollRegion: ScrollRegion;
202+
_listRowsEl: HTMLElement | null = null;
203+
204+
_setListRowsEl = (el: HTMLElement | null) => {
205+
this._listRowsEl = el;
206+
};
194207

195208
constructor(props) {
196209
super(props);
@@ -311,6 +324,10 @@ export class ListTabular extends Component<ListTabularProps, ListTabularState> {
311324
this._scrollRegion.scrollTo(node);
312325
}
313326

327+
focusListbox() {
328+
this._listRowsEl?.focus({ preventScroll: true });
329+
}
330+
314331
scrollByPage(direction) {
315332
if (!this._scrollRegion) {
316333
return;
@@ -426,6 +443,8 @@ export class ListTabular extends Component<ListTabularProps, ListTabularState> {
426443
role,
427444
ariaLabel,
428445
ariaMultiselectable,
446+
tabIndex,
447+
ariaActiveDescendant,
429448
onClick,
430449
onSelect,
431450
onDragEnd,
@@ -453,6 +472,9 @@ export class ListTabular extends Component<ListTabularProps, ListTabularState> {
453472
role={role}
454473
ariaLabel={ariaLabel}
455474
ariaMultiselectable={ariaMultiselectable}
475+
tabIndex={tabIndex}
476+
ariaActiveDescendant={ariaActiveDescendant}
477+
domRef={this._setListRowsEl}
456478
innerStyles={{
457479
height: count * itemHeight,
458480
backgroundSize: `100% ${this.props.itemHeight}px`,

app/src/components/menu.tsx

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,18 @@ import ReactDOM from 'react-dom';
55
import { PropTypes, DOMUtils } from 'mailspring-exports';
66

77
export interface MenuItemProps {
8+
id?: string;
89
onMouseDown?: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
910
divider?: string | boolean;
1011
selected?: boolean;
1112
checked?: boolean;
1213
content?: any;
14+
role?: string;
15+
// When true, overrides aria-selected to false regardless of `selected`.
16+
// Used in combobox popup mode where aria-activedescendant is the sole
17+
// mechanism for announcing the active item, and aria-selected mutations
18+
// would cause double-announcements.
19+
suppressAriaSelected?: boolean;
1320
}
1421

1522
export interface MenuNameEmailContentProps {
@@ -29,7 +36,9 @@ export interface MenuProps extends HTMLProps<any> {
2936
onSelect: (item: any) => any;
3037
onExpand?: (item: any) => any;
3138
onEscape?: (...args: any[]) => any;
39+
onActiveDescendantChange?: (id: string | null) => void;
3240
defaultSelectedIndex?: number;
41+
listboxId?: string;
3342
}
3443

3544
interface MenuState {
@@ -72,7 +81,17 @@ class MenuItem extends React.Component<MenuItemProps> {
7281
checked: this.props.checked,
7382
});
7483
return (
75-
<div className={className} onMouseDown={this.props.onMouseDown}>
84+
<div
85+
id={this.props.id}
86+
role={this.props.role}
87+
aria-selected={
88+
this.props.role === 'option' && !this.props.suppressAriaSelected
89+
? this.props.selected
90+
: undefined
91+
}
92+
className={className}
93+
onMouseDown={this.props.onMouseDown}
94+
>
7695
{this.props.content}
7796
</div>
7897
);
@@ -315,7 +334,7 @@ export class Menu extends React.Component<MenuProps, MenuState> {
315334
this._mounted = false;
316335
}
317336

318-
componentDidUpdate() {
337+
componentDidUpdate(prevProps: MenuProps, prevState: MenuState) {
319338
// Scroll selected item into view
320339
if ((this.props.items || []).length === 0) {
321340
return;
@@ -327,6 +346,22 @@ export class Menu extends React.Component<MenuProps, MenuState> {
327346
if (adjustment !== 0) {
328347
container.scrollTop += adjustment;
329348
}
349+
350+
// Notify parent when the active completion changes so the combobox input
351+
// can update aria-activedescendant and screen readers announce the new item.
352+
if (
353+
prevState.selectedItemKey !== this.state.selectedItemKey &&
354+
this.props.onActiveDescendantChange
355+
) {
356+
const activeId =
357+
this.props.listboxId &&
358+
this.state.selectedItemKey !== null &&
359+
this.state.selectedIndex >= 0 &&
360+
this.state.selectedIndex < this.props.items.length
361+
? `${this.props.listboxId}-option-${this.state.selectedItemKey}`
362+
: null;
363+
this.props.onActiveDescendantChange(activeId);
364+
}
330365
}
331366

332367
render() {
@@ -407,10 +442,13 @@ export class Menu extends React.Component<MenuProps, MenuState> {
407442
return (
408443
<MenuItem
409444
key={key}
445+
id={this.props.listboxId ? `${this.props.listboxId}-option-${key}` : undefined}
446+
role={this.props.listboxId ? 'option' : undefined}
410447
onMouseDown={onMouseDown}
411448
checked={this.props.itemChecked && this.props.itemChecked(item)}
412449
content={content}
413450
selected={this.state.selectedIndex === i}
451+
suppressAriaSelected={!!this.props.onActiveDescendantChange}
414452
/>
415453
);
416454
});
@@ -420,7 +458,15 @@ export class Menu extends React.Component<MenuProps, MenuState> {
420458
empty: items.length === 0,
421459
});
422460

423-
return <div className={contentClass}>{items}</div>;
461+
return (
462+
<div
463+
id={this.props.listboxId}
464+
role={this.props.listboxId ? 'listbox' : undefined}
465+
className={contentClass}
466+
>
467+
{items}
468+
</div>
469+
);
424470
};
425471

426472
_onShiftSelectedIndex = delta => {

app/src/components/participants-text-field.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,12 @@ export default class ParticipantsTextField extends React.Component<ParticipantsT
252252
tokenIsValid={p => ContactStore.isValidContact(p)}
253253
tokenRenderer={TokenRenderer}
254254
onRequestCompletions={async input =>
255-
(await Promise.all([
256-
ContactStore.searchContactGroups(input),
257-
ContactStore.searchContacts(input),
258-
])).flat()
255+
(
256+
await Promise.all([
257+
ContactStore.searchContactGroups(input),
258+
ContactStore.searchContacts(input),
259+
])
260+
).flat()
259261
}
260262
shouldBreakOnKeydown={this._shouldBreakOnKeydown}
261263
onInputTrySubmit={this._onInputTrySubmit}

app/src/components/tokenizing-text-field.tsx

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ class Token<T> extends React.Component<TokenProps<T>, TokenState> {
164164

165165
return (
166166
<div
167+
role="option"
168+
aria-selected={this.props.selected}
169+
aria-label={this.props.item ? this.props.item.toString() : undefined}
167170
className={`${classes} ${this.props.className}`}
168171
onDragStart={this._onDragStart}
169172
onDragEnd={this._onDragEnd}
@@ -262,6 +265,7 @@ type TokenizingTextFieldState<T> = {
262265
focus: boolean;
263266
completions: T[];
264267
selectedKeys: string[];
268+
activeDescendantId: string | null;
265269
};
266270

267271
/*
@@ -432,6 +436,7 @@ export class TokenizingTextField<T> extends React.Component<
432436
inputValue: props.defaultValue || '',
433437
completions: [],
434438
selectedKeys: [],
439+
activeDescendantId: null,
435440
};
436441
}
437442

@@ -445,7 +450,11 @@ export class TokenizingTextField<T> extends React.Component<
445450
}
446451

447452
componentDidUpdate(prevProps: TokenizingTextFieldProps<T>) {
448-
if (prevProps.tokens.length === 0 && this.props.tokens.length === 0 && this.state.inputValue.length === 0) {
453+
if (
454+
prevProps.tokens.length === 0 &&
455+
this.props.tokens.length === 0 &&
456+
this.state.inputValue.length === 0
457+
) {
449458
if (prevProps.defaultValue !== this.props.defaultValue) {
450459
const newDefaultValue = this.props.defaultValue || '';
451460
this.setState({ inputValue: newDefaultValue });
@@ -898,8 +907,22 @@ export class TokenizingTextField<T> extends React.Component<
898907

899908
// Rendering
900909

910+
_completionsId() {
911+
return `${this._inputId}-completions`;
912+
}
913+
914+
_valueDescriptionId() {
915+
return `${this._inputId}-value`;
916+
}
917+
918+
_onActiveDescendantChange = (id: string | null) => {
919+
this.setState({ activeDescendantId: id });
920+
};
921+
901922
_inputComponent() {
902-
const props = {
923+
const hasCompletions = this.state.completions.length > 0;
924+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
925+
const props: any = {
903926
onCopy: this._onCopy,
904927
onCut: this._onCut,
905928
onPaste: this._onPaste,
@@ -911,6 +934,14 @@ export class TokenizingTextField<T> extends React.Component<
911934
tabIndex: this.props.tabIndex || 0,
912935
value: this.state.inputValue,
913936
className: '',
937+
role: 'combobox',
938+
'aria-expanded': hasCompletions,
939+
'aria-haspopup': 'listbox',
940+
'aria-autocomplete': 'list',
941+
'aria-controls': this._completionsId(),
942+
'aria-label': this.props.label || undefined,
943+
'aria-describedby': this._valueDescriptionId(),
944+
'aria-activedescendant': this.state.activeDescendantId || undefined,
914945
};
915946

916947
// If we can't accept additional tokens, override the events that would
@@ -973,6 +1004,9 @@ export class TokenizingTextField<T> extends React.Component<
9731004
'tokenizing-field-input': true,
9741005
'at-max-tokens': this._atMaxTokens(),
9751006
});
1007+
// Build a screen-reader-only description of current token values so that
1008+
// when the user focuses the input, the AT announces what is already in the field.
1009+
const tokenDescription = this.props.tokens.map(t => t.toString()).join(', ');
9761010
return (
9771011
<KeyCommandsRegion
9781012
key="field-component"
@@ -989,10 +1023,18 @@ export class TokenizingTextField<T> extends React.Component<
9891023
{`${this.props.label}:`}
9901024
</label>
9911025
)}
1026+
{/* Visually hidden span read by screen readers via aria-describedby on the input.
1027+
Describes the current token values so the field doesn't appear empty. */}
1028+
<span
1029+
id={this._valueDescriptionId()}
1030+
style={{ position: 'absolute', left: -9999, width: 1, height: 1, overflow: 'hidden' }}
1031+
>
1032+
{tokenDescription}
1033+
</span>
9921034
<div className={fieldClasses}>
9931035
{this.state.inputValue.length > 0 ||
994-
this.props.placeholder === undefined ||
995-
this.props.tokens.length > 0 ? (
1036+
this.props.placeholder === undefined ||
1037+
this.props.tokens.length > 0 ? (
9961038
false
9971039
) : (
9981040
<div className="placeholder">{this.props.placeholder}</div>
@@ -1025,6 +1067,8 @@ export class TokenizingTextField<T> extends React.Component<
10251067
onFocus={this._onInputFocused}
10261068
onBlur={this._onInputBlurred}
10271069
onSelect={this._addToken}
1070+
onActiveDescendantChange={this._onActiveDescendantChange}
1071+
listboxId={this._completionsId()}
10281072
/>
10291073
);
10301074
}

0 commit comments

Comments
 (0)