diff --git a/packages/odyssey-react-mui/src/@types/react-augment.d.ts b/packages/odyssey-react-mui/src/@types/react-augment.d.ts index 1dc10d7a59..f1501f50df 100644 --- a/packages/odyssey-react-mui/src/@types/react-augment.d.ts +++ b/packages/odyssey-react-mui/src/@types/react-augment.d.ts @@ -11,13 +11,17 @@ */ import { FC } from "react"; - export interface ForwardRefWithType extends FC> { (props: WithForwardRefProps): ReturnType< FC> >; } -export type FocusHandle = { - focus: () => void; -}; +declare module "react" { + type DataAttributeKey = `data-${string}`; + interface InputHTMLAttributes extends HTMLAttributes { + // Allows data-* props to be passed to inputProps in nested MUI components + // see: https://github.com/mui/material-ui/issues/20160 + [dataAttribute: DataAttributeKey]: string | undefined; + } +} diff --git a/packages/odyssey-react-mui/src/Autocomplete.tsx b/packages/odyssey-react-mui/src/Autocomplete.tsx index 5a936a699a..0c2d1b5a23 100644 --- a/packages/odyssey-react-mui/src/Autocomplete.tsx +++ b/packages/odyssey-react-mui/src/Autocomplete.tsx @@ -267,6 +267,7 @@ const Autocomplete = < ...params.inputProps, "aria-errormessage": errorMessageElementId, "aria-labelledby": labelElementId, + "data-se": testId, }} aria-describedby={ariaDescribedBy} id={id} @@ -284,6 +285,7 @@ const Autocomplete = < isOptional, label, nameOverride, + testId, ] ); const onChange = useCallback< @@ -324,7 +326,6 @@ const Autocomplete = < {...inputValueProp} // AutoComplete is wrapped in a div within MUI which does not get the disabled attr. So this aria-disabled gets set in the div aria-disabled={isDisabled} - data-se={testId} disableCloseOnSelect={hasMultipleChoices} disabled={isDisabled} freeSolo={isCustomValueAllowed} diff --git a/packages/odyssey-react-mui/src/Button.tsx b/packages/odyssey-react-mui/src/Button.tsx index fa2978037a..0baa0e9dc1 100644 --- a/packages/odyssey-react-mui/src/Button.tsx +++ b/packages/odyssey-react-mui/src/Button.tsx @@ -23,7 +23,7 @@ import { import { MuiPropsContext, useMuiProps } from "./MuiPropsContext"; import { Tooltip } from "./Tooltip"; import type { AllowedProps } from "./AllowedProps"; -import { FocusHandle } from "./@types/react-augment"; +import { FocusHandle } from "./inputUtils"; export const buttonSizeValues = ["small", "medium", "large"] as const; export const buttonTypeValues = ["button", "submit", "reset"] as const; @@ -49,9 +49,9 @@ export type ButtonProps = { */ ariaDescribedBy?: string; /** - * The ref forwarded to the Button to expose focus() + * The ref forwarded to the Button */ - buttonFocusRef?: React.RefObject; + buttonRef?: React.RefObject; /** * The icon element to display at the end of the Button */ @@ -119,7 +119,7 @@ const Button = ({ ariaDescribedBy, ariaLabel, ariaLabelledBy, - buttonFocusRef, + buttonRef, endIcon, id, isDisabled, @@ -136,14 +136,13 @@ const Button = ({ }: ButtonProps) => { const muiProps = useMuiProps(); - const ref = useRef(null); + const localButtonRef = useRef(null); useImperativeHandle( - buttonFocusRef, + buttonRef, () => { - const element = ref.current; return { focus: () => { - element && element.focus(); + localButtonRef.current?.focus(); }, }; }, @@ -163,7 +162,7 @@ const Button = ({ fullWidth={isFullWidth} id={id} onClick={onClick} - ref={ref} + ref={localButtonRef} size={size} startIcon={startIcon} translate={translate} diff --git a/packages/odyssey-react-mui/src/Checkbox.tsx b/packages/odyssey-react-mui/src/Checkbox.tsx index e76a342a78..2d99fc9a5e 100644 --- a/packages/odyssey-react-mui/src/Checkbox.tsx +++ b/packages/odyssey-react-mui/src/Checkbox.tsx @@ -23,9 +23,12 @@ import { import { FieldComponentProps } from "./FieldComponentProps"; import { Typography } from "./Typography"; import type { AllowedProps } from "./AllowedProps"; -import { ComponentControlledState, getControlState } from "./inputUtils"; +import { + ComponentControlledState, + FocusHandle, + getControlState, +} from "./inputUtils"; import { CheckedFieldProps } from "./FormCheckedProps"; -import { FocusHandle } from "./@types/react-augment"; export const checkboxValidityValues = ["valid", "invalid", "inherit"] as const; @@ -43,9 +46,9 @@ export type CheckboxProps = { */ id?: string; /** - * The ref forwarded to the Checkbox to expose focus() + * The ref forwarded to the Checkbox */ - inputFocusRef?: React.RefObject; + inputRef?: React.RefObject; /** * Determines whether the Checkbox is disabled */ @@ -86,7 +89,7 @@ const Checkbox = ({ ariaLabel, ariaLabelledBy, id: idOverride, - inputFocusRef, + inputRef, isChecked, isDefaultChecked, isDisabled, @@ -116,14 +119,13 @@ const Checkbox = ({ return { defaultChecked: isDefaultChecked }; }, [isDefaultChecked, isChecked]); - const inputRef = useRef(null); + const localInputRef = useRef(null); useImperativeHandle( - inputFocusRef, + inputRef, () => { - const element = inputRef.current; return { focus: () => { - element && element.focus(); + localInputRef.current?.focus(); }, }; }, @@ -179,13 +181,15 @@ const Checkbox = ({ indeterminate={isIndeterminate} onChange={onChange} required={isRequired} - inputRef={inputRef} + inputProps={{ + "data-se": testId, + }} + inputRef={localInputRef} sx={() => ({ marginBlockStart: "2px", })} /> } - data-se={testId} disabled={isDisabled} id={idOverride} label={label} diff --git a/packages/odyssey-react-mui/src/Link.tsx b/packages/odyssey-react-mui/src/Link.tsx index 5f8b22403a..28a9cdea76 100644 --- a/packages/odyssey-react-mui/src/Link.tsx +++ b/packages/odyssey-react-mui/src/Link.tsx @@ -13,9 +13,9 @@ import { memo, ReactElement, useImperativeHandle, useRef } from "react"; import { ExternalLinkIcon } from "./icons.generated"; import type { AllowedProps } from "./AllowedProps"; +import { FocusHandle } from "./inputUtils"; import { Link as MuiLink, LinkProps as MuiLinkProps } from "@mui/material"; -import { FocusHandle } from "./@types/react-augment"; export const linkVariantValues = ["default", "monochrome"] as const; @@ -33,9 +33,9 @@ export type LinkProps = { */ icon?: ReactElement; /** - * The ref forwarded to the TextField to expose focus() + * The ref forwarded to the TextField */ - linkFocusRef?: React.RefObject; + linkRef?: React.RefObject; /** * The click event handler for the Link */ @@ -63,7 +63,7 @@ const Link = ({ children, href, icon, - linkFocusRef, + linkRef, rel, target, testId, @@ -71,14 +71,13 @@ const Link = ({ variant, onClick, }: LinkProps) => { - const ref = useRef(null); + const localLinkRef = useRef(null); useImperativeHandle( - linkFocusRef, + linkRef, () => { - const element = ref.current; return { focus: () => { - element && element.focus(); + localLinkRef.current?.focus(); }, }; }, @@ -89,7 +88,7 @@ const Link = ({ , HasMultipleChoices extends boolean > = { + /** + * This prop helps users to fill forms faster, especially on mobile devices. + * The name can be confusing, as it's more like an autofill. + * @see https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill + */ + autoCompleteType?: InputHTMLAttributes["autoComplete"]; /** * The options or optgroup elements within the NativeSelect */ @@ -53,6 +61,10 @@ export type NativeSelectProps< * If `true`, the Select allows multiple selections */ hasMultipleChoices?: HasMultipleChoices; + /** + * The ref forwarded to the NativeSelect + */ + inputRef?: React.RefObject; /** * @deprecated Use `hasMultipleChoices` instead */ @@ -98,6 +110,7 @@ const NativeSelect: ForwardRefWithType = forwardRef( HasMultipleChoices extends boolean >( { + autoCompleteType, defaultValue, errorMessage, errorMessageList, @@ -105,6 +118,7 @@ const NativeSelect: ForwardRefWithType = forwardRef( hint, HintLinkComponent, id: idOverride, + inputRef, isDisabled = false, isFullWidth = false, isMultiSelect, @@ -126,6 +140,20 @@ const NativeSelect: ForwardRefWithType = forwardRef( uncontrolledValue: defaultValue, }) ); + const localInputRef = useRef(null); + + useImperativeHandle( + inputRef, + () => { + return { + focus: () => { + localInputRef.current?.focus(); + }, + }; + }, + [] + ); + const inputValues = useInputValues({ defaultValue, value, @@ -153,13 +181,15 @@ const NativeSelect: ForwardRefWithType = forwardRef( ), [ + autoCompleteType, children, idOverride, inputValues, diff --git a/packages/odyssey-react-mui/src/PasswordField.tsx b/packages/odyssey-react-mui/src/PasswordField.tsx index b459a838ca..15b0c5c208 100644 --- a/packages/odyssey-react-mui/src/PasswordField.tsx +++ b/packages/odyssey-react-mui/src/PasswordField.tsx @@ -27,8 +27,7 @@ import { Field } from "./Field"; import { FieldComponentProps } from "./FieldComponentProps"; import type { AllowedProps } from "./AllowedProps"; import { useTranslation } from "react-i18next"; -import { getControlState, useInputValues } from "./inputUtils"; -import { FocusHandle } from "./@types/react-augment"; +import { FocusHandle, getControlState, useInputValues } from "./inputUtils"; export type PasswordFieldProps = { /** @@ -50,9 +49,9 @@ export type PasswordFieldProps = { */ hasShowPassword?: boolean; /** - * The ref forwarded to the TextField to expose focus() + * The ref forwarded to the TextField */ - inputFocusRef?: React.RefObject; + inputRef?: React.RefObject; /** * The label for the `input` element. */ @@ -90,7 +89,7 @@ const PasswordField = forwardRef( hasInitialFocus, hint, id: idOverride, - inputFocusRef, + inputRef, isDisabled = false, isFullWidth = false, isOptional = false, @@ -129,14 +128,13 @@ const PasswordField = forwardRef( controlState: controlledStateRef.current, }); - const inputRef = useRef(null); + const localInputRef = useRef(null); useImperativeHandle( - inputFocusRef, + inputRef, () => { - const element = inputRef.current; return { focus: () => { - element && element.focus(); + localInputRef.current?.focus(); }, }; }, @@ -160,7 +158,6 @@ const PasswordField = forwardRef( autoComplete={inputType === "password" ? autoCompleteType : "off"} /* eslint-disable-next-line jsx-a11y/no-autofocus */ autoFocus={hasInitialFocus} - data-se={testId} endAdornment={ hasShowPassword && ( @@ -181,10 +178,11 @@ const PasswordField = forwardRef( inputProps={{ "aria-errormessage": errorMessageElementId, "aria-labelledby": labelElementId, + "data-se": testId, // role: "textbox" Added because password inputs don't have an implicit role assigned. This causes problems with element selection. role: "textbox", }} - inputRef={inputRef} + inputRef={localInputRef} name={nameOverride ?? id} onChange={onChange} onFocus={onFocus} diff --git a/packages/odyssey-react-mui/src/Radio.tsx b/packages/odyssey-react-mui/src/Radio.tsx index 4dc7994821..f6c64487c6 100644 --- a/packages/odyssey-react-mui/src/Radio.tsx +++ b/packages/odyssey-react-mui/src/Radio.tsx @@ -20,13 +20,13 @@ import { memo, useCallback, useRef, useImperativeHandle } from "react"; import { FieldComponentProps } from "./FieldComponentProps"; import type { AllowedProps } from "./AllowedProps"; -import { FocusHandle } from "./@types/react-augment"; +import { FocusHandle } from "./inputUtils"; export type RadioProps = { /** - * The ref forwarded to the Radio to expose focus() + * The ref forwarded to the Radio */ - inputFocusRef?: React.RefObject; + inputRef?: React.RefObject; /** * If `true`, the Radio is selected */ @@ -55,7 +55,7 @@ export type RadioProps = { AllowedProps; const Radio = ({ - inputFocusRef, + inputRef, isChecked, isDisabled, isInvalid, @@ -67,14 +67,13 @@ const Radio = ({ onChange: onChangeProp, onBlur: onBlurProp, }: RadioProps) => { - const ref = useRef(null); + const localInputRef = useRef(null); useImperativeHandle( - inputFocusRef, + inputRef, () => { - const element = ref.current; return { focus: () => { - element && element.focus(); + localInputRef.current?.focus(); }, }; }, @@ -99,8 +98,15 @@ const Radio = ({ } - data-se={testId} + control={ + + } disabled={isDisabled} label={label} name={name} diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index 98d9ddce97..3ca7bf3100 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -36,10 +36,10 @@ import { CheckIcon } from "./icons.generated"; import type { AllowedProps } from "./AllowedProps"; import { ComponentControlledState, + FocusHandle, useInputValues, getControlState, } from "./inputUtils"; -import { FocusHandle } from "./@types/react-augment"; export type SelectOption = { text: string; @@ -63,9 +63,9 @@ export type SelectProps< */ hasMultipleChoices?: HasMultipleChoices; /** - * The ref forwarded to the Select to expose focus() + * The ref forwarded to the Select */ - inputFocusRef?: React.RefObject; + inputRef?: React.RefObject; /** * @deprecated Use `hasMultipleChoices` instead. */ @@ -136,7 +136,7 @@ const Select = < hint, HintLinkComponent, id: idOverride, - inputFocusRef, + inputRef, isDisabled = false, isFullWidth = false, isMultiSelect, @@ -164,15 +164,14 @@ const Select = < const [internalSelectedValues, setInternalSelectedValues] = useState( controlledStateRef.current === CONTROLLED ? value : defaultValue ); - const inputRef = useRef(null); + const localInputRef = useRef(null); useImperativeHandle( - inputFocusRef, + inputRef, () => { - const element = inputRef.current; return { focus: () => { - element && element.focus(); + localInputRef.current?.focus(); }, }; }, @@ -290,9 +289,9 @@ const Select = < aria-describedby={ariaDescribedBy} aria-errormessage={errorMessageElementId} children={children} - data-se={testId} id={id} - inputRef={inputRef} + inputProps={{ "data-se": testId }} + inputRef={localInputRef} labelId={labelElementId} multiple={hasMultipleChoices} name={nameOverride ?? id} diff --git a/packages/odyssey-react-mui/src/TextField.tsx b/packages/odyssey-react-mui/src/TextField.tsx index 65c0e70db2..937ede49d5 100644 --- a/packages/odyssey-react-mui/src/TextField.tsx +++ b/packages/odyssey-react-mui/src/TextField.tsx @@ -26,8 +26,7 @@ import { InputAdornment, InputBase } from "@mui/material"; import { FieldComponentProps } from "./FieldComponentProps"; import { Field } from "./Field"; import { AllowedProps } from "./AllowedProps"; -import { useInputValues, getControlState } from "./inputUtils"; -import { FocusHandle } from "./@types/react-augment"; +import { FocusHandle, useInputValues, getControlState } from "./inputUtils"; export const textFieldTypeValues = [ "email", @@ -57,9 +56,9 @@ export type TextFieldProps = { */ hasInitialFocus?: boolean; /** - * The ref forwarded to the TextField to expose focus() + * The ref forwarded to the TextField */ - inputFocusRef?: React.RefObject; + inputRef?: React.RefObject; /** * Hints at the type of data that might be entered by the user while editing the element or its contents * @see https://html.spec.whatwg.org/multipage/interaction.html#input-modalities:-the-inputmode-attribute @@ -116,7 +115,7 @@ const TextField = forwardRef( hint, HintLinkComponent, id: idOverride, - inputFocusRef, + inputRef, inputMode, isDisabled = false, isFullWidth = false, @@ -149,14 +148,13 @@ const TextField = forwardRef( controlState: controlledStateRef.current, }); - const inputRef = useRef(null); + const localInputRef = useRef(null); useImperativeHandle( - inputFocusRef, + inputRef, () => { - const element = inputRef.current; return { focus: () => { - element && element.focus(); + localInputRef.current?.focus(); }, }; }, @@ -180,7 +178,6 @@ const TextField = forwardRef( autoComplete={autoCompleteType} /* eslint-disable-next-line jsx-a11y/no-autofocus */ autoFocus={hasInitialFocus} - data-se={testId} endAdornment={ endAdornment && ( @@ -192,9 +189,10 @@ const TextField = forwardRef( inputProps={{ "aria-errormessage": errorMessageElementId, "aria-labelledby": labelElementId, + "data-se": testId, inputmode: inputMode, }} - inputRef={inputRef} + inputRef={localInputRef} multiline={isMultiline} name={nameOverride ?? id} onBlur={onBlur} diff --git a/packages/odyssey-react-mui/src/Typography.tsx b/packages/odyssey-react-mui/src/Typography.tsx index d174d39664..f991b79efb 100644 --- a/packages/odyssey-react-mui/src/Typography.tsx +++ b/packages/odyssey-react-mui/src/Typography.tsx @@ -23,7 +23,7 @@ import { useImperativeHandle, } from "react"; import { AllowedProps } from "./AllowedProps"; -import { FocusHandle } from "./@types/react-augment"; +import { FocusHandle } from "./inputUtils"; export type TypographyVariantValue = | "h1" @@ -87,9 +87,9 @@ export type TypographyProps = { */ component?: ElementType; /** - * The ref forwarded to the Typography to expose focus() + * The ref forwarded to the Typography */ - typographyFocusRef?: React.RefObject; + typographyRef?: React.RefObject; /** * The variant of Typography to render. */ @@ -105,7 +105,7 @@ const Typography = ({ component: componentProp, testId, translate, - typographyFocusRef, + typographyRef, variant = "body", }: TypographyProps) => { const component = useMemo(() => { @@ -121,14 +121,13 @@ const Typography = ({ return componentProp; }, [componentProp, variant]); - const ref = useRef(null); + const localTypographyRef = useRef(null); useImperativeHandle( - typographyFocusRef, + typographyRef, () => { - const element = ref.current; return { focus: () => { - element && element.focus(); + localTypographyRef.current?.focus(); }, }; }, @@ -144,7 +143,7 @@ const Typography = ({ color={color} component={component} data-se={testId} - ref={ref} + ref={localTypographyRef} tabIndex={-1} translate={translate} variant={typographyVariantMapping[variant]} diff --git a/packages/odyssey-react-mui/src/inputUtils.ts b/packages/odyssey-react-mui/src/inputUtils.ts index fc30b3539d..c336b78178 100644 --- a/packages/odyssey-react-mui/src/inputUtils.ts +++ b/packages/odyssey-react-mui/src/inputUtils.ts @@ -12,6 +12,10 @@ import { useMemo } from "react"; +export type FocusHandle = { + focus: () => void; +}; + type UseControlledStateProps = { controlledValue?: Value; uncontrolledValue?: Value; diff --git a/packages/odyssey-react-mui/src/labs/Switch.tsx b/packages/odyssey-react-mui/src/labs/Switch.tsx index b3aced16e1..7f4817655c 100644 --- a/packages/odyssey-react-mui/src/labs/Switch.tsx +++ b/packages/odyssey-react-mui/src/labs/Switch.tsx @@ -186,6 +186,7 @@ const Switch = ({ "aria-describedby": hintId, "aria-label": label, "aria-labelledby": labelElementId, + "data-se": testId, }} name={_name ?? id} onChange={handleOnChange} @@ -201,6 +202,7 @@ const Switch = ({ label, labelElementId, _name, + testId, ] ); @@ -213,7 +215,6 @@ const Switch = ({ ), - [errorMessage, errorMessageList, hint, isOptional, label, nameOverride] + [ + errorMessage, + errorMessageList, + hint, + isOptional, + label, + nameOverride, + testId, + ] ); const onChange = useCallback< NonNullable< @@ -313,7 +322,6 @@ const VirtualizedAutocomplete = < {...inputValueProp} // AutoComplete is wrapped in a div within MUI which does not get the disabled attr. So this aria-disabled gets set in the div aria-disabled={isDisabled} - data-se={testId} disableCloseOnSelect={hasMultipleChoices} disabled={isDisabled} freeSolo={isCustomValueAllowed}