From bf61d93bf44520d25bc0ee437c339f2374ffe221 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 20 Dec 2023 10:58:24 +0000 Subject: [PATCH] Prepare for switching AccessibleButton and derivatives to forwardRef (#12072) * Improve AccessibleButton props & docs Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve AccessibleTooltipButton props docs Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Simplify roving tab index hook usage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Ditch RefObject type casts Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Convert AccessibleTooltipButton to a Functional Component Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/structures/InteractiveAuth.tsx | 7 +- src/components/structures/SpaceRoomView.tsx | 4 +- .../auth/InteractiveAuthEntryComponents.tsx | 11 +- .../dialogs/AddExistingToSpaceDialog.tsx | 4 +- .../views/dialogs/DeactivateAccountDialog.tsx | 8 +- .../views/dialogs/InteractiveAuthDialog.tsx | 6 +- .../views/dialogs/ModalWidgetDialog.tsx | 4 +- .../views/elements/AccessibleButton.tsx | 43 +++--- .../elements/AccessibleTooltipButton.tsx | 132 ++++++++++-------- src/components/views/messages/CallEvent.tsx | 9 +- .../views/rooms/ReadReceiptGroup.tsx | 5 +- .../views/rooms/ReadReceiptMarker.tsx | 6 +- .../views/settings/ChangePassword.tsx | 4 +- 13 files changed, 140 insertions(+), 103 deletions(-) diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx index d35d403e5f..2547af77a1 100644 --- a/src/components/structures/InteractiveAuth.tsx +++ b/src/components/structures/InteractiveAuth.tsx @@ -26,7 +26,10 @@ import { import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; -import getEntryComponentForLoginType, { IStageComponent } from "../views/auth/InteractiveAuthEntryComponents"; +import getEntryComponentForLoginType, { + ContinueKind, + IStageComponent, +} from "../views/auth/InteractiveAuthEntryComponents"; import Spinner from "../views/elements/Spinner"; export const ERROR_USER_CANCELLED = new Error("User cancelled auth session"); @@ -59,7 +62,7 @@ export interface InteractiveAuthProps { continueIsManaged?: boolean; // continueText and continueKind are passed straight through to the AuthEntryComponent. continueText?: string; - continueKind?: string; + continueKind?: ContinueKind; // callback makeRequest(auth: IAuthDict | null): Promise; // callback called when the auth process has finished, diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 1acb6877f9..031d44d7f1 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -16,7 +16,7 @@ limitations under the License. import { EventType, RoomType, JoinRule, Preset, Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; -import React, { RefObject, useCallback, useContext, useRef, useState } from "react"; +import React, { useCallback, useContext, useRef, useState } from "react"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import createRoom, { IOpts } from "../../createRoom"; @@ -499,7 +499,7 @@ const SpaceSetupPrivateInvite: React.FC<{ const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const numFields = 3; - const fieldRefs = [useRef(), useRef(), useRef()] as RefObject[]; + const fieldRefs = [useRef(null), useRef(null), useRef(null)]; const [emailAddresses, setEmailAddress] = useStateArray(numFields, ""); const fields = new Array(numFields).fill(0).map((x, i) => { const name = "emailAddress" + i; diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index 9aaf02b09f..008f92af92 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -25,7 +25,7 @@ import { _t } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; import { LocalisedPolicy, Policies } from "../../../Terms"; import { AuthHeaderModifier } from "../../structures/auth/header/AuthHeaderModifier"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleButton, { AccessibleButtonKind, ButtonEvent } from "../elements/AccessibleButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import Field from "../elements/Field"; import Spinner from "../elements/Spinner"; @@ -780,9 +780,12 @@ export class RegistrationTokenAuthEntry extends React.Component; + interface ISSOAuthEntryProps extends IAuthEntryProps { continueText?: string; - continueKind?: string; + continueKind?: ContinueKind; onCancel?: () => void; } @@ -866,7 +869,7 @@ export class SSOAuthEntry extends React.Component {_t("action|cancel")} @@ -985,7 +988,7 @@ export interface IStageComponentProps extends IAuthEntryProps { inputs?: IInputs; stageState?: IStageStatus; continueText?: string; - continueKind?: string; + continueKind?: ContinueKind; setEmailSid?(sid: string): void; onCancel?(): void; requestEmailToken?(): Promise; diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index cb903dbfd2..77fb046bfa 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactElement, ReactNode, RefObject, useContext, useMemo, useRef, useState } from "react"; +import React, { ReactElement, ReactNode, useContext, useMemo, useRef, useState } from "react"; import classNames from "classnames"; import { Room, EventType } from "matrix-js-sdk/src/matrix"; import { sleep } from "matrix-js-sdk/src/utils"; @@ -144,7 +144,7 @@ export const AddExistingToSpace: React.FC = ({ [cli, msc3946ProcessDynamicPredecessor], ); - const scrollRef = useRef() as RefObject>; + const scrollRef = useRef>(null); const [scrollState, setScrollState] = useState({ // these are estimates which update as soon as it mounts scrollTop: 0, diff --git a/src/components/views/dialogs/DeactivateAccountDialog.tsx b/src/components/views/dialogs/DeactivateAccountDialog.tsx index 4af05a6d1a..1415f3befa 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.tsx +++ b/src/components/views/dialogs/DeactivateAccountDialog.tsx @@ -23,7 +23,7 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { _t } from "../../../languageHandler"; import InteractiveAuth, { ERROR_USER_CANCELLED, InteractiveAuthCallback } from "../../structures/InteractiveAuth"; -import { DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; +import { ContinueKind, DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; import StyledCheckbox from "../elements/StyledCheckbox"; import BaseDialog from "./BaseDialog"; import defaultDispatcher from "../../../dispatcher/dispatcher"; @@ -34,7 +34,7 @@ type DialogAesthetics = Partial<{ [x: number]: { body: string; continueText?: string; - continueKind?: string; + continueKind?: ContinueKind; }; }; }>; @@ -53,7 +53,7 @@ interface IState { // next to the InteractiveAuth component. bodyText?: string; continueText?: string; - continueKind?: string; + continueKind?: ContinueKind; } export default class DeactivateAccountDialog extends React.Component { @@ -98,7 +98,7 @@ export default class DeactivateAccountDialog extends React.Component; @@ -146,7 +146,7 @@ export default class InteractiveAuthDialog extends React.Component { - let kind = "secondary"; + let kind: AccessibleButtonKind = "secondary"; switch (def.kind) { case ModalButtonKind.Primary: kind = "primary"; diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index e679955d8a..e5b3688f49 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import React, { HTMLAttributes, InputHTMLAttributes, ReactNode } from "react"; +import React, { HTMLAttributes, InputHTMLAttributes } from "react"; import classnames from "classnames"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; @@ -22,7 +22,10 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; export type ButtonEvent = React.MouseEvent | React.KeyboardEvent | React.FormEvent; -type AccessibleButtonKind = +/** + * The kind of button, similar to how Bootstrap works. + */ +export type AccessibleButtonKind = | "primary" | "primary_outline" | "primary_sm" @@ -58,25 +61,31 @@ type DynamicElementProps = Partial< Omit, "onClick">; /** - * children: React's magic prop. Represents all children given to the element. - * element: (optional) The base element type. "div" by default. - * onClick: (required) Event handler for button activation. Should be - * implemented exactly like a normal onClick handler. + * Type of props accepted by {@link AccessibleButton}. + * + * Extends props accepted by the underlying element specified using the `element` prop. */ -type IProps = DynamicHtmlElementProps & { +type Props = DynamicHtmlElementProps & { inputRef?: React.Ref; + /** + * The base element type. "div" by default. + */ element?: T; - children?: ReactNode; - // The kind of button, similar to how Bootstrap works. - // See available classes for AccessibleButton for options. - kind?: AccessibleButtonKind | string; - // The ARIA role - role?: string; - // The tabIndex - tabIndex?: number; + /** + * The kind of button, similar to how Bootstrap works. + */ + kind?: AccessibleButtonKind; + /** + * Whether the button should be disabled. + */ disabled?: boolean; - className?: string; + /** + * Whether the button should trigger on mousedown event instead of on click event. Defaults to false (click event). + */ triggerOnMouseDown?: boolean; + /** + * Event handler for button activation. Should be implemented exactly like a normal `onClick` handler. + */ onClick: ((e: ButtonEvent) => void | Promise) | null; }; @@ -104,7 +113,7 @@ export default function AccessibleButton( onKeyUp, triggerOnMouseDown, ...restProps -}: IProps): JSX.Element { +}: Props): JSX.Element { const newProps: IAccessibleButtonProps = restProps; if (disabled) { newProps["aria-disabled"] = true; diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index 26c3825fda..8be307f041 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -15,85 +15,105 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { SyntheticEvent, FocusEvent } from "react"; +import React, { SyntheticEvent, FocusEvent, useEffect, useState } from "react"; import AccessibleButton from "./AccessibleButton"; import Tooltip, { Alignment } from "./Tooltip"; -interface IProps extends React.ComponentProps { +/** + * Type of props accepted by {@link AccessibleTooltipButton}. + * + * Extends that of {@link AccessibleButton}. + */ +interface Props extends React.ComponentProps { + /** + * Title to show in the tooltip and use as aria-label + */ title?: string; + /** + * Tooltip node to show in the tooltip, takes precedence over `title` + */ tooltip?: React.ReactNode; + /** + * Trigger label to render + */ label?: string; + /** + * Classname to apply to the tooltip + */ tooltipClassName?: string; + /** + * Force the tooltip to be hidden + */ forceHide?: boolean; + /** + * Alignment to render the tooltip with + */ alignment?: Alignment; + /** + * Function to call when the children are hovered over + */ onHover?: (hovering: boolean) => void; + /** + * Function to call when the tooltip goes from shown to hidden. + */ onHideTooltip?(ev: SyntheticEvent): void; } -interface IState { - hover: boolean; -} +function AccessibleTooltipButton({ + title, + tooltip, + children, + forceHide, + alignment, + onHideTooltip, + tooltipClassName, + ...props +}: Props): JSX.Element { + const [hover, setHover] = useState(false); -export default class AccessibleTooltipButton extends React.PureComponent { - public constructor(props: IProps) { - super(props); - this.state = { - hover: false, - }; - } - - public componentDidUpdate(prevProps: Readonly): void { - if (!prevProps.forceHide && this.props.forceHide && this.state.hover) { - this.setState({ - hover: false, - }); + useEffect(() => { + // If forceHide is set then force hover to off to hide the tooltip + if (forceHide && hover) { + setHover(false); } - } + }, [forceHide, hover]); - private showTooltip = (): void => { - if (this.props.onHover) this.props.onHover(true); - if (this.props.forceHide) return; - this.setState({ - hover: true, - }); + const showTooltip = (): void => { + props.onHover?.(true); + if (forceHide) return; + setHover(true); }; - private hideTooltip = (ev: SyntheticEvent): void => { - if (this.props.onHover) this.props.onHover(false); - this.setState({ - hover: false, - }); - this.props.onHideTooltip?.(ev); + const hideTooltip = (ev: SyntheticEvent): void => { + props.onHover?.(false); + setHover(false); + onHideTooltip?.(ev); }; - private onFocus = (ev: FocusEvent): void => { + const onFocus = (ev: FocusEvent): void => { // We only show the tooltip if focus arrived here from some other // element, to avoid leaving tooltips hanging around when a modal closes - if (ev.relatedTarget) this.showTooltip(); + if (ev.relatedTarget) showTooltip(); }; - public render(): React.ReactNode { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { title, tooltip, children, tooltipClassName, forceHide, alignment, onHideTooltip, ...props } = - this.props; - - const tip = this.state.hover && (title || tooltip) && ( - - ); - return ( - - {children} - {this.props.label} - {(tooltip || title) && tip} - - ); - } + const tip = hover && (title || tooltip) && ( + + ); + return ( + + {children} + {props.label} + {(tooltip || title) && tip} + + ); } + +export default AccessibleTooltipButton; diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index 8a74eaa34f..bfe959c005 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -28,13 +28,14 @@ import { import defaultDispatcher from "../../../dispatcher/dispatcher"; import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../../dispatcher/actions"; -import type { ButtonEvent } from "../elements/AccessibleButton"; +import { AccessibleButtonKind, ButtonEvent } from "../elements/AccessibleButton"; import MemberAvatar from "../avatars/MemberAvatar"; import { LiveContentSummary, LiveContentType } from "../rooms/LiveContentSummary"; import FacePile from "../elements/FacePile"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { CallDuration, SessionDuration } from "../voip/CallDuration"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import { ContinueKind } from "../auth/InteractiveAuthEntryComponents"; const MAX_FACES = 8; @@ -43,7 +44,7 @@ interface ActiveCallEventProps { call: ElementCall | null; participatingMembers: RoomMember[]; buttonText: string; - buttonKind: string; + buttonKind: AccessibleButtonKind; buttonDisabledTooltip?: string; onButtonClick: ((ev: ButtonEvent) => void) | null; } @@ -125,7 +126,9 @@ const ActiveLoadedCallEvent = forwardRef(({ mxE [call], ); - const [buttonText, buttonKind, onButtonClick] = useMemo(() => { + const [buttonText, buttonKind, onButtonClick] = useMemo< + [string, ContinueKind, null | ((ev: ButtonEvent) => void)] + >(() => { switch (connectionState) { case ConnectionState.Disconnected: return [_t("action|join"), "primary", connect]; diff --git a/src/components/views/rooms/ReadReceiptGroup.tsx b/src/components/views/rooms/ReadReceiptGroup.tsx index 7a8190b3ad..0cb43d25a5 100644 --- a/src/components/views/rooms/ReadReceiptGroup.tsx +++ b/src/components/views/rooms/ReadReceiptGroup.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { PropsWithChildren, useRef } from "react"; +import React, { PropsWithChildren } from "react"; import { User } from "matrix-js-sdk/src/matrix"; import ReadReceiptMarker, { IReadReceiptInfo } from "./ReadReceiptMarker"; @@ -284,8 +284,7 @@ interface ISectionHeaderProps { } function SectionHeader({ className, children }: PropsWithChildren): JSX.Element { - const ref = useRef(null); - const [onFocus] = useRovingTabIndex(ref); + const [onFocus, , ref] = useRovingTabIndex(); return (

diff --git a/src/components/views/rooms/ReadReceiptMarker.tsx b/src/components/views/rooms/ReadReceiptMarker.tsx index 5c7f920c35..06fe783ce6 100644 --- a/src/components/views/rooms/ReadReceiptMarker.tsx +++ b/src/components/views/rooms/ReadReceiptMarker.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef, RefObject } from "react"; +import React, { createRef } from "react"; import { RoomMember } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; @@ -73,7 +73,7 @@ interface IReadReceiptMarkerStyle { } export default class ReadReceiptMarker extends React.PureComponent { - private avatar: React.RefObject = createRef(); + private avatar = createRef(); public constructor(props: IProps) { super(props); @@ -199,7 +199,7 @@ export default class ReadReceiptMarker extends React.PureComponent} />; + return
; } const style = { diff --git a/src/components/views/settings/ChangePassword.tsx b/src/components/views/settings/ChangePassword.tsx index f7f17d0de5..35a8f68aec 100644 --- a/src/components/views/settings/ChangePassword.tsx +++ b/src/components/views/settings/ChangePassword.tsx @@ -20,7 +20,7 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix"; import Field from "../elements/Field"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import AccessibleButton from "../elements/AccessibleButton"; +import AccessibleButton, { AccessibleButtonKind } from "../elements/AccessibleButton"; import Spinner from "../elements/Spinner"; import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; import { UserFriendlyError, _t, _td } from "../../../languageHandler"; @@ -45,7 +45,7 @@ interface IProps { onError: (error: Error) => void; rowClassName?: string; buttonClassName?: string; - buttonKind?: string; + buttonKind?: AccessibleButtonKind; buttonLabel?: string; confirm?: boolean; // Whether to autoFocus the new password input