Skip to content

Commit

Permalink
Add arrow key controls to emoji and reaction pickers (#10637)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
t3chguy committed Apr 20, 2023
1 parent 0d9fa05 commit 2da5237
Show file tree
Hide file tree
Showing 15 changed files with 277 additions and 74 deletions.
2 changes: 1 addition & 1 deletion cypress/e2e/threads/threads.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ describe("Threads", () => {
.click({ force: true }); // Cypress has no ability to hover
cy.get(".mx_EmojiPicker").within(() => {
cy.get('input[type="text"]').type("wave");
cy.contains('[role="menuitem"]', "👋").click();
cy.contains('[role="gridcell"]', "👋").click();
});

cy.get(".mx_ThreadView").within(() => {
Expand Down
8 changes: 8 additions & 0 deletions res/css/views/emojipicker/_EmojiPicker.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ limitations under the License.
list-style: none;
width: 38px;
cursor: pointer;

&:focus-within {
background-color: $focus-bg-color;
}
}

.mx_EmojiPicker_body .mx_EmojiPicker_item_wrapper[tabindex="0"] .mx_EmojiPicker_item {
background-color: $focus-bg-color;
}

.mx_EmojiPicker_item {
Expand Down
8 changes: 4 additions & 4 deletions src/accessibility/RovingTabIndex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export interface IState {
refs: Ref[];
}

interface IContext {
export interface IContext {
state: IState;
dispatch: Dispatch<IAction>;
}
Expand All @@ -80,7 +80,7 @@ export enum Type {
SetFocus = "SET_FOCUS",
}

interface IAction {
export interface IAction {
type: Type;
payload: {
ref: Ref;
Expand Down Expand Up @@ -160,7 +160,7 @@ interface IProps {
handleUpDown?: boolean;
handleLeftRight?: boolean;
children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void }): ReactNode;
onKeyDown?(ev: React.KeyboardEvent, state: IState): void;
onKeyDown?(ev: React.KeyboardEvent, state: IState, dispatch: Dispatch<IAction>): void;
}

export const findSiblingElement = (
Expand Down Expand Up @@ -199,7 +199,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
const onKeyDownHandler = useCallback(
(ev: React.KeyboardEvent) => {
if (onKeyDown) {
onKeyDown(ev, context.state);
onKeyDown(ev, context.state, context.dispatch);
if (ev.defaultPrevented) {
return;
}
Expand Down
13 changes: 12 additions & 1 deletion src/accessibility/roving/RovingAccessibleButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,17 @@ import { Ref } from "./types";

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

// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, onFocus, ...props }) => {
export const RovingAccessibleButton: React.FC<IProps> = ({
inputRef,
onFocus,
onMouseOver,
focusOnMouseOver,
...props
}) => {
const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
return (
<AccessibleButton
Expand All @@ -34,6 +41,10 @@ export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, onFocus, ..
onFocusInternal();
onFocus?.(event);
}}
onMouseOver={(event: React.MouseEvent) => {
if (focusOnMouseOver) onFocusInternal();
onMouseOver?.(event);
}}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/components/structures/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export default class ContextMenu extends React.PureComponent<React.PropsWithChil

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

if (first) {
first.focus();
Expand Down
2 changes: 2 additions & 0 deletions src/components/views/elements/LazyRenderList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ interface IProps<T> {

element?: string;
className?: string;
role?: string;
}

interface IState {
Expand Down Expand Up @@ -128,6 +129,7 @@ export default class LazyRenderList<T = any> extends React.Component<IProps<T>,
const elementProps = {
style: { paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px` },
className: this.props.className,
role: this.props.role,
};
return React.createElement(element, elementProps, renderedItems.map(renderItem));
}
Expand Down
22 changes: 19 additions & 3 deletions src/components/views/emojipicker/Category.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPic
import LazyRenderList from "../elements/LazyRenderList";
import { DATA_BY_CATEGORY, IEmoji } from "../../../emoji";
import Emoji from "./Emoji";
import { ButtonEvent } from "../elements/AccessibleButton";

const OVERFLOW_ROWS = 3;

Expand All @@ -42,18 +43,31 @@ interface IProps {
heightBefore: number;
viewportHeight: number;
scrollTop: number;
onClick(emoji: IEmoji): void;
onClick(ev: ButtonEvent, emoji: IEmoji): void;
onMouseEnter(emoji: IEmoji): void;
onMouseLeave(emoji: IEmoji): void;
isEmojiDisabled?: (unicode: string) => boolean;
}

function hexEncode(str: string): string {
let hex: string;
let i: number;

let result = "";
for (i = 0; i < str.length; i++) {
hex = str.charCodeAt(i).toString(16);
result += ("000" + hex).slice(-4);
}

return result;
}

class Category extends React.PureComponent<IProps> {
private renderEmojiRow = (rowIndex: number): JSX.Element => {
const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props;
const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8);
return (
<div key={rowIndex}>
<div key={rowIndex} role="row">
{emojisForRow.map((emoji) => (
<Emoji
key={emoji.hexcode}
Expand All @@ -63,6 +77,8 @@ class Category extends React.PureComponent<IProps> {
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
disabled={this.props.isEmojiDisabled?.(emoji.unicode)}
id={`mx_EmojiPicker_item_${this.props.id}_${hexEncode(emoji.unicode)}`}
role="gridcell"
/>
))}
</div>
Expand Down Expand Up @@ -101,7 +117,6 @@ class Category extends React.PureComponent<IProps> {
>
<h2 className="mx_EmojiPicker_category_label">{name}</h2>
<LazyRenderList
element="ul"
className="mx_EmojiPicker_list"
itemHeight={EMOJI_HEIGHT}
items={rows}
Expand All @@ -110,6 +125,7 @@ class Category extends React.PureComponent<IProps> {
overflowItems={OVERFLOW_ROWS}
overflowMargin={0}
renderItem={this.renderEmojiRow}
role="grid"
/>
</section>
);
Expand Down
20 changes: 12 additions & 8 deletions src/components/views/emojipicker/Emoji.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,36 +17,40 @@ limitations under the License.

import React from "react";

import { MenuItem } from "../../structures/ContextMenu";
import { IEmoji } from "../../../emoji";
import { ButtonEvent } from "../elements/AccessibleButton";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";

interface IProps {
emoji: IEmoji;
selectedEmojis?: Set<string>;
onClick(emoji: IEmoji): void;
onClick(ev: ButtonEvent, emoji: IEmoji): void;
onMouseEnter(emoji: IEmoji): void;
onMouseLeave(emoji: IEmoji): void;
disabled?: boolean;
id?: string;
role?: string;
}

class Emoji extends React.PureComponent<IProps> {
public render(): React.ReactNode {
const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props;
const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode);
const isSelected = selectedEmojis?.has(emoji.unicode);
return (
<MenuItem
element="li"
onClick={() => onClick(emoji)}
<RovingAccessibleButton
id={this.props.id}
onClick={(ev) => onClick(ev, emoji)}
onMouseEnter={() => onMouseEnter(emoji)}
onMouseLeave={() => onMouseLeave(emoji)}
className="mx_EmojiPicker_item_wrapper"
label={emoji.unicode}
disabled={this.props.disabled}
role={this.props.role}
focusOnMouseOver
>
<div className={`mx_EmojiPicker_item ${isSelected ? "mx_EmojiPicker_item_selected" : ""}`}>
{emoji.unicode}
</div>
</MenuItem>
</RovingAccessibleButton>
);
}
}
Expand Down
Loading

0 comments on commit 2da5237

Please sign in to comment.