Skip to content

Commit fea4349

Browse files
committed
feat(ComboBox): clear on blur
1 parent 237da26 commit fea4349

File tree

4 files changed

+199
-0
lines changed

4 files changed

+199
-0
lines changed

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ A combobox component that combines a text input with a dropdown list, allowing u
3434

3535
**shouldCommitOnBlur** - When `true` (default), custom values are committed when the input loses focus. When `false`, custom values only commit when Enter is pressed. Only applies when `allowsCustomValue` is enabled.
3636

37+
**clearOnBlur** - When `true`, clears both the selection and input when the input loses focus. When `false` (default), the input reverts to showing the current selection. Only applies to non-custom-value mode. Useful for search-like scenarios where you want to reset after each interaction.
38+
3739
**popoverTrigger** - Controls how the popover opens:
3840
- `input` (default) - Opens when user types
3941
- `focus` - Opens when input receives focus
@@ -217,6 +219,26 @@ const [inputValue, setInputValue] = useState('');
217219
</ComboBox>
218220
```
219221

222+
### Clear on Blur
223+
224+
<Story of={ComboBoxStories.ClearOnBlur} />
225+
226+
```jsx
227+
const [selectedKey, setSelectedKey] = useState(null);
228+
229+
<ComboBox
230+
clearOnBlur
231+
label="Fruit"
232+
placeholder="Select a fruit..."
233+
selectedKey={selectedKey}
234+
onSelectionChange={setSelectedKey}
235+
>
236+
<ComboBox.Item key="apple">Apple</ComboBox.Item>
237+
<ComboBox.Item key="banana">Banana</ComboBox.Item>
238+
<ComboBox.Item key="cherry">Cherry</ComboBox.Item>
239+
</ComboBox>
240+
```
241+
220242
### With Sections
221243

222244
<Story of={ComboBoxStories.WithSections} />

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ const meta = {
7373
defaultValue: { summary: true },
7474
},
7575
},
76+
clearOnBlur: {
77+
control: { type: 'boolean' },
78+
description:
79+
'Whether to clear selection and input on blur (only applies to non-custom-value mode)',
80+
table: {
81+
defaultValue: { summary: false },
82+
},
83+
},
7684
isClearable: {
7785
control: { type: 'boolean' },
7886
description:
@@ -334,6 +342,37 @@ export const Clearable = () => (
334342
</ComboBox>
335343
);
336344

345+
export const ClearOnBlur = () => {
346+
const [selectedKey, setSelectedKey] = useState<string | null>(null);
347+
348+
return (
349+
<div>
350+
<ComboBox
351+
clearOnBlur
352+
label="Fruit"
353+
placeholder="Select a fruit..."
354+
selectedKey={selectedKey}
355+
onSelectionChange={setSelectedKey}
356+
>
357+
<ComboBox.Item key="apple">Apple</ComboBox.Item>
358+
<ComboBox.Item key="banana">Banana</ComboBox.Item>
359+
<ComboBox.Item key="cherry">Cherry</ComboBox.Item>
360+
<ComboBox.Item key="date">Date</ComboBox.Item>
361+
</ComboBox>
362+
<div style={{ marginTop: '16px' }}>
363+
<div>
364+
Selected: <strong>{selectedKey || 'none'}</strong>
365+
</div>
366+
<div style={{ fontSize: '12px', color: '#666', marginTop: '8px' }}>
367+
With clearOnBlur, selection and input are cleared when the input loses
368+
focus. This is useful for search-like scenarios where you want to
369+
reset after each interaction.
370+
</div>
371+
</div>
372+
</div>
373+
);
374+
};
375+
337376
export const WithSections = () => (
338377
<ComboBox label="Food" placeholder="Select food...">
339378
<ComboBox.Section key="fruits" title="Fruits">

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

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,128 @@ describe('<ComboBox />', () => {
463463
});
464464
});
465465

466+
it('should clear selection on blur when clearOnBlur is true', async () => {
467+
const onSelectionChange = jest.fn();
468+
469+
const { getByRole, getAllByRole, queryByRole } = renderWithRoot(
470+
<ComboBox clearOnBlur label="test" onSelectionChange={onSelectionChange}>
471+
{items.map((item) => (
472+
<ComboBox.Item key={item.key}>{item.children}</ComboBox.Item>
473+
))}
474+
</ComboBox>,
475+
);
476+
477+
const combobox = getByRole('combobox');
478+
479+
// Type to filter and open popover
480+
await userEvent.type(combobox, 're');
481+
482+
await waitFor(() => {
483+
expect(queryByRole('listbox')).toBeInTheDocument();
484+
});
485+
486+
// Click on first option (Red)
487+
const options = getAllByRole('option');
488+
await userEvent.click(options[0]);
489+
490+
// Verify selection was made
491+
await waitFor(() => {
492+
expect(onSelectionChange).toHaveBeenCalledWith('red');
493+
expect(combobox).toHaveValue('Red');
494+
});
495+
496+
// Blur the input
497+
await act(async () => {
498+
combobox.blur();
499+
});
500+
501+
// Should clear selection on blur
502+
await waitFor(() => {
503+
expect(onSelectionChange).toHaveBeenCalledWith(null);
504+
expect(combobox).toHaveValue('');
505+
});
506+
}, 15000);
507+
508+
it('should maintain selection on blur without clearOnBlur flag', async () => {
509+
const onSelectionChange = jest.fn();
510+
511+
const { getByRole, getAllByRole, queryByRole } = renderWithRoot(
512+
<ComboBox label="test" onSelectionChange={onSelectionChange}>
513+
{items.map((item) => (
514+
<ComboBox.Item key={item.key}>{item.children}</ComboBox.Item>
515+
))}
516+
</ComboBox>,
517+
);
518+
519+
const combobox = getByRole('combobox');
520+
521+
// Type to filter and open popover
522+
await userEvent.type(combobox, 're');
523+
524+
await waitFor(() => {
525+
expect(queryByRole('listbox')).toBeInTheDocument();
526+
});
527+
528+
// Click on first option (Red)
529+
const options = getAllByRole('option');
530+
await userEvent.click(options[0]);
531+
532+
// Verify selection was made
533+
await waitFor(() => {
534+
expect(onSelectionChange).toHaveBeenCalledWith('red');
535+
expect(combobox).toHaveValue('Red');
536+
});
537+
538+
// Clear the mock to check no additional calls happen
539+
onSelectionChange.mockClear();
540+
541+
// Blur the input
542+
await act(async () => {
543+
combobox.blur();
544+
});
545+
546+
// Wait a bit to ensure state has settled
547+
await new Promise((resolve) => setTimeout(resolve, 100));
548+
549+
// Should maintain selection on blur (default behavior)
550+
expect(onSelectionChange).not.toHaveBeenCalled();
551+
expect(combobox).toHaveValue('Red');
552+
}, 15000);
553+
554+
it('should not apply clearOnBlur in allowsCustomValue mode', async () => {
555+
const onSelectionChange = jest.fn();
556+
557+
const { getByRole } = renderWithRoot(
558+
<ComboBox
559+
allowsCustomValue
560+
clearOnBlur
561+
label="test"
562+
onSelectionChange={onSelectionChange}
563+
>
564+
{items.map((item) => (
565+
<ComboBox.Item key={item.key}>{item.children}</ComboBox.Item>
566+
))}
567+
</ComboBox>,
568+
);
569+
570+
const combobox = getByRole('combobox');
571+
572+
// Type custom value
573+
await userEvent.type(combobox, 'Custom Value');
574+
575+
// Blur without pressing Enter - should commit the custom value
576+
await act(async () => {
577+
combobox.blur();
578+
});
579+
580+
// In allowsCustomValue mode, clearOnBlur should not apply
581+
// Instead, shouldCommitOnBlur (default: true) behavior should apply
582+
await waitFor(() => {
583+
expect(onSelectionChange).toHaveBeenCalledWith('Custom Value');
584+
expect(combobox).toHaveValue('Custom Value');
585+
});
586+
});
587+
466588
it('should show all items when opening with no results', async () => {
467589
const { getByRole, getAllByRole, queryByRole, getByTestId } =
468590
renderWithRoot(

src/components/fields/ComboBox/ComboBox.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ export interface CubeComboBoxProps<T>
153153
allowsCustomValue?: boolean;
154154
/** Whether to commit custom value on blur in allowsCustomValue mode (default: true) */
155155
shouldCommitOnBlur?: boolean;
156+
/** Whether to clear selection and input on blur (default: false, only applies to non-custom-value mode) */
157+
clearOnBlur?: boolean;
156158
/** Whether the combobox is clearable using ESC key or clear button */
157159
isClearable?: boolean;
158160
/** Callback called when the clear button is pressed */
@@ -913,6 +915,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
913915
placeholder,
914916
allowsCustomValue,
915917
shouldCommitOnBlur = true,
918+
clearOnBlur,
916919
items,
917920
children: renderChildren,
918921
sectionStyles,
@@ -1269,6 +1272,19 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
12691272
return;
12701273
}
12711274

1275+
// In clearOnBlur mode (only for non-custom-value mode), clear selection and input
1276+
if (clearOnBlur && !allowsCustomValue) {
1277+
externalOnSelectionChange?.(null);
1278+
if (!isControlledKey) {
1279+
setInternalSelectedKey(null);
1280+
}
1281+
if (!isControlledInput) {
1282+
setInternalInputValue('');
1283+
}
1284+
onInputChange?.('');
1285+
return;
1286+
}
1287+
12721288
// Reset input to show current selection (or empty if none)
12731289
const nextValue =
12741290
effectiveSelectedKey != null ? getItemLabel(effectiveSelectedKey) : '';

0 commit comments

Comments
 (0)