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) => {