Extracting RovingGridIndex from EmojiPicker and move to shared components (#33650)
* Add shared roving grid navigation provider * Add RovingTabIndex stories and update RovingGridIndex stories * Handle multiple toolbars/grids in on roving group * Refactor EmojiPicker to use shared roving grid navigation * Exclude the roving stories from playwright tests * Fix Sonar issues
This commit is contained in:
@@ -9,6 +9,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
import React from "react";
|
||||
import {
|
||||
RovingAction,
|
||||
RovingGridIndexProvider as SharedRovingGridIndexProvider,
|
||||
type RovingGridIndexProviderProps,
|
||||
RovingTabIndexProvider as SharedRovingTabIndexProvider,
|
||||
type RovingTabIndexProviderProps,
|
||||
} from "@element-hq/web-shared-components";
|
||||
@@ -56,12 +58,17 @@ const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined =
|
||||
}
|
||||
};
|
||||
|
||||
type IProps = Omit<RovingTabIndexProviderProps, "getAction">;
|
||||
type IRovingTabIndexProps = Omit<RovingTabIndexProviderProps, "getAction">;
|
||||
type IRovingGridIndexProps = Omit<RovingGridIndexProviderProps, "getAction">;
|
||||
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = (props) => {
|
||||
export const RovingTabIndexProvider: React.FC<IRovingTabIndexProps> = (props) => {
|
||||
return <SharedRovingTabIndexProvider {...props} getAction={getWebRovingAction} />;
|
||||
};
|
||||
|
||||
export const RovingGridIndexProvider: React.FC<IRovingGridIndexProps> = (props) => {
|
||||
return <SharedRovingGridIndexProvider {...props} getAction={getWebRovingAction} />;
|
||||
};
|
||||
|
||||
// re-export the semantic helper components for simplicity
|
||||
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
|
||||
export { RovingAccessibleButton } from "./roving/RovingAccessibleButton";
|
||||
|
||||
@@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React, { type Dispatch } from "react";
|
||||
import { DATA_BY_CATEGORY, getEmojiFromUnicode, type Emoji as IEmoji } from "@matrix-org/emojibase-bindings";
|
||||
import { clamp } from "@element-hq/web-shared-components";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
@@ -24,7 +23,7 @@ import { filterBoolean } from "../../../utils/arrays";
|
||||
import {
|
||||
type IAction as RovingAction,
|
||||
type IState as RovingState,
|
||||
RovingTabIndexProvider,
|
||||
RovingGridIndexProvider,
|
||||
RovingStateActionType,
|
||||
} from "../../../accessibility/RovingTabIndex";
|
||||
import { Key } from "../../../Keyboard";
|
||||
@@ -126,83 +125,20 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||
};
|
||||
|
||||
// Given a roving emoji button returns the role=row element containing it
|
||||
private getRow(rovingNode?: Element): Element | undefined {
|
||||
private readonly 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 {
|
||||
private readonly 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 {
|
||||
const rowElement = this.getRow(state.activeNode);
|
||||
const gridcellNode = this.getGridcell(state.activeNode);
|
||||
if (!rowElement || !gridcellNode || !state.activeNode) return;
|
||||
|
||||
// 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);
|
||||
|
||||
let focusNode: HTMLElement | undefined;
|
||||
let newRowElement: Element | undefined;
|
||||
switch (ev.key) {
|
||||
case Key.ARROW_LEFT:
|
||||
focusNode = state.nodes[refIndex - 1];
|
||||
newRowElement = this.getRow(focusNode);
|
||||
break;
|
||||
|
||||
case Key.ARROW_RIGHT:
|
||||
focusNode = state.nodes[refIndex + 1];
|
||||
newRowElement = this.getRow(focusNode);
|
||||
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 node =
|
||||
ev.key === Key.ARROW_UP
|
||||
? state.nodes[refIndex - columnIndex - 1]
|
||||
: state.nodes[refIndex - columnIndex + EMOJIS_PER_ROW];
|
||||
newRowElement = this.getRow(node);
|
||||
if (newRowElement) {
|
||||
const newColumnIndex = clamp(columnIndex, 0, newRowElement.children.length - 1);
|
||||
const newTarget = this.getRovingNode(newRowElement?.children[newColumnIndex]);
|
||||
focusNode = state.nodes.find((r) => r === newTarget);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (focusNode) {
|
||||
// Only move actual DOM focus if an emoji already has focus
|
||||
// If the input has focus, keep using aria-activedescendant for virtual focus
|
||||
if (document.activeElement !== document.querySelector(".mx_EmojiPicker_search input")) {
|
||||
focusNode?.focus();
|
||||
}
|
||||
dispatch({
|
||||
type: RovingStateActionType.SetFocus,
|
||||
payload: { node: focusNode },
|
||||
});
|
||||
|
||||
if (rowElement !== newRowElement) {
|
||||
focusNode?.scrollIntoView({
|
||||
behavior: "auto",
|
||||
block: "center",
|
||||
inline: "center",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
private readonly getRovingNode = (gridcellNode: Element): HTMLElement | undefined => {
|
||||
const node = gridcellNode.children[0];
|
||||
return node instanceof HTMLElement ? node : undefined;
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void => {
|
||||
if (state.activeNode && [Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key)) {
|
||||
@@ -220,7 +156,20 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
this.keyboardNavigation(ev, state, dispatch);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly shouldMoveFocus = (): boolean => {
|
||||
return document.activeElement !== document.querySelector(".mx_EmojiPicker_search input");
|
||||
};
|
||||
|
||||
private readonly onGridNavigation = (ev: React.KeyboardEvent, focusNode: HTMLElement, state: RovingState): void => {
|
||||
if (this.getRow(state.activeNode) !== this.getRow(focusNode)) {
|
||||
focusNode.scrollIntoView({
|
||||
behavior: "auto",
|
||||
block: "center",
|
||||
inline: "center",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -381,7 +330,15 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||
|
||||
public render(): React.ReactNode {
|
||||
return (
|
||||
<RovingTabIndexProvider onKeyDown={this.onKeyDown}>
|
||||
<RovingGridIndexProvider
|
||||
getGridCell={this.getGridcell}
|
||||
getRow={this.getRow}
|
||||
getRovingNode={this.getRovingNode}
|
||||
handleInputFields
|
||||
moveFocus={this.shouldMoveFocus}
|
||||
onGridNavigation={this.onGridNavigation}
|
||||
onKeyDown={this.onKeyDown}
|
||||
>
|
||||
{({ onKeyDownHandler }) => {
|
||||
let heightBefore = 0;
|
||||
return (
|
||||
@@ -440,7 +397,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||
</section>
|
||||
);
|
||||
}}
|
||||
</RovingTabIndexProvider>
|
||||
</RovingGridIndexProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user