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
This commit is contained in:
parent
0d9fa0515d
commit
2da52372d4
15 changed files with 277 additions and 74 deletions
|
@ -174,7 +174,7 @@ describe("Threads", () => {
|
||||||
.click({ force: true }); // Cypress has no ability to hover
|
.click({ force: true }); // Cypress has no ability to hover
|
||||||
cy.get(".mx_EmojiPicker").within(() => {
|
cy.get(".mx_EmojiPicker").within(() => {
|
||||||
cy.get('input[type="text"]').type("wave");
|
cy.get('input[type="text"]').type("wave");
|
||||||
cy.contains('[role="menuitem"]', "👋").click();
|
cy.contains('[role="gridcell"]', "👋").click();
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.get(".mx_ThreadView").within(() => {
|
cy.get(".mx_ThreadView").within(() => {
|
||||||
|
|
|
@ -179,6 +179,14 @@ limitations under the License.
|
||||||
list-style: none;
|
list-style: none;
|
||||||
width: 38px;
|
width: 38px;
|
||||||
cursor: pointer;
|
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 {
|
.mx_EmojiPicker_item {
|
||||||
|
|
|
@ -61,7 +61,7 @@ export interface IState {
|
||||||
refs: Ref[];
|
refs: Ref[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IContext {
|
export interface IContext {
|
||||||
state: IState;
|
state: IState;
|
||||||
dispatch: Dispatch<IAction>;
|
dispatch: Dispatch<IAction>;
|
||||||
}
|
}
|
||||||
|
@ -80,7 +80,7 @@ export enum Type {
|
||||||
SetFocus = "SET_FOCUS",
|
SetFocus = "SET_FOCUS",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IAction {
|
export interface IAction {
|
||||||
type: Type;
|
type: Type;
|
||||||
payload: {
|
payload: {
|
||||||
ref: Ref;
|
ref: Ref;
|
||||||
|
@ -160,7 +160,7 @@ interface IProps {
|
||||||
handleUpDown?: boolean;
|
handleUpDown?: boolean;
|
||||||
handleLeftRight?: boolean;
|
handleLeftRight?: boolean;
|
||||||
children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void }): ReactNode;
|
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 = (
|
export const findSiblingElement = (
|
||||||
|
@ -199,7 +199,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||||
const onKeyDownHandler = useCallback(
|
const onKeyDownHandler = useCallback(
|
||||||
(ev: React.KeyboardEvent) => {
|
(ev: React.KeyboardEvent) => {
|
||||||
if (onKeyDown) {
|
if (onKeyDown) {
|
||||||
onKeyDown(ev, context.state);
|
onKeyDown(ev, context.state, context.dispatch);
|
||||||
if (ev.defaultPrevented) {
|
if (ev.defaultPrevented) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,10 +22,17 @@ import { Ref } from "./types";
|
||||||
|
|
||||||
interface IProps extends Omit<React.ComponentProps<typeof AccessibleButton>, "inputRef" | "tabIndex"> {
|
interface IProps extends Omit<React.ComponentProps<typeof AccessibleButton>, "inputRef" | "tabIndex"> {
|
||||||
inputRef?: Ref;
|
inputRef?: Ref;
|
||||||
|
focusOnMouseOver?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
|
// 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);
|
const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
|
||||||
return (
|
return (
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
|
@ -34,6 +41,10 @@ export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, onFocus, ..
|
||||||
onFocusInternal();
|
onFocusInternal();
|
||||||
onFocus?.(event);
|
onFocus?.(event);
|
||||||
}}
|
}}
|
||||||
|
onMouseOver={(event: React.MouseEvent) => {
|
||||||
|
if (focusOnMouseOver) onFocusInternal();
|
||||||
|
onMouseOver?.(event);
|
||||||
|
}}
|
||||||
inputRef={ref}
|
inputRef={ref}
|
||||||
tabIndex={isActive ? 0 : -1}
|
tabIndex={isActive ? 0 : -1}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -148,7 +148,7 @@ export default class ContextMenu extends React.PureComponent<React.PropsWithChil
|
||||||
|
|
||||||
const first =
|
const first =
|
||||||
element.querySelector<HTMLElement>('[role^="menuitem"]') ||
|
element.querySelector<HTMLElement>('[role^="menuitem"]') ||
|
||||||
element.querySelector<HTMLElement>("[tab-index]");
|
element.querySelector<HTMLElement>("[tabindex]");
|
||||||
|
|
||||||
if (first) {
|
if (first) {
|
||||||
first.focus();
|
first.focus();
|
||||||
|
|
|
@ -73,6 +73,7 @@ interface IProps<T> {
|
||||||
|
|
||||||
element?: string;
|
element?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
|
@ -128,6 +129,7 @@ export default class LazyRenderList<T = any> extends React.Component<IProps<T>,
|
||||||
const elementProps = {
|
const elementProps = {
|
||||||
style: { paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px` },
|
style: { paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px` },
|
||||||
className: this.props.className,
|
className: this.props.className,
|
||||||
|
role: this.props.role,
|
||||||
};
|
};
|
||||||
return React.createElement(element, elementProps, renderedItems.map(renderItem));
|
return React.createElement(element, elementProps, renderedItems.map(renderItem));
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPic
|
||||||
import LazyRenderList from "../elements/LazyRenderList";
|
import LazyRenderList from "../elements/LazyRenderList";
|
||||||
import { DATA_BY_CATEGORY, IEmoji } from "../../../emoji";
|
import { DATA_BY_CATEGORY, IEmoji } from "../../../emoji";
|
||||||
import Emoji from "./Emoji";
|
import Emoji from "./Emoji";
|
||||||
|
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||||
|
|
||||||
const OVERFLOW_ROWS = 3;
|
const OVERFLOW_ROWS = 3;
|
||||||
|
|
||||||
|
@ -42,18 +43,31 @@ interface IProps {
|
||||||
heightBefore: number;
|
heightBefore: number;
|
||||||
viewportHeight: number;
|
viewportHeight: number;
|
||||||
scrollTop: number;
|
scrollTop: number;
|
||||||
onClick(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;
|
||||||
isEmojiDisabled?: (unicode: string) => boolean;
|
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> {
|
class Category extends React.PureComponent<IProps> {
|
||||||
private renderEmojiRow = (rowIndex: number): JSX.Element => {
|
private renderEmojiRow = (rowIndex: number): JSX.Element => {
|
||||||
const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props;
|
const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props;
|
||||||
const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8);
|
const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8);
|
||||||
return (
|
return (
|
||||||
<div key={rowIndex}>
|
<div key={rowIndex} role="row">
|
||||||
{emojisForRow.map((emoji) => (
|
{emojisForRow.map((emoji) => (
|
||||||
<Emoji
|
<Emoji
|
||||||
key={emoji.hexcode}
|
key={emoji.hexcode}
|
||||||
|
@ -63,6 +77,8 @@ class Category extends React.PureComponent<IProps> {
|
||||||
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)}`}
|
||||||
|
role="gridcell"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -101,7 +117,6 @@ class Category extends React.PureComponent<IProps> {
|
||||||
>
|
>
|
||||||
<h2 className="mx_EmojiPicker_category_label">{name}</h2>
|
<h2 className="mx_EmojiPicker_category_label">{name}</h2>
|
||||||
<LazyRenderList
|
<LazyRenderList
|
||||||
element="ul"
|
|
||||||
className="mx_EmojiPicker_list"
|
className="mx_EmojiPicker_list"
|
||||||
itemHeight={EMOJI_HEIGHT}
|
itemHeight={EMOJI_HEIGHT}
|
||||||
items={rows}
|
items={rows}
|
||||||
|
@ -110,6 +125,7 @@ class Category extends React.PureComponent<IProps> {
|
||||||
overflowItems={OVERFLOW_ROWS}
|
overflowItems={OVERFLOW_ROWS}
|
||||||
overflowMargin={0}
|
overflowMargin={0}
|
||||||
renderItem={this.renderEmojiRow}
|
renderItem={this.renderEmojiRow}
|
||||||
|
role="grid"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
@ -17,36 +17,40 @@ limitations under the License.
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { MenuItem } from "../../structures/ContextMenu";
|
|
||||||
import { IEmoji } from "../../../emoji";
|
import { IEmoji } from "../../../emoji";
|
||||||
|
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||||
|
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
emoji: IEmoji;
|
emoji: IEmoji;
|
||||||
selectedEmojis?: Set<string>;
|
selectedEmojis?: Set<string>;
|
||||||
onClick(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;
|
||||||
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Emoji extends React.PureComponent<IProps> {
|
class Emoji extends React.PureComponent<IProps> {
|
||||||
public render(): React.ReactNode {
|
public render(): React.ReactNode {
|
||||||
const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props;
|
const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props;
|
||||||
const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode);
|
const isSelected = selectedEmojis?.has(emoji.unicode);
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<RovingAccessibleButton
|
||||||
element="li"
|
id={this.props.id}
|
||||||
onClick={() => onClick(emoji)}
|
onClick={(ev) => onClick(ev, emoji)}
|
||||||
onMouseEnter={() => onMouseEnter(emoji)}
|
onMouseEnter={() => onMouseEnter(emoji)}
|
||||||
onMouseLeave={() => onMouseLeave(emoji)}
|
onMouseLeave={() => onMouseLeave(emoji)}
|
||||||
className="mx_EmojiPicker_item_wrapper"
|
className="mx_EmojiPicker_item_wrapper"
|
||||||
label={emoji.unicode}
|
|
||||||
disabled={this.props.disabled}
|
disabled={this.props.disabled}
|
||||||
|
role={this.props.role}
|
||||||
|
focusOnMouseOver
|
||||||
>
|
>
|
||||||
<div className={`mx_EmojiPicker_item ${isSelected ? "mx_EmojiPicker_item_selected" : ""}`}>
|
<div className={`mx_EmojiPicker_item ${isSelected ? "mx_EmojiPicker_item_selected" : ""}`}>
|
||||||
{emoji.unicode}
|
{emoji.unicode}
|
||||||
</div>
|
</div>
|
||||||
</MenuItem>
|
</RovingAccessibleButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React, { Dispatch } from "react";
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import * as recent from "../../../emojipicker/recent";
|
import * as recent from "../../../emojipicker/recent";
|
||||||
|
@ -25,8 +25,18 @@ import Header from "./Header";
|
||||||
import Search from "./Search";
|
import Search from "./Search";
|
||||||
import Preview from "./Preview";
|
import Preview from "./Preview";
|
||||||
import QuickReactions from "./QuickReactions";
|
import QuickReactions from "./QuickReactions";
|
||||||
import Category, { ICategory, CategoryKey } from "./Category";
|
import Category, { CategoryKey, ICategory } from "./Category";
|
||||||
import { filterBoolean } from "../../../utils/arrays";
|
import { filterBoolean } from "../../../utils/arrays";
|
||||||
|
import {
|
||||||
|
IAction as RovingAction,
|
||||||
|
IState as RovingState,
|
||||||
|
RovingTabIndexProvider,
|
||||||
|
Type,
|
||||||
|
} from "../../../accessibility/RovingTabIndex";
|
||||||
|
import { Key } from "../../../Keyboard";
|
||||||
|
import { clamp } from "../../../utils/numbers";
|
||||||
|
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||||
|
import { Ref } from "../../../accessibility/roving/types";
|
||||||
|
|
||||||
export const CATEGORY_HEADER_HEIGHT = 20;
|
export const CATEGORY_HEADER_HEIGHT = 20;
|
||||||
export const EMOJI_HEIGHT = 35;
|
export const EMOJI_HEIGHT = 35;
|
||||||
|
@ -37,6 +47,7 @@ const ZERO_WIDTH_JOINER = "\u200D";
|
||||||
interface IProps {
|
interface IProps {
|
||||||
selectedEmojis?: Set<string>;
|
selectedEmojis?: Set<string>;
|
||||||
onChoose(unicode: string): boolean;
|
onChoose(unicode: string): boolean;
|
||||||
|
onFinished(): void;
|
||||||
isEmojiDisabled?: (unicode: string) => boolean;
|
isEmojiDisabled?: (unicode: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -150,6 +161,68 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||||
this.updateVisibility();
|
this.updateVisibility();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void {
|
||||||
|
const node = state.activeRef.current;
|
||||||
|
const parent = node.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
const rowIndex = Array.from(parent.children).indexOf(node);
|
||||||
|
const refIndex = state.refs.indexOf(state.activeRef);
|
||||||
|
|
||||||
|
let focusRef: Ref | undefined;
|
||||||
|
let newParent: HTMLElement | undefined;
|
||||||
|
switch (ev.key) {
|
||||||
|
case Key.ARROW_LEFT:
|
||||||
|
focusRef = state.refs[refIndex - 1];
|
||||||
|
newParent = focusRef?.current?.parentElement;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Key.ARROW_RIGHT:
|
||||||
|
focusRef = state.refs[refIndex + 1];
|
||||||
|
newParent = focusRef?.current?.parentElement;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Key.ARROW_UP:
|
||||||
|
case Key.ARROW_DOWN: {
|
||||||
|
// For up/down we find the prev/next parent by inspecting the refs either side of our row
|
||||||
|
const ref =
|
||||||
|
ev.key === Key.ARROW_UP
|
||||||
|
? state.refs[refIndex - rowIndex - 1]
|
||||||
|
: state.refs[refIndex - rowIndex + EMOJIS_PER_ROW];
|
||||||
|
newParent = ref?.current?.parentElement;
|
||||||
|
const newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)];
|
||||||
|
focusRef = state.refs.find((r) => r.current === newTarget);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusRef) {
|
||||||
|
dispatch({
|
||||||
|
type: Type.SetFocus,
|
||||||
|
payload: { ref: focusRef },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parent !== newParent) {
|
||||||
|
focusRef.current?.scrollIntoView({
|
||||||
|
behavior: "auto",
|
||||||
|
block: "center",
|
||||||
|
inline: "center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void => {
|
||||||
|
if (
|
||||||
|
state.activeRef?.current &&
|
||||||
|
[Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key)
|
||||||
|
) {
|
||||||
|
this.keyboardNavigation(ev, state, dispatch);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private updateVisibility = (): void => {
|
private updateVisibility = (): void => {
|
||||||
const body = this.scrollRef.current?.containerRef.current;
|
const body = this.scrollRef.current?.containerRef.current;
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
|
@ -239,11 +312,11 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private onEnterFilter = (): void => {
|
private onEnterFilter = (): void => {
|
||||||
const btn =
|
const btn = this.scrollRef.current?.containerRef.current?.querySelector<HTMLButtonElement>(
|
||||||
this.scrollRef.current?.containerRef.current?.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item");
|
'.mx_EmojiPicker_item_wrapper[tabindex="0"]',
|
||||||
if (btn) {
|
);
|
||||||
btn.click();
|
btn?.click();
|
||||||
}
|
this.props.onFinished();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onHoverEmoji = (emoji: IEmoji): void => {
|
private onHoverEmoji = (emoji: IEmoji): void => {
|
||||||
|
@ -258,10 +331,13 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private onClickEmoji = (emoji: IEmoji): void => {
|
private onClickEmoji = (ev: ButtonEvent, emoji: IEmoji): void => {
|
||||||
if (this.props.onChoose(emoji.unicode) !== false) {
|
if (this.props.onChoose(emoji.unicode) !== false) {
|
||||||
recent.add(emoji.unicode);
|
recent.add(emoji.unicode);
|
||||||
}
|
}
|
||||||
|
if ((ev as React.KeyboardEvent).key === Key.ENTER) {
|
||||||
|
this.props.onFinished();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private static categoryHeightForEmojiCount(count: number): number {
|
private static categoryHeightForEmojiCount(count: number): number {
|
||||||
|
@ -272,41 +348,60 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
public render(): React.ReactNode {
|
||||||
let heightBefore = 0;
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_EmojiPicker" data-testid="mx_EmojiPicker">
|
<RovingTabIndexProvider onKeyDown={this.onKeyDown}>
|
||||||
<Header categories={this.categories} onAnchorClick={this.scrollToCategory} />
|
{({ onKeyDownHandler }) => {
|
||||||
<Search query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} />
|
let heightBefore = 0;
|
||||||
<AutoHideScrollbar className="mx_EmojiPicker_body" ref={this.scrollRef} onScroll={this.onScroll}>
|
return (
|
||||||
{this.categories.map((category) => {
|
<div className="mx_EmojiPicker" data-testid="mx_EmojiPicker" onKeyDown={onKeyDownHandler}>
|
||||||
const emojis = this.memoizedDataByCategory[category.id];
|
<Header categories={this.categories} onAnchorClick={this.scrollToCategory} />
|
||||||
const categoryElement = (
|
<Search
|
||||||
<Category
|
query={this.state.filter}
|
||||||
key={category.id}
|
onChange={this.onChangeFilter}
|
||||||
id={category.id}
|
onEnter={this.onEnterFilter}
|
||||||
name={category.name}
|
onKeyDown={onKeyDownHandler}
|
||||||
heightBefore={heightBefore}
|
|
||||||
viewportHeight={this.state.viewportHeight}
|
|
||||||
scrollTop={this.state.scrollTop}
|
|
||||||
emojis={emojis}
|
|
||||||
onClick={this.onClickEmoji}
|
|
||||||
onMouseEnter={this.onHoverEmoji}
|
|
||||||
onMouseLeave={this.onHoverEmojiEnd}
|
|
||||||
isEmojiDisabled={this.props.isEmojiDisabled}
|
|
||||||
selectedEmojis={this.props.selectedEmojis}
|
|
||||||
/>
|
/>
|
||||||
);
|
<AutoHideScrollbar
|
||||||
const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length);
|
id="mx_EmojiPicker_body"
|
||||||
heightBefore += height;
|
className="mx_EmojiPicker_body"
|
||||||
return categoryElement;
|
ref={this.scrollRef}
|
||||||
})}
|
onScroll={this.onScroll}
|
||||||
</AutoHideScrollbar>
|
>
|
||||||
{this.state.previewEmoji ? (
|
{this.categories.map((category) => {
|
||||||
<Preview emoji={this.state.previewEmoji} />
|
const emojis = this.memoizedDataByCategory[category.id];
|
||||||
) : (
|
const categoryElement = (
|
||||||
<QuickReactions onClick={this.onClickEmoji} selectedEmojis={this.props.selectedEmojis} />
|
<Category
|
||||||
)}
|
key={category.id}
|
||||||
</div>
|
id={category.id}
|
||||||
|
name={category.name}
|
||||||
|
heightBefore={heightBefore}
|
||||||
|
viewportHeight={this.state.viewportHeight}
|
||||||
|
scrollTop={this.state.scrollTop}
|
||||||
|
emojis={emojis}
|
||||||
|
onClick={this.onClickEmoji}
|
||||||
|
onMouseEnter={this.onHoverEmoji}
|
||||||
|
onMouseLeave={this.onHoverEmojiEnd}
|
||||||
|
isEmojiDisabled={this.props.isEmojiDisabled}
|
||||||
|
selectedEmojis={this.props.selectedEmojis}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length);
|
||||||
|
heightBefore += height;
|
||||||
|
return categoryElement;
|
||||||
|
})}
|
||||||
|
</AutoHideScrollbar>
|
||||||
|
{this.state.previewEmoji ? (
|
||||||
|
<Preview emoji={this.state.previewEmoji} />
|
||||||
|
) : (
|
||||||
|
<QuickReactions
|
||||||
|
onClick={this.onClickEmoji}
|
||||||
|
selectedEmojis={this.props.selectedEmojis}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</RovingTabIndexProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { findLastIndex } from "lodash";
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { CategoryKey, ICategory } from "./Category";
|
import { CategoryKey, ICategory } from "./Category";
|
||||||
|
@ -40,7 +41,14 @@ class Header extends React.PureComponent<IProps> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private changeCategoryRelative(delta: number): void {
|
private changeCategoryRelative(delta: number): void {
|
||||||
const current = this.props.categories.findIndex((c) => c.visible);
|
let current: number;
|
||||||
|
// As multiple categories may be visible at once, we want to find the one closest to the relative direction
|
||||||
|
if (delta < 0) {
|
||||||
|
current = this.props.categories.findIndex((c) => c.visible);
|
||||||
|
} else {
|
||||||
|
// XXX: Switch to Array::findLastIndex once we enable ES2023
|
||||||
|
current = findLastIndex(this.props.categories, (c) => c.visible);
|
||||||
|
}
|
||||||
this.changeCategoryAbsolute(current + delta, delta);
|
this.changeCategoryAbsolute(current + delta, delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,8 @@ import React from "react";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { getEmojiFromUnicode, IEmoji } from "../../../emoji";
|
import { getEmojiFromUnicode, IEmoji } from "../../../emoji";
|
||||||
import Emoji from "./Emoji";
|
import Emoji from "./Emoji";
|
||||||
|
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||||
|
import Toolbar from "../../../accessibility/Toolbar";
|
||||||
|
|
||||||
// We use the variation-selector Heart in Quick Reactions for some reason
|
// We use the variation-selector Heart in Quick Reactions for some reason
|
||||||
const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map((emoji) => {
|
const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map((emoji) => {
|
||||||
|
@ -32,7 +34,7 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
selectedEmojis?: Set<string>;
|
selectedEmojis?: Set<string>;
|
||||||
onClick(emoji: IEmoji): void;
|
onClick(ev: ButtonEvent, emoji: IEmoji): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
|
@ -70,7 +72,7 @@ class QuickReactions extends React.Component<IProps, IState> {
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="mx_EmojiPicker_list" aria-label={_t("Quick Reactions")}>
|
<Toolbar className="mx_EmojiPicker_list" aria-label={_t("Quick Reactions")}>
|
||||||
{QUICK_REACTIONS.map((emoji) => (
|
{QUICK_REACTIONS.map((emoji) => (
|
||||||
<Emoji
|
<Emoji
|
||||||
key={emoji.hexcode}
|
key={emoji.hexcode}
|
||||||
|
@ -81,7 +83,7 @@ class QuickReactions extends React.Component<IProps, IState> {
|
||||||
selectedEmojis={this.props.selectedEmojis}
|
selectedEmojis={this.props.selectedEmojis}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Toolbar>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -135,6 +135,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
onChoose={this.onChoose}
|
onChoose={this.onChoose}
|
||||||
isEmojiDisabled={this.isEmojiDisabled}
|
isEmojiDisabled={this.isEmojiDisabled}
|
||||||
|
onFinished={this.props.onFinished}
|
||||||
selectedEmojis={this.state.selectedEmojis}
|
selectedEmojis={this.state.selectedEmojis}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -20,14 +20,19 @@ import React from "react";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
||||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||||
|
import { RovingTabIndexContext } from "../../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
query: string;
|
query: string;
|
||||||
onChange(value: string): void;
|
onChange(value: string): void;
|
||||||
onEnter(): void;
|
onEnter(): void;
|
||||||
|
onKeyDown(event: React.KeyboardEvent): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Search extends React.PureComponent<IProps> {
|
class Search extends React.PureComponent<IProps> {
|
||||||
|
public static contextType = RovingTabIndexContext;
|
||||||
|
public context!: React.ContextType<typeof RovingTabIndexContext>;
|
||||||
|
|
||||||
private inputRef = React.createRef<HTMLInputElement>();
|
private inputRef = React.createRef<HTMLInputElement>();
|
||||||
|
|
||||||
public componentDidMount(): void {
|
public componentDidMount(): void {
|
||||||
|
@ -43,11 +48,14 @@ class Search extends React.PureComponent<IProps> {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.props.onKeyDown(ev);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
public render(): React.ReactNode {
|
||||||
let rightButton;
|
let rightButton: JSX.Element;
|
||||||
if (this.props.query) {
|
if (this.props.query) {
|
||||||
rightButton = (
|
rightButton = (
|
||||||
<button
|
<button
|
||||||
|
@ -70,6 +78,10 @@ class Search extends React.PureComponent<IProps> {
|
||||||
onChange={(ev) => this.props.onChange(ev.target.value)}
|
onChange={(ev) => this.props.onChange(ev.target.value)}
|
||||||
onKeyDown={this.onKeyDown}
|
onKeyDown={this.onKeyDown}
|
||||||
ref={this.inputRef}
|
ref={this.inputRef}
|
||||||
|
aria-activedescendant={this.context.state.activeRef?.current?.id}
|
||||||
|
aria-controls="mx_EmojiPicker_body"
|
||||||
|
aria-haspopup="grid"
|
||||||
|
aria-autocomplete="list"
|
||||||
/>
|
/>
|
||||||
{rightButton}
|
{rightButton}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -36,17 +36,14 @@ export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonP
|
||||||
let contextMenu: React.ReactElement | null = null;
|
let contextMenu: React.ReactElement | null = null;
|
||||||
if (menuDisplayed && button.current) {
|
if (menuDisplayed && button.current) {
|
||||||
const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect());
|
const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect());
|
||||||
|
const onFinished = (): void => {
|
||||||
|
closeMenu();
|
||||||
|
overflowMenuCloser?.();
|
||||||
|
};
|
||||||
|
|
||||||
contextMenu = (
|
contextMenu = (
|
||||||
<ContextMenu
|
<ContextMenu {...position} onFinished={onFinished} managed={false}>
|
||||||
{...position}
|
<EmojiPicker onChoose={addEmoji} onFinished={onFinished} />
|
||||||
onFinished={() => {
|
|
||||||
closeMenu();
|
|
||||||
overflowMenuCloser?.();
|
|
||||||
}}
|
|
||||||
managed={false}
|
|
||||||
>
|
|
||||||
<EmojiPicker onChoose={addEmoji} />
|
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,10 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
import EmojiPicker from "../../../../src/components/views/emojipicker/EmojiPicker";
|
import EmojiPicker from "../../../../src/components/views/emojipicker/EmojiPicker";
|
||||||
import { stubClient } from "../../../test-utils";
|
import { stubClient } from "../../../test-utils";
|
||||||
|
|
||||||
|
@ -21,7 +25,7 @@ describe("EmojiPicker", function () {
|
||||||
stubClient();
|
stubClient();
|
||||||
|
|
||||||
it("sort emojis by shortcode and size", function () {
|
it("sort emojis by shortcode and size", function () {
|
||||||
const ep = new EmojiPicker({ onChoose: (str: String) => false });
|
const ep = new EmojiPicker({ onChoose: (str: string) => false, onFinished: jest.fn() });
|
||||||
|
|
||||||
//@ts-ignore private access
|
//@ts-ignore private access
|
||||||
ep.onChangeFilter("heart");
|
ep.onChangeFilter("heart");
|
||||||
|
@ -31,4 +35,47 @@ describe("EmojiPicker", function () {
|
||||||
//@ts-ignore private access
|
//@ts-ignore private access
|
||||||
expect(ep.memoizedDataByCategory["people"][1].shortcodes[0]).toEqual("heartbeat");
|
expect(ep.memoizedDataByCategory["people"][1].shortcodes[0]).toEqual("heartbeat");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should allow keyboard navigation using arrow keys", async () => {
|
||||||
|
// mock offsetParent
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
||||||
|
get() {
|
||||||
|
return this.parentNode;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onChoose = jest.fn();
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
|
||||||
|
|
||||||
|
const input = container.querySelector("input")!;
|
||||||
|
expect(input).toHaveFocus();
|
||||||
|
|
||||||
|
function getEmoji(): string {
|
||||||
|
const activeDescendant = input.getAttribute("aria-activedescendant");
|
||||||
|
return container.querySelector("#" + activeDescendant)!.textContent!;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getEmoji()).toEqual("😀");
|
||||||
|
await userEvent.keyboard("[ArrowDown]");
|
||||||
|
expect(getEmoji()).toEqual("🙂");
|
||||||
|
await userEvent.keyboard("[ArrowUp]");
|
||||||
|
expect(getEmoji()).toEqual("😀");
|
||||||
|
await userEvent.keyboard("Flag");
|
||||||
|
await userEvent.keyboard("[ArrowRight]");
|
||||||
|
await userEvent.keyboard("[ArrowRight]");
|
||||||
|
expect(getEmoji()).toEqual("📫️");
|
||||||
|
await userEvent.keyboard("[ArrowDown]");
|
||||||
|
expect(getEmoji()).toEqual("🇦🇨");
|
||||||
|
await userEvent.keyboard("[ArrowLeft]");
|
||||||
|
expect(getEmoji()).toEqual("📭️");
|
||||||
|
await userEvent.keyboard("[ArrowUp]");
|
||||||
|
expect(getEmoji()).toEqual("⛳️");
|
||||||
|
await userEvent.keyboard("[ArrowRight]");
|
||||||
|
expect(getEmoji()).toEqual("📫️");
|
||||||
|
await userEvent.keyboard("[Enter]");
|
||||||
|
|
||||||
|
expect(onChoose).toHaveBeenCalledWith("📫️");
|
||||||
|
expect(onFinished).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue