diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index d12f66e9c7..80193fd338 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -114,7 +114,7 @@ const Tile: React.FC = ({ (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 = ({ case KeyBindingAction.ArrowLeft: e.preventDefault(); e.stopPropagation(); - ref.current?.focus(); + nodeRef.current?.focus(); break; } }; @@ -315,7 +315,7 @@ const Tile: React.FC = ({ case KeyBindingAction.ArrowRight: handled = true; if (showChildren) { - const childSection = ref.current?.nextElementSibling; + const childSection = nodeRef.current?.nextElementSibling; childSection?.querySelector(".mx_SpaceHierarchy_roomTile")?.focus(); } else { toggleShowChildren(); @@ -790,7 +790,7 @@ const SpaceHierarchy: React.FC = ({ 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(); } }; diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx index 6e9df055cd..0a0a70d1b5 100644 --- a/src/components/views/dialogs/ForwardDialog.tsx +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -294,7 +294,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr const action = getKeyBindingsManager().getAccessibilityAction(ev); switch (action) { case KeyBindingAction.Enter: { - state.activeRef?.current?.querySelector(".mx_ForwardList_sendButton")?.click(); + state.activeNode?.querySelector(".mx_ForwardList_sendButton")?.click(); break; } @@ -347,13 +347,13 @@ const ForwardDialog: React.FC = ({ 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 = ({ 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" /> )} diff --git a/src/components/views/dialogs/spotlight/Option.tsx b/src/components/views/dialogs/spotlight/Option.tsx index 4c7e7ac4f4..6de93d0512 100644 --- a/src/components/views/dialogs/spotlight/Option.tsx +++ b/src/components/views/dialogs/spotlight/Option.tsx @@ -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; endAdornment?: ReactNode; id?: string; className?: string; @@ -21,8 +20,8 @@ interface OptionProps { children?: ReactNode; } -export const Option: React.FC = ({ inputRef, children, endAdornment, className, ...props }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); +export const Option: React.FC = ({ children, endAdornment, className, ...props }) => { + const [onFocus, isActive, ref] = useRovingTabIndex(); return ( ): 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 { @@ -498,13 +498,13 @@ const SpotlightDialog: React.FC = ({ 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 = ({ initialText = "", initialFilter = n break; } - let ref: RefObject | undefined; + let node: HTMLElement | undefined; const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev); switch (accessibilityAction) { case KeyBindingAction.Escape: @@ -1141,20 +1141,20 @@ const SpotlightDialog: React.FC = ({ 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 = ({ 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 = ({ 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 ( <> diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 540cf37cbe..cbd1ef2e36 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -73,9 +73,13 @@ interface IProps { // All other props pass through to the . } +type RefCallback = (node: T | null) => void; + export interface IInputProps extends IProps, InputHTMLAttributes { // The ref pass through to the input inputRef?: RefObject; + // Ref callback that will be attached to the input. This takes precedence over inputRef. + refCallback?: RefCallback; // The element to create. Defaults to "input". element: "input"; // The input's value. This is a controlled component, so the value is required. @@ -85,6 +89,8 @@ export interface IInputProps extends IProps, InputHTMLAttributes { // The ref pass through to the select inputRef?: RefObject; + // Ref callback that will be attached to the input. This takes precedence over inputRef. + refCallback?: RefCallback; // To define options for a select, use element: "select"; // The select's value. This is a controlled component, so the value is required. @@ -94,6 +100,8 @@ interface ISelectProps extends IProps, SelectHTMLAttributes { interface ITextareaProps extends IProps, TextareaHTMLAttributes { // The ref pass through to the textarea inputRef?: RefObject; + // Ref callback that will be attached to the input. This takes precedence over inputRef. + refCallback?: RefCallback; element: "textarea"; // The textarea's value. This is a controlled component, so the value is required. value: string; @@ -102,6 +110,8 @@ interface ITextareaProps extends IProps, TextareaHTMLAttributes { // The ref pass through to the input inputRef?: RefObject; + // Ref callback that will be attached to the input. This takes precedence over inputRef. + refCallback?: RefCallback; element: "input"; // The input's value. This is a controlled component, so the value is required. value: string; @@ -284,7 +294,7 @@ export default class Field extends React.PureComponent { const inputProps_: React.HTMLAttributes & React.ClassAttributes = { ...inputProps, - ref: this.inputRef, + ref: this.props.refCallback ?? this.inputRef, }; const fieldInput = React.createElement(this.props.element, inputProps_, children); diff --git a/src/components/views/elements/StyledCheckbox.tsx b/src/components/views/elements/StyledCheckbox.tsx index 388c3f0a3c..42a02ea9b7 100644 --- a/src/components/views/elements/StyledCheckbox.tsx +++ b/src/components/views/elements/StyledCheckbox.tsx @@ -16,7 +16,7 @@ export enum CheckboxStyle { } interface IProps extends React.InputHTMLAttributes { - inputRef?: React.RefObject; + inputRef?: (node: HTMLInputElement | null) => void; kind?: CheckboxStyle; id?: string; } diff --git a/src/components/views/elements/StyledRadioButton.tsx b/src/components/views/elements/StyledRadioButton.tsx index 72cf3645b3..74c9b4a2fc 100644 --- a/src/components/views/elements/StyledRadioButton.tsx +++ b/src/components/views/elements/StyledRadioButton.tsx @@ -10,7 +10,7 @@ import React from "react"; import classnames from "classnames"; interface IProps extends React.InputHTMLAttributes { - inputRef?: React.RefObject; + inputRef?: (node: HTMLInputElement | null) => void; outlined?: boolean; // If true (default), the children will be contained within a