From 1c5127787216ffa14a598a9023d527aa50f2862a Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Tue, 5 Dec 2023 13:41:25 -0500 Subject: [PATCH 01/21] feat: forward ref for Checkbox --- packages/odyssey-react-mui/src/Checkbox.tsx | 194 +++++++++--------- .../odyssey-mui/Checkbox/Checkbox.stories.tsx | 39 ++++ 2 files changed, 139 insertions(+), 94 deletions(-) diff --git a/packages/odyssey-react-mui/src/Checkbox.tsx b/packages/odyssey-react-mui/src/Checkbox.tsx index c5e184f7a2..62f8146a85 100644 --- a/packages/odyssey-react-mui/src/Checkbox.tsx +++ b/packages/odyssey-react-mui/src/Checkbox.tsx @@ -11,7 +11,7 @@ */ import { useTranslation } from "react-i18next"; -import { memo, useCallback, useMemo, useRef } from "react"; +import { memo, useCallback, useMemo, useRef, forwardRef } from "react"; import { Checkbox as MuiCheckbox, CheckboxProps as MuiCheckboxProps, @@ -77,103 +77,109 @@ export type CheckboxProps = { CheckedFieldProps & SeleniumProps; -const Checkbox = ({ - ariaLabel, - ariaLabelledBy, - id: idOverride, - isChecked, - isDefaultChecked, - isDisabled, - isIndeterminate, - isRequired, - label: labelProp, - hint, - name: nameOverride, - onChange: onChangeProp, - onBlur: onBlurProp, - testId, - validity = "inherit", - value, -}: CheckboxProps) => { - const { t } = useTranslation(); - const controlledStateRef = useRef( - getControlState({ - controlledValue: isChecked, - uncontrolledValue: isDefaultChecked, - }) - ); - const inputValues = useMemo(() => { - if (controlledStateRef.current === ComponentControlledState.CONTROLLED) { - return { checked: isChecked }; - } - return { defaultChecked: isDefaultChecked }; - }, [isDefaultChecked, isChecked]); - - const label = useMemo(() => { - return ( - <> - {labelProp} - {isRequired && ( - <> - {" "} - - ({t("fieldlabel.required.text")}) - - - )} - {hint && {hint}} - +const Checkbox = forwardRef( + ( + { + ariaLabel, + ariaLabelledBy, + id: idOverride, + isChecked, + isDefaultChecked, + isDisabled, + isIndeterminate, + isRequired, + label: labelProp, + hint, + name: nameOverride, + onChange: onChangeProp, + onBlur: onBlurProp, + testId, + validity = "inherit", + value, + }: CheckboxProps, + inputRef + ) => { + const { t } = useTranslation(); + const controlledStateRef = useRef( + getControlState({ + controlledValue: isChecked, + uncontrolledValue: isDefaultChecked, + }) ); - }, [isRequired, labelProp, hint, t]); + const inputValues = useMemo(() => { + if (controlledStateRef.current === ComponentControlledState.CONTROLLED) { + return { checked: isChecked }; + } + return { defaultChecked: isDefaultChecked }; + }, [isDefaultChecked, isChecked]); - const onChange = useCallback>( - (event, checked) => { - onChangeProp?.(event, checked); - }, - [onChangeProp] - ); + const label = useMemo(() => { + return ( + <> + {labelProp} + {isRequired && ( + <> + {" "} + + ({t("fieldlabel.required.text")}) + + + )} + {hint && {hint}} + + ); + }, [isRequired, labelProp, hint, t]); - const onBlur = useCallback>( - (event) => { - onBlurProp?.(event); - }, - [onBlurProp] - ); + const onChange = useCallback>( + (event, checked) => { + onChangeProp?.(event, checked); + }, + [onChangeProp] + ); - return ( - ({ - marginBlockStart: "2px", - })} - /> - } - data-se={testId} - disabled={isDisabled} - id={idOverride} - label={label} - name={nameOverride ?? idOverride} - value={value} - required={isRequired} - onBlur={onBlur} - /> - ); -}; + const onBlur = useCallback>( + (event) => { + onBlurProp?.(event); + }, + [onBlurProp] + ); + + return ( + ({ + marginBlockStart: "2px", + })} + /> + } + data-se={testId} + disabled={isDisabled} + id={idOverride} + label={label} + name={nameOverride ?? idOverride} + value={value} + required={isRequired} + onBlur={onBlur} + /> + ); + } +); const MemoizedCheckbox = memo(Checkbox); MemoizedCheckbox.displayName = "Checkbox"; diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx index 431e782385..20726a1c89 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and limitations under the License. */ +import { useRef } from "react"; import { Checkbox, CheckboxProps, @@ -18,6 +19,7 @@ import { import { Meta, StoryObj } from "@storybook/react"; import { userEvent, within } from "@storybook/testing-library"; import { expect } from "@storybook/jest"; +import { Box } from "@mui/material"; import { fieldComponentPropsMetaData } from "../../../fieldComponentPropsMetaData"; import { MuiThemeDecorator } from "../../../../.storybook/components"; @@ -145,6 +147,15 @@ const storybookMeta: Meta = { }, }, }, + ref: { + control: "ref", + description: "Forwarded ref from of internal input element", + table: { + type: { + summary: "HTMLInputElement", + }, + }, + }, }, decorators: [MuiThemeDecorator], tags: ["autodocs"], @@ -292,3 +303,31 @@ export const Controlled: StoryObj = { ); }, }; + +export const WithInputRef: StoryObj = { + args: { + label: "Display input html of forwarded inputRef", + }, + render: function C(args) { + const [refHtml, setRefHtml] = useState(""); + const ref = useRef(null); + + const handleGetRefInnerHtml = () => { + console.log(ref.current?.outerHTML); + setRefHtml(ref.current?.outerHTML as string); + }; + + return ( + + + + + +
Ref HTML: {refHtml}
+
+ ); + }, +}; From bf18be32f5e227d637d05eecf339a678a8946d83 Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Tue, 5 Dec 2023 13:54:21 -0500 Subject: [PATCH 02/21] feat: forward ref for Radio --- packages/odyssey-react-mui/src/Radio.tsx | 81 ++++++++++--------- .../odyssey-mui/Checkbox/Checkbox.stories.tsx | 3 +- .../odyssey-mui/Radio/Radio.stories.tsx | 42 +++++++++- 3 files changed, 84 insertions(+), 42 deletions(-) diff --git a/packages/odyssey-react-mui/src/Radio.tsx b/packages/odyssey-react-mui/src/Radio.tsx index 0551d55e99..a78a47dbe4 100644 --- a/packages/odyssey-react-mui/src/Radio.tsx +++ b/packages/odyssey-react-mui/src/Radio.tsx @@ -16,7 +16,7 @@ import { Radio as MuiRadio, RadioProps as MuiRadioProps, } from "@mui/material"; -import { memo, useCallback } from "react"; +import { memo, useCallback, forwardRef } from "react"; import { FieldComponentProps } from "./FieldComponentProps"; import type { SeleniumProps } from "./SeleniumProps"; @@ -49,45 +49,50 @@ export type RadioProps = { } & Pick & SeleniumProps; -const Radio = ({ - isChecked, - isDisabled, - isInvalid, - label, - name, - testId, - value, - onChange: onChangeProp, - onBlur: onBlurProp, -}: RadioProps) => { - const onChange = useCallback>( - (event, checked) => { - onChangeProp?.(event, checked); - }, - [onChangeProp] - ); +const Radio = forwardRef( + ( + { + isChecked, + isDisabled, + isInvalid, + label, + name, + testId, + value, + onChange: onChangeProp, + onBlur: onBlurProp, + }: RadioProps, + inputRef + ) => { + const onChange = useCallback>( + (event, checked) => { + onChangeProp?.(event, checked); + }, + [onChangeProp] + ); - const onBlur = useCallback>( - (event) => { - onBlurProp?.(event); - }, - [onBlurProp] - ); + const onBlur = useCallback>( + (event) => { + onBlurProp?.(event); + }, + [onBlurProp] + ); - return ( - } - data-se={testId} - disabled={isDisabled} - label={label} - name={name} - value={value} - onBlur={onBlur} - /> - ); -}; + return ( + } + data-se={testId} + disabled={isDisabled} + label={label} + name={name} + value={value} + onBlur={onBlur} + /> + ); + } +); const MemoizedRadio = memo(Radio); MemoizedRadio.displayName = "Radio"; diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx index 20726a1c89..3b6b9c1e75 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx @@ -148,7 +148,7 @@ const storybookMeta: Meta = { }, }, ref: { - control: "ref", + control: null, description: "Forwarded ref from of internal input element", table: { type: { @@ -313,7 +313,6 @@ export const WithInputRef: StoryObj = { const ref = useRef(null); const handleGetRefInnerHtml = () => { - console.log(ref.current?.outerHTML); setRefHtml(ref.current?.outerHTML as string); }; diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Radio/Radio.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Radio/Radio.stories.tsx index f8b5bb0e49..b6a068bc69 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Radio/Radio.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Radio/Radio.stories.tsx @@ -10,8 +10,10 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { Radio, RadioProps } from "@okta/odyssey-react-mui"; +import { useState, useRef } from "react"; +import { Radio } from "@okta/odyssey-react-mui"; import { Meta, StoryObj } from "@storybook/react"; +import { Box } from "@mui/material"; import { MuiThemeDecorator } from "../../../../.storybook/components"; import { userEvent, within } from "@storybook/testing-library"; import { expect } from "@storybook/jest"; @@ -19,7 +21,7 @@ import { expect } from "@storybook/jest"; import { fieldComponentPropsMetaData } from "../../../fieldComponentPropsMetaData"; import { axeRun } from "../../../axe-util"; -const storybookMeta: Meta = { +const storybookMeta: Meta = { title: "MUI Components/Forms/Radio", component: Radio, argTypes: { @@ -87,6 +89,15 @@ const storybookMeta: Meta = { name: "string", }, }, + ref: { + control: null, + description: "Forwarded ref from of internal input element", + table: { + type: { + summary: "HTMLInputElement", + }, + }, + }, }, args: { label: "Label", @@ -113,3 +124,30 @@ export const Default: StoryObj = { }); }, }; + +export const WithInputRef: StoryObj = { + args: { + label: "Display input html of forwarded inputRef", + }, + render: function C(args) { + const [refHtml, setRefHtml] = useState(""); + const ref = useRef(null); + + const handleGetRefInnerHtml = () => { + setRefHtml(ref.current?.outerHTML as string); + }; + + return ( + + + + + +
Ref HTML: {refHtml}
+
+ ); + }, +}; From 408a12f7a4f70f41f93914845d4a5b7d12468a25 Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Tue, 5 Dec 2023 15:38:02 -0500 Subject: [PATCH 03/21] feat: forward ref for Button --- packages/odyssey-react-mui/src/Button.tsx | 123 +++++++++--------- .../odyssey-mui/Button/Button.stories.tsx | 41 +++++- 2 files changed, 105 insertions(+), 59 deletions(-) diff --git a/packages/odyssey-react-mui/src/Button.tsx b/packages/odyssey-react-mui/src/Button.tsx index 776172e56e..1c987d5ef2 100644 --- a/packages/odyssey-react-mui/src/Button.tsx +++ b/packages/odyssey-react-mui/src/Button.tsx @@ -12,7 +12,7 @@ import { Button as MuiButton } from "@mui/material"; import type { ButtonProps as MuiButtonProps } from "@mui/material"; -import { memo, ReactElement, useCallback } from "react"; +import { forwardRef, memo, ReactElement, useCallback } from "react"; import { MuiPropsContext, useMuiProps } from "./MuiPropsContext"; import { Tooltip } from "./Tooltip"; @@ -104,47 +104,9 @@ export type ButtonProps = { ) & SeleniumProps; -const Button = ({ - ariaDescribedBy, - ariaLabel, - ariaLabelledBy, - endIcon, - id, - isDisabled, - isFullWidth, - label = "", - onClick, - size = "medium", - startIcon, - testId, - tooltipText, - type = "button", - variant, -}: ButtonProps) => { - const muiProps = useMuiProps(); - - const renderButton = useCallback( - (muiProps) => ( - - {label} - - ), - [ +const Button = forwardRef( + ( + { ariaDescribedBy, ariaLabel, ariaLabelledBy, @@ -152,28 +114,73 @@ const Button = ({ id, isDisabled, isFullWidth, - label, + label = "", onClick, - size, + size = "medium", startIcon, testId, - type, + tooltipText, + type = "button", variant, - ] - ); + }: ButtonProps, + ref + ) => { + const muiProps = useMuiProps(); + + const renderButton = useCallback( + (muiProps) => ( + + {label} + + ), + [ + ariaDescribedBy, + ariaLabel, + ariaLabelledBy, + endIcon, + id, + isDisabled, + isFullWidth, + label, + onClick, + size, + startIcon, + testId, + type, + variant, + ref, + ] + ); - return ( - <> - {tooltipText && !isDisabled && ( - - {renderButton} - - )} + return ( + <> + {tooltipText && !isDisabled && ( + + {renderButton} + + )} - {(isDisabled || !tooltipText) && renderButton(muiProps)} - - ); -}; + {(isDisabled || !tooltipText) && renderButton(muiProps)} + + ); + } +); const MemoizedButton = memo(Button); MemoizedButton.displayName = "Button"; diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx index 362b40e07b..9864770669 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and limitations under the License. */ +import { useState, useRef } from "react"; import { Box } from "@mui/material"; import { Button, @@ -34,7 +35,7 @@ type playType = { step: PlaywrightProps["step"]; }; -const storybookMeta: Meta = { +const storybookMeta: Meta = { title: "MUI Components/Button", component: Button, argTypes: { @@ -165,6 +166,15 @@ const storybookMeta: Meta = { value: "radio", }, }, + ref: { + control: null, + description: "The ref is forwarded to the root element.", + table: { + type: { + summary: "HTMLElement", + }, + }, + }, }, args: { label: "Add crew", @@ -434,3 +444,32 @@ export const KitchenSink: StoryObj = { ), }; + +export const WithInputRef: StoryObj = { + args: { + label: "Button with Ref", + }, + render: function C(args) { + const [refHtml, setRefHtml] = useState(""); + const ref = useRef(null); + + const handleGetRefInnerHtml = () => { + setRefHtml(ref.current?.outerHTML as string); + }; + + return ( + + + + +
Ref HTML: {refHtml}
+
+ ); + }, +}; From 8da0a386aa9d6589d3aae6a06a978c0c01de7f74 Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Tue, 5 Dec 2023 16:27:20 -0500 Subject: [PATCH 04/21] feat: forward ref for Typography --- packages/odyssey-react-mui/src/Typography.tsx | 76 ++++++++++--------- .../Typography/Typography.stories.tsx | 48 +++++++++++- 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/packages/odyssey-react-mui/src/Typography.tsx b/packages/odyssey-react-mui/src/Typography.tsx index eb70e636cd..f453f3ceba 100644 --- a/packages/odyssey-react-mui/src/Typography.tsx +++ b/packages/odyssey-react-mui/src/Typography.tsx @@ -14,7 +14,7 @@ import { Typography as MuiTypography, TypographyProps as MuiTypographyProps, } from "@mui/material"; -import { ElementType, ReactNode, memo, useMemo } from "react"; +import { ElementType, ReactNode, forwardRef, memo, useMemo } from "react"; import { SeleniumProps } from "./SeleniumProps"; export type TypographyVariantValue = @@ -84,42 +84,48 @@ export type TypographyProps = { variant?: keyof typeof typographyVariantMapping; } & SeleniumProps; -const Typography = ({ - ariaDescribedBy, - ariaLabel, - ariaLabelledBy, - children, - color, - component: componentProp, - testId, - variant = "body", -}: TypographyProps) => { - const component = useMemo(() => { - if (!componentProp) { - if (variant === "body") { - return "p"; - } else if (variant === "subordinate" || variant === "support") { - return "p"; - } else { - return variant; +const Typography = forwardRef( + ( + { + ariaDescribedBy, + ariaLabel, + ariaLabelledBy, + children, + color, + component: componentProp, + testId, + variant = "body", + }: TypographyProps, + ref + ) => { + const component = useMemo(() => { + if (!componentProp) { + if (variant === "body") { + return "p"; + } else if (variant === "subordinate" || variant === "support") { + return "p"; + } else { + return variant; + } } - } - return componentProp; - }, [componentProp, variant]); + return componentProp; + }, [componentProp, variant]); - return ( - - ); -}; + return ( + + ); + } +); const MemoizedTypography = memo(Typography); MemoizedTypography.displayName = "Typography"; diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Typography/Typography.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Typography/Typography.stories.tsx index e2dc0c05ee..4424dd7ef5 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Typography/Typography.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Typography/Typography.stories.tsx @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and limitations under the License. */ +import { useState, useRef } from "react"; import { Meta, StoryObj } from "@storybook/react"; import { Typography, @@ -28,6 +29,7 @@ import { typographyVariantMapping, TypographyVariantValue, } from "@okta/odyssey-react-mui"; +import { Box } from "@mui/material"; import { MuiThemeDecorator } from "../../../../.storybook/components"; import { axeRun } from "../../../axe-util"; import { createElement } from "react"; @@ -45,7 +47,7 @@ const variantMapping = { legend: Legend, }; -const storybookMeta: Meta = { +const storybookMeta: Meta = { title: "MUI Components/Typography", component: Typography, parameters: { @@ -124,6 +126,15 @@ const storybookMeta: Meta = { }, }, }, + ref: { + control: null, + description: "The ref is forwarded to the root element.", + table: { + type: { + summary: "HTMLElement", + }, + }, + }, }, args: { children: "Spice is vital for space travel.", @@ -133,7 +144,7 @@ const storybookMeta: Meta = { export default storybookMeta; -export const TypographyStory: StoryObj = { +export const TypographyWithRefStory: StoryObj = { name: "Generic Typography", args: { children: "This is standard text.", @@ -148,6 +159,39 @@ export const TypographyStory: StoryObj = { }, }; +export const TypographyStory: StoryObj = { + name: "Generic Typography", + args: { + children: "This is standard text.", + variant: "body", + }, + render: function C(args) { + const [refHtml, setRefHtml] = useState(""); + const ref = useRef(null); + + const handleGetRefInnerHtml = () => { + setRefHtml(ref.current?.outerHTML as string); + }; + + return ( + + + + {args.children} + + + + + +
Ref HTML: {refHtml}
+
+ ); + }, +}; + export const Heading1Story: StoryObj = { name: "Heading 1", args: { From 05224dba6eb342b213b5cbb321380f2c6fbb0d1a Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Tue, 5 Dec 2023 16:44:52 -0500 Subject: [PATCH 05/21] feat: forward ref to Link --- packages/odyssey-react-mui/src/Link.tsx | 51 +++++++++---------- .../odyssey-mui/Link/Link.stories.tsx | 40 +++++++++++++++ 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/packages/odyssey-react-mui/src/Link.tsx b/packages/odyssey-react-mui/src/Link.tsx index 458264f809..65d93fb350 100644 --- a/packages/odyssey-react-mui/src/Link.tsx +++ b/packages/odyssey-react-mui/src/Link.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { memo, ReactElement } from "react"; +import { forwardRef, memo, ReactElement } from "react"; import { ExternalLinkIcon } from "./icons.generated"; import type { SeleniumProps } from "./SeleniumProps"; @@ -54,34 +54,31 @@ export type LinkProps = { variant?: (typeof linkVariantValues)[number]; } & SeleniumProps; -const Link = ({ - children, - href, - icon, - rel, - target, - testId, - variant, - onClick, -}: LinkProps) => ( - - {icon && {icon}} +const Link = forwardRef( + ( + { children, href, icon, rel, target, testId, variant, onClick }: LinkProps, + ref + ) => ( + + {icon && {icon}} - {children} + {children} - {target === "_blank" && ( - - - - )} - + {target === "_blank" && ( + + + + )} + + ) ); const MemoizedLink = memo(Link); diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Link/Link.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Link/Link.stories.tsx index 18b34a59b9..108476dea3 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Link/Link.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Link/Link.stories.tsx @@ -10,7 +10,9 @@ * See the License for the specific language governing permissions and limitations under the License. */ +import { useState, useRef } from "react"; import type { StoryObj } from "@storybook/react"; +import { Box } from "@mui/material"; import { MuiThemeDecorator } from "../../../../.storybook/components"; import { Link, LinkProps, linkVariantValues } from "@okta/odyssey-react-mui"; @@ -101,6 +103,15 @@ export default { defaultValue: "", }, }, + ref: { + control: null, + description: "The ref is forwarded to the root element.", + table: { + type: { + summary: "HTMLElement", + }, + }, + }, }, decorators: [MuiThemeDecorator], }; @@ -137,3 +148,32 @@ export const External: StoryObj = { target: "_blank", }, }; + +export const WithRef: StoryObj = { + args: { + href: "#anchor", + variant: "default", + children: "Anchor link", + }, + render: function C(args) { + const [refHtml, setRefHtml] = useState(""); + const ref = useRef(null); + + const handleGetRefInnerHtml = () => { + setRefHtml(ref.current?.outerHTML as string); + }; + + return ( + + + + + +
Ref HTML: {refHtml}
+
+ ); + }, +}; From f0e812ef3259e0999545cc065db5f38867a5681c Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Wed, 6 Dec 2023 11:04:02 -0500 Subject: [PATCH 06/21] feat: forward inputRef for Select --- packages/odyssey-react-mui/src/Select.tsx | 28 ++++++++++++- .../odyssey-mui/Select/Select.stories.tsx | 39 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index a79fb1b32d..0a9b4a9841 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -10,7 +10,15 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useImperativeHandle, +} from "react"; import { Box, Checkbox as MuiCheckbox, @@ -82,6 +90,10 @@ export type SelectProps< * The value or values selected in the Select */ value?: Value; + /** + * The ref is forwarded to input element in the Select + */ + inputRef: React.RefObject; } & Pick< FieldComponentProps, | "errorMessage" @@ -133,6 +145,7 @@ const Select = < options, testId, value, + inputRef, }: SelectProps) => { const hasMultipleChoices = useMemo( () => @@ -147,6 +160,7 @@ const Select = < const [internalSelectedValues, setInternalSelectedValues] = useState( controlledStateRef.current === CONTROLLED ? value : defaultValue ); + const ref = useRef(null); useEffect(() => { if (controlledStateRef.current === CONTROLLED) { @@ -160,6 +174,17 @@ const Select = < controlState: controlledStateRef.current, }); + useImperativeHandle( + inputRef, + () => { + const inputElement = ( + ref.current as unknown as HTMLElement + ).querySelector("input"); + return inputElement as HTMLInputElement; + }, + [] + ); + const onChange = useCallback["onChange"]>>( (event, child) => { const { @@ -268,6 +293,7 @@ const Select = < onChange={onChange} onFocus={onFocus} renderValue={hasMultipleChoices ? renderValue : undefined} + ref={ref} /> ), [ diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.stories.tsx index 74a22af03d..b51a107786 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.stories.tsx @@ -10,6 +10,8 @@ * See the License for the specific language governing permissions and limitations under the License. */ +import { useRef } from "react"; +import { Box } from "@mui/material"; import { Meta, StoryObj } from "@storybook/react"; import { Select, SelectProps } from "@okta/odyssey-react-mui"; import { userEvent, waitFor, screen } from "@storybook/testing-library"; @@ -207,6 +209,15 @@ const storybookMeta: Meta> = { }, }, }, + inputRef: { + control: null, + description: "The ref is forwarded to the root element.", + table: { + type: { + summary: "HTMLElement", + }, + }, + }, }, args: { hint: "Select your destination in the Sol system.", @@ -386,3 +397,31 @@ export const ControlledPreselectedMultipleSelect: StoryObj = { return + + + +
Ref HTML: {refHtml}
+ + ); + }, +}; From 06b13fc9101c63e5b8b3ca65647af35cb3f1c953 Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Wed, 6 Dec 2023 11:42:15 -0500 Subject: [PATCH 07/21] feat: update checkbox to use inputRef as prop --- packages/odyssey-react-mui/src/Checkbox.tsx | 212 ++++++++++-------- .../odyssey-mui/Checkbox/Checkbox.stories.tsx | 6 +- 2 files changed, 116 insertions(+), 102 deletions(-) diff --git a/packages/odyssey-react-mui/src/Checkbox.tsx b/packages/odyssey-react-mui/src/Checkbox.tsx index 62f8146a85..6d8bec89ef 100644 --- a/packages/odyssey-react-mui/src/Checkbox.tsx +++ b/packages/odyssey-react-mui/src/Checkbox.tsx @@ -11,7 +11,7 @@ */ import { useTranslation } from "react-i18next"; -import { memo, useCallback, useMemo, useRef, forwardRef } from "react"; +import { memo, useCallback, useMemo, useRef, useImperativeHandle } from "react"; import { Checkbox as MuiCheckbox, CheckboxProps as MuiCheckboxProps, @@ -73,113 +73,127 @@ export type CheckboxProps = { * Callback fired when the blur event happens. Provides event value. */ onBlur?: MuiFormControlLabelProps["onBlur"]; + /** + * The ref is forwarded to input element in the Checkbox + */ + inputRef?: React.RefObject; } & Pick & CheckedFieldProps & SeleniumProps; -const Checkbox = forwardRef( - ( - { - ariaLabel, - ariaLabelledBy, - id: idOverride, - isChecked, - isDefaultChecked, - isDisabled, - isIndeterminate, - isRequired, - label: labelProp, - hint, - name: nameOverride, - onChange: onChangeProp, - onBlur: onBlurProp, - testId, - validity = "inherit", - value, - }: CheckboxProps, - inputRef - ) => { - const { t } = useTranslation(); - const controlledStateRef = useRef( - getControlState({ - controlledValue: isChecked, - uncontrolledValue: isDefaultChecked, - }) - ); - const inputValues = useMemo(() => { - if (controlledStateRef.current === ComponentControlledState.CONTROLLED) { - return { checked: isChecked }; - } - return { defaultChecked: isDefaultChecked }; - }, [isDefaultChecked, isChecked]); +const Checkbox = ({ + ariaLabel, + ariaLabelledBy, + id: idOverride, + isChecked, + isDefaultChecked, + isDisabled, + isIndeterminate, + isRequired, + label: labelProp, + hint, + name: nameOverride, + onChange: onChangeProp, + onBlur: onBlurProp, + testId, + validity = "inherit", + value, + inputRef, +}: CheckboxProps) => { + const { t } = useTranslation(); + const controlledStateRef = useRef( + getControlState({ + controlledValue: isChecked, + uncontrolledValue: isDefaultChecked, + }) + ); + const inputValues = useMemo(() => { + if (controlledStateRef.current === ComponentControlledState.CONTROLLED) { + return { checked: isChecked }; + } + return { defaultChecked: isDefaultChecked }; + }, [isDefaultChecked, isChecked]); + const ref = useRef(null); - const label = useMemo(() => { - return ( - <> - {labelProp} - {isRequired && ( - <> - {" "} - - ({t("fieldlabel.required.text")}) - - - )} - {hint && {hint}} - - ); - }, [isRequired, labelProp, hint, t]); + useImperativeHandle( + inputRef, + () => { + const element = ref.current as unknown as HTMLElement; + if (element.tagName === "INPUT") { + return element as HTMLInputElement; + } + const inputElement = element.querySelector("input"); + return inputElement as HTMLInputElement; + }, + [] + ); - const onChange = useCallback>( - (event, checked) => { - onChangeProp?.(event, checked); - }, - [onChangeProp] + const label = useMemo(() => { + return ( + <> + {labelProp} + {isRequired && ( + <> + {" "} + + ({t("fieldlabel.required.text")}) + + + )} + {hint && {hint}} + ); + }, [isRequired, labelProp, hint, t]); - const onBlur = useCallback>( - (event) => { - onBlurProp?.(event); - }, - [onBlurProp] - ); + const onChange = useCallback>( + (event, checked) => { + onChangeProp?.(event, checked); + }, + [onChangeProp] + ); - return ( - ({ - marginBlockStart: "2px", - })} - /> - } - data-se={testId} - disabled={isDisabled} - id={idOverride} - label={label} - name={nameOverride ?? idOverride} - value={value} - required={isRequired} - onBlur={onBlur} - /> - ); - } -); + const onBlur = useCallback>( + (event) => { + onBlurProp?.(event); + }, + [onBlurProp] + ); + + return ( + ({ + marginBlockStart: "2px", + })} + /> + } + data-se={testId} + disabled={isDisabled} + id={idOverride} + label={label} + name={nameOverride ?? idOverride} + value={value} + required={isRequired} + onBlur={onBlur} + /> + ); +}; const MemoizedCheckbox = memo(Checkbox); MemoizedCheckbox.displayName = "Checkbox"; diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx index 3b6b9c1e75..03bc180f6c 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx @@ -147,9 +147,9 @@ const storybookMeta: Meta = { }, }, }, - ref: { + inputRef: { control: null, - description: "Forwarded ref from of internal input element", + description: "The ref is forwarded to input element in the Checkbox", table: { type: { summary: "HTMLInputElement", @@ -321,7 +321,7 @@ export const WithInputRef: StoryObj = { component="div" sx={{ display: "flex", flexFlow: "column", gap: "1rem" }} > - + From bd1825afeb3ab6a1ca096d70409be2a1a7b7582f Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Wed, 6 Dec 2023 11:49:09 -0500 Subject: [PATCH 08/21] feat: update Radio to use inputRef as prop --- packages/odyssey-react-mui/src/Radio.tsx | 101 ++++++++++-------- .../odyssey-mui/Radio/Radio.stories.tsx | 6 +- 2 files changed, 61 insertions(+), 46 deletions(-) diff --git a/packages/odyssey-react-mui/src/Radio.tsx b/packages/odyssey-react-mui/src/Radio.tsx index a78a47dbe4..b87b8d9dde 100644 --- a/packages/odyssey-react-mui/src/Radio.tsx +++ b/packages/odyssey-react-mui/src/Radio.tsx @@ -16,7 +16,7 @@ import { Radio as MuiRadio, RadioProps as MuiRadioProps, } from "@mui/material"; -import { memo, useCallback, forwardRef } from "react"; +import { memo, useCallback, useRef, useImperativeHandle } from "react"; import { FieldComponentProps } from "./FieldComponentProps"; import type { SeleniumProps } from "./SeleniumProps"; @@ -46,53 +46,68 @@ export type RadioProps = { * Callback fired when the blur event happens. Provides event value. */ onBlur?: MuiFormControlLabelProps["onBlur"]; + /** + * The ref is forwarded to input element in the Radio + */ + inputRef?: React.RefObject; } & Pick & SeleniumProps; -const Radio = forwardRef( - ( - { - isChecked, - isDisabled, - isInvalid, - label, - name, - testId, - value, - onChange: onChangeProp, - onBlur: onBlurProp, - }: RadioProps, - inputRef - ) => { - const onChange = useCallback>( - (event, checked) => { - onChangeProp?.(event, checked); - }, - [onChangeProp] - ); +const Radio = ({ + isChecked, + isDisabled, + isInvalid, + label, + name, + testId, + value, + onChange: onChangeProp, + onBlur: onBlurProp, + inputRef, +}: RadioProps) => { + const ref = useRef(null); + + useImperativeHandle( + inputRef, + () => { + const element = ref.current as unknown as HTMLElement; + if (element.tagName === "INPUT") { + return element as HTMLInputElement; + } + const inputElement = element.querySelector("input"); + return inputElement as HTMLInputElement; + }, + [] + ); + + const onChange = useCallback>( + (event, checked) => { + onChangeProp?.(event, checked); + }, + [onChangeProp] + ); - const onBlur = useCallback>( - (event) => { - onBlurProp?.(event); - }, - [onBlurProp] - ); + const onBlur = useCallback>( + (event) => { + onBlurProp?.(event); + }, + [onBlurProp] + ); - return ( - } - data-se={testId} - disabled={isDisabled} - label={label} - name={name} - value={value} - onBlur={onBlur} - /> - ); - } -); + return ( + } + data-se={testId} + disabled={isDisabled} + label={label} + name={name} + value={value} + onBlur={onBlur} + /> + ); +}; const MemoizedRadio = memo(Radio); MemoizedRadio.displayName = "Radio"; diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Radio/Radio.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Radio/Radio.stories.tsx index b6a068bc69..cd7bee7380 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Radio/Radio.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Radio/Radio.stories.tsx @@ -89,9 +89,9 @@ const storybookMeta: Meta = { name: "string", }, }, - ref: { + inputRef: { control: null, - description: "Forwarded ref from of internal input element", + description: "The ref is forwarded to input element in the Radio", table: { type: { summary: "HTMLInputElement", @@ -142,7 +142,7 @@ export const WithInputRef: StoryObj = { component="div" sx={{ display: "flex", flexFlow: "column", gap: "1rem" }} > - + From 42e229f0ee530ec31f87782dd3ff43efe0854e7d Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Wed, 6 Dec 2023 11:56:55 -0500 Subject: [PATCH 09/21] fix: update Button story --- .../src/components/odyssey-mui/Button/Button.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx index 9864770669..2c0f06148c 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx @@ -445,7 +445,7 @@ export const KitchenSink: StoryObj = { ), }; -export const WithInputRef: StoryObj = { +export const WithRef: StoryObj = { args: { label: "Button with Ref", }, From 7575fd6883c0a6c59567e37448ca0c3830baf832 Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Fri, 8 Dec 2023 14:33:43 -0500 Subject: [PATCH 10/21] fix: remove formhelper from errosrList --- packages/odyssey-react-mui/src/Checkbox.tsx | 17 ++++++--- .../odyssey-mui/Checkbox/Checkbox.stories.tsx | 38 ------------------- 2 files changed, 12 insertions(+), 43 deletions(-) diff --git a/packages/odyssey-react-mui/src/Checkbox.tsx b/packages/odyssey-react-mui/src/Checkbox.tsx index 6d8bec89ef..142b023289 100644 --- a/packages/odyssey-react-mui/src/Checkbox.tsx +++ b/packages/odyssey-react-mui/src/Checkbox.tsx @@ -76,7 +76,9 @@ export type CheckboxProps = { /** * The ref is forwarded to input element in the Checkbox */ - inputRef?: React.RefObject; + inputRef?: React.RefObject<{ + focus: () => void; + } | null>; } & Pick & CheckedFieldProps & SeleniumProps; @@ -119,11 +121,16 @@ const Checkbox = ({ inputRef, () => { const element = ref.current as unknown as HTMLElement; - if (element.tagName === "INPUT") { - return element as HTMLInputElement; + const inputElement = + element.tagName === "INPUT" + ? element + : (element.querySelector("input") as HTMLInputElement); + if (!inputElement) { + return null; } - const inputElement = element.querySelector("input"); - return inputElement as HTMLInputElement; + return { + focus: inputElement.focus, + }; }, [] ); diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx index 03bc180f6c..431e782385 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Checkbox/Checkbox.stories.tsx @@ -10,7 +10,6 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { useRef } from "react"; import { Checkbox, CheckboxProps, @@ -19,7 +18,6 @@ import { import { Meta, StoryObj } from "@storybook/react"; import { userEvent, within } from "@storybook/testing-library"; import { expect } from "@storybook/jest"; -import { Box } from "@mui/material"; import { fieldComponentPropsMetaData } from "../../../fieldComponentPropsMetaData"; import { MuiThemeDecorator } from "../../../../.storybook/components"; @@ -147,15 +145,6 @@ const storybookMeta: Meta = { }, }, }, - inputRef: { - control: null, - description: "The ref is forwarded to input element in the Checkbox", - table: { - type: { - summary: "HTMLInputElement", - }, - }, - }, }, decorators: [MuiThemeDecorator], tags: ["autodocs"], @@ -303,30 +292,3 @@ export const Controlled: StoryObj = { ); }, }; - -export const WithInputRef: StoryObj = { - args: { - label: "Display input html of forwarded inputRef", - }, - render: function C(args) { - const [refHtml, setRefHtml] = useState(""); - const ref = useRef(null); - - const handleGetRefInnerHtml = () => { - setRefHtml(ref.current?.outerHTML as string); - }; - - return ( - - - - - -
Ref HTML: {refHtml}
-
- ); - }, -}; From 5810f13e20ed62128104c81a31f5e3294d38e50c Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Fri, 8 Dec 2023 14:36:57 -0500 Subject: [PATCH 11/21] fix: only expose focus for Radio --- packages/odyssey-react-mui/src/Radio.tsx | 17 +++++--- .../odyssey-mui/Radio/Radio.stories.tsx | 42 +------------------ 2 files changed, 14 insertions(+), 45 deletions(-) diff --git a/packages/odyssey-react-mui/src/Radio.tsx b/packages/odyssey-react-mui/src/Radio.tsx index b87b8d9dde..904408c22d 100644 --- a/packages/odyssey-react-mui/src/Radio.tsx +++ b/packages/odyssey-react-mui/src/Radio.tsx @@ -49,7 +49,9 @@ export type RadioProps = { /** * The ref is forwarded to input element in the Radio */ - inputRef?: React.RefObject; + inputRef?: React.RefObject<{ + focus: () => void; + } | null>; } & Pick & SeleniumProps; @@ -71,11 +73,16 @@ const Radio = ({ inputRef, () => { const element = ref.current as unknown as HTMLElement; - if (element.tagName === "INPUT") { - return element as HTMLInputElement; + const inputElement = + element.tagName === "INPUT" + ? element + : (element.querySelector("input") as HTMLInputElement); + if (!inputElement) { + return null; } - const inputElement = element.querySelector("input"); - return inputElement as HTMLInputElement; + return { + focus: inputElement.focus, + }; }, [] ); diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Radio/Radio.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Radio/Radio.stories.tsx index cd7bee7380..f8b5bb0e49 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Radio/Radio.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Radio/Radio.stories.tsx @@ -10,10 +10,8 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { useState, useRef } from "react"; -import { Radio } from "@okta/odyssey-react-mui"; +import { Radio, RadioProps } from "@okta/odyssey-react-mui"; import { Meta, StoryObj } from "@storybook/react"; -import { Box } from "@mui/material"; import { MuiThemeDecorator } from "../../../../.storybook/components"; import { userEvent, within } from "@storybook/testing-library"; import { expect } from "@storybook/jest"; @@ -21,7 +19,7 @@ import { expect } from "@storybook/jest"; import { fieldComponentPropsMetaData } from "../../../fieldComponentPropsMetaData"; import { axeRun } from "../../../axe-util"; -const storybookMeta: Meta = { +const storybookMeta: Meta = { title: "MUI Components/Forms/Radio", component: Radio, argTypes: { @@ -89,15 +87,6 @@ const storybookMeta: Meta = { name: "string", }, }, - inputRef: { - control: null, - description: "The ref is forwarded to input element in the Radio", - table: { - type: { - summary: "HTMLInputElement", - }, - }, - }, }, args: { label: "Label", @@ -124,30 +113,3 @@ export const Default: StoryObj = { }); }, }; - -export const WithInputRef: StoryObj = { - args: { - label: "Display input html of forwarded inputRef", - }, - render: function C(args) { - const [refHtml, setRefHtml] = useState(""); - const ref = useRef(null); - - const handleGetRefInnerHtml = () => { - setRefHtml(ref.current?.outerHTML as string); - }; - - return ( - - - - - -
Ref HTML: {refHtml}
-
- ); - }, -}; From a6a7ff86b2525f0d42d111584dd5ba037acf0128 Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Fri, 8 Dec 2023 14:43:24 -0500 Subject: [PATCH 12/21] fix: only expose focus on Select --- packages/odyssey-react-mui/src/Select.tsx | 11 +++++- .../odyssey-mui/Select/Select.stories.tsx | 39 ------------------- 2 files changed, 9 insertions(+), 41 deletions(-) diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index 0a9b4a9841..27c427e6d1 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -93,7 +93,9 @@ export type SelectProps< /** * The ref is forwarded to input element in the Select */ - inputRef: React.RefObject; + inputRef: React.RefObject<{ + focus: () => void; + } | null>; } & Pick< FieldComponentProps, | "errorMessage" @@ -180,7 +182,12 @@ const Select = < const inputElement = ( ref.current as unknown as HTMLElement ).querySelector("input"); - return inputElement as HTMLInputElement; + if (!inputElement) { + return null; + } + return { + focus: inputElement.focus, + }; }, [] ); diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.stories.tsx index b51a107786..74a22af03d 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.stories.tsx @@ -10,8 +10,6 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { useRef } from "react"; -import { Box } from "@mui/material"; import { Meta, StoryObj } from "@storybook/react"; import { Select, SelectProps } from "@okta/odyssey-react-mui"; import { userEvent, waitFor, screen } from "@storybook/testing-library"; @@ -209,15 +207,6 @@ const storybookMeta: Meta> = { }, }, }, - inputRef: { - control: null, - description: "The ref is forwarded to the root element.", - table: { - type: { - summary: "HTMLElement", - }, - }, - }, }, args: { hint: "Select your destination in the Sol system.", @@ -397,31 +386,3 @@ export const ControlledPreselectedMultipleSelect: StoryObj = { return - - - -
Ref HTML: {refHtml}
- - ); - }, -}; From 40908e5f5e3ebfb0014771f050ac07acfb0c15a8 Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Fri, 8 Dec 2023 15:21:22 -0500 Subject: [PATCH 13/21] fix: typography and button --- .../src/@types/react-augment.d.ts | 4 ++ packages/odyssey-react-mui/src/Button.tsx | 28 +++++++++-- packages/odyssey-react-mui/src/Typography.tsx | 28 +++++++++-- .../odyssey-mui/Button/Button.stories.tsx | 39 --------------- .../Typography/Typography.stories.tsx | 48 +------------------ 5 files changed, 55 insertions(+), 92 deletions(-) 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 e6d1d7dd34..1dc10d7a59 100644 --- a/packages/odyssey-react-mui/src/@types/react-augment.d.ts +++ b/packages/odyssey-react-mui/src/@types/react-augment.d.ts @@ -17,3 +17,7 @@ export interface ForwardRefWithType extends FC> { FC> >; } + +export type FocusHandle = { + focus: () => void; +}; diff --git a/packages/odyssey-react-mui/src/Button.tsx b/packages/odyssey-react-mui/src/Button.tsx index 1c987d5ef2..05a591de8b 100644 --- a/packages/odyssey-react-mui/src/Button.tsx +++ b/packages/odyssey-react-mui/src/Button.tsx @@ -12,11 +12,19 @@ import { Button as MuiButton } from "@mui/material"; import type { ButtonProps as MuiButtonProps } from "@mui/material"; -import { forwardRef, memo, ReactElement, useCallback } from "react"; +import { + forwardRef, + memo, + ReactElement, + useCallback, + useImperativeHandle, + useRef, +} from "react"; import { MuiPropsContext, useMuiProps } from "./MuiPropsContext"; import { Tooltip } from "./Tooltip"; import type { SeleniumProps } from "./SeleniumProps"; +import { FocusHandle } from "./@types/react-augment"; export const buttonSizeValues = ["small", "medium", "large"] as const; export const buttonTypeValues = ["button", "submit", "reset"] as const; @@ -104,7 +112,7 @@ export type ButtonProps = { ) & SeleniumProps; -const Button = forwardRef( +const Button = forwardRef( ( { ariaDescribedBy, @@ -126,6 +134,7 @@ const Button = forwardRef( ref ) => { const muiProps = useMuiProps(); + const buttonRef = useRef(null); const renderButton = useCallback( (muiProps) => ( @@ -144,7 +153,7 @@ const Button = forwardRef( startIcon={startIcon} type={type} variant={variant} - ref={ref} + ref={buttonRef} > {label} @@ -164,10 +173,21 @@ const Button = forwardRef( testId, type, variant, - ref, ] ); + useImperativeHandle( + ref, + () => { + return { + focus: () => { + (buttonRef.current! as HTMLButtonElement).focus(); + }, + }; + }, + [] + ); + return ( <> {tooltipText && !isDisabled && ( diff --git a/packages/odyssey-react-mui/src/Typography.tsx b/packages/odyssey-react-mui/src/Typography.tsx index f453f3ceba..8972d0f243 100644 --- a/packages/odyssey-react-mui/src/Typography.tsx +++ b/packages/odyssey-react-mui/src/Typography.tsx @@ -14,8 +14,17 @@ import { Typography as MuiTypography, TypographyProps as MuiTypographyProps, } from "@mui/material"; -import { ElementType, ReactNode, forwardRef, memo, useMemo } from "react"; +import { + ElementType, + ReactNode, + forwardRef, + memo, + useMemo, + useRef, + useImperativeHandle, +} from "react"; import { SeleniumProps } from "./SeleniumProps"; +import { FocusHandle } from "./@types/react-augment"; export type TypographyVariantValue = | "h1" @@ -84,7 +93,7 @@ export type TypographyProps = { variant?: keyof typeof typographyVariantMapping; } & SeleniumProps; -const Typography = forwardRef( +const Typography = forwardRef( ( { ariaDescribedBy, @@ -98,6 +107,7 @@ const Typography = forwardRef( }: TypographyProps, ref ) => { + const typographyRef = useRef(null); const component = useMemo(() => { if (!componentProp) { if (variant === "body") { @@ -111,6 +121,18 @@ const Typography = forwardRef( return componentProp; }, [componentProp, variant]); + useImperativeHandle( + ref, + () => { + return { + focus: () => { + (typographyRef.current! as HTMLElement).focus(); + }, + }; + }, + [] + ); + return ( ( component={component} data-se={testId} variant={typographyVariantMapping[variant]} - ref={ref} + ref={typographyRef} /> ); } diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx index 2c0f06148c..a5e154776a 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx @@ -10,7 +10,6 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { useState, useRef } from "react"; import { Box } from "@mui/material"; import { Button, @@ -166,15 +165,6 @@ const storybookMeta: Meta = { value: "radio", }, }, - ref: { - control: null, - description: "The ref is forwarded to the root element.", - table: { - type: { - summary: "HTMLElement", - }, - }, - }, }, args: { label: "Add crew", @@ -444,32 +434,3 @@ export const KitchenSink: StoryObj = { ), }; - -export const WithRef: StoryObj = { - args: { - label: "Button with Ref", - }, - render: function C(args) { - const [refHtml, setRefHtml] = useState(""); - const ref = useRef(null); - - const handleGetRefInnerHtml = () => { - setRefHtml(ref.current?.outerHTML as string); - }; - - return ( - - - - -
Ref HTML: {refHtml}
-
- ); - }, -}; diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Typography/Typography.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Typography/Typography.stories.tsx index 4424dd7ef5..e2dc0c05ee 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Typography/Typography.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Typography/Typography.stories.tsx @@ -10,7 +10,6 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { useState, useRef } from "react"; import { Meta, StoryObj } from "@storybook/react"; import { Typography, @@ -29,7 +28,6 @@ import { typographyVariantMapping, TypographyVariantValue, } from "@okta/odyssey-react-mui"; -import { Box } from "@mui/material"; import { MuiThemeDecorator } from "../../../../.storybook/components"; import { axeRun } from "../../../axe-util"; import { createElement } from "react"; @@ -47,7 +45,7 @@ const variantMapping = { legend: Legend, }; -const storybookMeta: Meta = { +const storybookMeta: Meta = { title: "MUI Components/Typography", component: Typography, parameters: { @@ -126,15 +124,6 @@ const storybookMeta: Meta = { }, }, }, - ref: { - control: null, - description: "The ref is forwarded to the root element.", - table: { - type: { - summary: "HTMLElement", - }, - }, - }, }, args: { children: "Spice is vital for space travel.", @@ -144,7 +133,7 @@ const storybookMeta: Meta = { export default storybookMeta; -export const TypographyWithRefStory: StoryObj = { +export const TypographyStory: StoryObj = { name: "Generic Typography", args: { children: "This is standard text.", @@ -159,39 +148,6 @@ export const TypographyWithRefStory: StoryObj = { }, }; -export const TypographyStory: StoryObj = { - name: "Generic Typography", - args: { - children: "This is standard text.", - variant: "body", - }, - render: function C(args) { - const [refHtml, setRefHtml] = useState(""); - const ref = useRef(null); - - const handleGetRefInnerHtml = () => { - setRefHtml(ref.current?.outerHTML as string); - }; - - return ( - - - - {args.children} - - - - - -
Ref HTML: {refHtml}
-
- ); - }, -}; - export const Heading1Story: StoryObj = { name: "Heading 1", args: { From ed6504af02122fa20ea88ea16f322e2110e4ec02 Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Fri, 8 Dec 2023 15:34:15 -0500 Subject: [PATCH 14/21] fix: types --- packages/odyssey-react-mui/src/Button.tsx | 4 +- packages/odyssey-react-mui/src/Checkbox.tsx | 16 ++--- packages/odyssey-react-mui/src/Link.tsx | 65 +++++++++++++------ packages/odyssey-react-mui/src/Radio.tsx | 16 ++--- packages/odyssey-react-mui/src/Select.tsx | 12 ++-- packages/odyssey-react-mui/src/Typography.tsx | 4 +- .../odyssey-mui/Button/Button.stories.tsx | 2 +- .../odyssey-mui/Link/Link.stories.tsx | 40 ------------ 8 files changed, 66 insertions(+), 93 deletions(-) diff --git a/packages/odyssey-react-mui/src/Button.tsx b/packages/odyssey-react-mui/src/Button.tsx index 05a591de8b..3045a4b82a 100644 --- a/packages/odyssey-react-mui/src/Button.tsx +++ b/packages/odyssey-react-mui/src/Button.tsx @@ -134,7 +134,7 @@ const Button = forwardRef( ref ) => { const muiProps = useMuiProps(); - const buttonRef = useRef(null); + const buttonRef = useRef(null); const renderButton = useCallback( (muiProps) => ( @@ -181,7 +181,7 @@ const Button = forwardRef( () => { return { focus: () => { - (buttonRef.current! as HTMLButtonElement).focus(); + buttonRef.current?.focus(); }, }; }, diff --git a/packages/odyssey-react-mui/src/Checkbox.tsx b/packages/odyssey-react-mui/src/Checkbox.tsx index 142b023289..2ca032abbd 100644 --- a/packages/odyssey-react-mui/src/Checkbox.tsx +++ b/packages/odyssey-react-mui/src/Checkbox.tsx @@ -25,6 +25,7 @@ import { Typography } from "./Typography"; import type { SeleniumProps } from "./SeleniumProps"; import { ComponentControlledState, getControlState } from "./inputUtils"; import { CheckedFieldProps } from "./FormCheckedProps"; +import { FocusHandle } from "./@types/react-augment"; export const checkboxValidityValues = ["valid", "invalid", "inherit"] as const; @@ -76,9 +77,7 @@ export type CheckboxProps = { /** * The ref is forwarded to input element in the Checkbox */ - inputRef?: React.RefObject<{ - focus: () => void; - } | null>; + inputRef?: React.RefObject; } & Pick & CheckedFieldProps & SeleniumProps; @@ -122,14 +121,11 @@ const Checkbox = ({ () => { const element = ref.current as unknown as HTMLElement; const inputElement = - element.tagName === "INPUT" - ? element - : (element.querySelector("input") as HTMLInputElement); - if (!inputElement) { - return null; - } + element.tagName === "INPUT" ? element : element.querySelector("input"); return { - focus: inputElement.focus, + focus: () => { + inputElement?.focus(); + }, }; }, [] diff --git a/packages/odyssey-react-mui/src/Link.tsx b/packages/odyssey-react-mui/src/Link.tsx index 65d93fb350..cd48ce31fd 100644 --- a/packages/odyssey-react-mui/src/Link.tsx +++ b/packages/odyssey-react-mui/src/Link.tsx @@ -10,11 +10,18 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { forwardRef, memo, ReactElement } from "react"; +import { + forwardRef, + memo, + ReactElement, + useRef, + useImperativeHandle, +} from "react"; import { ExternalLinkIcon } from "./icons.generated"; import type { SeleniumProps } from "./SeleniumProps"; import { Link as MuiLink, LinkProps as MuiLinkProps } from "@mui/material"; +import { FocusHandle } from "./@types/react-augment"; export const linkVariantValues = ["default", "monochrome"] as const; @@ -54,31 +61,47 @@ export type LinkProps = { variant?: (typeof linkVariantValues)[number]; } & SeleniumProps; -const Link = forwardRef( +const Link = forwardRef( ( { children, href, icon, rel, target, testId, variant, onClick }: LinkProps, ref - ) => ( - - {icon && {icon}} + ) => { + const linkRef = useRef(null); - {children} + useImperativeHandle( + ref, + () => { + return { + focus: () => { + linkRef.current?.focus(); + }, + }; + }, + [] + ); - {target === "_blank" && ( - - - - )} - - ) + return ( + + {icon && {icon}} + + {children} + + {target === "_blank" && ( + + + + )} + + ); + } ); const MemoizedLink = memo(Link); diff --git a/packages/odyssey-react-mui/src/Radio.tsx b/packages/odyssey-react-mui/src/Radio.tsx index 904408c22d..4d2f01dceb 100644 --- a/packages/odyssey-react-mui/src/Radio.tsx +++ b/packages/odyssey-react-mui/src/Radio.tsx @@ -20,6 +20,7 @@ import { memo, useCallback, useRef, useImperativeHandle } from "react"; import { FieldComponentProps } from "./FieldComponentProps"; import type { SeleniumProps } from "./SeleniumProps"; +import { FocusHandle } from "./@types/react-augment"; export type RadioProps = { /** @@ -49,9 +50,7 @@ export type RadioProps = { /** * The ref is forwarded to input element in the Radio */ - inputRef?: React.RefObject<{ - focus: () => void; - } | null>; + inputRef?: React.RefObject; } & Pick & SeleniumProps; @@ -74,14 +73,11 @@ const Radio = ({ () => { const element = ref.current as unknown as HTMLElement; const inputElement = - element.tagName === "INPUT" - ? element - : (element.querySelector("input") as HTMLInputElement); - if (!inputElement) { - return null; - } + element.tagName === "INPUT" ? element : element.querySelector("input"); return { - focus: inputElement.focus, + focus: () => { + inputElement?.focus(); + }, }; }, [] diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index 27c427e6d1..c5c8cdf4ed 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -39,6 +39,7 @@ import { useInputValues, getControlState, } from "./inputUtils"; +import { FocusHandle } from "./@types/react-augment"; export type SelectOption = { text: string; @@ -93,9 +94,7 @@ export type SelectProps< /** * The ref is forwarded to input element in the Select */ - inputRef: React.RefObject<{ - focus: () => void; - } | null>; + inputRef: React.RefObject; } & Pick< FieldComponentProps, | "errorMessage" @@ -182,11 +181,10 @@ const Select = < const inputElement = ( ref.current as unknown as HTMLElement ).querySelector("input"); - if (!inputElement) { - return null; - } return { - focus: inputElement.focus, + focus: () => { + inputElement?.focus(); + }, }; }, [] diff --git a/packages/odyssey-react-mui/src/Typography.tsx b/packages/odyssey-react-mui/src/Typography.tsx index 8972d0f243..9e41f5fdf1 100644 --- a/packages/odyssey-react-mui/src/Typography.tsx +++ b/packages/odyssey-react-mui/src/Typography.tsx @@ -107,7 +107,7 @@ const Typography = forwardRef( }: TypographyProps, ref ) => { - const typographyRef = useRef(null); + const typographyRef = useRef(null); const component = useMemo(() => { if (!componentProp) { if (variant === "body") { @@ -126,7 +126,7 @@ const Typography = forwardRef( () => { return { focus: () => { - (typographyRef.current! as HTMLElement).focus(); + typographyRef.current?.focus(); }, }; }, diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx index a5e154776a..362b40e07b 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Button/Button.stories.tsx @@ -34,7 +34,7 @@ type playType = { step: PlaywrightProps["step"]; }; -const storybookMeta: Meta = { +const storybookMeta: Meta = { title: "MUI Components/Button", component: Button, argTypes: { diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Link/Link.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Link/Link.stories.tsx index 108476dea3..18b34a59b9 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Link/Link.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Link/Link.stories.tsx @@ -10,9 +10,7 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { useState, useRef } from "react"; import type { StoryObj } from "@storybook/react"; -import { Box } from "@mui/material"; import { MuiThemeDecorator } from "../../../../.storybook/components"; import { Link, LinkProps, linkVariantValues } from "@okta/odyssey-react-mui"; @@ -103,15 +101,6 @@ export default { defaultValue: "", }, }, - ref: { - control: null, - description: "The ref is forwarded to the root element.", - table: { - type: { - summary: "HTMLElement", - }, - }, - }, }, decorators: [MuiThemeDecorator], }; @@ -148,32 +137,3 @@ export const External: StoryObj = { target: "_blank", }, }; - -export const WithRef: StoryObj = { - args: { - href: "#anchor", - variant: "default", - children: "Anchor link", - }, - render: function C(args) { - const [refHtml, setRefHtml] = useState(""); - const ref = useRef(null); - - const handleGetRefInnerHtml = () => { - setRefHtml(ref.current?.outerHTML as string); - }; - - return ( - - - - - -
Ref HTML: {refHtml}
-
- ); - }, -}; From d7f7088033ac7fa14cd2434c4f8f48d80671f289 Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Fri, 8 Dec 2023 15:59:12 -0500 Subject: [PATCH 15/21] fix: remove ref for button --- packages/odyssey-react-mui/src/Button.tsx | 143 +++++++++------------- 1 file changed, 58 insertions(+), 85 deletions(-) diff --git a/packages/odyssey-react-mui/src/Button.tsx b/packages/odyssey-react-mui/src/Button.tsx index 3045a4b82a..776172e56e 100644 --- a/packages/odyssey-react-mui/src/Button.tsx +++ b/packages/odyssey-react-mui/src/Button.tsx @@ -12,19 +12,11 @@ import { Button as MuiButton } from "@mui/material"; import type { ButtonProps as MuiButtonProps } from "@mui/material"; -import { - forwardRef, - memo, - ReactElement, - useCallback, - useImperativeHandle, - useRef, -} from "react"; +import { memo, ReactElement, useCallback } from "react"; import { MuiPropsContext, useMuiProps } from "./MuiPropsContext"; import { Tooltip } from "./Tooltip"; import type { SeleniumProps } from "./SeleniumProps"; -import { FocusHandle } from "./@types/react-augment"; export const buttonSizeValues = ["small", "medium", "large"] as const; export const buttonTypeValues = ["button", "submit", "reset"] as const; @@ -112,9 +104,47 @@ export type ButtonProps = { ) & SeleniumProps; -const Button = forwardRef( - ( - { +const Button = ({ + ariaDescribedBy, + ariaLabel, + ariaLabelledBy, + endIcon, + id, + isDisabled, + isFullWidth, + label = "", + onClick, + size = "medium", + startIcon, + testId, + tooltipText, + type = "button", + variant, +}: ButtonProps) => { + const muiProps = useMuiProps(); + + const renderButton = useCallback( + (muiProps) => ( + + {label} + + ), + [ ariaDescribedBy, ariaLabel, ariaLabelledBy, @@ -122,85 +152,28 @@ const Button = forwardRef( id, isDisabled, isFullWidth, - label = "", + label, onClick, - size = "medium", + size, startIcon, testId, - tooltipText, - type = "button", + type, variant, - }: ButtonProps, - ref - ) => { - const muiProps = useMuiProps(); - const buttonRef = useRef(null); - - const renderButton = useCallback( - (muiProps) => ( - - {label} - - ), - [ - ariaDescribedBy, - ariaLabel, - ariaLabelledBy, - endIcon, - id, - isDisabled, - isFullWidth, - label, - onClick, - size, - startIcon, - testId, - type, - variant, - ] - ); - - useImperativeHandle( - ref, - () => { - return { - focus: () => { - buttonRef.current?.focus(); - }, - }; - }, - [] - ); + ] + ); - return ( - <> - {tooltipText && !isDisabled && ( - - {renderButton} - - )} + return ( + <> + {tooltipText && !isDisabled && ( + + {renderButton} + + )} - {(isDisabled || !tooltipText) && renderButton(muiProps)} - - ); - } -); + {(isDisabled || !tooltipText) && renderButton(muiProps)} + + ); +}; const MemoizedButton = memo(Button); MemoizedButton.displayName = "Button"; From 3997570a7387e3f57eb8ecda2f4d2431e6dc11d9 Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Fri, 8 Dec 2023 16:00:16 -0500 Subject: [PATCH 16/21] fix: remove ref for link --- packages/odyssey-react-mui/src/Link.tsx | 78 ++++++++++--------------- 1 file changed, 30 insertions(+), 48 deletions(-) diff --git a/packages/odyssey-react-mui/src/Link.tsx b/packages/odyssey-react-mui/src/Link.tsx index cd48ce31fd..d49ec697b8 100644 --- a/packages/odyssey-react-mui/src/Link.tsx +++ b/packages/odyssey-react-mui/src/Link.tsx @@ -10,18 +10,11 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { - forwardRef, - memo, - ReactElement, - useRef, - useImperativeHandle, -} from "react"; +import { memo, ReactElement } from "react"; import { ExternalLinkIcon } from "./icons.generated"; import type { SeleniumProps } from "./SeleniumProps"; import { Link as MuiLink, LinkProps as MuiLinkProps } from "@mui/material"; -import { FocusHandle } from "./@types/react-augment"; export const linkVariantValues = ["default", "monochrome"] as const; @@ -61,48 +54,37 @@ export type LinkProps = { variant?: (typeof linkVariantValues)[number]; } & SeleniumProps; -const Link = forwardRef( - ( - { children, href, icon, rel, target, testId, variant, onClick }: LinkProps, - ref - ) => { - const linkRef = useRef(null); +const Link = ({ + children, + href, + icon, + rel, + target, + testId, + variant, + onClick, +}: LinkProps) => { + return ( + + {icon && {icon}} - useImperativeHandle( - ref, - () => { - return { - focus: () => { - linkRef.current?.focus(); - }, - }; - }, - [] - ); + {children} - return ( - - {icon && {icon}} - - {children} - - {target === "_blank" && ( - - - - )} - - ); - } -); + {target === "_blank" && ( + + + + )} + + ); +}; const MemoizedLink = memo(Link); From cfe628c7964e7713bcce4574f330fc53bdd9012a Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Fri, 8 Dec 2023 16:01:04 -0500 Subject: [PATCH 17/21] fix: diff --- packages/odyssey-react-mui/src/Link.tsx | 38 ++++++++++++------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/odyssey-react-mui/src/Link.tsx b/packages/odyssey-react-mui/src/Link.tsx index d49ec697b8..458264f809 100644 --- a/packages/odyssey-react-mui/src/Link.tsx +++ b/packages/odyssey-react-mui/src/Link.tsx @@ -63,28 +63,26 @@ const Link = ({ testId, variant, onClick, -}: LinkProps) => { - return ( - - {icon && {icon}} +}: LinkProps) => ( + + {icon && {icon}} - {children} + {children} - {target === "_blank" && ( - - - - )} - - ); -}; + {target === "_blank" && ( + + + + )} + +); const MemoizedLink = memo(Link); From f7431b980e35df4ee245165a239e7514ae6334bf Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Fri, 8 Dec 2023 16:18:10 -0500 Subject: [PATCH 18/21] fix: handle null case --- packages/odyssey-react-mui/src/Checkbox.tsx | 2 +- packages/odyssey-react-mui/src/Radio.tsx | 2 +- packages/odyssey-react-mui/src/Select.tsx | 2 +- packages/odyssey-react-mui/src/Typography.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/odyssey-react-mui/src/Checkbox.tsx b/packages/odyssey-react-mui/src/Checkbox.tsx index 2ca032abbd..7ed23e6f6c 100644 --- a/packages/odyssey-react-mui/src/Checkbox.tsx +++ b/packages/odyssey-react-mui/src/Checkbox.tsx @@ -124,7 +124,7 @@ const Checkbox = ({ element.tagName === "INPUT" ? element : element.querySelector("input"); return { focus: () => { - inputElement?.focus(); + inputElement && inputElement.focus(); }, }; }, diff --git a/packages/odyssey-react-mui/src/Radio.tsx b/packages/odyssey-react-mui/src/Radio.tsx index 4d2f01dceb..c81e6a37c3 100644 --- a/packages/odyssey-react-mui/src/Radio.tsx +++ b/packages/odyssey-react-mui/src/Radio.tsx @@ -76,7 +76,7 @@ const Radio = ({ element.tagName === "INPUT" ? element : element.querySelector("input"); return { focus: () => { - inputElement?.focus(); + inputElement && inputElement.focus(); }, }; }, diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index c5c8cdf4ed..3ce0f0ee8c 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -183,7 +183,7 @@ const Select = < ).querySelector("input"); return { focus: () => { - inputElement?.focus(); + inputElement && inputElement.focus(); }, }; }, diff --git a/packages/odyssey-react-mui/src/Typography.tsx b/packages/odyssey-react-mui/src/Typography.tsx index 9e41f5fdf1..1715ebeeb2 100644 --- a/packages/odyssey-react-mui/src/Typography.tsx +++ b/packages/odyssey-react-mui/src/Typography.tsx @@ -126,7 +126,7 @@ const Typography = forwardRef( () => { return { focus: () => { - typographyRef.current?.focus(); + typographyRef.current && typographyRef.current.focus(); }, }; }, From 359cde02af493ef16ee42fa12940d3c688f0c5a3 Mon Sep 17 00:00:00 2001 From: Shuo Wu Date: Tue, 19 Dec 2023 14:13:40 -0500 Subject: [PATCH 19/21] fix: resolve comments --- packages/odyssey-react-mui/src/Checkbox.tsx | 8 +- packages/odyssey-react-mui/src/Radio.tsx | 8 +- packages/odyssey-react-mui/src/Select.tsx | 6 +- packages/odyssey-react-mui/src/Typography.tsx | 105 +++++++++--------- 4 files changed, 60 insertions(+), 67 deletions(-) diff --git a/packages/odyssey-react-mui/src/Checkbox.tsx b/packages/odyssey-react-mui/src/Checkbox.tsx index 7ed23e6f6c..a824af34ab 100644 --- a/packages/odyssey-react-mui/src/Checkbox.tsx +++ b/packages/odyssey-react-mui/src/Checkbox.tsx @@ -114,17 +114,15 @@ const Checkbox = ({ } return { defaultChecked: isDefaultChecked }; }, [isDefaultChecked, isChecked]); - const ref = useRef(null); + const ref = useRef(null); useImperativeHandle( inputRef, () => { - const element = ref.current as unknown as HTMLElement; - const inputElement = - element.tagName === "INPUT" ? element : element.querySelector("input"); + const element = ref.current; return { focus: () => { - inputElement && inputElement.focus(); + element && element.focus(); }, }; }, diff --git a/packages/odyssey-react-mui/src/Radio.tsx b/packages/odyssey-react-mui/src/Radio.tsx index c81e6a37c3..987cabf54b 100644 --- a/packages/odyssey-react-mui/src/Radio.tsx +++ b/packages/odyssey-react-mui/src/Radio.tsx @@ -66,17 +66,15 @@ const Radio = ({ onBlur: onBlurProp, inputRef, }: RadioProps) => { - const ref = useRef(null); + const ref = useRef(null); useImperativeHandle( inputRef, () => { - const element = ref.current as unknown as HTMLElement; - const inputElement = - element.tagName === "INPUT" ? element : element.querySelector("input"); + const element = ref.current; return { focus: () => { - inputElement && inputElement.focus(); + element && element.focus(); }, }; }, diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index 3ce0f0ee8c..9407d651e5 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -161,7 +161,7 @@ const Select = < const [internalSelectedValues, setInternalSelectedValues] = useState( controlledStateRef.current === CONTROLLED ? value : defaultValue ); - const ref = useRef(null); + const ref = useRef(null); useEffect(() => { if (controlledStateRef.current === CONTROLLED) { @@ -178,9 +178,7 @@ const Select = < useImperativeHandle( inputRef, () => { - const inputElement = ( - ref.current as unknown as HTMLElement - ).querySelector("input"); + const inputElement = (ref.current as HTMLElement).querySelector("input"); return { focus: () => { inputElement && inputElement.focus(); diff --git a/packages/odyssey-react-mui/src/Typography.tsx b/packages/odyssey-react-mui/src/Typography.tsx index 1715ebeeb2..7edd88d9c7 100644 --- a/packages/odyssey-react-mui/src/Typography.tsx +++ b/packages/odyssey-react-mui/src/Typography.tsx @@ -17,7 +17,6 @@ import { import { ElementType, ReactNode, - forwardRef, memo, useMemo, useRef, @@ -91,63 +90,63 @@ export type TypographyProps = { * The variant of Typography to render. */ variant?: keyof typeof typographyVariantMapping; + /** + * The ref is forwarded to focus handle + */ + typographyRef?: React.RefObject; } & SeleniumProps; -const Typography = forwardRef( - ( - { - ariaDescribedBy, - ariaLabel, - ariaLabelledBy, - children, - color, - component: componentProp, - testId, - variant = "body", - }: TypographyProps, - ref - ) => { - const typographyRef = useRef(null); - const component = useMemo(() => { - if (!componentProp) { - if (variant === "body") { - return "p"; - } else if (variant === "subordinate" || variant === "support") { - return "p"; - } else { - return variant; - } +const Typography = ({ + ariaDescribedBy, + ariaLabel, + ariaLabelledBy, + children, + color, + component: componentProp, + testId, + variant = "body", + typographyRef, +}: TypographyProps) => { + const ref = useRef(null); + const component = useMemo(() => { + if (!componentProp) { + if (variant === "body") { + return "p"; + } else if (variant === "subordinate" || variant === "support") { + return "p"; + } else { + return variant; } - return componentProp; - }, [componentProp, variant]); + } + return componentProp; + }, [componentProp, variant]); - useImperativeHandle( - ref, - () => { - return { - focus: () => { - typographyRef.current && typographyRef.current.focus(); - }, - }; - }, - [] - ); + useImperativeHandle( + typographyRef, + () => { + return { + focus: () => { + ref.current && ref.current.focus(); + }, + }; + }, + [] + ); - return ( - - ); - } -); + return ( + + ); +}; const MemoizedTypography = memo(Typography); MemoizedTypography.displayName = "Typography"; From f929184ca082f9bddf10f41930146c5ed12e40b4 Mon Sep 17 00:00:00 2001 From: Trevor Ing Date: Wed, 20 Dec 2023 09:21:03 -0800 Subject: [PATCH 20/21] feat: clean up and add ref support to Button, Typography, and Link --- packages/odyssey-react-mui/src/Button.tsx | 29 ++++++++- packages/odyssey-react-mui/src/Checkbox.tsx | 18 +++--- packages/odyssey-react-mui/src/Link.tsx | 61 +++++++++++++------ .../odyssey-react-mui/src/PasswordField.tsx | 22 +++++++ packages/odyssey-react-mui/src/Radio.tsx | 13 ++-- packages/odyssey-react-mui/src/Select.tsx | 40 ++++++------ packages/odyssey-react-mui/src/TextField.tsx | 22 +++++++ packages/odyssey-react-mui/src/Typography.tsx | 20 +++--- 8 files changed, 160 insertions(+), 65 deletions(-) diff --git a/packages/odyssey-react-mui/src/Button.tsx b/packages/odyssey-react-mui/src/Button.tsx index 776172e56e..d442cb0a35 100644 --- a/packages/odyssey-react-mui/src/Button.tsx +++ b/packages/odyssey-react-mui/src/Button.tsx @@ -12,11 +12,18 @@ import { Button as MuiButton } from "@mui/material"; import type { ButtonProps as MuiButtonProps } from "@mui/material"; -import { memo, ReactElement, useCallback } from "react"; +import { + memo, + ReactElement, + useCallback, + useImperativeHandle, + useRef, +} from "react"; import { MuiPropsContext, useMuiProps } from "./MuiPropsContext"; import { Tooltip } from "./Tooltip"; import type { SeleniumProps } from "./SeleniumProps"; +import { FocusHandle } from "./@types/react-augment"; export const buttonSizeValues = ["small", "medium", "large"] as const; export const buttonTypeValues = ["button", "submit", "reset"] as const; @@ -49,6 +56,10 @@ export type ButtonProps = { * The ID of the Button */ id?: string; + /** + * The ref forwarded to the Button to expose focus() + */ + buttonFocusRef?: React.RefObject; /** * Determines whether the Button is disabled */ @@ -108,6 +119,7 @@ const Button = ({ ariaDescribedBy, ariaLabel, ariaLabelledBy, + buttonFocusRef, endIcon, id, isDisabled, @@ -123,6 +135,20 @@ const Button = ({ }: ButtonProps) => { const muiProps = useMuiProps(); + const ref = useRef(null); + useImperativeHandle( + buttonFocusRef, + () => { + const element = ref.current; + return { + focus: () => { + element && element.focus(); + }, + }; + }, + [] + ); + const renderButton = useCallback( (muiProps) => ( ; /** * Determines whether the Checkbox is disabled */ @@ -74,10 +78,6 @@ export type CheckboxProps = { * Callback fired when the blur event happens. Provides event value. */ onBlur?: MuiFormControlLabelProps["onBlur"]; - /** - * The ref is forwarded to input element in the Checkbox - */ - inputRef?: React.RefObject; } & Pick & CheckedFieldProps & SeleniumProps; @@ -86,6 +86,7 @@ const Checkbox = ({ ariaLabel, ariaLabelledBy, id: idOverride, + inputFocusRef, isChecked, isDefaultChecked, isDisabled, @@ -99,7 +100,6 @@ const Checkbox = ({ testId, validity = "inherit", value, - inputRef, }: CheckboxProps) => { const { t } = useTranslation(); const controlledStateRef = useRef( @@ -114,12 +114,12 @@ const Checkbox = ({ } return { defaultChecked: isDefaultChecked }; }, [isDefaultChecked, isChecked]); - const ref = useRef(null); + const inputRef = useRef(null); useImperativeHandle( - inputRef, + inputFocusRef, () => { - const element = ref.current; + const element = inputRef.current; return { focus: () => { element && element.focus(); @@ -178,7 +178,7 @@ const Checkbox = ({ indeterminate={isIndeterminate} onChange={onChange} required={isRequired} - inputRef={ref} + inputRef={inputRef} sx={() => ({ marginBlockStart: "2px", })} diff --git a/packages/odyssey-react-mui/src/Link.tsx b/packages/odyssey-react-mui/src/Link.tsx index 458264f809..1caaed05b0 100644 --- a/packages/odyssey-react-mui/src/Link.tsx +++ b/packages/odyssey-react-mui/src/Link.tsx @@ -10,11 +10,12 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { memo, ReactElement } from "react"; +import { memo, ReactElement, useImperativeHandle, useRef } from "react"; import { ExternalLinkIcon } from "./icons.generated"; import type { SeleniumProps } from "./SeleniumProps"; import { Link as MuiLink, LinkProps as MuiLinkProps } from "@mui/material"; +import { FocusHandle } from "./@types/react-augment"; export const linkVariantValues = ["default", "monochrome"] as const; @@ -31,6 +32,10 @@ export type LinkProps = { * An optional Icon component at the start of the Link */ icon?: ReactElement; + /** + * The ref forwarded to the TextField to expose focus() + */ + linkFocusRef?: React.RefObject; /** * The click event handler for the Link */ @@ -58,31 +63,49 @@ const Link = ({ children, href, icon, + linkFocusRef, rel, target, testId, variant, onClick, -}: LinkProps) => ( - - {icon && {icon}} +}: LinkProps) => { + const ref = useRef(null); + useImperativeHandle( + linkFocusRef, + () => { + const element = ref.current; + return { + focus: () => { + element && element.focus(); + }, + }; + }, + [] + ); + + return ( + + {icon && {icon}} - {children} + {children} - {target === "_blank" && ( - - - - )} - -); + {target === "_blank" && ( + + + + )} + + ); +}; const MemoizedLink = memo(Link); diff --git a/packages/odyssey-react-mui/src/PasswordField.tsx b/packages/odyssey-react-mui/src/PasswordField.tsx index 444554f4b0..7178f07b76 100644 --- a/packages/odyssey-react-mui/src/PasswordField.tsx +++ b/packages/odyssey-react-mui/src/PasswordField.tsx @@ -17,6 +17,7 @@ import { forwardRef, memo, useCallback, + useImperativeHandle, useRef, useState, } from "react"; @@ -27,6 +28,7 @@ import { FieldComponentProps } from "./FieldComponentProps"; import type { SeleniumProps } from "./SeleniumProps"; import { useTranslation } from "react-i18next"; import { getControlState, useInputValues } from "./inputUtils"; +import { FocusHandle } from "./@types/react-augment"; export type PasswordFieldProps = { /** @@ -47,6 +49,10 @@ export type PasswordFieldProps = { * If `true`, the show/hide icon is not shown to the user */ hasShowPassword?: boolean; + /** + * The ref forwarded to the TextField to expose focus() + */ + inputFocusRef?: React.RefObject; /** * The label for the `input` element. */ @@ -83,6 +89,7 @@ const PasswordField = forwardRef( hasInitialFocus, hint, id: idOverride, + inputFocusRef, isDisabled = false, isFullWidth = false, isOptional = false, @@ -120,6 +127,20 @@ const PasswordField = forwardRef( controlState: controlledStateRef.current, }); + const inputRef = useRef(null); + useImperativeHandle( + inputFocusRef, + () => { + const element = inputRef.current; + return { + focus: () => { + element && element.focus(); + }, + }; + }, + [] + ); + const onChange = useCallback< ChangeEventHandler >( @@ -161,6 +182,7 @@ const PasswordField = forwardRef( // role: "textbox" Added because password inputs don't have an implicit role assigned. This causes problems with element selection. role: "textbox", }} + inputRef={inputRef} 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 987cabf54b..8e06f07eb3 100644 --- a/packages/odyssey-react-mui/src/Radio.tsx +++ b/packages/odyssey-react-mui/src/Radio.tsx @@ -23,6 +23,10 @@ import type { SeleniumProps } from "./SeleniumProps"; import { FocusHandle } from "./@types/react-augment"; export type RadioProps = { + /** + * The ref forwarded to the Radio to expose focus() + */ + inputFocusRef?: React.RefObject; /** * If `true`, the Radio is selected */ @@ -47,14 +51,11 @@ export type RadioProps = { * Callback fired when the blur event happens. Provides event value. */ onBlur?: MuiFormControlLabelProps["onBlur"]; - /** - * The ref is forwarded to input element in the Radio - */ - inputRef?: React.RefObject; } & Pick & SeleniumProps; const Radio = ({ + inputFocusRef, isChecked, isDisabled, isInvalid, @@ -64,12 +65,10 @@ const Radio = ({ value, onChange: onChangeProp, onBlur: onBlurProp, - inputRef, }: RadioProps) => { const ref = useRef(null); - useImperativeHandle( - inputRef, + inputFocusRef, () => { const element = ref.current; return { diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index 9407d651e5..cbc819f153 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -62,6 +62,10 @@ export type SelectProps< * If `true`, the Select allows multiple selections */ hasMultipleChoices?: HasMultipleChoices; + /** + * The ref forwarded to the Select to expose focus() + */ + inputFocusRef?: React.RefObject; /** * @deprecated Use `hasMultipleChoices` instead. */ @@ -91,10 +95,6 @@ export type SelectProps< * The value or values selected in the Select */ value?: Value; - /** - * The ref is forwarded to input element in the Select - */ - inputRef: React.RefObject; } & Pick< FieldComponentProps, | "errorMessage" @@ -134,6 +134,7 @@ const Select = < hint, HintLinkComponent, id: idOverride, + inputFocusRef, isDisabled = false, isFullWidth = false, isMultiSelect, @@ -146,7 +147,6 @@ const Select = < options, testId, value, - inputRef, }: SelectProps) => { const hasMultipleChoices = useMemo( () => @@ -161,7 +161,20 @@ const Select = < const [internalSelectedValues, setInternalSelectedValues] = useState( controlledStateRef.current === CONTROLLED ? value : defaultValue ); - const ref = useRef(null); + const inputRef = useRef(null); + + useImperativeHandle( + inputFocusRef, + () => { + const element = inputRef.current; + return { + focus: () => { + element && element.focus(); + }, + }; + }, + [] + ); useEffect(() => { if (controlledStateRef.current === CONTROLLED) { @@ -175,19 +188,6 @@ const Select = < controlState: controlledStateRef.current, }); - useImperativeHandle( - inputRef, - () => { - const inputElement = (ref.current as HTMLElement).querySelector("input"); - return { - focus: () => { - inputElement && inputElement.focus(); - }, - }; - }, - [] - ); - const onChange = useCallback["onChange"]>>( (event, child) => { const { @@ -289,6 +289,7 @@ const Select = < children={children} data-se={testId} id={id} + inputRef={inputRef} labelId={labelElementId} multiple={hasMultipleChoices} name={nameOverride ?? id} @@ -296,7 +297,6 @@ const Select = < onChange={onChange} onFocus={onFocus} renderValue={hasMultipleChoices ? renderValue : undefined} - ref={ref} /> ), [ diff --git a/packages/odyssey-react-mui/src/TextField.tsx b/packages/odyssey-react-mui/src/TextField.tsx index ab10d425ed..3d8d8150fa 100644 --- a/packages/odyssey-react-mui/src/TextField.tsx +++ b/packages/odyssey-react-mui/src/TextField.tsx @@ -18,6 +18,7 @@ import { memo, ReactElement, useCallback, + useImperativeHandle, useRef, } from "react"; import { InputAdornment, InputBase } from "@mui/material"; @@ -26,6 +27,7 @@ import { FieldComponentProps } from "./FieldComponentProps"; import { Field } from "./Field"; import { SeleniumProps } from "./SeleniumProps"; import { useInputValues, getControlState } from "./inputUtils"; +import { FocusHandle } from "./@types/react-augment"; export const textFieldTypeValues = [ "email", @@ -54,6 +56,10 @@ export type TextFieldProps = { * If `true`, the component will receive focus automatically. */ hasInitialFocus?: boolean; + /** + * The ref forwarded to the TextField to expose focus() + */ + inputFocusRef?: React.RefObject; /** * If `true`, a [TextareaAutosize](/material-ui/react-textarea-autosize/) element is rendered. */ @@ -104,6 +110,7 @@ const TextField = forwardRef( hint, HintLinkComponent, id: idOverride, + inputFocusRef, isDisabled = false, isFullWidth = false, isMultiline = false, @@ -134,6 +141,20 @@ const TextField = forwardRef( controlState: controlledStateRef.current, }); + const inputRef = useRef(null); + useImperativeHandle( + inputFocusRef, + () => { + const element = inputRef.current; + return { + focus: () => { + element && element.focus(); + }, + }; + }, + [] + ); + const onChange = useCallback< NonNullable> >( @@ -162,6 +183,7 @@ const TextField = forwardRef( ) } id={id} + inputRef={inputRef} 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 7edd88d9c7..4179622769 100644 --- a/packages/odyssey-react-mui/src/Typography.tsx +++ b/packages/odyssey-react-mui/src/Typography.tsx @@ -87,13 +87,13 @@ export type TypographyProps = { */ component?: ElementType; /** - * The variant of Typography to render. + * The ref forwarded to the Typography to expose focus() */ - variant?: keyof typeof typographyVariantMapping; + typographyFocusRef?: React.RefObject; /** - * The ref is forwarded to focus handle + * The variant of Typography to render. */ - typographyRef?: React.RefObject; + variant?: keyof typeof typographyVariantMapping; } & SeleniumProps; const Typography = ({ @@ -104,10 +104,9 @@ const Typography = ({ color, component: componentProp, testId, + typographyFocusRef, variant = "body", - typographyRef, }: TypographyProps) => { - const ref = useRef(null); const component = useMemo(() => { if (!componentProp) { if (variant === "body") { @@ -121,12 +120,14 @@ const Typography = ({ return componentProp; }, [componentProp, variant]); + const ref = useRef(null); useImperativeHandle( - typographyRef, + typographyFocusRef, () => { + const element = ref.current; return { focus: () => { - ref.current && ref.current.focus(); + element && element.focus(); }, }; }, @@ -142,8 +143,9 @@ const Typography = ({ color={color} component={component} data-se={testId} - variant={typographyVariantMapping[variant]} ref={ref} + tabIndex={-1} + variant={typographyVariantMapping[variant]} /> ); }; From e3f530e49f052f45fbc633a47bdf8542bab3abbd Mon Sep 17 00:00:00 2001 From: Trevor Ing Date: Wed, 20 Dec 2023 09:26:09 -0800 Subject: [PATCH 21/21] fix: reorder props in Button --- packages/odyssey-react-mui/src/Button.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/odyssey-react-mui/src/Button.tsx b/packages/odyssey-react-mui/src/Button.tsx index d442cb0a35..4cbdfd5d70 100644 --- a/packages/odyssey-react-mui/src/Button.tsx +++ b/packages/odyssey-react-mui/src/Button.tsx @@ -48,6 +48,10 @@ export type ButtonProps = { * The ID of the element that describes the Button */ ariaDescribedBy?: string; + /** + * The ref forwarded to the Button to expose focus() + */ + buttonFocusRef?: React.RefObject; /** * The icon element to display at the end of the Button */ @@ -56,10 +60,6 @@ export type ButtonProps = { * The ID of the Button */ id?: string; - /** - * The ref forwarded to the Button to expose focus() - */ - buttonFocusRef?: React.RefObject; /** * Determines whether the Button is disabled */