Merge pull request #28452 from element-hq/midhun/fix-spotlight-1
This commit is contained in:
commit
18ef975386
14 changed files with 270 additions and 212 deletions
|
@ -10,7 +10,6 @@ import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useReducer,
|
useReducer,
|
||||||
|
@ -18,11 +17,12 @@ import React, {
|
||||||
Dispatch,
|
Dispatch,
|
||||||
RefObject,
|
RefObject,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
|
RefCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { getKeyBindingsManager } from "../KeyBindingsManager";
|
import { getKeyBindingsManager } from "../KeyBindingsManager";
|
||||||
import { KeyBindingAction } from "./KeyboardShortcuts";
|
import { KeyBindingAction } from "./KeyboardShortcuts";
|
||||||
import { FocusHandler, Ref } from "./roving/types";
|
import { FocusHandler } from "./roving/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module to simplify implementing the Roving TabIndex accessibility technique
|
* Module to simplify implementing the Roving TabIndex accessibility technique
|
||||||
|
@ -49,8 +49,8 @@ export function checkInputableElement(el: HTMLElement): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IState {
|
export interface IState {
|
||||||
activeRef?: Ref;
|
activeNode?: HTMLElement;
|
||||||
refs: Ref[];
|
nodes: HTMLElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IContext {
|
export interface IContext {
|
||||||
|
@ -60,7 +60,7 @@ export interface IContext {
|
||||||
|
|
||||||
export const RovingTabIndexContext = createContext<IContext>({
|
export const RovingTabIndexContext = createContext<IContext>({
|
||||||
state: {
|
state: {
|
||||||
refs: [], // list of refs in DOM order
|
nodes: [], // list of nodes in DOM order
|
||||||
},
|
},
|
||||||
dispatch: () => {},
|
dispatch: () => {},
|
||||||
});
|
});
|
||||||
|
@ -76,7 +76,7 @@ export enum Type {
|
||||||
export interface IAction {
|
export interface IAction {
|
||||||
type: Exclude<Type, Type.Update>;
|
type: Exclude<Type, Type.Update>;
|
||||||
payload: {
|
payload: {
|
||||||
ref: Ref;
|
node: HTMLElement;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,12 +87,12 @@ interface UpdateAction {
|
||||||
|
|
||||||
type Action = IAction | UpdateAction;
|
type Action = IAction | UpdateAction;
|
||||||
|
|
||||||
const refSorter = (a: Ref, b: Ref): number => {
|
const nodeSorter = (a: HTMLElement, b: HTMLElement): number => {
|
||||||
if (a === b) {
|
if (a === b) {
|
||||||
return 0;
|
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) {
|
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
|
||||||
return -1;
|
return -1;
|
||||||
|
@ -106,54 +106,56 @@ const refSorter = (a: Ref, b: Ref): number => {
|
||||||
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
|
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case Type.Register: {
|
case Type.Register: {
|
||||||
if (!state.activeRef) {
|
if (!state.activeNode) {
|
||||||
// Our list of refs was empty, set activeRef to this first item
|
// Our list of nodes was empty, set activeNode to this first item
|
||||||
state.activeRef = action.payload.ref;
|
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
|
// 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.nodes.push(action.payload.node);
|
||||||
state.refs.sort(refSorter);
|
state.nodes.sort(nodeSorter);
|
||||||
|
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
case Type.Unregister: {
|
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) {
|
if (oldIndex === -1) {
|
||||||
return state; // already removed, this should not happen
|
return state; // already removed, this should not happen
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.refs.splice(oldIndex, 1)[0] === state.activeRef) {
|
if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) {
|
||||||
// we just removed the active ref, need to replace it
|
// we just removed the active node, need to replace it
|
||||||
// pick the ref closest to the index the old ref was in
|
// pick the node closest to the index the old node was in
|
||||||
if (oldIndex >= state.refs.length) {
|
if (oldIndex >= state.nodes.length) {
|
||||||
state.activeRef = findSiblingElement(state.refs, state.refs.length - 1, true);
|
state.activeNode = findSiblingElement(state.nodes, state.nodes.length - 1, true);
|
||||||
} else {
|
} else {
|
||||||
state.activeRef =
|
state.activeNode =
|
||||||
findSiblingElement(state.refs, oldIndex) || findSiblingElement(state.refs, oldIndex, true);
|
findSiblingElement(state.nodes, oldIndex) || findSiblingElement(state.nodes, oldIndex, true);
|
||||||
}
|
}
|
||||||
if (document.activeElement === document.body) {
|
if (document.activeElement === document.body) {
|
||||||
// if the focus got reverted to the body then the user was likely focused on the unmounted element
|
// 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 };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
case Type.SetFocus: {
|
case Type.SetFocus: {
|
||||||
// if the ref doesn't change just return the same object reference to skip a re-render
|
// if the node doesn't change just return the same object reference to skip a re-render
|
||||||
if (state.activeRef === action.payload.ref) return state;
|
if (state.activeNode === action.payload.node) return state;
|
||||||
// update active ref
|
// update active node
|
||||||
state.activeRef = action.payload.ref;
|
state.activeNode = action.payload.node;
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
case Type.Update: {
|
case Type.Update: {
|
||||||
state.refs.sort(refSorter);
|
state.nodes.sort(nodeSorter);
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,28 +176,28 @@ interface IProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const findSiblingElement = (
|
export const findSiblingElement = (
|
||||||
refs: RefObject<HTMLElement>[],
|
nodes: HTMLElement[],
|
||||||
startIndex: number,
|
startIndex: number,
|
||||||
backwards = false,
|
backwards = false,
|
||||||
loop = false,
|
loop = false,
|
||||||
): RefObject<HTMLElement> | undefined => {
|
): HTMLElement | undefined => {
|
||||||
if (backwards) {
|
if (backwards) {
|
||||||
for (let i = startIndex; i < refs.length && i >= 0; i--) {
|
for (let i = startIndex; i < nodes.length && i >= 0; i--) {
|
||||||
if (refs[i].current?.offsetParent !== null) {
|
if (nodes[i]?.offsetParent !== null) {
|
||||||
return refs[i];
|
return nodes[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (loop) {
|
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 {
|
} else {
|
||||||
for (let i = startIndex; i < refs.length && i >= 0; i++) {
|
for (let i = startIndex; i < nodes.length && i >= 0; i++) {
|
||||||
if (refs[i].current?.offsetParent !== null) {
|
if (nodes[i]?.offsetParent !== null) {
|
||||||
return refs[i];
|
return nodes[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (loop) {
|
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,
|
onKeyDown,
|
||||||
}) => {
|
}) => {
|
||||||
const [state, dispatch] = useReducer<Reducer<IState, Action>>(reducer, {
|
const [state, dispatch] = useReducer<Reducer<IState, Action>>(reducer, {
|
||||||
refs: [],
|
nodes: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
||||||
|
@ -227,17 +229,17 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||||
|
|
||||||
let handled = false;
|
let handled = false;
|
||||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||||
let focusRef: RefObject<HTMLElement> | undefined;
|
let focusNode: HTMLElement | undefined;
|
||||||
// Don't interfere with input default keydown behaviour
|
// Don't interfere with input default keydown behaviour
|
||||||
// but allow people to move focus from it with Tab.
|
// but allow people to move focus from it with Tab.
|
||||||
if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) {
|
if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case KeyBindingAction.Tab:
|
case KeyBindingAction.Tab:
|
||||||
handled = true;
|
handled = true;
|
||||||
if (context.state.refs.length > 0) {
|
if (context.state.nodes.length > 0) {
|
||||||
const idx = context.state.refs.indexOf(context.state.activeRef!);
|
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
||||||
focusRef = findSiblingElement(
|
focusNode = findSiblingElement(
|
||||||
context.state.refs,
|
context.state.nodes,
|
||||||
idx + (ev.shiftKey ? -1 : 1),
|
idx + (ev.shiftKey ? -1 : 1),
|
||||||
ev.shiftKey,
|
ev.shiftKey,
|
||||||
);
|
);
|
||||||
|
@ -251,7 +253,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||||
if (handleHomeEnd) {
|
if (handleHomeEnd) {
|
||||||
handled = true;
|
handled = true;
|
||||||
// move focus to first (visible) item
|
// move focus to first (visible) item
|
||||||
focusRef = findSiblingElement(context.state.refs, 0);
|
focusNode = findSiblingElement(context.state.nodes, 0);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
@ -259,7 +261,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||||
if (handleHomeEnd) {
|
if (handleHomeEnd) {
|
||||||
handled = true;
|
handled = true;
|
||||||
// move focus to last (visible) item
|
// 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;
|
break;
|
||||||
|
|
||||||
|
@ -270,9 +272,9 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||||
(action === KeyBindingAction.ArrowRight && handleLeftRight)
|
(action === KeyBindingAction.ArrowRight && handleLeftRight)
|
||||||
) {
|
) {
|
||||||
handled = true;
|
handled = true;
|
||||||
if (context.state.refs.length > 0) {
|
if (context.state.nodes.length > 0) {
|
||||||
const idx = context.state.refs.indexOf(context.state.activeRef!);
|
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
||||||
focusRef = findSiblingElement(context.state.refs, idx + 1, false, handleLoop);
|
focusNode = findSiblingElement(context.state.nodes, idx + 1, false, handleLoop);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -284,9 +286,9 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||||
(action === KeyBindingAction.ArrowLeft && handleLeftRight)
|
(action === KeyBindingAction.ArrowLeft && handleLeftRight)
|
||||||
) {
|
) {
|
||||||
handled = true;
|
handled = true;
|
||||||
if (context.state.refs.length > 0) {
|
if (context.state.nodes.length > 0) {
|
||||||
const idx = context.state.refs.indexOf(context.state.activeRef!);
|
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
||||||
focusRef = findSiblingElement(context.state.refs, idx - 1, true, handleLoop);
|
focusNode = findSiblingElement(context.state.nodes, idx - 1, true, handleLoop);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -298,17 +300,17 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (focusRef) {
|
if (focusNode) {
|
||||||
focusRef.current?.focus();
|
focusNode?.focus();
|
||||||
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
|
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
|
||||||
dispatch({
|
dispatch({
|
||||||
type: Type.SetFocus,
|
type: Type.SetFocus,
|
||||||
payload: {
|
payload: {
|
||||||
ref: focusRef,
|
node: focusNode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (scrollIntoView) {
|
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
|
* Hook to register a roving tab index.
|
||||||
// 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}`
|
* inputRef is an optional argument; when passed this ref points to the DOM element
|
||||||
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
|
* 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>(
|
export const useRovingTabIndex = <T extends HTMLElement>(
|
||||||
inputRef?: RefObject<T>,
|
inputRef?: RefObject<T>,
|
||||||
): [FocusHandler, boolean, RefObject<T>] => {
|
): [FocusHandler, boolean, RefCallback<T>, RefObject<T | null>] => {
|
||||||
const context = useContext(RovingTabIndexContext);
|
const context = useContext(RovingTabIndexContext);
|
||||||
let ref = useRef<T>(null);
|
|
||||||
|
let nodeRef = useRef<T | null>(null);
|
||||||
|
|
||||||
if (inputRef) {
|
if (inputRef) {
|
||||||
// if we are given a ref, use it instead of ours
|
// if we are given a ref, use it instead of ours
|
||||||
ref = inputRef;
|
nodeRef = inputRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
// setup (after refs)
|
const ref = useCallback((node: T | null) => {
|
||||||
useEffect(() => {
|
if (node) {
|
||||||
context.dispatch({
|
nodeRef.current = node;
|
||||||
type: Type.Register,
|
context.dispatch({
|
||||||
payload: { ref },
|
type: Type.Register,
|
||||||
});
|
payload: { node },
|
||||||
// teardown
|
});
|
||||||
return () => {
|
} else {
|
||||||
context.dispatch({
|
context.dispatch({
|
||||||
type: Type.Unregister,
|
type: Type.Unregister,
|
||||||
payload: { ref },
|
payload: { node: nodeRef.current! },
|
||||||
});
|
});
|
||||||
};
|
nodeRef.current = null;
|
||||||
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const onFocus = useCallback(() => {
|
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({
|
context.dispatch({
|
||||||
type: Type.SetFocus,
|
type: Type.SetFocus,
|
||||||
payload: { ref },
|
payload: { node: nodeRef.current },
|
||||||
});
|
});
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const isActive = context.state.activeRef === ref;
|
const isActive = context.state.activeNode === nodeRef.current;
|
||||||
return [onFocus, isActive, ref];
|
return [onFocus, isActive, ref, nodeRef];
|
||||||
};
|
};
|
||||||
|
|
||||||
// re-export the semantic helper components for simplicity
|
// re-export the semantic helper components for simplicity
|
||||||
|
|
|
@ -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.
|
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 { useRovingTabIndex } from "../RovingTabIndex";
|
||||||
import { FocusHandler, Ref } from "./types";
|
import { FocusHandler, Ref } from "./types";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
inputRef?: Ref;
|
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.
|
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
|
||||||
|
|
|
@ -114,7 +114,7 @@ const Tile: React.FC<ITileProps> = ({
|
||||||
(room.room_type === RoomType.Space ? _t("common|unnamed_space") : _t("common|unnamed_room"));
|
(room.room_type === RoomType.Space ? _t("common|unnamed_space") : _t("common|unnamed_room"));
|
||||||
|
|
||||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
const [onFocus, isActive, ref, nodeRef] = useRovingTabIndex();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const onPreviewClick = (ev: ButtonEvent): void => {
|
const onPreviewClick = (ev: ButtonEvent): void => {
|
||||||
|
@ -288,7 +288,7 @@ const Tile: React.FC<ITileProps> = ({
|
||||||
case KeyBindingAction.ArrowLeft:
|
case KeyBindingAction.ArrowLeft:
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
ref.current?.focus();
|
nodeRef.current?.focus();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -315,7 +315,7 @@ const Tile: React.FC<ITileProps> = ({
|
||||||
case KeyBindingAction.ArrowRight:
|
case KeyBindingAction.ArrowRight:
|
||||||
handled = true;
|
handled = true;
|
||||||
if (showChildren) {
|
if (showChildren) {
|
||||||
const childSection = ref.current?.nextElementSibling;
|
const childSection = nodeRef.current?.nextElementSibling;
|
||||||
childSection?.querySelector<HTMLDivElement>(".mx_SpaceHierarchy_roomTile")?.focus();
|
childSection?.querySelector<HTMLDivElement>(".mx_SpaceHierarchy_roomTile")?.focus();
|
||||||
} else {
|
} else {
|
||||||
toggleShowChildren();
|
toggleShowChildren();
|
||||||
|
@ -790,7 +790,7 @@ const SpaceHierarchy: React.FC<IProps> = ({ space, initialText = "", showRoom, a
|
||||||
const onKeyDown = (ev: KeyboardEvent, state: IState): void => {
|
const onKeyDown = (ev: KeyboardEvent, state: IState): void => {
|
||||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||||
if (action === KeyBindingAction.ArrowDown && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) {
|
if (action === KeyBindingAction.ArrowDown && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) {
|
||||||
state.refs[0]?.current?.focus();
|
state.nodes[0]?.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -294,7 +294,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
|
||||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case KeyBindingAction.Enter: {
|
case KeyBindingAction.Enter: {
|
||||||
state.activeRef?.current?.querySelector<HTMLButtonElement>(".mx_ForwardList_sendButton")?.click();
|
state.activeNode?.querySelector<HTMLButtonElement>(".mx_ForwardList_sendButton")?.click();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -347,13 +347,13 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
|
||||||
onSearch={(query: string): void => {
|
onSearch={(query: string): void => {
|
||||||
setQuery(query);
|
setQuery(query);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const ref = context.state.refs[0];
|
const node = context.state.nodes[0];
|
||||||
if (ref) {
|
if (node) {
|
||||||
context.dispatch({
|
context.dispatch({
|
||||||
type: Type.SetFocus,
|
type: Type.SetFocus,
|
||||||
payload: { ref },
|
payload: { node },
|
||||||
});
|
});
|
||||||
ref.current?.scrollIntoView?.({
|
node?.scrollIntoView?.({
|
||||||
block: "nearest",
|
block: "nearest",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -361,7 +361,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
|
||||||
}}
|
}}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
onKeyDown={onKeyDownHandler}
|
onKeyDown={onKeyDownHandler}
|
||||||
aria-activedescendant={context.state.activeRef?.current?.id}
|
aria-activedescendant={context.state.activeNode?.id}
|
||||||
aria-owns="mx_ForwardDialog_resultsList"
|
aria-owns="mx_ForwardDialog_resultsList"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -7,13 +7,12 @@ Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import React, { ReactNode, RefObject } from "react";
|
import React, { ReactNode } from "react";
|
||||||
|
|
||||||
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
|
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
|
||||||
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
|
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
|
||||||
|
|
||||||
interface OptionProps {
|
interface OptionProps {
|
||||||
inputRef?: RefObject<HTMLLIElement>;
|
|
||||||
endAdornment?: ReactNode;
|
endAdornment?: ReactNode;
|
||||||
id?: string;
|
id?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
@ -21,8 +20,8 @@ interface OptionProps {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Option: React.FC<OptionProps> = ({ inputRef, children, endAdornment, className, ...props }) => {
|
export const Option: React.FC<OptionProps> = ({ children, endAdornment, className, ...props }) => {
|
||||||
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLLIElement>(inputRef);
|
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLLIElement>();
|
||||||
return (
|
return (
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
@ -20,7 +20,7 @@ import {
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||||
import { normalize } from "matrix-js-sdk/src/utils";
|
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 sanitizeHtml from "sanitize-html";
|
||||||
|
|
||||||
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
|
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
|
||||||
|
@ -90,8 +90,8 @@ interface IProps {
|
||||||
onFinished(): void;
|
onFinished(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function refIsForRecentlyViewed(ref?: RefObject<HTMLElement>): boolean {
|
function nodeIsForRecentlyViewed(node?: HTMLElement): boolean {
|
||||||
return ref?.current?.id?.startsWith("mx_SpotlightDialog_button_recentlyViewed_") === true;
|
return node?.id?.startsWith("mx_SpotlightDialog_button_recentlyViewed_") === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRoomTypes(filter: Filter | null): Set<RoomType | null> {
|
function getRoomTypes(filter: Filter | null): Set<RoomType | null> {
|
||||||
|
@ -498,13 +498,13 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const ref = rovingContext.state.refs[0];
|
const node = rovingContext.state.nodes[0];
|
||||||
if (ref) {
|
if (node) {
|
||||||
rovingContext.dispatch({
|
rovingContext.dispatch({
|
||||||
type: Type.SetFocus,
|
type: Type.SetFocus,
|
||||||
payload: { ref },
|
payload: { node },
|
||||||
});
|
});
|
||||||
ref.current?.scrollIntoView?.({
|
node?.scrollIntoView?.({
|
||||||
block: "nearest",
|
block: "nearest",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1128,7 +1128,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ref: RefObject<HTMLElement> | undefined;
|
let node: HTMLElement | undefined;
|
||||||
const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev);
|
const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||||
switch (accessibilityAction) {
|
switch (accessibilityAction) {
|
||||||
case KeyBindingAction.Escape:
|
case KeyBindingAction.Escape:
|
||||||
|
@ -1141,20 +1141,20 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
if (rovingContext.state.activeRef && rovingContext.state.refs.length > 0) {
|
if (rovingContext.state.activeNode && rovingContext.state.nodes.length > 0) {
|
||||||
let refs = rovingContext.state.refs;
|
let nodes = rovingContext.state.nodes;
|
||||||
if (!query && !filter !== null) {
|
if (!query && !filter !== null) {
|
||||||
// If the current selection is not in the recently viewed row then only include the
|
// 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.
|
// first recently viewed so that is the target when the user is switching into recently viewed.
|
||||||
const keptRecentlyViewedRef = refIsForRecentlyViewed(rovingContext.state.activeRef)
|
const keptRecentlyViewedRef = nodeIsForRecentlyViewed(rovingContext.state.activeNode)
|
||||||
? rovingContext.state.activeRef
|
? rovingContext.state.activeNode
|
||||||
: refs.find(refIsForRecentlyViewed);
|
: nodes.find(nodeIsForRecentlyViewed);
|
||||||
// exclude all other recently viewed items from the list so up/down arrows skip them
|
// 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);
|
const idx = nodes.indexOf(rovingContext.state.activeNode);
|
||||||
ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
|
node = findSiblingElement(nodes, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
@ -1164,27 +1164,30 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||||
if (
|
if (
|
||||||
!query &&
|
!query &&
|
||||||
!filter !== null &&
|
!filter !== null &&
|
||||||
rovingContext.state.activeRef &&
|
rovingContext.state.activeNode &&
|
||||||
rovingContext.state.refs.length > 0 &&
|
rovingContext.state.nodes.length > 0 &&
|
||||||
refIsForRecentlyViewed(rovingContext.state.activeRef)
|
nodeIsForRecentlyViewed(rovingContext.state.activeNode)
|
||||||
) {
|
) {
|
||||||
// we only intercept left/right arrows when the field is empty, and they'd do nothing anyway
|
// we only intercept left/right arrows when the field is empty, and they'd do nothing anyway
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
const refs = rovingContext.state.refs.filter(refIsForRecentlyViewed);
|
const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed);
|
||||||
const idx = refs.indexOf(rovingContext.state.activeRef);
|
const idx = nodes.indexOf(rovingContext.state.activeNode);
|
||||||
ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1));
|
node = findSiblingElement(
|
||||||
|
nodes,
|
||||||
|
idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ref) {
|
if (node) {
|
||||||
rovingContext.dispatch({
|
rovingContext.dispatch({
|
||||||
type: Type.SetFocus,
|
type: Type.SetFocus,
|
||||||
payload: { ref },
|
payload: { node },
|
||||||
});
|
});
|
||||||
ref.current?.scrollIntoView({
|
node?.scrollIntoView({
|
||||||
block: "nearest",
|
block: "nearest",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1204,12 +1207,12 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||||
case KeyBindingAction.Enter:
|
case KeyBindingAction.Enter:
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
rovingContext.state.activeRef?.current?.click();
|
rovingContext.state.activeNode?.click();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeDescendant = rovingContext.state.activeRef?.current?.id;
|
const activeDescendant = rovingContext.state.activeNode?.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -12,6 +12,9 @@ import React, {
|
||||||
RefObject,
|
RefObject,
|
||||||
createRef,
|
createRef,
|
||||||
ComponentProps,
|
ComponentProps,
|
||||||
|
MutableRefObject,
|
||||||
|
RefCallback,
|
||||||
|
Ref,
|
||||||
} from "react";
|
} from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
|
@ -75,7 +78,7 @@ interface IProps {
|
||||||
|
|
||||||
export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
||||||
// The ref pass through to the input
|
// The ref pass through to the input
|
||||||
inputRef?: RefObject<HTMLInputElement>;
|
inputRef?: Ref<HTMLInputElement>;
|
||||||
// The element to create. Defaults to "input".
|
// The element to create. Defaults to "input".
|
||||||
element: "input";
|
element: "input";
|
||||||
// The input's value. This is a controlled component, so the value is required.
|
// 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> {
|
interface ISelectProps extends IProps, SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
// The ref pass through to the select
|
// The ref pass through to the select
|
||||||
inputRef?: RefObject<HTMLSelectElement>;
|
inputRef?: Ref<HTMLSelectElement>;
|
||||||
// To define options for a select, use <Field><option ... /></Field>
|
// To define options for a select, use <Field><option ... /></Field>
|
||||||
element: "select";
|
element: "select";
|
||||||
// The select's value. This is a controlled component, so the value is required.
|
// 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> {
|
interface ITextareaProps extends IProps, TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
// The ref pass through to the textarea
|
// The ref pass through to the textarea
|
||||||
inputRef?: RefObject<HTMLTextAreaElement>;
|
inputRef?: Ref<HTMLTextAreaElement>;
|
||||||
element: "textarea";
|
element: "textarea";
|
||||||
// The textarea's value. This is a controlled component, so the value is required.
|
// The textarea's value. This is a controlled component, so the value is required.
|
||||||
value: string;
|
value: string;
|
||||||
|
@ -101,7 +104,7 @@ interface ITextareaProps extends IProps, TextareaHTMLAttributes<HTMLTextAreaElem
|
||||||
|
|
||||||
export interface INativeOnChangeInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
export interface INativeOnChangeInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
||||||
// The ref pass through to the input
|
// The ref pass through to the input
|
||||||
inputRef?: RefObject<HTMLInputElement>;
|
inputRef?: Ref<HTMLInputElement>;
|
||||||
element: "input";
|
element: "input";
|
||||||
// The input's value. This is a controlled component, so the value is required.
|
// The input's value. This is a controlled component, so the value is required.
|
||||||
value: string;
|
value: string;
|
||||||
|
@ -118,7 +121,17 @@ interface IState {
|
||||||
|
|
||||||
export default class Field extends React.PureComponent<PropShapes, IState> {
|
export default class Field extends React.PureComponent<PropShapes, IState> {
|
||||||
private readonly id: string;
|
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 = {
|
public static readonly defaultProps = {
|
||||||
element: "input",
|
element: "input",
|
||||||
|
@ -230,7 +243,12 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private get inputRef(): RefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> {
|
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 => {
|
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> &
|
const inputProps_: React.HTMLAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> &
|
||||||
React.ClassAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> = {
|
React.ClassAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> = {
|
||||||
...inputProps,
|
...inputProps,
|
||||||
ref: this.inputRef,
|
ref: typeof this.props.inputRef === "function" ? this.callbackRef : this.inputRef,
|
||||||
};
|
};
|
||||||
|
|
||||||
const fieldInput = React.createElement(this.props.element, inputProps_, children);
|
const fieldInput = React.createElement(this.props.element, inputProps_, children);
|
||||||
|
|
|
@ -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.
|
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 { randomString } from "matrix-js-sdk/src/randomstring";
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ export enum CheckboxStyle {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
inputRef?: React.RefObject<HTMLInputElement>;
|
inputRef?: Ref<HTMLInputElement>;
|
||||||
kind?: CheckboxStyle;
|
kind?: CheckboxStyle;
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React, { Ref } from "react";
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
|
|
||||||
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
inputRef?: React.RefObject<HTMLInputElement>;
|
inputRef?: Ref<HTMLInputElement>;
|
||||||
outlined?: boolean;
|
outlined?: boolean;
|
||||||
// If true (default), the children will be contained within a <label> element
|
// 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
|
// If false, they'll be in a div. Putting interactive components that have labels
|
||||||
|
|
|
@ -28,7 +28,6 @@ import {
|
||||||
import { Key } from "../../../Keyboard";
|
import { Key } from "../../../Keyboard";
|
||||||
import { clamp } from "../../../utils/numbers";
|
import { clamp } from "../../../utils/numbers";
|
||||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
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;
|
||||||
|
@ -154,47 +153,47 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void {
|
private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void {
|
||||||
const node = state.activeRef?.current;
|
const node = state.activeNode;
|
||||||
const parent = node?.parentElement;
|
const parent = node?.parentElement;
|
||||||
if (!parent || !state.activeRef) return;
|
if (!parent || !state.activeNode) return;
|
||||||
const rowIndex = Array.from(parent.children).indexOf(node);
|
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;
|
let newParent: HTMLElement | undefined;
|
||||||
switch (ev.key) {
|
switch (ev.key) {
|
||||||
case Key.ARROW_LEFT:
|
case Key.ARROW_LEFT:
|
||||||
focusRef = state.refs[refIndex - 1];
|
focusNode = state.nodes[refIndex - 1];
|
||||||
newParent = focusRef?.current?.parentElement ?? undefined;
|
newParent = focusNode?.parentElement ?? undefined;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Key.ARROW_RIGHT:
|
case Key.ARROW_RIGHT:
|
||||||
focusRef = state.refs[refIndex + 1];
|
focusNode = state.nodes[refIndex + 1];
|
||||||
newParent = focusRef?.current?.parentElement ?? undefined;
|
newParent = focusNode?.parentElement ?? undefined;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Key.ARROW_UP:
|
case Key.ARROW_UP:
|
||||||
case Key.ARROW_DOWN: {
|
case Key.ARROW_DOWN: {
|
||||||
// For up/down we find the prev/next parent by inspecting the refs either side of our row
|
// 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
|
ev.key === Key.ARROW_UP
|
||||||
? state.refs[refIndex - rowIndex - 1]
|
? state.nodes[refIndex - rowIndex - 1]
|
||||||
: state.refs[refIndex - rowIndex + EMOJIS_PER_ROW];
|
: state.nodes[refIndex - rowIndex + EMOJIS_PER_ROW];
|
||||||
newParent = ref?.current?.parentElement ?? undefined;
|
newParent = node?.parentElement ?? undefined;
|
||||||
const newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)];
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (focusRef) {
|
if (focusNode) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: Type.SetFocus,
|
type: Type.SetFocus,
|
||||||
payload: { ref: focusRef },
|
payload: { node: focusNode },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (parent !== newParent) {
|
if (parent !== newParent) {
|
||||||
focusRef.current?.scrollIntoView({
|
focusNode?.scrollIntoView({
|
||||||
behavior: "auto",
|
behavior: "auto",
|
||||||
block: "center",
|
block: "center",
|
||||||
inline: "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 => {
|
private onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void => {
|
||||||
if (
|
if (state.activeNode && [Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key)) {
|
||||||
state.activeRef?.current &&
|
|
||||||
[Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key)
|
|
||||||
) {
|
|
||||||
this.keyboardNavigation(ev, state, dispatch);
|
this.keyboardNavigation(ev, state, dispatch);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -70,7 +70,7 @@ 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-activedescendant={this.context.state.activeNode?.id}
|
||||||
aria-controls="mx_EmojiPicker_body"
|
aria-controls="mx_EmojiPicker_body"
|
||||||
aria-haspopup="grid"
|
aria-haspopup="grid"
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
|
|
|
@ -23,7 +23,7 @@ const JumpToDatePicker: React.FC<IProps> = ({ ts, onDatePicked }: IProps) => {
|
||||||
const dateInputDefaultValue = formatDateForInput(date);
|
const dateInputDefaultValue = formatDateForInput(date);
|
||||||
|
|
||||||
const [dateValue, setDateValue] = useState(dateInputDefaultValue);
|
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 onDateValueInput = (ev: React.ChangeEvent<HTMLInputElement>): void => setDateValue(ev.target.value);
|
||||||
const onJumpToDateSubmit = (ev: FormEvent): void => {
|
const onJumpToDateSubmit = (ev: FormEvent): void => {
|
||||||
|
@ -45,7 +45,7 @@ const JumpToDatePicker: React.FC<IProps> = ({ ts, onDatePicked }: IProps) => {
|
||||||
className="mx_JumpToDatePicker_datePicker"
|
className="mx_JumpToDatePicker_datePicker"
|
||||||
label={_t("room|jump_to_date_prompt")}
|
label={_t("room|jump_to_date_prompt")}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
inputRef={ref}
|
inputRef={refCallback}
|
||||||
tabIndex={isActive ? 0 : -1}
|
tabIndex={isActive ? 0 : -1}
|
||||||
/>
|
/>
|
||||||
<RovingAccessibleButton
|
<RovingAccessibleButton
|
||||||
|
|
|
@ -73,7 +73,7 @@ export const SpaceButton = <T extends keyof JSX.IntrinsicElements>({
|
||||||
...props
|
...props
|
||||||
}: ButtonProps<T>): JSX.Element => {
|
}: ButtonProps<T>): JSX.Element => {
|
||||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLElement>(innerRef);
|
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 tabIndex = isActive ? 0 : -1;
|
||||||
|
|
||||||
const spaceKey = _spaceKey ?? space?.roomId;
|
const spaceKey = _spaceKey ?? space?.roomId;
|
||||||
|
@ -144,7 +144,7 @@ export const SpaceButton = <T extends keyof JSX.IntrinsicElements>({
|
||||||
title={!isNarrow || menuDisplayed ? undefined : label}
|
title={!isNarrow || menuDisplayed ? undefined : label}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onContextMenu={openMenu}
|
onContextMenu={openMenu}
|
||||||
ref={handle}
|
ref={ref}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
>
|
>
|
||||||
|
|
|
@ -28,6 +28,12 @@ const checkTabIndexes = (buttons: NodeListOf<HTMLElement>, expectations: number[
|
||||||
expect([...buttons].map((b) => b.tabIndex)).toStrictEqual(expectations);
|
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
|
// give the buttons keys for the fibre reconciler to not treat them all as the same
|
||||||
const button1 = <Button key={1}>a</Button>;
|
const button1 = <Button key={1}>a</Button>;
|
||||||
const button2 = <Button key={2}>b</Button>;
|
const button2 = <Button key={2}>b</Button>;
|
||||||
|
@ -114,6 +120,25 @@ describe("RovingTabIndex", () => {
|
||||||
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
|
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", () => {
|
it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<RovingTabIndexProvider>
|
<RovingTabIndexProvider>
|
||||||
|
@ -123,11 +148,7 @@ describe("RovingTabIndex", () => {
|
||||||
{button2}
|
{button2}
|
||||||
<RovingTabIndexWrapper>
|
<RovingTabIndexWrapper>
|
||||||
{({ onFocus, isActive, ref }) => (
|
{({ onFocus, isActive, ref }) => (
|
||||||
<button
|
<button onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref}>
|
||||||
onFocus={onFocus}
|
|
||||||
tabIndex={isActive ? 0 : -1}
|
|
||||||
ref={ref as React.RefObject<HTMLButtonElement>}
|
|
||||||
>
|
|
||||||
.
|
.
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
@ -147,75 +168,75 @@ describe("RovingTabIndex", () => {
|
||||||
|
|
||||||
describe("reducer functions as expected", () => {
|
describe("reducer functions as expected", () => {
|
||||||
it("SetFocus works as expected", () => {
|
it("SetFocus works as expected", () => {
|
||||||
const ref1 = React.createRef<HTMLElement>();
|
const node1 = createButtonElement("Button 1");
|
||||||
const ref2 = React.createRef<HTMLElement>();
|
const node2 = createButtonElement("Button 2");
|
||||||
expect(
|
expect(
|
||||||
reducer(
|
reducer(
|
||||||
{
|
{
|
||||||
activeRef: ref1,
|
activeNode: node1,
|
||||||
refs: [ref1, ref2],
|
nodes: [node1, node2],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: Type.SetFocus,
|
type: Type.SetFocus,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref2,
|
node: node2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
).toStrictEqual({
|
).toStrictEqual({
|
||||||
activeRef: ref2,
|
activeNode: node2,
|
||||||
refs: [ref1, ref2],
|
nodes: [node1, node2],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Unregister works as expected", () => {
|
it("Unregister works as expected", () => {
|
||||||
const ref1 = React.createRef<HTMLElement>();
|
const button1 = createButtonElement("Button 1");
|
||||||
const ref2 = React.createRef<HTMLElement>();
|
const button2 = createButtonElement("Button 2");
|
||||||
const ref3 = React.createRef<HTMLElement>();
|
const button3 = createButtonElement("Button 3");
|
||||||
const ref4 = React.createRef<HTMLElement>();
|
const button4 = createButtonElement("Button 4");
|
||||||
|
|
||||||
let state: IState = {
|
let state: IState = {
|
||||||
refs: [ref1, ref2, ref3, ref4],
|
nodes: [button1, button2, button3, button4],
|
||||||
};
|
};
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: Type.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref2,
|
node: button2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
refs: [ref1, ref3, ref4],
|
nodes: [button1, button3, button4],
|
||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: Type.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref3,
|
node: button3,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
refs: [ref1, ref4],
|
nodes: [button1, button4],
|
||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: Type.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref4,
|
node: button4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
refs: [ref1],
|
nodes: [button1],
|
||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: Type.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref1,
|
node: button1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
refs: [],
|
nodes: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -235,122 +256,122 @@ describe("RovingTabIndex", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
let state: IState = {
|
let state: IState = {
|
||||||
refs: [],
|
nodes: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: Type.Register,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref1,
|
node: ref1.current!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
activeRef: ref1,
|
activeNode: ref1.current,
|
||||||
refs: [ref1],
|
nodes: [ref1.current],
|
||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: Type.Register,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref2,
|
node: ref2.current!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
activeRef: ref1,
|
activeNode: ref1.current,
|
||||||
refs: [ref1, ref2],
|
nodes: [ref1.current, ref2.current],
|
||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: Type.Register,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref3,
|
node: ref3.current!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
activeRef: ref1,
|
activeNode: ref1.current,
|
||||||
refs: [ref1, ref2, ref3],
|
nodes: [ref1.current, ref2.current, ref3.current],
|
||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: Type.Register,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref4,
|
node: ref4.current!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
activeRef: ref1,
|
activeNode: ref1.current,
|
||||||
refs: [ref1, ref2, ref3, ref4],
|
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
|
||||||
});
|
});
|
||||||
|
|
||||||
// test that the automatic focus switch works for unmounting
|
// test that the automatic focus switch works for unmounting
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.SetFocus,
|
type: Type.SetFocus,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref2,
|
node: ref2.current!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
activeRef: ref2,
|
activeNode: ref2.current,
|
||||||
refs: [ref1, ref2, ref3, ref4],
|
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
|
||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: Type.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref2,
|
node: ref2.current!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
activeRef: ref3,
|
activeNode: ref3.current,
|
||||||
refs: [ref1, ref3, ref4],
|
nodes: [ref1.current, ref3.current, ref4.current],
|
||||||
});
|
});
|
||||||
|
|
||||||
// test that the insert into the middle works as expected
|
// test that the insert into the middle works as expected
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: Type.Register,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref2,
|
node: ref2.current!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
activeRef: ref3,
|
activeNode: ref3.current,
|
||||||
refs: [ref1, ref2, ref3, ref4],
|
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
|
||||||
});
|
});
|
||||||
|
|
||||||
// test that insertion at the edges works
|
// test that insertion at the edges works
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: Type.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref1,
|
node: ref1.current!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Unregister,
|
type: Type.Unregister,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref4,
|
node: ref4.current!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
activeRef: ref3,
|
activeNode: ref3.current,
|
||||||
refs: [ref2, ref3],
|
nodes: [ref2.current, ref3.current],
|
||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: Type.Register,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref1,
|
node: ref1.current!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
state = reducer(state, {
|
state = reducer(state, {
|
||||||
type: Type.Register,
|
type: Type.Register,
|
||||||
payload: {
|
payload: {
|
||||||
ref: ref4,
|
node: ref4.current!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(state).toStrictEqual({
|
expect(state).toStrictEqual({
|
||||||
activeRef: ref3,
|
activeNode: ref3.current,
|
||||||
refs: [ref1, ref2, ref3, ref4],
|
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue