Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 2da5237

Browse files
authored
Add arrow key controls to emoji and reaction pickers (#10637)
* Add arrow key controls to emoji and reaction pickers * Iterate types * Switch to using aria-activedescendant * Add tests * Fix tests * Iterate * Update test * Tweak header keyboard navigation behaviour * Also handle scrolling on left/right arrow keys * Iterate
1 parent 0d9fa05 commit 2da5237

File tree

15 files changed

+277
-74
lines changed

15 files changed

+277
-74
lines changed

cypress/e2e/threads/threads.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ describe("Threads", () => {
174174
.click({ force: true }); // Cypress has no ability to hover
175175
cy.get(".mx_EmojiPicker").within(() => {
176176
cy.get('input[type="text"]').type("wave");
177-
cy.contains('[role="menuitem"]', "👋").click();
177+
cy.contains('[role="gridcell"]', "👋").click();
178178
});
179179

180180
cy.get(".mx_ThreadView").within(() => {

res/css/views/emojipicker/_EmojiPicker.pcss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,14 @@ limitations under the License.
179179
list-style: none;
180180
width: 38px;
181181
cursor: pointer;
182+
183+
&:focus-within {
184+
background-color: $focus-bg-color;
185+
}
186+
}
187+
188+
.mx_EmojiPicker_body .mx_EmojiPicker_item_wrapper[tabindex="0"] .mx_EmojiPicker_item {
189+
background-color: $focus-bg-color;
182190
}
183191

184192
.mx_EmojiPicker_item {

src/accessibility/RovingTabIndex.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export interface IState {
6161
refs: Ref[];
6262
}
6363

64-
interface IContext {
64+
export interface IContext {
6565
state: IState;
6666
dispatch: Dispatch<IAction>;
6767
}
@@ -80,7 +80,7 @@ export enum Type {
8080
SetFocus = "SET_FOCUS",
8181
}
8282

83-
interface IAction {
83+
export interface IAction {
8484
type: Type;
8585
payload: {
8686
ref: Ref;
@@ -160,7 +160,7 @@ interface IProps {
160160
handleUpDown?: boolean;
161161
handleLeftRight?: boolean;
162162
children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void }): ReactNode;
163-
onKeyDown?(ev: React.KeyboardEvent, state: IState): void;
163+
onKeyDown?(ev: React.KeyboardEvent, state: IState, dispatch: Dispatch<IAction>): void;
164164
}
165165

166166
export const findSiblingElement = (
@@ -199,7 +199,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
199199
const onKeyDownHandler = useCallback(
200200
(ev: React.KeyboardEvent) => {
201201
if (onKeyDown) {
202-
onKeyDown(ev, context.state);
202+
onKeyDown(ev, context.state, context.dispatch);
203203
if (ev.defaultPrevented) {
204204
return;
205205
}

src/accessibility/roving/RovingAccessibleButton.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,17 @@ import { Ref } from "./types";
2222

2323
interface IProps extends Omit<React.ComponentProps<typeof AccessibleButton>, "inputRef" | "tabIndex"> {
2424
inputRef?: Ref;
25+
focusOnMouseOver?: boolean;
2526
}
2627

2728
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
28-
export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, onFocus, ...props }) => {
29+
export const RovingAccessibleButton: React.FC<IProps> = ({
30+
inputRef,
31+
onFocus,
32+
onMouseOver,
33+
focusOnMouseOver,
34+
...props
35+
}) => {
2936
const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
3037
return (
3138
<AccessibleButton
@@ -34,6 +41,10 @@ export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, onFocus, ..
3441
onFocusInternal();
3542
onFocus?.(event);
3643
}}
44+
onMouseOver={(event: React.MouseEvent) => {
45+
if (focusOnMouseOver) onFocusInternal();
46+
onMouseOver?.(event);
47+
}}
3748
inputRef={ref}
3849
tabIndex={isActive ? 0 : -1}
3950
/>

src/components/structures/ContextMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export default class ContextMenu extends React.PureComponent<React.PropsWithChil
148148

149149
const first =
150150
element.querySelector<HTMLElement>('[role^="menuitem"]') ||
151-
element.querySelector<HTMLElement>("[tab-index]");
151+
element.querySelector<HTMLElement>("[tabindex]");
152152

153153
if (first) {
154154
first.focus();

src/components/views/elements/LazyRenderList.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ interface IProps<T> {
7373

7474
element?: string;
7575
className?: string;
76+
role?: string;
7677
}
7778

7879
interface IState {
@@ -128,6 +129,7 @@ export default class LazyRenderList<T = any> extends React.Component<IProps<T>,
128129
const elementProps = {
129130
style: { paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px` },
130131
className: this.props.className,
132+
role: this.props.role,
131133
};
132134
return React.createElement(element, elementProps, renderedItems.map(renderItem));
133135
}

src/components/views/emojipicker/Category.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPic
2121
import LazyRenderList from "../elements/LazyRenderList";
2222
import { DATA_BY_CATEGORY, IEmoji } from "../../../emoji";
2323
import Emoji from "./Emoji";
24+
import { ButtonEvent } from "../elements/AccessibleButton";
2425

2526
const OVERFLOW_ROWS = 3;
2627

@@ -42,18 +43,31 @@ interface IProps {
4243
heightBefore: number;
4344
viewportHeight: number;
4445
scrollTop: number;
45-
onClick(emoji: IEmoji): void;
46+
onClick(ev: ButtonEvent, emoji: IEmoji): void;
4647
onMouseEnter(emoji: IEmoji): void;
4748
onMouseLeave(emoji: IEmoji): void;
4849
isEmojiDisabled?: (unicode: string) => boolean;
4950
}
5051

52+
function hexEncode(str: string): string {
53+
let hex: string;
54+
let i: number;
55+
56+
let result = "";
57+
for (i = 0; i < str.length; i++) {
58+
hex = str.charCodeAt(i).toString(16);
59+
result += ("000" + hex).slice(-4);
60+
}
61+
62+
return result;
63+
}
64+
5165
class Category extends React.PureComponent<IProps> {
5266
private renderEmojiRow = (rowIndex: number): JSX.Element => {
5367
const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props;
5468
const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8);
5569
return (
56-
<div key={rowIndex}>
70+
<div key={rowIndex} role="row">
5771
{emojisForRow.map((emoji) => (
5872
<Emoji
5973
key={emoji.hexcode}
@@ -63,6 +77,8 @@ class Category extends React.PureComponent<IProps> {
6377
onMouseEnter={onMouseEnter}
6478
onMouseLeave={onMouseLeave}
6579
disabled={this.props.isEmojiDisabled?.(emoji.unicode)}
80+
id={`mx_EmojiPicker_item_${this.props.id}_${hexEncode(emoji.unicode)}`}
81+
role="gridcell"
6682
/>
6783
))}
6884
</div>
@@ -101,7 +117,6 @@ class Category extends React.PureComponent<IProps> {
101117
>
102118
<h2 className="mx_EmojiPicker_category_label">{name}</h2>
103119
<LazyRenderList
104-
element="ul"
105120
className="mx_EmojiPicker_list"
106121
itemHeight={EMOJI_HEIGHT}
107122
items={rows}
@@ -110,6 +125,7 @@ class Category extends React.PureComponent<IProps> {
110125
overflowItems={OVERFLOW_ROWS}
111126
overflowMargin={0}
112127
renderItem={this.renderEmojiRow}
128+
role="grid"
113129
/>
114130
</section>
115131
);

src/components/views/emojipicker/Emoji.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,36 +17,40 @@ limitations under the License.
1717

1818
import React from "react";
1919

20-
import { MenuItem } from "../../structures/ContextMenu";
2120
import { IEmoji } from "../../../emoji";
21+
import { ButtonEvent } from "../elements/AccessibleButton";
22+
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
2223

2324
interface IProps {
2425
emoji: IEmoji;
2526
selectedEmojis?: Set<string>;
26-
onClick(emoji: IEmoji): void;
27+
onClick(ev: ButtonEvent, emoji: IEmoji): void;
2728
onMouseEnter(emoji: IEmoji): void;
2829
onMouseLeave(emoji: IEmoji): void;
2930
disabled?: boolean;
31+
id?: string;
32+
role?: string;
3033
}
3134

3235
class Emoji extends React.PureComponent<IProps> {
3336
public render(): React.ReactNode {
3437
const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props;
35-
const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode);
38+
const isSelected = selectedEmojis?.has(emoji.unicode);
3639
return (
37-
<MenuItem
38-
element="li"
39-
onClick={() => onClick(emoji)}
40+
<RovingAccessibleButton
41+
id={this.props.id}
42+
onClick={(ev) => onClick(ev, emoji)}
4043
onMouseEnter={() => onMouseEnter(emoji)}
4144
onMouseLeave={() => onMouseLeave(emoji)}
4245
className="mx_EmojiPicker_item_wrapper"
43-
label={emoji.unicode}
4446
disabled={this.props.disabled}
47+
role={this.props.role}
48+
focusOnMouseOver
4549
>
4650
<div className={`mx_EmojiPicker_item ${isSelected ? "mx_EmojiPicker_item_selected" : ""}`}>
4751
{emoji.unicode}
4852
</div>
49-
</MenuItem>
53+
</RovingAccessibleButton>
5054
);
5155
}
5256
}

0 commit comments

Comments
 (0)