Add relevant aria attribute for selected emoji in the emoji picker (#31125)

* Add relevant aria attribute for selected emoji in the emoji picker

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add aria-multiselectable

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Do not specify aria-selected/pressed when element is disabled

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Use checkbox role for reaction picker as gridcell + aria-selected has very inconsistent screenreader support

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix keyboard handling for modified DOM structure

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix enter behaviour

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2025-10-31 11:49:59 +00:00
committed by GitHub
parent fdf54dd9c2
commit 43485594b5
4 changed files with 57 additions and 29 deletions
+12 -11
View File
@@ -61,17 +61,17 @@ class Category extends React.PureComponent<IProps> {
return ( return (
<div key={rowIndex} role="row"> <div key={rowIndex} role="row">
{emojisForRow.map((emoji) => ( {emojisForRow.map((emoji) => (
<Emoji <div role="gridcell" className="mx_EmojiPicker_item_wrapper" key={emoji.hexcode}>
key={emoji.hexcode} <Emoji
emoji={emoji} emoji={emoji}
selectedEmojis={selectedEmojis} selectedEmojis={selectedEmojis}
onClick={onClick} onClick={onClick}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
disabled={this.props.isEmojiDisabled?.(emoji.unicode)} disabled={this.props.isEmojiDisabled?.(emoji.unicode)}
id={`mx_EmojiPicker_item_${this.props.id}_${hexEncode(emoji.unicode)}`} id={`mx_EmojiPicker_item_${this.props.id}_${hexEncode(emoji.unicode)}`}
role="gridcell" />
/> </div>
))} ))}
</div> </div>
); );
@@ -118,6 +118,7 @@ class Category extends React.PureComponent<IProps> {
overflowMargin={0} overflowMargin={0}
renderItem={this.renderEmojiRow} renderItem={this.renderEmojiRow}
role="grid" role="grid"
aria-multiselectable
/> />
</section> </section>
); );
+9 -4
View File
@@ -15,13 +15,17 @@ import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
interface IProps { interface IProps {
emoji: IEmoji; emoji: IEmoji;
/**
* Set of which emojis are already selected and should be decorated as such.
* If specified, emoji will use a checkbox role with aria-checked set appropriately.
*/
selectedEmojis?: Set<string>; selectedEmojis?: Set<string>;
onClick(ev: ButtonEvent, emoji: IEmoji): void; onClick(ev: ButtonEvent, emoji: IEmoji): void;
onMouseEnter(emoji: IEmoji): void; onMouseEnter(emoji: IEmoji): void;
onMouseLeave(emoji: IEmoji): void; onMouseLeave(emoji: IEmoji): void;
disabled?: boolean; disabled?: boolean;
id?: string; id?: string;
role?: string; className?: string;
} }
class Emoji extends React.PureComponent<IProps> { class Emoji extends React.PureComponent<IProps> {
@@ -34,9 +38,10 @@ class Emoji extends React.PureComponent<IProps> {
onClick={(ev: ButtonEvent) => onClick(ev, emoji)} onClick={(ev: ButtonEvent) => onClick(ev, emoji)}
onMouseEnter={() => onMouseEnter(emoji)} onMouseEnter={() => onMouseEnter(emoji)}
onMouseLeave={() => onMouseLeave(emoji)} onMouseLeave={() => onMouseLeave(emoji)}
className="mx_EmojiPicker_item_wrapper" className={this.props.className}
disabled={this.props.disabled} disabled={this.props.disabled || undefined}
role={this.props.role} role={selectedEmojis ? "checkbox" : undefined}
aria-checked={this.props.disabled ? undefined : isSelected}
focusOnMouseOver focusOnMouseOver
> >
<div className={`mx_EmojiPicker_item ${isSelected ? "mx_EmojiPicker_item_selected" : ""}`}> <div className={`mx_EmojiPicker_item ${isSelected ? "mx_EmojiPicker_item_selected" : ""}`}>
@@ -152,24 +152,42 @@ class EmojiPicker extends React.Component<IProps, IState> {
this.updateVisibility(); this.updateVisibility();
}; };
// Given a roving emoji button returns the role=row element containing it
private getRow(rovingNode?: Element): Element | undefined {
return this.getGridcell(rovingNode)?.parentElement ?? undefined;
}
// Given a roving emoji button returns the role=gridcell element containing it
private getGridcell(rovingNode?: Element): Element | undefined {
return rovingNode?.parentElement ?? undefined;
}
// Given a role=gridcell node returns the roving emoji button contained within
private getRovingNode(gridcellNode?: Element): Element | undefined {
return gridcellNode?.children[0];
}
private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void { private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void {
const node = state.activeNode; const rowElement = this.getRow(state.activeNode);
const parent = node?.parentElement; const gridcellNode = this.getGridcell(state.activeNode);
if (!parent || !state.activeNode) return; if (!rowElement || !gridcellNode || !state.activeNode) return;
const rowIndex = Array.from(parent.children).indexOf(node);
// Index of element within row container
const columnIndex = Array.from(rowElement.children).indexOf(gridcellNode);
// Index of element within the list of roving nodes
const refIndex = state.nodes.indexOf(state.activeNode); const refIndex = state.nodes.indexOf(state.activeNode);
let focusNode: HTMLElement | undefined; let focusNode: HTMLElement | undefined;
let newParent: HTMLElement | undefined; let newRowElement: Element | undefined;
switch (ev.key) { switch (ev.key) {
case Key.ARROW_LEFT: case Key.ARROW_LEFT:
focusNode = state.nodes[refIndex - 1]; focusNode = state.nodes[refIndex - 1];
newParent = focusNode?.parentElement ?? undefined; newRowElement = this.getRow(focusNode);
break; break;
case Key.ARROW_RIGHT: case Key.ARROW_RIGHT:
focusNode = state.nodes[refIndex + 1]; focusNode = state.nodes[refIndex + 1];
newParent = focusNode?.parentElement ?? undefined; newRowElement = this.getRow(focusNode);
break; break;
case Key.ARROW_UP: case Key.ARROW_UP:
@@ -177,11 +195,14 @@ class EmojiPicker extends React.Component<IProps, IState> {
// For up/down we find the prev/next parent by inspecting the refs either side of our row // For up/down we find the prev/next parent by inspecting the refs either side of our row
const node = const node =
ev.key === Key.ARROW_UP ev.key === Key.ARROW_UP
? state.nodes[refIndex - rowIndex - 1] ? state.nodes[refIndex - columnIndex - 1]
: state.nodes[refIndex - rowIndex + EMOJIS_PER_ROW]; : state.nodes[refIndex - columnIndex + EMOJIS_PER_ROW];
newParent = node?.parentElement ?? undefined; newRowElement = this.getRow(node);
const newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)]; if (newRowElement) {
focusNode = state.nodes.find((r) => r === newTarget); const newColumnIndex = clamp(columnIndex, 0, newRowElement.children.length - 1);
const newTarget = this.getRovingNode(newRowElement?.children[newColumnIndex]);
focusNode = state.nodes.find((r) => r === newTarget);
}
break; break;
} }
} }
@@ -197,7 +218,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
payload: { node: focusNode }, payload: { node: focusNode },
}); });
if (parent !== newParent) { if (rowElement !== newRowElement) {
focusNode?.scrollIntoView({ focusNode?.scrollIntoView({
behavior: "auto", behavior: "auto",
block: "center", block: "center",
@@ -315,7 +336,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
private onEnterFilter = (): void => { private onEnterFilter = (): void => {
const btn = this.scrollRef.current?.containerRef.current?.querySelector<HTMLButtonElement>( const btn = this.scrollRef.current?.containerRef.current?.querySelector<HTMLButtonElement>(
'.mx_EmojiPicker_item_wrapper[tabindex="0"]', '.mx_EmojiPicker_item_wrapper [tabindex="0"]',
); );
btn?.click(); btn?.click();
this.props.onFinished(); this.props.onFinished();
@@ -73,6 +73,7 @@ class QuickReactions extends React.Component<IProps, IState> {
onMouseEnter={this.onMouseEnter} onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave} onMouseLeave={this.onMouseLeave}
selectedEmojis={this.props.selectedEmojis} selectedEmojis={this.props.selectedEmojis}
className="mx_EmojiPicker_item_wrapper"
/> />
))} ))}
</Toolbar> </Toolbar>