Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add arrow key controls to emoji and reaction pickers #10637

Merged
merged 11 commits into from
Apr 20, 2023
2 changes: 1 addition & 1 deletion cypress/e2e/threads/threads.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,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
20 changes: 13 additions & 7 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 @@ -159,8 +159,12 @@ interface IProps {
handleHomeEnd?: boolean;
handleUpDown?: boolean;
handleLeftRight?: boolean;
handleInputKeys?: boolean;
// Whether to only dispatch SetFocus on keyboard handling
// useful for aria-activedescendant widgets
onlySetFocus?: 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 @@ -188,6 +192,8 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
handleHomeEnd,
handleUpDown,
handleLeftRight,
handleInputKeys,
onlySetFocus,
onKeyDown,
}) => {
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
Expand All @@ -199,7 +205,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 All @@ -210,7 +216,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
let focusRef: RefObject<HTMLElement> | undefined;
// Don't interfere with input default keydown behaviour
// but allow people to move focus from it with Tab.
if (checkInputableElement(ev.target as HTMLElement)) {
if (!handleInputKeys && checkInputableElement(ev.target as HTMLElement)) {
switch (action) {
case KeyBindingAction.Tab:
handled = true;
Expand Down Expand Up @@ -279,7 +285,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
}

if (focusRef) {
focusRef.current?.focus();
if (!onlySetFocus) focusRef.current?.focus();
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
dispatch({
type: Type.SetFocus,
Expand All @@ -289,7 +295,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
});
}
},
[context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight],
[context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight, handleInputKeys, onlySetFocus],
);

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