diff --git a/.changeset/big-impalas-applaud.md b/.changeset/big-impalas-applaud.md new file mode 100644 index 00000000000..d14ce90b016 --- /dev/null +++ b/.changeset/big-impalas-applaud.md @@ -0,0 +1,5 @@ +--- +"@itwin/itwinui-react": patch +--- + +Fixed a bug in `ComboBox` where the `isSelected` passed to `itemRenderer` was always `false` whenever `multiple` was `true`. diff --git a/.changeset/happy-mirrors-stare.md b/.changeset/happy-mirrors-stare.md new file mode 100644 index 00000000000..bac218c3cee --- /dev/null +++ b/.changeset/happy-mirrors-stare.md @@ -0,0 +1,9 @@ +--- +"@itwin/itwinui-react": minor +--- + +Fixed a bug in `ComboBox` where the controlled state (`value` prop) was not given priority over the uncontrolled state. + +As a result: +* Setting the default value using `value={myDefaultValue}` will no longer work. Instead, use the new `defaultValue` prop. +* Resetting the value using `value={null}` will now force the ComboBox to be in *controlled* mode. If you want to reset the value but be in *uncontrolled* mode, then use `value={undefined}` instead. diff --git a/.changeset/six-buckets-shake.md b/.changeset/six-buckets-shake.md new file mode 100644 index 00000000000..09fbfe64358 --- /dev/null +++ b/.changeset/six-buckets-shake.md @@ -0,0 +1,5 @@ +--- +"@itwin/itwinui-react": minor +--- + +Added a new `defaultValue` prop to `ComboBox`. This is useful when you don't want to maintain your own state but still want to control the initial `value`. diff --git a/packages/itwinui-react/src/core/ComboBox/ComboBox.test.tsx b/packages/itwinui-react/src/core/ComboBox/ComboBox.test.tsx index b00ff681a8b..ede0d8fa304 100644 --- a/packages/itwinui-react/src/core/ComboBox/ComboBox.test.tsx +++ b/packages/itwinui-react/src/core/ComboBox/ComboBox.test.tsx @@ -488,6 +488,12 @@ it('should accept inputProps', () => { ); }); +it('should use the defaultValue when passed', () => { + const { container } = renderComponent({ defaultValue: 1 }); + const input = assertBaseElement(container); + expect(input).toHaveValue('Item 1'); +}); + it('should work with custom itemRenderer', async () => { const mockOnChange = vi.fn(); const { container, getByText } = renderComponent({ @@ -688,10 +694,39 @@ it('should accept ReactNode in emptyStateMessage', async () => { }); it('should programmatically clear value', async () => { - const mockOnChange = vi.fn(); const options = [0, 1, 2].map((value) => ({ value, label: `Item ${value}` })); const { container, rerender } = render( + , + ); + + const input = container.querySelector('.iui-input') as HTMLInputElement; + expect(input).toHaveValue('Item 1'); + + rerender(); + + // value={undefined} = reset value + uncontrolled state. + expect(input).toHaveValue(''); // should be reset + + await userEvent.tab(); + await userEvent.click(screen.getByText('Item 2')); + expect(input).toHaveValue('Item 2'); // uncontrolled state should work + + rerender(); + + // value={null} = reset value + controlled state. + expect(input).toHaveValue(''); // should be reset + + await userEvent.click(input); + await userEvent.click(screen.getByText('Item 0')); + expect(input).toHaveValue(''); // should not change since controlled state +}); + +it('should respect controlled state', async () => { + const mockOnChange = vi.fn(); + const options = [0, 1, 2].map((value) => ({ value, label: `Item ${value}` })); + + const { container } = render( , ); @@ -699,13 +734,9 @@ it('should programmatically clear value', async () => { await userEvent.click(screen.getByText('Item 2')); const input = container.querySelector('.iui-input') as HTMLInputElement; expect(mockOnChange).toHaveBeenCalledWith(2); - expect(input).toHaveValue('Item 2'); - rerender( - , - ); - expect(mockOnChange).not.toHaveBeenCalledTimes(2); - expect(input).toHaveValue(''); + // In controlled state, passed value should take priority + expect(input).toHaveValue('Item 1'); }); it('should update options (have selected option in new options list)', async () => { @@ -1010,7 +1041,7 @@ it('should update live region when selection changes', async () => { ({ value, label: `Item ${value}` }))} multiple - value={[0]} + defaultValue={[0]} />, ); diff --git a/packages/itwinui-react/src/core/ComboBox/ComboBox.tsx b/packages/itwinui-react/src/core/ComboBox/ComboBox.tsx index 185e9154a80..cb21a7ad498 100644 --- a/packages/itwinui-react/src/core/ComboBox/ComboBox.tsx +++ b/packages/itwinui-react/src/core/ComboBox/ComboBox.tsx @@ -14,15 +14,11 @@ import { useLayoutEffect, AutoclearingHiddenLiveRegion, useId, + useControlledState, } from '../../utils/index.js'; import { usePopover } from '../Popover/Popover.js'; import type { InputContainerProps, CommonProps } from '../../utils/index.js'; -import { - ComboBoxActionContext, - comboBoxReducer, - ComboBoxRefsContext, - ComboBoxStateContext, -} from './helpers.js'; +import { ComboBoxRefsContext, ComboBoxStateContext } from './helpers.js'; import { ComboBoxEndIcon } from './ComboBoxEndIcon.js'; import { ComboBoxInput } from './ComboBoxInput.js'; import { ComboBoxInputContainer } from './ComboBoxInputContainer.js'; @@ -31,10 +27,13 @@ import { ComboBoxMenuItem } from './ComboBoxMenuItem.js'; // Type guard for enabling multiple const isMultipleEnabled = ( - variable: (T | undefined) | (T[] | undefined), + variable: (T | null | undefined) | (T[] | null | undefined), multiple: boolean, -): variable is T[] | undefined => { - return multiple && (Array.isArray(variable) || variable === undefined); +): variable is T[] | null | undefined => { + return ( + multiple && + (Array.isArray(variable) || variable === null || variable === undefined) + ); }; // Type guard for user onChange @@ -64,8 +63,17 @@ export type ComboboxMultipleTypeProps = /** * Controlled value of ComboBox. * If `multiple` is enabled, it is an array of values. + * + * Pass `null` or `undefined` to reset the value. Apart from resetting the value: + * * `value={null}` will switch to/remain in the *controlled* state. + * * `value={undefined}` will switch to/remain in the *uncontrolled* state. + */ + value?: T | null | undefined; + /** + * Default value of `value` that is set on initial render. This is useful when you don't want to + * maintain your own state but still want to control the initial `value`. */ - value?: T; + defaultValue?: T | null; /** * Callback fired when selected value changes. */ @@ -73,7 +81,8 @@ export type ComboboxMultipleTypeProps = } | { multiple: true; - value?: T[]; + value?: T[] | null | undefined; + defaultValue?: T[] | null; onChange?: (value: T[], event: MultipleOnChangeProps) => void; }; @@ -172,11 +181,20 @@ export const ComboBox = React.forwardRef( ) => { const idPrefix = useId(); + const defaultFilterFunction = React.useCallback( + (options: SelectOption[], inputValue: string) => { + return options.filter((option) => + option.label.toLowerCase().includes(inputValue.toLowerCase()), + ); + }, + [], + ); + const { options, value: valueProp, onChange, - filterFunction, + filterFunction = defaultFilterFunction, inputProps, endIconProps, dropdownMenuProps, @@ -187,6 +205,7 @@ export const ComboBox = React.forwardRef( onShow: onShowProp, onHide: onHideProp, id = inputProps?.id ? `iui-${inputProps.id}-cb` : idPrefix, + defaultValue, ...rest } = props; @@ -195,67 +214,84 @@ export const ComboBox = React.forwardRef( const menuRef = React.useRef(null); const onChangeProp = useLatestRef(onChange); const optionsRef = useLatestRef(options); + const filterFunctionRef = useLatestRef(filterFunction); - // Record to store all extra information (e.g. original indexes), where the key is the id of the option - const optionsExtraInfoRef = React.useRef< - Record - >({}); + const optionsExtraInfo = React.useMemo(() => { + const newOptionsExtraInfo: Record = + {}; - // Clear the extra info when the options change so that it can be reinitialized below - React.useEffect(() => { - optionsExtraInfoRef.current = {}; - }, [options]); - - // Initialize the extra info only if it is not already initialized - if ( - options.length > 0 && - Object.keys(optionsExtraInfoRef.current).length === 0 - ) { options.forEach((option, index) => { - optionsExtraInfoRef.current[getOptionId(option, id)] = { + newOptionsExtraInfo[getOptionId(option, id)] = { __originalIndex: index, }; }); - } - // Get indices of selected elements in options array when we have selected values. - const getSelectedIndexes = React.useCallback(() => { - if (isMultipleEnabled(valueProp, multiple)) { - const indexArray: number[] = []; - valueProp?.forEach((value) => { - const indexToAdd = options.findIndex( - (option) => option.value === value, - ); - if (indexToAdd > -1) { - indexArray.push(indexToAdd); - } - }); - return indexArray; - } else { - return options.findIndex((option) => option.value === valueProp); - } - }, [multiple, options, valueProp]); - - // Reducer where all the component-wide state is stored - const [{ isOpen, selected, focusedIndex }, dispatch] = React.useReducer( - comboBoxReducer, - { - isOpen: false, - selected: getSelectedIndexes(), - focusedIndex: -1, + return newOptionsExtraInfo; + }, [id, options]); + + /** + * - When multiple is enabled, it is an array of indices. + * - When multiple is disabled, it is a single index; -1 if no item is selected. + */ + const getSelectedIndexes = React.useCallback( + (value: typeof valueProp) => { + // If value is undefined, use uncontrolled state + if (value === undefined) { + return undefined; + } + + if (isMultipleEnabled(value, multiple)) { + const indexArray: number[] = []; + value?.forEach((value) => { + const indexToAdd = options.findIndex( + (option) => option.value === value, + ); + if (indexToAdd > -1) { + indexArray.push(indexToAdd); + } + }); + return indexArray; + } else { + return options.findIndex((option) => option.value === value); + } }, + [multiple, options], + ); + + const [selectedIndexes, setSelectedIndexes] = useControlledState( + getSelectedIndexes(defaultValue) ?? (multiple ? [] : -1), + getSelectedIndexes(valueProp), ); + const previousValue = React.useRef(valueProp); + useLayoutEffect(() => { + if (valueProp !== previousValue.current) { + previousValue.current = valueProp; + + // Passing value={undefined} resets the value (needed to prevent a breaking change) + if (valueProp === undefined) { + if (isMultipleEnabled(selectedIndexes, multiple)) { + setSelectedIndexes([]); + } else { + setSelectedIndexes(-1); + } + } + } + }, [multiple, selectedIndexes, setSelectedIndexes, valueProp]); + + const [isOpen, setIsOpen] = React.useState(false); + const [focusedIndex, setFocusedIndex] = React.useState(-1); + const onShowRef = useLatestRef(onShowProp); const onHideRef = useLatestRef(onHideProp); const show = React.useCallback(() => { - dispatch({ type: 'open' }); + setIsOpen(true); onShowRef.current?.(); }, [onShowRef]); const hide = React.useCallback(() => { - dispatch({ type: 'close' }); + setIsOpen(false); onHideRef.current?.(); }, [onHideRef]); @@ -263,49 +299,70 @@ export const ComboBox = React.forwardRef( // When the dropdown opens if (isOpen) { inputRef.current?.focus(); // Focus the input - // Reset the filtered list (does not reset when multiple enabled) - if (!multiple) { - setFilteredOptions(optionsRef.current); - dispatch({ type: 'focus', value: undefined }); + if (!isMultipleEnabled(selectedIndexes, multiple)) { + setFocusedIndex(selectedIndexes ?? -1); } } // When the dropdown closes else { // Reset the focused index - dispatch({ type: 'focus', value: undefined }); - // Reset the input value if not multiple - if (!isMultipleEnabled(selected, multiple)) { + setFocusedIndex(-1); + // Reset/update the input value if not multiple + if (!isMultipleEnabled(selectedIndexes, multiple)) { setInputValue( - selected != undefined && selected >= 0 - ? optionsRef.current[selected]?.label + selectedIndexes >= 0 + ? optionsRef.current[selectedIndexes]?.label ?? '' : '', ); } } - }, [isOpen, multiple, optionsRef, selected]); + }, [isOpen, multiple, optionsRef, selectedIndexes]); - // Update filtered options to the latest value options according to input value - const [filteredOptions, setFilteredOptions] = React.useState(options); + // To reconfigure internal state whenever the options change + const previousOptions = React.useRef(options); React.useEffect(() => { - if (inputValue) { - setFilteredOptions( - filterFunction?.(options, inputValue) ?? - options.filter((option) => - option.label.toLowerCase().includes(inputValue.toLowerCase()), - ), - ); - } else { - setFilteredOptions(options); + if (options !== previousOptions.current) { + previousOptions.current = options; + onOptionsChange(); + } + + /** + * Should be called internally whenever the options change. + */ + function onOptionsChange() { + // If multiple=false, refocus the selected option. + // If no option is selected (i.e. selected === -1), reset the focus to the input. + if (!isMultipleEnabled(selectedIndexes, multiple)) { + setFocusedIndex(selectedIndexes); + } + // If multiple=true, reset the focus to the input. + else { + setFocusedIndex(-1); + } + + // Reset/update the input value if multiple=false and if the dropdown is closed (i.e. don't override user input when dropdown is open) + if (!isMultipleEnabled(selectedIndexes, multiple) && !isOpen) { + setInputValue( + selectedIndexes >= 0 ? options[selectedIndexes]?.label : '', + ); + } } - dispatch({ type: 'focus', value: undefined }); - // Only need to call on options update - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options]); + }, [options, isOpen, multiple, selectedIndexes]); // Filter options based on input value const [inputValue, setInputValue] = React.useState( inputProps?.value?.toString() ?? '', ); + const [isInputDirty, setIsInputDirty] = React.useState(false); + + const filteredOptions = React.useMemo(() => { + // We filter the list only when the user is typing (i.e. input is "dirty") + if (!isInputDirty) { + return options; + } + + return filterFunctionRef.current?.(options, inputValue); + }, [filterFunctionRef, inputValue, options, isInputDirty]); const [liveRegionSelection, setLiveRegionSelection] = React.useState(''); @@ -314,86 +371,63 @@ export const ComboBox = React.forwardRef( const { value } = event.currentTarget; setInputValue(value); show(); // reopen when typing - setFilteredOptions( - filterFunction?.(optionsRef.current, value) ?? - optionsRef.current.filter((option) => - option.label.toLowerCase().includes(value.toLowerCase()), - ), - ); + setIsInputDirty(true); + if (focusedIndex != -1) { - dispatch({ type: 'focus', value: -1 }); + setFocusedIndex(-1); } inputProps?.onChange?.(event); }, - [filterFunction, focusedIndex, inputProps, optionsRef, show], + [focusedIndex, inputProps, show], ); - // When the value prop changes, update the selected index/indices - React.useEffect(() => { - if (isMultipleEnabled(valueProp, multiple)) { - if (valueProp) { - // If user provided array of selected values - const indexes = valueProp.map((value) => { - return options.findIndex((option) => option.value === value); - }); - dispatch({ - type: 'multiselect', - value: indexes.filter((index) => index !== -1), // Add available options - }); - } else { - // if user provided one value or undefined - dispatch({ - type: 'multiselect', - value: [], // Add empty list - }); - } - } else { - dispatch({ - type: 'select', - value: options.findIndex((option) => option.value === valueProp), - }); - } - }, [valueProp, options, multiple]); - const isMenuItemSelected = React.useCallback( (index: number) => { - if (isMultipleEnabled(selected, multiple)) { - return !!selected.includes(index as number); + if (isMultipleEnabled(selectedIndexes, multiple)) { + return selectedIndexes.includes(index); } else { - return selected === index; + return selectedIndexes === index; } }, - [multiple, selected], + [multiple, selectedIndexes], ); - // Generates new array when item is added or removed + /** + * Generates new array when item is added or removed. Only applicable when multiple is enabled. + */ const selectedChangeHandler = React.useCallback( (__originalIndex: number, action: ActionType) => { + if (!isMultipleEnabled(selectedIndexes, multiple)) { + return; + } + if (action === 'added') { - return [...(selected as number[]), __originalIndex]; + return [...selectedIndexes, __originalIndex]; } else { - return (selected as number[]).filter( - (index) => index !== __originalIndex, - ); + return selectedIndexes?.filter((index) => index !== __originalIndex); } }, - [selected], + [selectedIndexes, multiple], ); - // Calls user defined onChange + /** + * Calls user defined onChange + */ const onChangeHandler = React.useCallback( ( __originalIndex: number, actionType?: ActionType, - newArray?: number[], + newSelectedIndexes?: number[], ) => { if (isSingleOnChange(onChangeProp.current, multiple)) { onChangeProp.current?.(optionsRef.current[__originalIndex]?.value); } else { actionType && - newArray && + newSelectedIndexes && onChangeProp.current?.( - newArray?.map((item) => optionsRef.current[item]?.value), + newSelectedIndexes?.map( + (index) => optionsRef.current[index]?.value, + ), { value: optionsRef.current[__originalIndex]?.value, type: actionType, @@ -412,23 +446,31 @@ export const ComboBox = React.forwardRef( return; } - if (isMultipleEnabled(selected, multiple)) { + setIsInputDirty(false); + if (multiple) { const actionType = isMenuItemSelected(__originalIndex) ? 'removed' : 'added'; - const newArray = selectedChangeHandler(__originalIndex, actionType); - dispatch({ type: 'multiselect', value: newArray }); - onChangeHandler(__originalIndex, actionType, newArray); + const newSelectedIndexes = selectedChangeHandler( + __originalIndex, + actionType, + ); + + if (newSelectedIndexes == null) { + return; + } + setSelectedIndexes(newSelectedIndexes); + onChangeHandler(__originalIndex, actionType, newSelectedIndexes); // update live region setLiveRegionSelection( - newArray + newSelectedIndexes .map((item) => optionsRef.current[item]?.label) .filter(Boolean) .join(', '), ); } else { - dispatch({ type: 'select', value: __originalIndex }); + setSelectedIndexes(__originalIndex); hide(); onChangeHandler(__originalIndex); } @@ -438,16 +480,16 @@ export const ComboBox = React.forwardRef( isMenuItemSelected, multiple, onChangeHandler, - selected, optionsRef, hide, + setSelectedIndexes, ], ); const getMenuItem = React.useCallback( (option: SelectOption, filteredIndex?: number) => { const optionId = getOptionId(option, id); - const { __originalIndex } = optionsExtraInfoRef.current[optionId]; + const { __originalIndex } = optionsExtraInfo[optionId]; const { icon, startIcon: startIconProp, @@ -460,7 +502,7 @@ export const ComboBox = React.forwardRef( const customItem = itemRenderer ? itemRenderer(option, { isFocused: focusedIndex === __originalIndex, - isSelected: selected === __originalIndex, + isSelected: isMenuItemSelected(__originalIndex), index: __originalIndex, id: optionId, }) @@ -507,7 +549,7 @@ export const ComboBox = React.forwardRef( isMenuItemSelected, itemRenderer, onClickHandler, - selected, + optionsExtraInfo, ], ); @@ -536,64 +578,66 @@ export const ComboBox = React.forwardRef( return ( - - + - - <> - { - const item = optionsRef.current[index]; + <> + { + const item = options[index]; + return ( ); }) - : undefined - } - /> - - - - {multiple ? ( - - ) : null} - - - {filteredOptions.length > 0 && !enableVirtualization - ? filteredOptions.map(getMenuItem) - : emptyContent} - - - + + + + {multiple ? ( + + ) : null} + + + {filteredOptions.length > 0 && !enableVirtualization + ? filteredOptions.map(getMenuItem) + : emptyContent} + + ); }, diff --git a/packages/itwinui-react/src/core/ComboBox/ComboBoxInput.tsx b/packages/itwinui-react/src/core/ComboBox/ComboBoxInput.tsx index 1ee9304d362..f573b64c951 100644 --- a/packages/itwinui-react/src/core/ComboBox/ComboBoxInput.tsx +++ b/packages/itwinui-react/src/core/ComboBox/ComboBoxInput.tsx @@ -13,11 +13,7 @@ import { } from '../../utils/index.js'; import type { PolymorphicForwardRefComponent } from '../../utils/index.js'; import { ComboBoxMultipleContainer } from './ComboBoxMultipleContainer.js'; -import { - ComboBoxStateContext, - ComboBoxActionContext, - ComboBoxRefsContext, -} from './helpers.js'; +import { ComboBoxStateContext, ComboBoxRefsContext } from './helpers.js'; type ComboBoxInputProps = { selectTags?: JSX.Element[] } & React.ComponentProps< typeof Input @@ -30,6 +26,7 @@ export const ComboBoxInput = React.forwardRef((props, forwardedRef) => { isOpen, id, focusedIndex, + setFocusedIndex, enableVirtualization, multiple, onClickHandler, @@ -37,8 +34,7 @@ export const ComboBoxInput = React.forwardRef((props, forwardedRef) => { show, hide, } = useSafeContext(ComboBoxStateContext); - const dispatch = useSafeContext(ComboBoxActionContext); - const { inputRef, menuRef, optionsExtraInfoRef } = + const { inputRef, menuRef, optionsExtraInfo } = useSafeContext(ComboBoxRefsContext); const refs = useMergedRefs(inputRef, popover.refs.setReference, forwardedRef); @@ -52,7 +48,7 @@ export const ComboBoxInput = React.forwardRef((props, forwardedRef) => { const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - const length = Object.keys(optionsExtraInfoRef.current).length ?? 0; + const length = Object.keys(optionsExtraInfo).length ?? 0; if (event.altKey) { return; @@ -72,12 +68,9 @@ export const ComboBoxInput = React.forwardRef((props, forwardedRef) => { if (focusedIndexRef.current === -1) { const currentElement = menuRef.current?.querySelector('[data-iui-index]'); - return dispatch({ - type: 'focus', - value: Number( - currentElement?.getAttribute('data-iui-index') ?? 0, - ), - }); + return setFocusedIndex( + Number(currentElement?.getAttribute('data-iui-index') ?? 0), + ); } // If virtualization is enabled, dont let round scrolling @@ -101,7 +94,7 @@ export const ComboBoxInput = React.forwardRef((props, forwardedRef) => { nextIndex = Number(nextElement?.getAttribute('data-iui-index')); if (nextElement) { - return dispatch({ type: 'focus', value: nextIndex }); + return setFocusedIndex(nextIndex); } } while (nextIndex !== focusedIndexRef.current); break; @@ -116,7 +109,7 @@ export const ComboBoxInput = React.forwardRef((props, forwardedRef) => { return; } - // If virtualization is enabled, dont let round scrolling + // If virtualization is enabled, don't let round scrolling if ( enableVirtualization && !menuRef.current?.querySelector( @@ -127,12 +120,10 @@ export const ComboBoxInput = React.forwardRef((props, forwardedRef) => { } if (focusedIndexRef.current === -1) { - return dispatch({ - type: 'focus', - value: - Object.values(optionsExtraInfoRef.current)?.[length - 1] - .__originalIndex ?? -1, - }); + return setFocusedIndex( + Object.values(optionsExtraInfo)?.[length - 1].__originalIndex ?? + -1, + ); } let prevIndex = focusedIndexRef.current; @@ -146,7 +137,7 @@ export const ComboBoxInput = React.forwardRef((props, forwardedRef) => { prevIndex = Number(prevElement?.getAttribute('data-iui-index')); if (prevElement) { - return dispatch({ type: 'focus', value: prevIndex }); + return setFocusedIndex(prevIndex); } } while (prevIndex !== focusedIndexRef.current); break; @@ -173,13 +164,13 @@ export const ComboBoxInput = React.forwardRef((props, forwardedRef) => { } }, [ - dispatch, + setFocusedIndex, enableVirtualization, focusedIndexRef, isOpen, menuRef, onClickHandler, - optionsExtraInfoRef, + optionsExtraInfo, show, hide, ], diff --git a/packages/itwinui-react/src/core/ComboBox/helpers.ts b/packages/itwinui-react/src/core/ComboBox/helpers.ts index 6e7412b1a66..8343a5b1a74 100644 --- a/packages/itwinui-react/src/core/ComboBox/helpers.ts +++ b/packages/itwinui-react/src/core/ComboBox/helpers.ts @@ -6,69 +6,11 @@ import * as React from 'react'; import type { SelectOption } from '../Select/Select.js'; import type { usePopover } from '../Popover/Popover.js'; -type ComboBoxAction = - | { type: 'multiselect'; value: number[] } - | { type: 'open' } - | { type: 'close' } - | { type: 'select'; value: number } - | { type: 'focus'; value: number | undefined }; - -export const comboBoxReducer = ( - state: { - isOpen: boolean; - selected: number | number[]; - focusedIndex: number; - }, - action: ComboBoxAction, -) => { - switch (action.type) { - case 'open': { - return { ...state, isOpen: true }; - } - case 'close': { - return { ...state, isOpen: false }; - } - case 'select': { - if (Array.isArray(state.selected)) { - return { ...state }; - } - return { - ...state, - selected: action.value ?? state.selected, - focusedIndex: action.value ?? state.focusedIndex, - }; - } - case 'multiselect': { - if (!Array.isArray(state.selected)) { - return { ...state }; - } - return { ...state, selected: action.value }; - } - case 'focus': { - if (Array.isArray(state.selected)) { - return { - ...state, - focusedIndex: action.value ?? -1, - }; - } - return { - ...state, - focusedIndex: action.value ?? state.selected ?? -1, - }; - } - default: { - return state; - } - } -}; - export const ComboBoxRefsContext = React.createContext< | { inputRef: React.RefObject; menuRef: React.RefObject; - optionsExtraInfoRef: React.MutableRefObject< - Record - >; + optionsExtraInfo: Record; } | undefined >(undefined); @@ -81,7 +23,8 @@ type ComboBoxStateContextProps = { filteredOptions: SelectOption[]; onClickHandler?: (prop: number) => void; getMenuItem: (option: SelectOption, filteredIndex?: number) => JSX.Element; - focusedIndex?: number; + focusedIndex: number; + setFocusedIndex: React.Dispatch>; multiple?: boolean; popover: ReturnType; show: () => void; @@ -92,8 +35,3 @@ export const ComboBoxStateContext = React.createContext< ComboBoxStateContextProps | undefined >(undefined); ComboBoxStateContext.displayName = 'ComboBoxStateContext'; - -export const ComboBoxActionContext = React.createContext< - ((x: ComboBoxAction) => void) | undefined ->(undefined); -ComboBoxActionContext.displayName = 'ComboBoxActionContext'; diff --git a/packages/itwinui-react/src/utils/hooks/useControlledState.ts b/packages/itwinui-react/src/utils/hooks/useControlledState.ts index 324a9d41268..c41ca5ecc58 100644 --- a/packages/itwinui-react/src/utils/hooks/useControlledState.ts +++ b/packages/itwinui-react/src/utils/hooks/useControlledState.ts @@ -17,14 +17,16 @@ import * as React from 'react'; */ export const useControlledState = ( initialValue: T, - controlledState: T, + controlledState: T | undefined, setControlledState?: React.Dispatch>, ) => { const [uncontrolledState, setUncontrolledState] = React.useState(initialValue); - const state = - controlledState !== undefined ? controlledState : uncontrolledState; + const state = React.useMemo( + () => (controlledState !== undefined ? controlledState : uncontrolledState), + [controlledState, uncontrolledState], + ); const setState = React.useCallback( (value) => {