Skip to content

Commit dbef0b0

Browse files
committed
feat(ComboBox): default input value
1 parent 69936e5 commit dbef0b0

File tree

4 files changed

+163
-10
lines changed

4 files changed

+163
-10
lines changed

src/components/fields/ComboBox/ComboBox.docs.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ A combobox component that combines a text input with a dropdown list, allowing u
2828

2929
#### Notable Properties
3030

31+
**defaultInputValue** - Sets the initial input value in uncontrolled mode. This is useful when you want to display a specific text in the input field on mount without selecting any option. When both `defaultInputValue` and `defaultSelectedKey` are provided, `defaultInputValue` takes priority. The value can be changed by the user and is not enforced after initial render.
32+
3133
**sortSelectedToTop** - When using the `items` prop (data-driven mode), the selected item will automatically appear at the top of the list when the popover opens. This is enabled by default when using `items`, but disabled when using JSX children. Set to `false` to maintain the original order.
3234

3335
**allowsCustomValue** - Enables users to enter values that aren't in the predefined options list. Custom values can be committed by pressing Enter or on blur (controlled by `shouldCommitOnBlur`). **Important**: When enabled, typing in the input will clear the current selection. When disabled (default), typing only filters the options without clearing the selection.
@@ -148,7 +150,7 @@ The `mods` property accepts the following modifiers you can override:
148150
</ComboBox>
149151
```
150152

151-
### With Default Value
153+
### With Default Selected Value
152154

153155
<Story of={ComboBoxStories.WithDefaultValue} />
154156

src/components/fields/ComboBox/ComboBox.stories.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,19 @@ export const WithDefaultValue = () => (
238238
</ComboBox>
239239
);
240240

241+
export const WithDefaultInputValue = () => (
242+
<ComboBox
243+
label="Search Query"
244+
placeholder="Start typing..."
245+
defaultInputValue="Pre-filled search text"
246+
>
247+
<ComboBox.Item key="apple">Apple</ComboBox.Item>
248+
<ComboBox.Item key="banana">Banana</ComboBox.Item>
249+
<ComboBox.Item key="cherry">Cherry</ComboBox.Item>
250+
<ComboBox.Item key="date">Date</ComboBox.Item>
251+
</ComboBox>
252+
);
253+
241254
export const Controlled = () => {
242255
const [selectedKey, setSelectedKey] = useState<string | null>('apple');
243256

src/components/fields/ComboBox/ComboBox.test.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,4 +1005,123 @@ describe('<ComboBox />', () => {
10051005
expect(onBlur).toHaveBeenCalledTimes(1);
10061006
});
10071007
});
1008+
1009+
it('should initialize input with defaultInputValue', async () => {
1010+
const { getByRole } = renderWithRoot(
1011+
<ComboBox label="test" defaultInputValue="Initial text">
1012+
{items.map((item) => (
1013+
<ComboBox.Item key={item.key}>{item.children}</ComboBox.Item>
1014+
))}
1015+
</ComboBox>,
1016+
);
1017+
1018+
const combobox = getByRole('combobox');
1019+
1020+
// Input should show the defaultInputValue immediately
1021+
expect(combobox).toHaveValue('Initial text');
1022+
});
1023+
1024+
it('should prioritize defaultInputValue over defaultSelectedKey', async () => {
1025+
const { getByRole } = renderWithRoot(
1026+
<ComboBox
1027+
label="test"
1028+
defaultInputValue="Custom initial text"
1029+
defaultSelectedKey="green"
1030+
>
1031+
{items.map((item) => (
1032+
<ComboBox.Item key={item.key}>{item.children}</ComboBox.Item>
1033+
))}
1034+
</ComboBox>,
1035+
);
1036+
1037+
const combobox = getByRole('combobox');
1038+
1039+
// Input should show defaultInputValue, not the label from defaultSelectedKey
1040+
expect(combobox).toHaveValue('Custom initial text');
1041+
});
1042+
1043+
it('should allow typing when defaultInputValue is set', async () => {
1044+
const onInputChange = jest.fn();
1045+
1046+
const { getByRole } = renderWithRoot(
1047+
<ComboBox
1048+
label="test"
1049+
defaultInputValue="Initial"
1050+
onInputChange={onInputChange}
1051+
>
1052+
{items.map((item) => (
1053+
<ComboBox.Item key={item.key}>{item.children}</ComboBox.Item>
1054+
))}
1055+
</ComboBox>,
1056+
);
1057+
1058+
const combobox = getByRole('combobox');
1059+
1060+
// Clear and type new value
1061+
await userEvent.clear(combobox);
1062+
await userEvent.type(combobox, 'New text');
1063+
1064+
expect(combobox).toHaveValue('New text');
1065+
expect(onInputChange).toHaveBeenCalled();
1066+
});
1067+
1068+
it('should work with defaultInputValue in allowsCustomValue mode', async () => {
1069+
const onSelectionChange = jest.fn();
1070+
1071+
const { getByRole } = renderWithRoot(
1072+
<ComboBox
1073+
allowsCustomValue
1074+
label="test"
1075+
defaultInputValue="Custom value"
1076+
onSelectionChange={onSelectionChange}
1077+
>
1078+
{items.map((item) => (
1079+
<ComboBox.Item key={item.key}>{item.children}</ComboBox.Item>
1080+
))}
1081+
</ComboBox>,
1082+
);
1083+
1084+
const combobox = getByRole('combobox');
1085+
1086+
// Input should show defaultInputValue
1087+
expect(combobox).toHaveValue('Custom value');
1088+
1089+
// Focus and modify the input, then blur to commit
1090+
await act(async () => {
1091+
combobox.focus();
1092+
});
1093+
1094+
await userEvent.clear(combobox);
1095+
await userEvent.type(combobox, 'Modified value');
1096+
1097+
await act(async () => {
1098+
combobox.blur();
1099+
});
1100+
1101+
await waitFor(() => {
1102+
expect(onSelectionChange).toHaveBeenCalledWith('Modified value');
1103+
});
1104+
});
1105+
1106+
it('should not interfere with controlled inputValue', async () => {
1107+
const onInputChange = jest.fn();
1108+
1109+
const { getByRole } = renderWithRoot(
1110+
<ComboBox
1111+
label="test"
1112+
inputValue="Controlled value"
1113+
defaultInputValue="Should be ignored"
1114+
onInputChange={onInputChange}
1115+
>
1116+
{items.map((item) => (
1117+
<ComboBox.Item key={item.key}>{item.children}</ComboBox.Item>
1118+
))}
1119+
</ComboBox>,
1120+
);
1121+
1122+
const combobox = getByRole('combobox');
1123+
1124+
// Input should show controlled value, not defaultInputValue
1125+
expect(combobox).toHaveValue('Controlled value');
1126+
});
10081127
});

src/components/fields/ComboBox/ComboBox.tsx

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ export interface CubeComboBoxProps<T>
126126

127127
/** The input value in controlled mode */
128128
inputValue?: string;
129+
/** The default input value in uncontrolled mode */
130+
defaultInputValue?: string;
129131
/** Callback fired when input value changes */
130132
onInputChange?: (value: string) => void;
131133
/** Placeholder text for the input */
@@ -239,6 +241,7 @@ interface UseComboBoxStateProps {
239241
selectedKey?: string | null;
240242
defaultSelectedKey?: string | null;
241243
inputValue?: string;
244+
defaultInputValue?: string;
242245
comboBoxId: string;
243246
}
244247

@@ -257,6 +260,7 @@ function useComboBoxState({
257260
selectedKey,
258261
defaultSelectedKey,
259262
inputValue,
263+
defaultInputValue,
260264
comboBoxId,
261265
}: UseComboBoxStateProps): UseComboBoxStateReturn {
262266
// Get event bus for menu synchronization
@@ -266,7 +270,9 @@ function useComboBoxState({
266270
const [internalSelectedKey, setInternalSelectedKey] = useState<Key | null>(
267271
defaultSelectedKey ?? null,
268272
);
269-
const [internalInputValue, setInternalInputValue] = useState('');
273+
const [internalInputValue, setInternalInputValue] = useState(
274+
defaultInputValue ?? '',
275+
);
270276

271277
const isControlledKey = selectedKey !== undefined;
272278
const isControlledInput = inputValue !== undefined;
@@ -1006,6 +1012,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
10061012
selectedKey,
10071013
defaultSelectedKey,
10081014
inputValue,
1015+
defaultInputValue,
10091016
onInputChange,
10101017
isClearable,
10111018
onClear,
@@ -1048,6 +1055,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
10481055
selectedKey,
10491056
defaultSelectedKey,
10501057
inputValue,
1058+
defaultInputValue,
10511059
comboBoxId,
10521060
});
10531061

@@ -1335,23 +1343,34 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
13351343
},
13361344
);
13371345

1338-
// Initialize input value from default selected key (uncontrolled mode only, one-time)
1346+
// Initialize input value from defaultInputValue or defaultSelectedKey (uncontrolled mode only, one-time)
13391347
const [hasInitialized, setHasInitialized] = useState(false);
13401348

13411349
useEffect(() => {
1342-
// Only initialize once, in uncontrolled input mode, when defaultSelectedKey is provided
1343-
if (hasInitialized || isControlledInput || !defaultSelectedKey) return;
1350+
// Only initialize once, in uncontrolled input mode
1351+
if (hasInitialized || isControlledInput) return;
13441352

1345-
// Wait for collection to be ready
1346-
if (!listStateRef.current?.collection) return;
1353+
// Priority 1: defaultInputValue takes precedence
1354+
if (defaultInputValue !== undefined) {
1355+
setInternalInputValue(defaultInputValue);
1356+
setHasInitialized(true);
1357+
return;
1358+
}
1359+
1360+
// Priority 2: fall back to defaultSelectedKey's label
1361+
if (defaultSelectedKey) {
1362+
// Wait for collection to be ready
1363+
if (!listStateRef.current?.collection) return;
13471364

1348-
const label = getItemLabel(defaultSelectedKey);
1365+
const label = getItemLabel(defaultSelectedKey);
13491366

1350-
setInternalInputValue(label);
1351-
setHasInitialized(true);
1367+
setInternalInputValue(label);
1368+
setHasInitialized(true);
1369+
}
13521370
}, [
13531371
hasInitialized,
13541372
isControlledInput,
1373+
defaultInputValue,
13551374
defaultSelectedKey,
13561375
getItemLabel,
13571376
children,

0 commit comments

Comments
 (0)