From c3f3f547804a60ccab7d14faa8e1bab26e0a8bc1 Mon Sep 17 00:00:00 2001 From: Chris Pulsinelli Date: Fri, 24 Nov 2023 16:39:32 -0500 Subject: [PATCH] fix: refactor controlled and uncontrolled state workings --- .../odyssey-react-mui/src/Autocomplete.tsx | 100 ++++++++---------- packages/odyssey-react-mui/src/Checkbox.tsx | 26 ++--- .../odyssey-react-mui/src/NativeSelect.tsx | 34 +++--- .../odyssey-react-mui/src/PasswordField.tsx | 28 ++--- packages/odyssey-react-mui/src/RadioGroup.tsx | 24 ++--- .../odyssey-react-mui/src/SearchField.tsx | 40 ++++--- packages/odyssey-react-mui/src/Select.tsx | 49 +++++---- packages/odyssey-react-mui/src/TextField.tsx | 32 +++--- packages/odyssey-react-mui/src/inputUtils.ts | 76 +++++++++++++ .../src/labs/VirtualizedAutocomplete.tsx | 98 ++++++++--------- .../src/useControlledState.ts | 56 ---------- .../Autocomplete/Autocomplete.stories.tsx | 16 +-- 12 files changed, 294 insertions(+), 285 deletions(-) create mode 100644 packages/odyssey-react-mui/src/inputUtils.ts delete mode 100644 packages/odyssey-react-mui/src/useControlledState.ts diff --git a/packages/odyssey-react-mui/src/Autocomplete.tsx b/packages/odyssey-react-mui/src/Autocomplete.tsx index 6c5a6e26d8..b1a1d987d9 100644 --- a/packages/odyssey-react-mui/src/Autocomplete.tsx +++ b/packages/odyssey-react-mui/src/Autocomplete.tsx @@ -17,12 +17,16 @@ import { UseAutocompleteProps, AutocompleteValue, } from "@mui/material"; -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback, useMemo, useRef } from "react"; import { Field } from "./Field"; import { FieldComponentProps } from "./FieldComponentProps"; import type { SeleniumProps } from "./SeleniumProps"; -import { useControlledState } from "./useControlledState"; +import { + ComponentControlledState, + useInputValues, + getControlState, +} from "./inputUtils"; export type AutocompleteProps< OptionType, @@ -31,7 +35,6 @@ export type AutocompleteProps< > = { /** * The default value. Use when the component is not controlled. - * @default props.multiple ? [] : null */ defaultValue?: UseAutocompleteProps< OptionType, @@ -189,6 +192,45 @@ const Autocomplete = < getIsOptionEqualToValue, testId, }: AutocompleteProps) => { + const controlledStateRef = useRef( + getControlState({ controlledValue: value, uncontrolledValue: defaultValue }) + ); + const defaultValueProp = useMemo< + | AutocompleteValue< + OptionType, + HasMultipleChoices, + undefined, + IsCustomValueAllowed + > + | undefined + >(() => { + if (hasMultipleChoices) { + if (value === undefined) { + return defaultValue; + } + return [] as AutocompleteValue< + OptionType, + HasMultipleChoices, + undefined, + IsCustomValueAllowed + >; + } + return value === undefined ? defaultValue : undefined; + }, [defaultValue, hasMultipleChoices, value]); + + const valueProps = useInputValues({ + defaultValue: defaultValueProp, + value: value, + controlState: controlledStateRef.current, + }); + + const inputValueProp = useMemo(() => { + if (controlledStateRef.current === ComponentControlledState.CONTROLLED) { + return { inputValue }; + } + return undefined; + }, [inputValue]); + const renderInput = useCallback( ({ InputLabelProps, InputProps, ...params }) => ( - | undefined - >(() => { - if (hasMultipleChoices) { - return defaultValue === undefined - ? ([] as AutocompleteValue< - OptionType, - HasMultipleChoices, - undefined, - IsCustomValueAllowed - >) - : defaultValue; - } - return defaultValue ?? undefined; - }, [defaultValue, hasMultipleChoices]); - - const [localValue, setLocalValue] = useControlledState({ - controlledValue: value, - uncontrolledValue: defaultValuesProp, - }); - - const valueProps = useMemo(() => { - if (localValue === undefined) { - return { defaultValue: defaultValuesProp }; - } - return { value: localValue }; - }, [defaultValuesProp, localValue]); - - const [localInputValue, setLocalInputValue] = useControlledState({ - controlledValue: inputValue, - uncontrolledValue: undefined, - }); - - const inputValueProp = useMemo(() => { - return { - inputValue: inputValue === undefined ? undefined : localInputValue, - }; - }, [inputValue, localInputValue]); - const onChange = useCallback< NonNullable< UseAutocompleteProps< @@ -280,10 +276,9 @@ const Autocomplete = < > >( (event, value, reason, details) => { - setLocalValue(value); onChangeProp?.(event, value, reason, details); }, - [onChangeProp, setLocalValue] + [onChangeProp] ); const onInputChange = useCallback< @@ -297,10 +292,9 @@ const Autocomplete = < > >( (event, value, reason) => { - setLocalInputValue(value); onInputChangeProp?.(event, value, reason); }, - [onInputChangeProp, setLocalInputValue] + [onInputChangeProp] ); return ( diff --git a/packages/odyssey-react-mui/src/Checkbox.tsx b/packages/odyssey-react-mui/src/Checkbox.tsx index 26f55cb70b..6585d72b18 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 } from "react"; +import { memo, useCallback, useMemo, useRef } from "react"; import { Checkbox as MuiCheckbox, CheckboxProps as MuiCheckboxProps, @@ -22,7 +22,7 @@ import { import { FieldComponentProps } from "./FieldComponentProps"; import { Typography } from "./Typography"; import type { SeleniumProps } from "./SeleniumProps"; -import { useControlledState } from "./useControlledState"; +import { ComponentControlledState, getControlState } from "./inputUtils"; import { CheckedFieldProps } from "./FormCheckedProps"; export const checkboxValidityValues = ["valid", "invalid", "inherit"] as const; @@ -90,17 +90,18 @@ const Checkbox = ({ value, }: CheckboxProps) => { const { t } = useTranslation(); - const [isLocalChecked, setIsLocalChecked] = useControlledState({ - controlledValue: isChecked, - uncontrolledValue: isDefaultChecked, - }); - + const controlledStateRef = useRef( + getControlState({ + controlledValue: isChecked, + uncontrolledValue: isDefaultChecked, + }) + ); const inputValues = useMemo(() => { - if (isLocalChecked === undefined) { - return { defaultChecked: isDefaultChecked }; + if (controlledStateRef.current === ComponentControlledState.CONTROLLED) { + return { checked: isChecked }; } - return { checked: isLocalChecked }; - }, [isDefaultChecked, isLocalChecked]); + return { defaultChecked: isDefaultChecked }; + }, [isDefaultChecked, isChecked]); const label = useMemo(() => { return ( @@ -121,10 +122,9 @@ const Checkbox = ({ const onChange = useCallback>( (event, checked) => { - setIsLocalChecked(checked); onChangeProp?.(event, checked); }, - [onChangeProp, setIsLocalChecked] + [onChangeProp] ); return ( diff --git a/packages/odyssey-react-mui/src/NativeSelect.tsx b/packages/odyssey-react-mui/src/NativeSelect.tsx index 6bcdd1ea9d..92845bd383 100644 --- a/packages/odyssey-react-mui/src/NativeSelect.tsx +++ b/packages/odyssey-react-mui/src/NativeSelect.tsx @@ -16,6 +16,7 @@ import React, { memo, useCallback, useMemo, + useRef, } from "react"; import { Select as MuiSelect, @@ -24,7 +25,7 @@ import { import { Field } from "./Field"; import { FieldComponentProps } from "./FieldComponentProps"; import type { SeleniumProps } from "./SeleniumProps"; -import { useControlledState } from "./useControlledState"; +import { getControlState, useInputValues } from "./inputUtils"; import { ForwardRefWithType } from "./@types/react-augment"; export type NativeSelectOption = { @@ -103,37 +104,30 @@ const NativeSelect: ForwardRefWithType = forwardRef( onChange: onChangeProp, onFocus, testId, - value: valueProp, + value, children, }: NativeSelectProps, ref?: React.Ref ) => { - const [localValue, setLocalValue] = useControlledState({ - controlledValue: valueProp, - uncontrolledValue: defaultValue, + const controlledStateRef = useRef( + getControlState({ + controlledValue: value, + uncontrolledValue: defaultValue, + }) + ); + const inputValues = useInputValues({ + defaultValue, + value, + controlState: controlledStateRef.current, }); - const inputValues = useMemo(() => { - if (localValue === undefined) { - return { defaultValue }; - } - return { value: localValue }; - }, [defaultValue, localValue]); - const onChange = useCallback< NonNullable["onChange"]> >( (event, child) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-expect-error - const { options } = event.target as HTMLSelectElement; - const selectedOptions = [...options] - .filter((option) => option.selected) - .map((selectedOption) => selectedOption.value); - setLocalValue(selectedOptions as Value); onChangeProp?.(event, child); }, - [onChangeProp, setLocalValue] + [onChangeProp] ); const hasMultipleChoices = useMemo( diff --git a/packages/odyssey-react-mui/src/PasswordField.tsx b/packages/odyssey-react-mui/src/PasswordField.tsx index 6c1bf4bc3e..4ccc491d2f 100644 --- a/packages/odyssey-react-mui/src/PasswordField.tsx +++ b/packages/odyssey-react-mui/src/PasswordField.tsx @@ -17,7 +17,7 @@ import { forwardRef, memo, useCallback, - useMemo, + useRef, useState, } from "react"; @@ -26,7 +26,7 @@ import { Field } from "./Field"; import { FieldComponentProps } from "./FieldComponentProps"; import type { SeleniumProps } from "./SeleniumProps"; import { useTranslation } from "react-i18next"; -import { useControlledState } from "./useControlledState"; +import { getControlState, useInputValues } from "./inputUtils"; export type PasswordFieldProps = { /** @@ -94,7 +94,7 @@ const PasswordField = forwardRef( onBlur, placeholder, testId, - value: valueProp, + value, }, ref ) => { @@ -107,25 +107,25 @@ const PasswordField = forwardRef( ); }, []); - const [localValue, setLocalValue] = useControlledState({ - controlledValue: valueProp, - uncontrolledValue: defaultValue, + const controlledStateRef = useRef( + getControlState({ + controlledValue: value, + uncontrolledValue: defaultValue, + }) + ); + const inputValues = useInputValues({ + defaultValue, + value, + controlState: controlledStateRef.current, }); - const inputValues = useMemo(() => { - if (localValue === undefined) { - return { defaultValue }; - } - return { value: localValue }; - }, [defaultValue, localValue]); const onChange = useCallback< ChangeEventHandler >( (event) => { - setLocalValue(event.target.value); onChangeProp?.(event); }, - [onChangeProp, setLocalValue] + [onChangeProp] ); const renderFieldComponent = useCallback( diff --git a/packages/odyssey-react-mui/src/RadioGroup.tsx b/packages/odyssey-react-mui/src/RadioGroup.tsx index 994e9ce878..d3d24c28d1 100644 --- a/packages/odyssey-react-mui/src/RadioGroup.tsx +++ b/packages/odyssey-react-mui/src/RadioGroup.tsx @@ -14,13 +14,13 @@ import { RadioGroup as MuiRadioGroup, type RadioGroupProps as MuiRadioGroupProps, } from "@mui/material"; -import { memo, ReactElement, useCallback, useMemo } from "react"; +import { memo, ReactElement, useCallback, useRef } from "react"; import { Radio, RadioProps } from "./Radio"; import { Field } from "./Field"; import { FieldComponentProps } from "./FieldComponentProps"; import type { SeleniumProps } from "./SeleniumProps"; -import { useControlledState } from "./useControlledState"; +import { getControlState, useInputValues } from "./inputUtils"; export type RadioGroupProps = { /** @@ -62,24 +62,20 @@ const RadioGroup = ({ testId, value, }: RadioGroupProps) => { - const [localValue, setLocalValue] = useControlledState({ - controlledValue: value, - uncontrolledValue: defaultValue, + const controlledStateRef = useRef( + getControlState({ controlledValue: value, uncontrolledValue: defaultValue }) + ); + const inputValues = useInputValues({ + defaultValue, + value, + controlState: controlledStateRef.current, }); - const inputValues = useMemo(() => { - if (localValue === undefined) { - return { defaultValue }; - } - return { value: localValue }; - }, [localValue, defaultValue]); - const onChange = useCallback>( (event, value) => { - setLocalValue(value); onChangeProp?.(event, value); }, - [onChangeProp, setLocalValue] + [onChangeProp] ); const renderFieldComponent = useCallback( ({ ariaDescribedBy, errorMessageElementId, id, labelElementId }) => ( diff --git a/packages/odyssey-react-mui/src/SearchField.tsx b/packages/odyssey-react-mui/src/SearchField.tsx index f87af51ebb..7290ca77d9 100644 --- a/packages/odyssey-react-mui/src/SearchField.tsx +++ b/packages/odyssey-react-mui/src/SearchField.tsx @@ -18,14 +18,14 @@ import { InputHTMLAttributes, memo, useCallback, - useMemo, + useRef, } from "react"; import { CloseCircleFilledIcon, SearchIcon } from "./icons.generated"; import { Field } from "./Field"; import { FieldComponentProps } from "./FieldComponentProps"; import type { SeleniumProps } from "./SeleniumProps"; -import { useControlledState } from "./useControlledState"; +import { getControlState, useInputValues } from "./inputUtils"; export type SearchFieldProps = { /** @@ -85,7 +85,7 @@ const SearchField = forwardRef( ( { autoCompleteType, - defaultValue: uncontrolledValue, + defaultValue, hasInitialFocus, id: idOverride, isDisabled = false, @@ -97,35 +97,33 @@ const SearchField = forwardRef( onClear: onClearProp, placeholder, testId, - value: controlledValue, + value, }, ref ) => { - const [localValue, setLocalValue] = useControlledState({ - controlledValue, - uncontrolledValue, - }); - const onChange: ChangeEventHandler = useCallback( (event) => { - setLocalValue(event.currentTarget.value); onChangeProp?.(event); }, - [onChangeProp, setLocalValue] + [onChangeProp] ); const onClear = useCallback(() => { - setLocalValue(""); onClearProp?.(); - }, [onClearProp, setLocalValue]); + }, [onClearProp]); - const inputValues = useMemo(() => { - if (localValue === undefined) { - return { defaultValue: uncontrolledValue }; - } - return { value: localValue }; - }, [uncontrolledValue, localValue]); + const controlledStateRef = useRef( + getControlState({ + controlledValue: value, + uncontrolledValue: defaultValue, + }) + ); + const inputValues = useInputValues({ + defaultValue, + value, + controlState: controlledStateRef.current, + }); const renderFieldComponent = useCallback( ({ ariaDescribedBy, id }) => ( @@ -137,7 +135,7 @@ const SearchField = forwardRef( autoFocus={hasInitialFocus} data-se={testId} endAdornment={ - uncontrolledValue && ( + defaultValue && ( ( ), [ autoCompleteType, + defaultValue, hasInitialFocus, inputValues, isDisabled, @@ -178,7 +177,6 @@ const SearchField = forwardRef( placeholder, ref, testId, - uncontrolledValue, ] ); diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index ab7a917068..5faeeaf274 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Checkbox as MuiCheckbox, @@ -26,7 +26,11 @@ import { Field } from "./Field"; import { FieldComponentProps } from "./FieldComponentProps"; import { CheckIcon } from "./icons.generated"; import type { SeleniumProps } from "./SeleniumProps"; -import { useControlledState } from "./useControlledState"; +import { + ComponentControlledState, + useInputValues, + getControlState, +} from "./inputUtils"; export type SelectOption = { text: string; @@ -99,6 +103,7 @@ export type SelectProps< * - { text: string, type: "heading" } — Used to display a group heading with the text */ +const { CONTROLLED } = ComponentControlledState; const Select = < Value extends SelectValueType, HasMultipleChoices extends boolean @@ -127,29 +132,38 @@ const Select = < : hasMultipleChoicesProp, [hasMultipleChoicesProp, isMultiSelect] ); - - const [localSelectedValue, setLocalSelectedValue] = useControlledState( - { controlledValue: value, uncontrolledValue: defaultValue } + const controlledStateRef = useRef( + getControlState({ controlledValue: value, uncontrolledValue: defaultValue }) + ); + const [internalSelectedValues, setInternalSelectedValues] = useState( + controlledStateRef.current === CONTROLLED ? value : defaultValue ); - const inputValues = useMemo(() => { - if (localSelectedValue === undefined) { - return { defaultValue }; + useEffect(() => { + if (controlledStateRef.current === CONTROLLED) { + setInternalSelectedValues(value); } - return { value: localSelectedValue }; - }, [localSelectedValue, defaultValue]); + }, [value]); + + const inputValues = useInputValues({ + defaultValue, + value, + controlState: controlledStateRef.current, + }); const onChange = useCallback["onChange"]>>( (event, child) => { const { target: { value }, } = event; - setLocalSelectedValue( - (typeof value === "string" ? value.split(",") : value) as Value - ); + if (controlledStateRef.current !== CONTROLLED) { + setInternalSelectedValues( + (typeof value === "string" ? value.split(",") : value) as Value + ); + } onChangeProp?.(event, child); }, - [onChangeProp, setLocalSelectedValue] + [onChangeProp] ); // Normalize the options array to accommodate the various @@ -210,16 +224,15 @@ const Select = < if (option.type === "heading") { return {option.text}; } - return ( {hasMultipleChoices && ( )} {option.text} - {localSelectedValue == option.value && ( + {internalSelectedValues === option.value && ( @@ -227,7 +240,7 @@ const Select = < ); }), - [hasMultipleChoices, normalizedOptions, localSelectedValue] + [hasMultipleChoices, normalizedOptions, internalSelectedValues] ); const renderFieldComponent = useCallback( diff --git a/packages/odyssey-react-mui/src/TextField.tsx b/packages/odyssey-react-mui/src/TextField.tsx index 89d8a105f8..60fa8a7714 100644 --- a/packages/odyssey-react-mui/src/TextField.tsx +++ b/packages/odyssey-react-mui/src/TextField.tsx @@ -18,14 +18,14 @@ import { memo, ReactElement, useCallback, - useMemo, + useRef, } from "react"; import { InputAdornment, InputBase } from "@mui/material"; import { FieldComponentProps } from "./FieldComponentProps"; import { Field } from "./Field"; import { SeleniumProps } from "./SeleniumProps"; -import { useControlledState } from "./useControlledState"; +import { useInputValues, getControlState } from "./inputUtils"; export const textFieldTypeValues = [ "email", @@ -116,35 +116,35 @@ const TextField = forwardRef( startAdornment, testId, type = "text", - value: valueProp, + value: value, }, ref ) => { - const [localValue, setLocalValue] = useControlledState({ - controlledValue: valueProp, - uncontrolledValue: defaultValue, + const controlledStateRef = useRef( + getControlState({ + controlledValue: value, + uncontrolledValue: defaultValue, + }) + ); + const inputValues = useInputValues({ + defaultValue, + value, + controlState: controlledStateRef.current, }); - const inputValue = useMemo(() => { - if (localValue === undefined) { - return { defaultValue }; - } - return { value: localValue }; - }, [localValue, defaultValue]); const onChange = useCallback< NonNullable> >( (event) => { - setLocalValue(event.target.value); onChangeProp?.(event); }, - [onChangeProp, setLocalValue] + [onChangeProp] ); const renderFieldComponent = useCallback( ({ ariaDescribedBy, errorMessageElementId, id, labelElementId }) => ( ( ), [ autoCompleteType, - inputValue, + inputValues, hasInitialFocus, endAdornment, isMultiline, diff --git a/packages/odyssey-react-mui/src/inputUtils.ts b/packages/odyssey-react-mui/src/inputUtils.ts new file mode 100644 index 0000000000..4283cd15ee --- /dev/null +++ b/packages/odyssey-react-mui/src/inputUtils.ts @@ -0,0 +1,76 @@ +/*! + * Copyright (c) 2023-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { useMemo } from "react"; + +type UseControlledStateProps = { + controlledValue?: Value; // isChecked + uncontrolledValue?: Value; // isDefaultChecked +}; + +export const ComponentControlledState = { + CONTROLLED: "CONTROLLED", + UNCONTROLLED: "UNCONTROLLED", +}; + +export type ModeType = keyof typeof ComponentControlledState; +export type ModeTypeValue = (typeof ComponentControlledState)[ModeType]; + +export const getControlState = ({ + controlledValue, + uncontrolledValue, +}: UseControlledStateProps): ModeTypeValue => { + if (uncontrolledValue !== undefined || controlledValue === undefined) { + return ComponentControlledState.UNCONTROLLED; + } + return ComponentControlledState.CONTROLLED; +}; + +type InputValueProps = { + defaultValue?: Value; + value?: Value; + controlState: ModeTypeValue; +}; + +type InputValue = + | { + defaultValue: Value | undefined; + value?: undefined; + } + | { + value: Value | undefined; + defaultValue?: undefined; + }; + +/** + * In components that support being used in a controlled or uncontrolled way, the defaultValue and value props need + * to be suppled values in a mutually exclusive way. + * If a `value` is being provided to the component, then it is being used in a controlled manner and `defaultValue` needs to be undefined. + * If `value` is undefined, then that means the component is being used in an uncontrolled way and `defaultValue` is either Value or undefined. + * This helper helps ensure this mutual exclusivity between the 2 props so the component can operate as expected. + * + * @param {InputValueProps}: { defaultValue: Value | undefined, value: Value | undefined } + * @returns {InputValue}: { defaultValue: Value | undefined, value?: undefined } | { defaultValue?: undefined, value: Value } + */ +export const useInputValues = ({ + defaultValue, + value, + controlState, +}: InputValueProps): InputValue => { + const inputValues = useMemo(() => { + if (controlState === ComponentControlledState.CONTROLLED) { + return { value }; + } + return { defaultValue }; + }, [defaultValue, value]); + return inputValues; +}; diff --git a/packages/odyssey-react-mui/src/labs/VirtualizedAutocomplete.tsx b/packages/odyssey-react-mui/src/labs/VirtualizedAutocomplete.tsx index 99a5a8e6cf..5a2bdfce22 100644 --- a/packages/odyssey-react-mui/src/labs/VirtualizedAutocomplete.tsx +++ b/packages/odyssey-react-mui/src/labs/VirtualizedAutocomplete.tsx @@ -17,12 +17,16 @@ import { UseAutocompleteProps, AutocompleteValue, } from "@mui/material"; -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback, useMemo, useRef } from "react"; import { Field } from "../Field"; import { FieldComponentProps } from "../FieldComponentProps"; import type { SeleniumProps } from "../SeleniumProps"; -import { useControlledState } from "../useControlledState"; +import { + ComponentControlledState, + getControlState, + useInputValues, +} from "../inputUtils"; export type AutocompleteProps< OptionType, @@ -196,6 +200,44 @@ const VirtualizedAutocomplete = < getIsOptionEqualToValue, testId, }: AutocompleteProps) => { + const controlledStateRef = useRef( + getControlState({ controlledValue: value, uncontrolledValue: defaultValue }) + ); + const defaultValueProp = useMemo< + | AutocompleteValue< + OptionType, + HasMultipleChoices, + undefined, + IsCustomValueAllowed + > + | undefined + >(() => { + if (hasMultipleChoices) { + return defaultValue === undefined + ? ([] as AutocompleteValue< + OptionType, + HasMultipleChoices, + undefined, + IsCustomValueAllowed + >) + : defaultValue; + } + return defaultValue ?? undefined; + }, [defaultValue, hasMultipleChoices]); + + const valueProps = useInputValues({ + defaultValue: defaultValueProp, + value: value, + controlState: controlledStateRef.current, + }); + + const inputValueProp = useMemo(() => { + if (controlledStateRef.current === ComponentControlledState.CONTROLLED) { + return { inputValue }; + } + return undefined; + }, [inputValue]); + const renderInput = useCallback( ({ InputLabelProps, InputProps, ...params }) => ( - | undefined - >(() => { - if (hasMultipleChoices) { - return defaultValue === undefined - ? ([] as AutocompleteValue< - OptionType, - HasMultipleChoices, - undefined, - IsCustomValueAllowed - >) - : defaultValue; - } - return defaultValue ?? undefined; - }, [defaultValue, hasMultipleChoices]); - - const [localValue, setLocalValue] = useControlledState({ - controlledValue: value, - uncontrolledValue: defaultValuesProp, - }); - - const valueProps = useMemo(() => { - if (localValue === undefined) { - return { defaultValue: defaultValuesProp }; - } - return { value: localValue }; - }, [defaultValuesProp, localValue]); - - const [localInputValue, setLocalInputValue] = useControlledState({ - controlledValue: inputValue, - uncontrolledValue: undefined, - }); - - const inputValueProp = useMemo(() => { - return { - inputValue: inputValue === undefined ? undefined : localInputValue, - }; - }, [inputValue, localInputValue]); - const onChange = useCallback< NonNullable< UseAutocompleteProps< @@ -287,10 +283,9 @@ const VirtualizedAutocomplete = < > >( (event, value, reason, details) => { - setLocalValue(value); onChangeProp?.(event, value, reason, details); }, - [onChangeProp, setLocalValue] + [onChangeProp] ); const onInputChange = useCallback< @@ -304,10 +299,9 @@ const VirtualizedAutocomplete = < > >( (event, value, reason) => { - setLocalInputValue(value); onInputChangeProp?.(event, value, reason); }, - [onInputChangeProp, setLocalInputValue] + [onInputChangeProp] ); return ( diff --git a/packages/odyssey-react-mui/src/useControlledState.ts b/packages/odyssey-react-mui/src/useControlledState.ts deleted file mode 100644 index 9b7c52d481..0000000000 --- a/packages/odyssey-react-mui/src/useControlledState.ts +++ /dev/null @@ -1,56 +0,0 @@ -/*! - * Copyright (c) 2023-present, Okta, Inc. and/or its affiliates. All rights reserved. - * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") - * - * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * - * See the License for the specific language governing permissions and limitations under the License. - */ - -import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; - -type UseControlledStateProps = { - controlledValue?: Value; // isChecked - uncontrolledValue?: Value; // isDefaultChecked -}; - -type ControlledState = [ - stateValue: Value | undefined, - setState: Dispatch> -]; - -/** - * Use the same way as `useState`. Returns a stateful value, and a function to update it. - * When `initialState` is passed, the returned function to update it does nothing. This is - * useful to handle values in components that may be controlled externally when that value is - * passed in props and thus wish to prevent internal updates of the same value. - * - * @param initialState - * @see https://react.dev/reference/react/useState - */ -export const useControlledState = ({ - controlledValue, - uncontrolledValue, -}: UseControlledStateProps): ControlledState => { - const isControlledMode = useRef(controlledValue !== undefined); - const [stateValue, setStateValue] = useState( - isControlledMode.current ? controlledValue : uncontrolledValue - ); - - useEffect(() => { - if (isControlledMode.current) { - setStateValue(controlledValue); - } - }, [controlledValue]); - - const setState: typeof setStateValue = (value) => { - if (!isControlledMode.current) { - setStateValue(value); - } - }; - - return [stateValue, setState]; -}; diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Autocomplete/Autocomplete.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Autocomplete/Autocomplete.stories.tsx index 8e9b4158f4..62f6744cf0 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Autocomplete/Autocomplete.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Autocomplete/Autocomplete.stories.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { Autocomplete, AutocompleteProps } from "@okta/odyssey-react-mui"; +import { Autocomplete } from "@okta/odyssey-react-mui"; import { Meta, StoryObj } from "@storybook/react"; import { expect } from "@storybook/jest"; import { userEvent, waitFor, within, screen } from "@storybook/testing-library"; @@ -176,11 +176,7 @@ const storybookMeta: Meta = { export default storybookMeta; type StationType = { label: string }; -type AutocompleteType = AutocompleteProps< - StationType | undefined, - boolean | undefined, - boolean | undefined ->; +type AutocompleteType = typeof Autocomplete; export const Default: StoryObj = { play: async ({ canvasElement, step }) => { @@ -224,6 +220,7 @@ export const Disabled: StoryObj = { args: { isDisabled: true, value: { label: "Tycho Station" }, + getIsOptionEqualToValue: (option, value) => option.label === value.label, }, }; @@ -310,7 +307,8 @@ export const MultipleDisabled: StoryObj = { args: { hasMultipleChoices: true, isDisabled: true, - value: [{ label: "Tycho Station" }], + defaultValue: [{ label: "Tycho Station" }], + getIsOptionEqualToValue: (option, value) => option.label === value.label, }, }; @@ -318,7 +316,8 @@ export const MultipleReadOnly: StoryObj = { args: { hasMultipleChoices: true, isReadOnly: true, - value: [{ label: "Tycho Station" }], + defaultValue: [{ label: "Tycho Station" }], + getIsOptionEqualToValue: (option, value) => option.label === value.label, }, }; @@ -332,6 +331,7 @@ export const ReadOnly: StoryObj = { args: { isReadOnly: true, defaultValue: { label: "Tycho Station" }, + getIsOptionEqualToValue: (option, value) => option.label === value.label, }, };