Merge pull request #28452 from element-hq/midhun/fix-spotlight-1

This commit is contained in:
Michael Telatynski 2024-11-13 15:54:43 +00:00 committed by GitHub
commit 18ef975386
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 270 additions and 212 deletions

View file

@ -10,7 +10,6 @@ import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useReducer,
@ -18,11 +17,12 @@ import React, {
Dispatch,
RefObject,
ReactNode,
RefCallback,
} from "react";
import { getKeyBindingsManager } from "../KeyBindingsManager";
import { KeyBindingAction } from "./KeyboardShortcuts";
import { FocusHandler, Ref } from "./roving/types";
import { FocusHandler } from "./roving/types";
/**
* Module to simplify implementing the Roving TabIndex accessibility technique
@ -49,8 +49,8 @@ export function checkInputableElement(el: HTMLElement): boolean {
}
export interface IState {
activeRef?: Ref;
refs: Ref[];
activeNode?: HTMLElement;
nodes: HTMLElement[];
}
export interface IContext {
@ -60,7 +60,7 @@ export interface IContext {
export const RovingTabIndexContext = createContext<IContext>({
state: {
refs: [], // list of refs in DOM order
nodes: [], // list of nodes in DOM order
},
dispatch: () => {},
});
@ -76,7 +76,7 @@ export enum Type {
export interface IAction {
type: Exclude<Type, Type.Update>;
payload: {
ref: Ref;
node: HTMLElement;
};
}
@ -87,12 +87,12 @@ interface UpdateAction {
type Action = IAction | UpdateAction;
const refSorter = (a: Ref, b: Ref): number => {
const nodeSorter = (a: HTMLElement, b: HTMLElement): number => {
if (a === b) {
return 0;
}
const position = a.current!.compareDocumentPosition(b.current!);
const position = a.compareDocumentPosition(b);
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
return -1;
@ -106,54 +106,56 @@ const refSorter = (a: Ref, b: Ref): number => {
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
switch (action.type) {
case Type.Register: {
if (!state.activeRef) {
// Our list of refs was empty, set activeRef to this first item
state.activeRef = action.payload.ref;
if (!state.activeNode) {
// Our list of nodes was empty, set activeNode to this first item
state.activeNode = action.payload.node;
}
if (state.nodes.includes(action.payload.node)) return state;
// Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert
state.refs.push(action.payload.ref);
state.refs.sort(refSorter);
state.nodes.push(action.payload.node);
state.nodes.sort(nodeSorter);
return { ...state };
}
case Type.Unregister: {
const oldIndex = state.refs.findIndex((r) => r === action.payload.ref);
const oldIndex = state.nodes.findIndex((r) => r === action.payload.node);
if (oldIndex === -1) {
return state; // already removed, this should not happen
}
if (state.refs.splice(oldIndex, 1)[0] === state.activeRef) {
// we just removed the active ref, need to replace it
// pick the ref closest to the index the old ref was in
if (oldIndex >= state.refs.length) {
state.activeRef = findSiblingElement(state.refs, state.refs.length - 1, true);
if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) {
// we just removed the active node, need to replace it
// pick the node closest to the index the old node was in
if (oldIndex >= state.nodes.length) {
state.activeNode = findSiblingElement(state.nodes, state.nodes.length - 1, true);
} else {
state.activeRef =
findSiblingElement(state.refs, oldIndex) || findSiblingElement(state.refs, oldIndex, true);
state.activeNode =
findSiblingElement(state.nodes, oldIndex) || findSiblingElement(state.nodes, oldIndex, true);
}
if (document.activeElement === document.body) {
// if the focus got reverted to the body then the user was likely focused on the unmounted element
setTimeout(() => state.activeRef?.current?.focus(), 0);
setTimeout(() => state.activeNode?.focus(), 0);
}
}
// update the refs list
// update the nodes list
return { ...state };
}
case Type.SetFocus: {
// if the ref doesn't change just return the same object reference to skip a re-render
if (state.activeRef === action.payload.ref) return state;
// update active ref
state.activeRef = action.payload.ref;
// if the node doesn't change just return the same object reference to skip a re-render
if (state.activeNode === action.payload.node) return state;
// update active node
state.activeNode = action.payload.node;
return { ...state };
}
case Type.Update: {
state.refs.sort(refSorter);
state.nodes.sort(nodeSorter);
return { ...state };
}
@ -174,28 +176,28 @@ interface IProps {
}
export const findSiblingElement = (
refs: RefObject<HTMLElement>[],
nodes: HTMLElement[],
startIndex: number,
backwards = false,
loop = false,
): RefObject<HTMLElement> | undefined => {
): HTMLElement | undefined => {
if (backwards) {
for (let i = startIndex; i < refs.length && i >= 0; i--) {
if (refs[i].current?.offsetParent !== null) {
return refs[i];
for (let i = startIndex; i < nodes.length && i >= 0; i--) {
if (nodes[i]?.offsetParent !== null) {
return nodes[i];
}
}
if (loop) {
return findSiblingElement(refs.slice(startIndex + 1), refs.length - 1, true, false);
return findSiblingElement(nodes.slice(startIndex + 1), nodes.length - 1, true, false);
}
} else {
for (let i = startIndex; i < refs.length && i >= 0; i++) {
if (refs[i].current?.offsetParent !== null) {
return refs[i];
for (let i = startIndex; i < nodes.length && i >= 0; i++) {
if (nodes[i]?.offsetParent !== null) {
return nodes[i];
}
}
if (loop) {
return findSiblingElement(refs.slice(0, startIndex), 0, false, false);
return findSiblingElement(nodes.slice(0, startIndex), 0, false, false);
}
}
};
@ -211,7 +213,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
onKeyDown,
}) => {
const [state, dispatch] = useReducer<Reducer<IState, Action>>(reducer, {
refs: [],
nodes: [],
});
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
@ -227,17 +229,17 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
let handled = false;
const action = getKeyBindingsManager().getAccessibilityAction(ev);
let focusRef: RefObject<HTMLElement> | undefined;
let focusNode: HTMLElement | undefined;
// Don't interfere with input default keydown behaviour
// but allow people to move focus from it with Tab.
if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) {
switch (action) {
case KeyBindingAction.Tab:
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef!);
focusRef = findSiblingElement(
context.state.refs,
if (context.state.nodes.length > 0) {
const idx = context.state.nodes.indexOf(context.state.activeNode!);
focusNode = findSiblingElement(
context.state.nodes,
idx + (ev.shiftKey ? -1 : 1),
ev.shiftKey,
);
@ -251,7 +253,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
if (handleHomeEnd) {
handled = true;
// move focus to first (visible) item
focusRef = findSiblingElement(context.state.refs, 0);
focusNode = findSiblingElement(context.state.nodes, 0);
}
break;
@ -259,7 +261,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
if (handleHomeEnd) {
handled = true;
// move focus to last (visible) item
focusRef = findSiblingElement(context.state.refs, context.state.refs.length - 1, true);
focusNode = findSiblingElement(context.state.nodes, context.state.nodes.length - 1, true);
}
break;
@ -270,9 +272,9 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
(action === KeyBindingAction.ArrowRight && handleLeftRight)
) {
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef!);
focusRef = findSiblingElement(context.state.refs, idx + 1, false, handleLoop);
if (context.state.nodes.length > 0) {
const idx = context.state.nodes.indexOf(context.state.activeNode!);
focusNode = findSiblingElement(context.state.nodes, idx + 1, false, handleLoop);
}
}
break;
@ -284,9 +286,9 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
(action === KeyBindingAction.ArrowLeft && handleLeftRight)
) {
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef!);
focusRef = findSiblingElement(context.state.refs, idx - 1, true, handleLoop);
if (context.state.nodes.length > 0) {
const idx = context.state.nodes.indexOf(context.state.activeNode!);
focusNode = findSiblingElement(context.state.nodes, idx - 1, true, handleLoop);
}
}
break;
@ -298,17 +300,17 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
ev.stopPropagation();
}
if (focusRef) {
focusRef.current?.focus();
if (focusNode) {
focusNode?.focus();
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
dispatch({
type: Type.SetFocus,
payload: {
ref: focusRef,
node: focusNode,
},
});
if (scrollIntoView) {
focusRef.current?.scrollIntoView(scrollIntoView);
focusNode?.scrollIntoView(scrollIntoView);
}
}
},
@ -337,46 +339,61 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
);
};
// Hook to register a roving tab index
// inputRef parameter specifies the ref to use
// onFocus should be called when the index gained focus in any manner
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
/**
* Hook to register a roving tab index.
*
* inputRef is an optional argument; when passed this ref points to the DOM element
* to which the callback ref is attached.
*
* Returns:
* onFocus should be called when the index gained focus in any manner.
* isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`.
* ref is a callback ref that should be passed to a DOM node which will be used for DOM compareDocumentPosition.
* nodeRef is a ref that points to the DOM element to which the ref mentioned above is attached.
*
* nodeRef = inputRef when inputRef argument is provided.
*/
export const useRovingTabIndex = <T extends HTMLElement>(
inputRef?: RefObject<T>,
): [FocusHandler, boolean, RefObject<T>] => {
): [FocusHandler, boolean, RefCallback<T>, RefObject<T | null>] => {
const context = useContext(RovingTabIndexContext);
let ref = useRef<T>(null);
let nodeRef = useRef<T | null>(null);
if (inputRef) {
// if we are given a ref, use it instead of ours
ref = inputRef;
nodeRef = inputRef;
}
// setup (after refs)
useEffect(() => {
context.dispatch({
type: Type.Register,
payload: { ref },
});
// teardown
return () => {
const ref = useCallback((node: T | null) => {
if (node) {
nodeRef.current = node;
context.dispatch({
type: Type.Register,
payload: { node },
});
} else {
context.dispatch({
type: Type.Unregister,
payload: { ref },
payload: { node: nodeRef.current! },
});
};
nodeRef.current = null;
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const onFocus = useCallback(() => {
if (!nodeRef.current) {
console.warn("useRovingTabIndex.onFocus called but the react ref does not point to any DOM element!");
return;
}
context.dispatch({
type: Type.SetFocus,
payload: { ref },
payload: { node: nodeRef.current },
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const isActive = context.state.activeRef === ref;
return [onFocus, isActive, ref];
const isActive = context.state.activeNode === nodeRef.current;
return [onFocus, isActive, ref, nodeRef];
};
// re-export the semantic helper components for simplicity

View file

@ -6,14 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactElement } from "react";
import React, { ReactElement, RefCallback } from "react";
import { useRovingTabIndex } from "../RovingTabIndex";
import { FocusHandler, Ref } from "./types";
interface IProps {
inputRef?: Ref;
children(renderProps: { onFocus: FocusHandler; isActive: boolean; ref: Ref }): ReactElement<any, any>;
children(renderProps: {
onFocus: FocusHandler;
isActive: boolean;
ref: RefCallback<HTMLElement>;
}): ReactElement<any, any>;
}
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.

View file

@ -114,7 +114,7 @@ const Tile: React.FC<ITileProps> = ({
(room.room_type === RoomType.Space ? _t("common|unnamed_space") : _t("common|unnamed_room"));
const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref] = useRovingTabIndex();
const [onFocus, isActive, ref, nodeRef] = useRovingTabIndex();
const [busy, setBusy] = useState(false);
const onPreviewClick = (ev: ButtonEvent): void => {
@ -288,7 +288,7 @@ const Tile: React.FC<ITileProps> = ({
case KeyBindingAction.ArrowLeft:
e.preventDefault();
e.stopPropagation();
ref.current?.focus();
nodeRef.current?.focus();
break;
}
};
@ -315,7 +315,7 @@ const Tile: React.FC<ITileProps> = ({
case KeyBindingAction.ArrowRight:
handled = true;
if (showChildren) {
const childSection = ref.current?.nextElementSibling;
const childSection = nodeRef.current?.nextElementSibling;
childSection?.querySelector<HTMLDivElement>(".mx_SpaceHierarchy_roomTile")?.focus();
} else {
toggleShowChildren();
@ -790,7 +790,7 @@ const SpaceHierarchy: React.FC<IProps> = ({ space, initialText = "", showRoom, a
const onKeyDown = (ev: KeyboardEvent, state: IState): void => {
const action = getKeyBindingsManager().getAccessibilityAction(ev);
if (action === KeyBindingAction.ArrowDown && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) {
state.refs[0]?.current?.focus();
state.nodes[0]?.focus();
}
};

View file

@ -294,7 +294,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) {
case KeyBindingAction.Enter: {
state.activeRef?.current?.querySelector<HTMLButtonElement>(".mx_ForwardList_sendButton")?.click();
state.activeNode?.querySelector<HTMLButtonElement>(".mx_ForwardList_sendButton")?.click();
break;
}
@ -347,13 +347,13 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
onSearch={(query: string): void => {
setQuery(query);
setTimeout(() => {
const ref = context.state.refs[0];
if (ref) {
const node = context.state.nodes[0];
if (node) {
context.dispatch({
type: Type.SetFocus,
payload: { ref },
payload: { node },
});
ref.current?.scrollIntoView?.({
node?.scrollIntoView?.({
block: "nearest",
});
}
@ -361,7 +361,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
}}
autoFocus={true}
onKeyDown={onKeyDownHandler}
aria-activedescendant={context.state.activeRef?.current?.id}
aria-activedescendant={context.state.activeNode?.id}
aria-owns="mx_ForwardDialog_resultsList"
/>
)}

View file

@ -7,13 +7,12 @@ Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { ReactNode, RefObject } from "react";
import React, { ReactNode } from "react";
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
interface OptionProps {
inputRef?: RefObject<HTMLLIElement>;
endAdornment?: ReactNode;
id?: string;
className?: string;
@ -21,8 +20,8 @@ interface OptionProps {
children?: ReactNode;
}
export const Option: React.FC<OptionProps> = ({ inputRef, children, endAdornment, className, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLLIElement>(inputRef);
export const Option: React.FC<OptionProps> = ({ children, endAdornment, className, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLLIElement>();
return (
<AccessibleButton
{...props}

View file

@ -20,7 +20,7 @@ import {
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { normalize } from "matrix-js-sdk/src/utils";
import React, { ChangeEvent, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import React, { ChangeEvent, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import sanitizeHtml from "sanitize-html";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
@ -90,8 +90,8 @@ interface IProps {
onFinished(): void;
}
function refIsForRecentlyViewed(ref?: RefObject<HTMLElement>): boolean {
return ref?.current?.id?.startsWith("mx_SpotlightDialog_button_recentlyViewed_") === true;
function nodeIsForRecentlyViewed(node?: HTMLElement): boolean {
return node?.id?.startsWith("mx_SpotlightDialog_button_recentlyViewed_") === true;
}
function getRoomTypes(filter: Filter | null): Set<RoomType | null> {
@ -498,13 +498,13 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
};
useEffect(() => {
setTimeout(() => {
const ref = rovingContext.state.refs[0];
if (ref) {
const node = rovingContext.state.nodes[0];
if (node) {
rovingContext.dispatch({
type: Type.SetFocus,
payload: { ref },
payload: { node },
});
ref.current?.scrollIntoView?.({
node?.scrollIntoView?.({
block: "nearest",
});
}
@ -1128,7 +1128,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
break;
}
let ref: RefObject<HTMLElement> | undefined;
let node: HTMLElement | undefined;
const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev);
switch (accessibilityAction) {
case KeyBindingAction.Escape:
@ -1141,20 +1141,20 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
ev.stopPropagation();
ev.preventDefault();
if (rovingContext.state.activeRef && rovingContext.state.refs.length > 0) {
let refs = rovingContext.state.refs;
if (rovingContext.state.activeNode && rovingContext.state.nodes.length > 0) {
let nodes = rovingContext.state.nodes;
if (!query && !filter !== null) {
// If the current selection is not in the recently viewed row then only include the
// first recently viewed so that is the target when the user is switching into recently viewed.
const keptRecentlyViewedRef = refIsForRecentlyViewed(rovingContext.state.activeRef)
? rovingContext.state.activeRef
: refs.find(refIsForRecentlyViewed);
const keptRecentlyViewedRef = nodeIsForRecentlyViewed(rovingContext.state.activeNode)
? rovingContext.state.activeNode
: nodes.find(nodeIsForRecentlyViewed);
// exclude all other recently viewed items from the list so up/down arrows skip them
refs = refs.filter((ref) => ref === keptRecentlyViewedRef || !refIsForRecentlyViewed(ref));
nodes = nodes.filter((ref) => ref === keptRecentlyViewedRef || !nodeIsForRecentlyViewed(ref));
}
const idx = refs.indexOf(rovingContext.state.activeRef);
ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
const idx = nodes.indexOf(rovingContext.state.activeNode);
node = findSiblingElement(nodes, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
}
break;
@ -1164,27 +1164,30 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
if (
!query &&
!filter !== null &&
rovingContext.state.activeRef &&
rovingContext.state.refs.length > 0 &&
refIsForRecentlyViewed(rovingContext.state.activeRef)
rovingContext.state.activeNode &&
rovingContext.state.nodes.length > 0 &&
nodeIsForRecentlyViewed(rovingContext.state.activeNode)
) {
// we only intercept left/right arrows when the field is empty, and they'd do nothing anyway
ev.stopPropagation();
ev.preventDefault();
const refs = rovingContext.state.refs.filter(refIsForRecentlyViewed);
const idx = refs.indexOf(rovingContext.state.activeRef);
ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1));
const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed);
const idx = nodes.indexOf(rovingContext.state.activeNode);
node = findSiblingElement(
nodes,
idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1),
);
}
break;
}
if (ref) {
if (node) {
rovingContext.dispatch({
type: Type.SetFocus,
payload: { ref },
payload: { node },
});
ref.current?.scrollIntoView({
node?.scrollIntoView({
block: "nearest",
});
}
@ -1204,12 +1207,12 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
case KeyBindingAction.Enter:
ev.stopPropagation();
ev.preventDefault();
rovingContext.state.activeRef?.current?.click();
rovingContext.state.activeNode?.click();
break;
}
};
const activeDescendant = rovingContext.state.activeRef?.current?.id;
const activeDescendant = rovingContext.state.activeNode?.id;
return (
<>

View file

@ -12,6 +12,9 @@ import React, {
RefObject,
createRef,
ComponentProps,
MutableRefObject,
RefCallback,
Ref,
} from "react";
import classNames from "classnames";
import { debounce } from "lodash";
@ -75,7 +78,7 @@ interface IProps {
export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
// The ref pass through to the input
inputRef?: RefObject<HTMLInputElement>;
inputRef?: Ref<HTMLInputElement>;
// The element to create. Defaults to "input".
element: "input";
// The input's value. This is a controlled component, so the value is required.
@ -84,7 +87,7 @@ export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElemen
interface ISelectProps extends IProps, SelectHTMLAttributes<HTMLSelectElement> {
// The ref pass through to the select
inputRef?: RefObject<HTMLSelectElement>;
inputRef?: Ref<HTMLSelectElement>;
// To define options for a select, use <Field><option ... /></Field>
element: "select";
// The select's value. This is a controlled component, so the value is required.
@ -93,7 +96,7 @@ interface ISelectProps extends IProps, SelectHTMLAttributes<HTMLSelectElement> {
interface ITextareaProps extends IProps, TextareaHTMLAttributes<HTMLTextAreaElement> {
// The ref pass through to the textarea
inputRef?: RefObject<HTMLTextAreaElement>;
inputRef?: Ref<HTMLTextAreaElement>;
element: "textarea";
// The textarea's value. This is a controlled component, so the value is required.
value: string;
@ -101,7 +104,7 @@ interface ITextareaProps extends IProps, TextareaHTMLAttributes<HTMLTextAreaElem
export interface INativeOnChangeInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
// The ref pass through to the input
inputRef?: RefObject<HTMLInputElement>;
inputRef?: Ref<HTMLInputElement>;
element: "input";
// The input's value. This is a controlled component, so the value is required.
value: string;
@ -118,7 +121,17 @@ interface IState {
export default class Field extends React.PureComponent<PropShapes, IState> {
private readonly id: string;
private readonly _inputRef = createRef<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>();
private readonly _inputRef: MutableRefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null> =
createRef();
/**
* When props.inputRef is a callback ref, we will pass callbackRef to the DOM element.
* This is so that other methods here can still access the DOM element via this._inputRef.
*/
private readonly callbackRef: RefCallback<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> = (node) => {
this._inputRef.current = node;
(this.props.inputRef as RefCallback<unknown>)(node);
};
public static readonly defaultProps = {
element: "input",
@ -230,7 +243,12 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
}
private get inputRef(): RefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> {
return this.props.inputRef ?? this._inputRef;
const inputRef = this.props.inputRef;
if (typeof inputRef === "function") {
// This is a callback ref, so return _inputRef which will point to the actual DOM element.
return this._inputRef;
}
return (inputRef ?? this._inputRef) as RefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>;
}
private onTooltipOpenChange = (open: boolean): void => {
@ -284,7 +302,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
const inputProps_: React.HTMLAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> &
React.ClassAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> = {
...inputProps,
ref: this.inputRef,
ref: typeof this.props.inputRef === "function" ? this.callbackRef : this.inputRef,
};
const fieldInput = React.createElement(this.props.element, inputProps_, children);

View file

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import React, { Ref } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import classnames from "classnames";
@ -16,7 +16,7 @@ export enum CheckboxStyle {
}
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
inputRef?: React.RefObject<HTMLInputElement>;
inputRef?: Ref<HTMLInputElement>;
kind?: CheckboxStyle;
id?: string;
}

View file

@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import React, { Ref } from "react";
import classnames from "classnames";
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
inputRef?: React.RefObject<HTMLInputElement>;
inputRef?: Ref<HTMLInputElement>;
outlined?: boolean;
// If true (default), the children will be contained within a <label> element
// If false, they'll be in a div. Putting interactive components that have labels

View file

@ -28,7 +28,6 @@ import {
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 EMOJI_HEIGHT = 35;
@ -154,47 +153,47 @@ class EmojiPicker extends React.Component<IProps, IState> {
};
private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void {
const node = state.activeRef?.current;
const node = state.activeNode;
const parent = node?.parentElement;
if (!parent || !state.activeRef) return;
if (!parent || !state.activeNode) return;
const rowIndex = Array.from(parent.children).indexOf(node);
const refIndex = state.refs.indexOf(state.activeRef);
const refIndex = state.nodes.indexOf(state.activeNode);
let focusRef: Ref | undefined;
let focusNode: HTMLElement | undefined;
let newParent: HTMLElement | undefined;
switch (ev.key) {
case Key.ARROW_LEFT:
focusRef = state.refs[refIndex - 1];
newParent = focusRef?.current?.parentElement ?? undefined;
focusNode = state.nodes[refIndex - 1];
newParent = focusNode?.parentElement ?? undefined;
break;
case Key.ARROW_RIGHT:
focusRef = state.refs[refIndex + 1];
newParent = focusRef?.current?.parentElement ?? undefined;
focusNode = state.nodes[refIndex + 1];
newParent = focusNode?.parentElement ?? undefined;
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 =
const node =
ev.key === Key.ARROW_UP
? state.refs[refIndex - rowIndex - 1]
: state.refs[refIndex - rowIndex + EMOJIS_PER_ROW];
newParent = ref?.current?.parentElement ?? undefined;
? state.nodes[refIndex - rowIndex - 1]
: state.nodes[refIndex - rowIndex + EMOJIS_PER_ROW];
newParent = node?.parentElement ?? undefined;
const newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)];
focusRef = state.refs.find((r) => r.current === newTarget);
focusNode = state.nodes.find((r) => r === newTarget);
break;
}
}
if (focusRef) {
if (focusNode) {
dispatch({
type: Type.SetFocus,
payload: { ref: focusRef },
payload: { node: focusNode },
});
if (parent !== newParent) {
focusRef.current?.scrollIntoView({
focusNode?.scrollIntoView({
behavior: "auto",
block: "center",
inline: "center",
@ -207,10 +206,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
}
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)
) {
if (state.activeNode && [Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key)) {
this.keyboardNavigation(ev, state, dispatch);
}
};

View file

@ -70,7 +70,7 @@ class Search extends React.PureComponent<IProps> {
onChange={(ev) => this.props.onChange(ev.target.value)}
onKeyDown={this.onKeyDown}
ref={this.inputRef}
aria-activedescendant={this.context.state.activeRef?.current?.id}
aria-activedescendant={this.context.state.activeNode?.id}
aria-controls="mx_EmojiPicker_body"
aria-haspopup="grid"
aria-autocomplete="list"

View file

@ -23,7 +23,7 @@ const JumpToDatePicker: React.FC<IProps> = ({ ts, onDatePicked }: IProps) => {
const dateInputDefaultValue = formatDateForInput(date);
const [dateValue, setDateValue] = useState(dateInputDefaultValue);
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
const [onFocus, isActive, refCallback] = useRovingTabIndex<HTMLInputElement>();
const onDateValueInput = (ev: React.ChangeEvent<HTMLInputElement>): void => setDateValue(ev.target.value);
const onJumpToDateSubmit = (ev: FormEvent): void => {
@ -45,7 +45,7 @@ const JumpToDatePicker: React.FC<IProps> = ({ ts, onDatePicked }: IProps) => {
className="mx_JumpToDatePicker_datePicker"
label={_t("room|jump_to_date_prompt")}
onFocus={onFocus}
inputRef={ref}
inputRef={refCallback}
tabIndex={isActive ? 0 : -1}
/>
<RovingAccessibleButton

View file

@ -73,7 +73,7 @@ export const SpaceButton = <T extends keyof JSX.IntrinsicElements>({
...props
}: ButtonProps<T>): JSX.Element => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLElement>(innerRef);
const [onFocus, isActive] = useRovingTabIndex(handle);
const [onFocus, isActive, ref] = useRovingTabIndex(handle);
const tabIndex = isActive ? 0 : -1;
const spaceKey = _spaceKey ?? space?.roomId;
@ -144,7 +144,7 @@ export const SpaceButton = <T extends keyof JSX.IntrinsicElements>({
title={!isNarrow || menuDisplayed ? undefined : label}
onClick={onClick}
onContextMenu={openMenu}
ref={handle}
ref={ref}
tabIndex={tabIndex}
onFocus={onFocus}
>

View file

@ -28,6 +28,12 @@ const checkTabIndexes = (buttons: NodeListOf<HTMLElement>, expectations: number[
expect([...buttons].map((b) => b.tabIndex)).toStrictEqual(expectations);
};
const createButtonElement = (text: string): HTMLButtonElement => {
const button = document.createElement("button");
button.textContent = text;
return button;
};
// give the buttons keys for the fibre reconciler to not treat them all as the same
const button1 = <Button key={1}>a</Button>;
const button2 = <Button key={2}>b</Button>;
@ -114,6 +120,25 @@ describe("RovingTabIndex", () => {
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
});
it("RovingTabIndexProvider provides a ref to the dom element", () => {
const nodeRef = React.createRef<HTMLButtonElement>();
const MyButton = (props: HTMLAttributes<HTMLButtonElement>) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLButtonElement>(nodeRef);
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
};
const { container } = render(
<RovingTabIndexProvider>
{() => (
<React.Fragment>
<MyButton />
</React.Fragment>
)}
</RovingTabIndexProvider>,
);
// nodeRef should point to button
expect(nodeRef.current).toBe(container.querySelector("button"));
});
it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => {
const { container } = render(
<RovingTabIndexProvider>
@ -123,11 +148,7 @@ describe("RovingTabIndex", () => {
{button2}
<RovingTabIndexWrapper>
{({ onFocus, isActive, ref }) => (
<button
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
ref={ref as React.RefObject<HTMLButtonElement>}
>
<button onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref}>
.
</button>
)}
@ -147,75 +168,75 @@ describe("RovingTabIndex", () => {
describe("reducer functions as expected", () => {
it("SetFocus works as expected", () => {
const ref1 = React.createRef<HTMLElement>();
const ref2 = React.createRef<HTMLElement>();
const node1 = createButtonElement("Button 1");
const node2 = createButtonElement("Button 2");
expect(
reducer(
{
activeRef: ref1,
refs: [ref1, ref2],
activeNode: node1,
nodes: [node1, node2],
},
{
type: Type.SetFocus,
payload: {
ref: ref2,
node: node2,
},
},
),
).toStrictEqual({
activeRef: ref2,
refs: [ref1, ref2],
activeNode: node2,
nodes: [node1, node2],
});
});
it("Unregister works as expected", () => {
const ref1 = React.createRef<HTMLElement>();
const ref2 = React.createRef<HTMLElement>();
const ref3 = React.createRef<HTMLElement>();
const ref4 = React.createRef<HTMLElement>();
const button1 = createButtonElement("Button 1");
const button2 = createButtonElement("Button 2");
const button3 = createButtonElement("Button 3");
const button4 = createButtonElement("Button 4");
let state: IState = {
refs: [ref1, ref2, ref3, ref4],
nodes: [button1, button2, button3, button4],
};
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref2,
node: button2,
},
});
expect(state).toStrictEqual({
refs: [ref1, ref3, ref4],
nodes: [button1, button3, button4],
});
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref3,
node: button3,
},
});
expect(state).toStrictEqual({
refs: [ref1, ref4],
nodes: [button1, button4],
});
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref4,
node: button4,
},
});
expect(state).toStrictEqual({
refs: [ref1],
nodes: [button1],
});
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref1,
node: button1,
},
});
expect(state).toStrictEqual({
refs: [],
nodes: [],
});
});
@ -235,122 +256,122 @@ describe("RovingTabIndex", () => {
);
let state: IState = {
refs: [],
nodes: [],
};
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref1,
node: ref1.current!,
},
});
expect(state).toStrictEqual({
activeRef: ref1,
refs: [ref1],
activeNode: ref1.current,
nodes: [ref1.current],
});
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref2,
node: ref2.current!,
},
});
expect(state).toStrictEqual({
activeRef: ref1,
refs: [ref1, ref2],
activeNode: ref1.current,
nodes: [ref1.current, ref2.current],
});
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref3,
node: ref3.current!,
},
});
expect(state).toStrictEqual({
activeRef: ref1,
refs: [ref1, ref2, ref3],
activeNode: ref1.current,
nodes: [ref1.current, ref2.current, ref3.current],
});
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref4,
node: ref4.current!,
},
});
expect(state).toStrictEqual({
activeRef: ref1,
refs: [ref1, ref2, ref3, ref4],
activeNode: ref1.current,
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
});
// test that the automatic focus switch works for unmounting
state = reducer(state, {
type: Type.SetFocus,
payload: {
ref: ref2,
node: ref2.current!,
},
});
expect(state).toStrictEqual({
activeRef: ref2,
refs: [ref1, ref2, ref3, ref4],
activeNode: ref2.current,
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
});
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref2,
node: ref2.current!,
},
});
expect(state).toStrictEqual({
activeRef: ref3,
refs: [ref1, ref3, ref4],
activeNode: ref3.current,
nodes: [ref1.current, ref3.current, ref4.current],
});
// test that the insert into the middle works as expected
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref2,
node: ref2.current!,
},
});
expect(state).toStrictEqual({
activeRef: ref3,
refs: [ref1, ref2, ref3, ref4],
activeNode: ref3.current,
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
});
// test that insertion at the edges works
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref1,
node: ref1.current!,
},
});
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref4,
node: ref4.current!,
},
});
expect(state).toStrictEqual({
activeRef: ref3,
refs: [ref2, ref3],
activeNode: ref3.current,
nodes: [ref2.current, ref3.current],
});
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref1,
node: ref1.current!,
},
});
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref4,
node: ref4.current!,
},
});
expect(state).toStrictEqual({
activeRef: ref3,
refs: [ref1, ref2, ref3, ref4],
activeNode: ref3.current,
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
});
});
});