feat(design-system): improve combobox input behavior

This commit is contained in:
heymynameisrob 2026-07-20 13:14:11 +01:00
parent cd74deaf69
commit a9d28626f5
5 changed files with 91 additions and 2 deletions

View File

@ -141,13 +141,14 @@ export const Items = {
},
template: `
<div style="${storyContainerStyle}">
<Combobox :items="args.items" v-model="value" placeholder="Search status..." />
<Combobox v-bind="args" v-model="value" placeholder="Search status..." />
</div>
`,
}),
args: {
items: statusItems,
modelValue: undefined,
openOnFocus: true,
},
} satisfies Story;

View File

@ -353,6 +353,19 @@ describe('v2/components/Combobox', () => {
});
});
it('should clear the selection when the input value is deleted', async () => {
const wrapper = render(Combobox, {
props: {
items: ['Option 1', 'Option 2'],
modelValue: 'Option 1',
},
});
await userEvent.clear(getComboboxInput(wrapper));
expect(wrapper.emitted('update:modelValue')).toContainEqual([undefined]);
});
it('should display selected value for string items', async () => {
const wrapper = render(Combobox, {
props: {
@ -449,6 +462,49 @@ describe('v2/components/Combobox', () => {
});
describe('events', () => {
it('should open when the input receives focus', async () => {
const wrapper = render(Combobox, {
props: {
items: ['Option 1', 'Option 2', 'Option 3'],
},
});
getComboboxInput(wrapper).focus();
await getPopoverContainer();
});
it('should stay closed on focus when openOnFocus is false', async () => {
const wrapper = render(Combobox, {
props: {
items: ['Option 1', 'Option 2', 'Option 3'],
openOnFocus: false,
},
});
getComboboxInput(wrapper).focus();
await waitFor(() => {
expect(document.querySelector('[role="listbox"][data-state="open"]')).toBeNull();
});
});
it('should close after selecting an item when opened on focus', async () => {
const wrapper = render(Combobox, {
props: {
items: ['Option 1', 'Option 2', 'Option 3'],
},
});
getComboboxInput(wrapper).focus();
const { popover } = await getPopoverContainer();
await userEvent.click(within(popover).getByRole('option', { name: 'Option 1' }));
await waitFor(() => {
expect(document.querySelector('[role="listbox"][data-state="open"]')).toBeNull();
});
});
it('should emit update:open when dropdown opens', async () => {
const wrapper = render(Combobox, {
props: {

View File

@ -11,6 +11,7 @@ export type AcceptableValue = string | Record<string, unknown>;
export type ComboboxProps = Omit<ComboboxRootProps<AcceptableValue>, 'dir' | 'openOnFocus'> &
Pick<ComboboxContentProps, 'side' | 'sideOffset' | 'align'> & {
openOnFocus?: boolean;
size?: ComboboxSizes;
icon?: IconName;
placeholder?: string;

View File

@ -14,7 +14,14 @@ import {
ComboboxViewport,
useForwardPropsEmits,
} from 'reka-ui';
import { computed, nextTick, useCssModule, useTemplateRef } from 'vue';
import {
computed,
getCurrentInstance,
nextTick,
useAttrs,
useCssModule,
useTemplateRef,
} from 'vue';
import Icon from '@n8n/design-system/components/N8nIcon/Icon.vue';
import { useI18n } from '@n8n/design-system/composables/useI18n';
@ -49,6 +56,18 @@ const props = withDefaults(defineProps<ComboboxProps>(), {
clearable: false,
teleported: true,
});
const attrs = useAttrs();
const instance = getCurrentInstance();
const openOnFocus = computed(() => {
const assignedProps = instance?.vnode.props;
const isAssigned =
Object.hasOwn(assignedProps ?? {}, 'openOnFocus') ||
Object.hasOwn(assignedProps ?? {}, 'open-on-focus');
if (!isAssigned) return true;
return (attrs.openOnFocus ?? props.openOnFocus) !== false;
});
const emit = defineEmits<ComboboxEmits>();
const slots = defineSlots<ComboboxSlots>();
const { t } = useI18n();
@ -200,6 +219,15 @@ function onClear() {
function onTagsUpdate(value: TagsInputValue[]) {
emit('update:modelValue', value);
}
function onInput(event: Event) {
if (props.multiple || !hasValue.value) return;
if (!(event.target instanceof HTMLInputElement)) return;
if (event.target.value === '') {
emit('update:modelValue', undefined);
}
}
</script>
<template>
@ -209,6 +237,7 @@ function onTagsUpdate(value: TagsInputValue[]) {
:disabled="props.disabled"
:default-value="props.defaultValue"
:model-value="props.modelValue"
:open-on-focus="openOnFocus"
>
<ComboboxArrowKeyLoop />
<ComboboxAnchor
@ -272,6 +301,7 @@ function onTagsUpdate(value: TagsInputValue[]) {
:auto-focus="props.autoFocus"
:display-value="getDisplayValue"
:aria-label="$attrs['aria-label'] ?? props.placeholder"
@input="onInput"
/>
<button

View File

@ -51,6 +51,7 @@ When `multiple` is true, selected values render via embedded `N8nTagsInput2` (sh
- `resetSearchTermOnBlur?: boolean` — Reset search text on blur | reka default: `true`
- `resetSearchTermOnSelect?: boolean` — Reset search text on select | reka default: `true`
- `openOnClick?: boolean` — Open dropdown when input is clicked | reka default: `false`
- `openOnFocus?: boolean` — Open dropdown when input receives focus | default: `true`
- `highlightOnHover?: boolean` — Highlight items on hover (reka-ui root)
- `clearable?: boolean` — When `true`, shows a clear button (×) when a value is selected. Hidden when disabled or empty. Default: `false`
- `teleported?: boolean` — Whether to teleport the dropdown to body. Default: `true`