feat(editor): Add new Combobox component to @n8n/design-system (#33622)

Co-authored-by: heymynameisrob <robhough180@gmail.com>
Co-authored-by: Alex Grozav <alex@grozav.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sebastien Powell 2026-08-21 10:43:01 +00:00 committed by GitHub
parent eb59810117
commit 4c2e9d5398
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 3656 additions and 85 deletions

View File

@ -238,6 +238,7 @@
"v-code-diff": "patches/v-code-diff.patch",
"vuedraggable@4.1.0": "patches/vuedraggable@4.1.0.patch",
"assert@2.1.0": "patches/assert@2.1.0.patch",
"reka-ui@2.5.0": "patches/reka-ui@2.5.0.patch",
"lodash@4.18.1": "patches/lodash@4.18.1.patch",
"@langchain/openai@1.4.4": "patches/@langchain__openai@1.4.4.patch",
"@langchain/google-common@2.1.24": "patches/@langchain__google-common@2.1.24.patch",

View File

@ -423,6 +423,7 @@ onBeforeUnmount(() => {
</template>
<style module lang="scss">
@use '@n8n/design-system/css/mixins/floating-item' as floating-item;
@use '../../css/common/var';
@use '../../css/mixins/mixins' as scrollbar-mixins;
@ -451,18 +452,9 @@ onBeforeUnmount(() => {
}
.item {
font-size: var(--font-size--2xs);
line-height: 1;
border-radius: var(--radius--2xs);
display: flex;
align-items: center;
min-height: var(--spacing--xl);
padding: var(--spacing--2xs);
position: relative;
user-select: none;
@include floating-item.floating-item;
color: var(--text-color);
gap: var(--spacing--2xs);
outline: none;
&:not([data-disabled]) {
&:hover,

View File

@ -272,6 +272,7 @@ defineExpose({ focus, blur, select });
<style module lang="scss">
@use '../../css/mixins/focus';
@use '../../css/mixins/input' as input-mixin;
.inputContainer {
display: inline-flex;
@ -279,52 +280,28 @@ defineExpose({ focus, blur, select });
width: 100%;
gap: var(--spacing--3xs);
--input--height: var(--height--lg);
--input--radius: var(--radius--2xs);
--input--font-size: var(--font-size--sm);
--input--padding: var(--spacing--xs);
@include input-mixin.size-variables;
--input--color--background: light-dark(var(--color--neutral-white), var(--color--neutral-950));
--input--shadow: 0 0 0 0 transparent;
--input--shadow--hover: 0 0 0 0 transparent;
--input--shadow--focus: 0 0 0 0 transparent;
--input--border-color: var(--border-color);
--input--border-color--hover: var(--border-color--strong);
--input--border-color--focus: var(--focus--border-color);
--input--border--shadow: 0 0 0 1px var(--input--border-color);
--input--border--shadow--hover: 0 0 0 1px var(--input--border-color--hover);
--input--border--shadow--focus: 0 0 0 1px var(--input--border-color--focus);
@include input-mixin.theme-variables;
&.xlarge {
--input--height: var(--height--xl);
--input--radius: var(--radius--2xs);
--input--font-size: var(--font-size--md);
@include input-mixin.size-variables('xlarge');
}
&.large {
--input--height: var(--height--lg);
--input--radius: var(--radius--2xs);
--input--font-size: var(--font-size--sm);
@include input-mixin.size-variables('large');
}
&.medium {
--input--height: var(--height--md);
--input--radius: var(--radius--3xs);
--input--font-size: var(--font-size--sm);
@include input-mixin.size-variables('medium');
}
&.small {
--input--height: var(--height--sm);
--input--radius: var(--radius--3xs);
--input--font-size: var(--font-size--xs);
--input--padding: var(--spacing--2xs);
@include input-mixin.size-variables('small');
}
&.mini {
--input--height: var(--height--xs);
--input--radius: var(--radius--3xs);
--input--font-size: var(--font-size--2xs);
--input--padding: var(--spacing--2xs);
@include input-mixin.size-variables('mini');
}
}
@ -405,11 +382,11 @@ defineExpose({ focus, blur, select });
outline: none;
font-family: inherit;
font-size: var(--input--font-size, var(--font-size--md));
color: var(--color--text--shade-1);
color: var(--input--color--text);
}
.input::placeholder {
color: var(--color--text--tint-1);
color: var(--input--placeholder--color);
}
.input:read-only {
@ -418,7 +395,11 @@ defineExpose({ focus, blur, select });
.input:disabled {
cursor: not-allowed;
color: var(--color--text--tint-1);
color: var(--input--color--disabled);
&::placeholder {
color: var(--input--placeholder--color--disabled);
}
}
.textarea {
@ -435,7 +416,7 @@ defineExpose({ focus, blur, select });
}
.textarea::placeholder {
color: var(--color--text--tint-1);
color: var(--input--placeholder--color);
}
.textarea:read-only {
@ -444,7 +425,11 @@ defineExpose({ focus, blur, select });
.textarea:disabled {
cursor: not-allowed;
color: var(--color--text--tint-1);
color: var(--input--color--disabled);
&::placeholder {
color: var(--input--placeholder--color--disabled);
}
}
/* Masks a multiline secret (e.g. a PEM private key) as dots via

View File

@ -0,0 +1,13 @@
@mixin floating-item {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
position: relative;
user-select: none;
outline: none;
font-size: var(--font-size--sm);
line-height: var(--line-height--sm);
min-height: var(--spacing--xl);
padding: var(--spacing--2xs);
border-radius: var(--radius--2xs);
}

View File

@ -0,0 +1,80 @@
@use 'motion';
@use '../common/var';
@mixin popover-surface($z-index: var.$index-popper, $slide-distance: var(--spacing--5xs)) {
--n8n--popover--offset--slide-distance: #{$slide-distance};
--n8n--popover--offset--slide-x: 0;
--n8n--popover--offset--slide-y: 0;
--n8n--popover--offset--origin-x: center;
--n8n--popover--offset--origin-y: center;
--animation--popover-in--translate-x: var(--n8n--popover--offset--slide-x);
--animation--popover-in--translate-y: var(--n8n--popover--offset--slide-y);
overflow: hidden;
border-radius: var(--popover-content--radius, var(--radius--xs));
background-color: var(--background--surface);
--shadow-color--outline: var(--border-color);
box-shadow:
var(--shadow--md),
inset var(--shadow--outline);
will-change: transform, opacity;
transform-origin: var(--n8n--popover--offset--origin-x) var(--n8n--popover--offset--origin-y);
@if $z-index {
z-index: $z-index;
}
&[data-state='open'] {
@include motion.popover-in;
}
&[data-state='closed'] {
display: none;
}
}
@mixin popover-placement-offsets() {
&[data-state='open'][data-side='top'] {
--n8n--popover--offset--slide-y: calc(-1 * var(--n8n--popover--offset--slide-distance));
--n8n--popover--offset--origin-y: bottom;
}
&[data-state='open'][data-side='right'] {
--n8n--popover--offset--slide-x: var(--n8n--popover--offset--slide-distance);
--n8n--popover--offset--origin-x: left;
}
&[data-state='open'][data-side='bottom'] {
--n8n--popover--offset--slide-y: var(--n8n--popover--offset--slide-distance);
--n8n--popover--offset--origin-y: top;
}
&[data-state='open'][data-side='left'] {
--n8n--popover--offset--slide-x: calc(-1 * var(--n8n--popover--offset--slide-distance));
--n8n--popover--offset--origin-x: right;
}
&[data-state='open'][data-side='top'][data-align='start'],
&[data-state='open'][data-side='bottom'][data-align='start'] {
--n8n--popover--offset--slide-x: calc(-1 * var(--n8n--popover--offset--slide-distance));
--n8n--popover--offset--origin-x: left;
}
&[data-state='open'][data-side='top'][data-align='end'],
&[data-state='open'][data-side='bottom'][data-align='end'] {
--n8n--popover--offset--slide-x: var(--n8n--popover--offset--slide-distance);
--n8n--popover--offset--origin-x: right;
}
&[data-state='open'][data-side='left'][data-align='start'],
&[data-state='open'][data-side='right'][data-align='start'] {
--n8n--popover--offset--slide-y: calc(-1 * var(--n8n--popover--offset--slide-distance));
--n8n--popover--offset--origin-y: top;
}
&[data-state='open'][data-side='left'][data-align='end'],
&[data-state='open'][data-side='right'][data-align='end'] {
--n8n--popover--offset--slide-y: var(--n8n--popover--offset--slide-distance);
--n8n--popover--offset--origin-y: bottom;
}
}

View File

@ -1,9 +1,11 @@
@forward 'breakpoints';
@forward 'button' hide $B, $E;
@forward 'config';
@forward 'floating-item';
@forward 'focus';
@forward 'function';
@forward 'input';
@forward 'markdown';
@forward 'mixins';
@forward 'popover';
@forward 'utils';

View File

@ -28,6 +28,22 @@ export type * from './v2/components/RadioGroup/RadioGroupItem.types';
export type * from './v2/components/RadioGroup/RadioGroup.types';
export { default as N8nTree2 } from './v2/components/Tree/Tree.vue';
export type * from './v2/components/Tree/Tree.types';
export { default as N8nCombobox2 } from './v2/components/Combobox/Combobox.vue';
export { default as N8nCombobox2Item } from './v2/components/Combobox/ComboboxItem.vue';
export type { ComboboxItemProps } from 'reka-ui';
export type {
ComboboxEmits,
ComboboxGroupItem,
ComboboxItem,
ComboboxItemSlots,
ComboboxOptionBase,
ComboboxProps,
ComboboxSeparatorItem,
ComboboxSizes,
ComboboxSlots,
ComboboxStructuralItem,
ComboboxValue,
} from './v2/components/Combobox/Combobox.types';
export { default as N8nTagsInput2 } from './v2/components/TagsInput/TagsInput.vue';
export {
TagsInputInput,

View File

@ -202,6 +202,11 @@ export default {
'collapsiblePanel.expand': 'Expand',
'collapsiblePanel.dragToReorder': 'Drag to reorder',
'collapsiblePanel.delete': 'Delete',
'combobox.clearSelection': 'Clear selection',
'combobox.showPopup': 'Show popup',
'combobox.placeholder': 'Select an option',
'combobox.emptyText': 'No results found.',
'tagsInput.removeTag': 'Remove {tag}',
'markdownEditor.bold': 'Bold',
'markdownEditor.italic': 'Italic',
'markdownEditor.strikethrough': 'Strikethrough',

View File

@ -0,0 +1,760 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { computed, ref } from 'vue';
import N8nButton from '@n8n/design-system/components/N8nButton/Button.vue';
import N8nDialog from '@n8n/design-system/components/N8nDialog/Dialog.vue';
import N8nDialogClose from '@n8n/design-system/components/N8nDialog/DialogClose.vue';
import N8nDialogFooter from '@n8n/design-system/components/N8nDialog/DialogFooter.vue';
import N8nIcon from '@n8n/design-system/components/N8nIcon/Icon.vue';
import N8nInput from '@n8n/design-system/components/N8nInput';
import type { ComboboxItem as ComboboxItemType } from './Combobox.types';
import Combobox from './Combobox.vue';
type GenericMeta<C> = Omit<Meta<C>, 'component'> & {
component: Record<keyof C, unknown>;
};
const statusItems = [
{ label: 'Backlog', value: 'Backlog' },
{ label: 'Todo', value: 'Todo' },
{ label: 'In Progress', value: 'In Progress' },
{ label: 'Done', value: 'Done' },
];
const objectItems: ComboboxItemType[] = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
];
const controlledDemoItems: ComboboxItemType[] = [
{ label: 'Workflows', value: 'workflows', icon: 'bolt-filled' },
{ label: 'Credentials', value: 'credentials', icon: 'lock' },
{ label: 'Executions', value: 'executions', icon: 'list' },
{ label: 'Variables', value: 'variables', icon: 'variable' },
];
const fruitItems: ComboboxItemType[] = [
{
type: 'group',
label: 'Fruits',
items: [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Orange', value: 'orange' },
{ label: 'Grapes', value: 'grapes' },
{ label: 'Mango', value: 'mango' },
{ label: 'Pineapple', value: 'pineapple' },
{ label: 'Strawberry', value: 'strawberry' },
{ label: 'Blueberry', value: 'blueberry' },
{ label: 'Watermelon', value: 'watermelon' },
{ label: 'Papaya', value: 'papaya' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Peach', value: 'peach' },
{ label: 'Pear', value: 'pear' },
{ label: 'Plum', value: 'plum' },
{ label: 'Kiwi', value: 'kiwi' },
{ label: 'Lemon', value: 'lemon' },
{ label: 'Lime', value: 'lime' },
{ label: 'Coconut', value: 'coconut' },
],
},
{ type: 'separator' },
{
type: 'group',
label: 'More Fruits',
items: [
{ label: 'Pomegranate', value: 'pomegranate' },
{ label: 'Guava', value: 'guava' },
{ label: 'Dragon Fruit', value: 'dragon_fruit' },
{ label: 'Lychee', value: 'lychee' },
{ label: 'Fig', value: 'fig' },
{ label: 'Apricot', value: 'apricot' },
{ label: 'Raspberry', value: 'raspberry' },
{ label: 'Blackberry', value: 'blackberry' },
{ label: 'Cantaloupe', value: 'cantaloupe' },
{ label: 'Passion Fruit', value: 'passion_fruit' },
{ label: 'Cranberry', value: 'cranberry' },
{ label: 'Tangerine', value: 'tangerine' },
],
},
];
const iconItems: ComboboxItemType[] = [
{
value: 'system',
label: 'System Default',
icon: 'settings',
disabled: true,
},
{
value: 'light',
label: 'Light',
icon: 'wrench',
},
{
value: 'dark',
label: 'Dark',
icon: 'filled-square',
},
];
const slotItems: ComboboxItemType[] = [
{
value: 'system',
label: 'System Default',
icon: 'settings',
disabled: true,
},
{
value: 'light',
label: 'Light',
icon: 'wrench',
},
{
value: 'dark',
label: 'Dark',
icon: 'filled-square',
},
{
value: 'dark2',
label: 'Dark2',
icon: 'filled-square',
},
];
const itemsWithDisabledOption: ComboboxItemType[] = [
{ label: 'Backlog', value: 'backlog' },
{ label: 'Todo', value: 'todo' },
{ label: 'In Progress', value: 'in_progress' },
{ label: 'Done', value: 'done', disabled: true },
];
const itemsWithKeywords: ComboboxItemType[] = [
{ label: 'Germany', value: 'de', keywords: ['Deutschland', 'DE'] },
{ label: 'Japan', value: 'jp', keywords: ['Nippon', 'JP'] },
{ label: 'United Kingdom', value: 'uk', keywords: ['Britain', 'England', 'UK', 'GB'] },
{ label: 'United States', value: 'us', keywords: ['USA', 'America', 'US'] },
];
const storyContainerStyle = 'padding: 40px; max-width: 400px';
const meta = {
title: 'Experimental/Combobox',
component: Combobox,
tags: ['autodocs'],
parameters: {
docs: {
source: { type: 'dynamic' },
},
},
} satisfies GenericMeta<typeof Combobox>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Items = {
name: 'Items',
render: (args) => ({
components: { Combobox },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyContainerStyle}">
<Combobox v-bind="args" v-model="value" placeholder="Search status..." />
</div>
`,
}),
args: {
items: statusItems,
modelValue: undefined,
},
parameters: {
docs: {
description: {
story:
'Items as `{ label, value }` objects. The selected model value is the item `value`, while `label` is shown in the list and trigger.',
},
},
},
} satisfies Story;
export const ItemsTypes = {
render: (args) => ({
components: { Combobox },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyContainerStyle}">
<Combobox
v-bind="args"
v-model="value"
placeholder="Select a fruit..."
/>
</div>
`,
}),
args: {
items: fruitItems,
modelValue: undefined,
},
} satisfies Story;
export const Sizes = {
render: (args) => ({
components: { Combobox },
setup() {
const xlargeValue = ref(args.modelValue);
const largeValue = ref(args.modelValue);
const mediumValue = ref(args.modelValue);
const smallValue = ref(args.modelValue);
const miniValue = ref(args.modelValue);
const xlargeMultiple = ref(['apple', 'banana', 'orange']);
const largeMultiple = ref(['apple', 'banana', 'orange']);
const mediumMultiple = ref(['apple', 'banana', 'orange']);
const smallMultiple = ref(['apple', 'banana', 'orange']);
const miniMultiple = ref(['apple', 'banana', 'orange']);
return {
args,
xlargeValue,
largeValue,
mediumValue,
smallValue,
miniValue,
xlargeMultiple,
largeMultiple,
mediumMultiple,
smallMultiple,
miniMultiple,
fruitItems,
};
},
template: `
<div style="${storyContainerStyle}; display: flex; flex-direction: column; gap: var(--spacing--xl);">
<section style="display: flex; flex-direction: column; gap: var(--spacing--md);">
<h3 style="margin: 0; font-size: var(--font-size--sm); font-weight: var(--font-weight--bold);">
Single
</h3>
<Combobox v-bind="args" v-model="xlargeValue" size="xlarge" placeholder="xlarge (40px)" />
<Combobox v-bind="args" v-model="largeValue" size="large" placeholder="large (36px, default)" />
<Combobox v-bind="args" v-model="mediumValue" size="medium" placeholder="medium (32px)" />
<Combobox v-bind="args" v-model="smallValue" size="small" placeholder="small (28px)" />
<Combobox v-bind="args" v-model="miniValue" size="mini" placeholder="mini (24px)" />
</section>
<section style="display: flex; flex-direction: column; gap: var(--spacing--md);">
<h3 style="margin: 0; font-size: var(--font-size--sm); font-weight: var(--font-weight--bold);">
Multiple
</h3>
<Combobox :items="fruitItems" v-model="xlargeMultiple" size="xlarge" multiple clearable placeholder="xlarge (40px)" />
<Combobox :items="fruitItems" v-model="largeMultiple" size="large" multiple clearable placeholder="large (36px, default)" />
<Combobox :items="fruitItems" v-model="mediumMultiple" size="medium" multiple clearable placeholder="medium (32px)" />
<Combobox :items="fruitItems" v-model="smallMultiple" size="small" multiple clearable placeholder="small (28px)" />
<Combobox :items="fruitItems" v-model="miniMultiple" size="mini" multiple clearable placeholder="mini (24px)" />
</section>
</div>
`,
}),
args: {
items: statusItems,
modelValue: undefined,
},
} satisfies Story;
export const Disabled = {
render: (args) => ({
components: { Combobox },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyContainerStyle}; display: flex; flex-direction: column; gap: var(--spacing--md);">
<Combobox v-bind="args" v-model="value" disabled placeholder="Search options..." />
</div>
`,
}),
args: {
items: objectItems,
modelValue: undefined,
},
} satisfies Story;
export const Clearable = {
render: (args) => ({
components: { Combobox },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyContainerStyle}">
<Combobox
v-bind="args"
v-model="value"
clearable
placeholder="Search status..."
/>
</div>
`,
}),
args: {
items: statusItems,
modelValue: 'In Progress',
},
} satisfies Story;
export const Empty = {
name: 'Empty',
render: (args) => ({
components: { Combobox },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyContainerStyle}">
<Combobox
v-bind="args"
v-model="value"
placeholder="Search options..."
/>
</div>
`,
}),
args: {
items: [],
emptyText: 'No results found.',
modelValue: undefined,
},
parameters: {
docs: {
description: {
story:
'Empty list state (`role="status"`) when there are no matching options. Focus or open the combobox to see it.',
},
},
},
} satisfies Story;
export const AsyncItems = {
name: 'Async Items',
render: () => ({
components: { Combobox, N8nButton },
setup() {
const value = ref('in_progress');
const items = ref<ComboboxItemType[]>([]);
const loading = ref(false);
const loadCount = ref(0);
async function loadItems(remount = false) {
loading.value = true;
items.value = [];
if (remount) {
loadCount.value += 1;
}
await new Promise((resolve) => setTimeout(resolve, 1500));
items.value = itemsWithDisabledOption;
loading.value = false;
}
void loadItems();
return { value, items, loading, loadCount, loadItems };
},
template: `
<div style="${storyContainerStyle}; display: flex; flex-direction: column; gap: var(--spacing--md);">
<p style="margin: 0; font-size: var(--font-size--sm); color: var(--text-color--subtle);">
<code>v-model</code> is already <code>in_progress</code> while <code>items</code> is empty.
After 1.5s the options arrive and the input should update from the raw id to
<strong>In Progress</strong>.
</p>
<Combobox
:key="loadCount"
v-model="value"
:items="items"
placeholder="Search status..."
/>
<N8nButton
:label="loading ? 'Loading items…' : 'Reload items'"
:disabled="loading"
variant="outline"
@click="loadItems(true)"
/>
<p style="margin: 0; font-size: var(--font-size--sm);">
Selected: <strong>{{ value }}</strong>
· Items: <strong>{{ items.length }}</strong>
</p>
</div>
`,
}),
parameters: {
docs: {
description: {
story:
'Simulates a selected value arriving before async options. The input falls back to the raw value until items resolve, then updates to the matching label. Reload remounts the field so you can watch the handoff again.',
},
},
},
} satisfies Story;
export const Multiple = {
render: (args) => ({
components: { Combobox },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyContainerStyle}">
<Combobox
v-bind="args"
v-model="value"
multiple
clearable
placeholder="Select fruits..."
/>
</div>
`,
}),
args: {
items: fruitItems,
modelValue: ['apple', 'banana'],
},
} satisfies Story;
export const ControlledUncontrolled = {
name: 'Controlled/Uncontrolled',
render: () => ({
components: { Combobox },
setup() {
const controlledValue = ref<string | undefined>('workflows');
const controlledOpen = ref(false);
const presets = [
{ label: 'Workflows', value: 'workflows' as string | undefined },
{ label: 'Credentials', value: 'credentials' as string | undefined },
{ label: 'Clear', value: undefined },
];
return {
controlledDemoItems,
controlledValue,
controlledOpen,
presets,
};
},
template: `
<div style="${storyContainerStyle}; display: flex; flex-direction: column; gap: var(--spacing--xl);">
<section>
<h3 style="margin: 0 0 var(--spacing--sm); font-size: var(--font-size--sm); font-weight: var(--font-weight--bold);">
Controlled
</h3>
<Combobox
key="controlled"
:items="controlledDemoItems"
v-model="controlledValue"
v-model:open="controlledOpen"
placeholder="Search..."
/>
<div style="display: flex; gap: var(--spacing--2xs); margin-top: var(--spacing--sm); flex-wrap: wrap;">
<button
v-for="preset in presets"
:key="preset.label"
type="button"
style="
padding: var(--spacing--3xs) var(--spacing--xs);
border: var(--border);
border-radius: var(--radius--2xs);
background: var(--background--surface);
color: var(--text-color);
cursor: pointer;
font: inherit;
font-size: var(--font-size--xs);
"
@click="controlledValue = preset.value"
>
{{ preset.label }}
</button>
<button
type="button"
style="
padding: var(--spacing--3xs) var(--spacing--xs);
border: var(--border);
border-radius: var(--radius--2xs);
background: var(--background--surface);
color: var(--text-color);
cursor: pointer;
font: inherit;
font-size: var(--font-size--xs);
"
@click="controlledOpen = !controlledOpen"
>
{{ controlledOpen ? 'Close' : 'Open' }}
</button>
</div>
<p style="margin-top: var(--spacing--sm); font-size: var(--font-size--sm);">
Selected: <strong>{{ controlledValue ?? '(empty)' }}</strong>
· Open: <strong>{{ controlledOpen }}</strong>
</p>
</section>
<section>
<h3 style="margin: 0 0 var(--spacing--sm); font-size: var(--font-size--sm); font-weight: var(--font-weight--bold);">
Uncontrolled
</h3>
<Combobox
key="uncontrolled"
:items="controlledDemoItems"
default-value="workflows"
:default-open="false"
placeholder="Search..."
/>
</section>
</div>
`,
}),
} satisfies Story;
export const WithDisabledItem = {
render: (args) => ({
components: { Combobox },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyContainerStyle}">
<Combobox
v-bind="args"
v-model="value"
placeholder="Select status..."
/>
</div>
`,
}),
args: {
items: itemsWithDisabledOption,
modelValue: undefined,
},
} satisfies Story;
export const WithKeywords = {
name: 'With Keywords',
render: (args) => ({
components: { Combobox },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyContainerStyle}">
<Combobox
v-bind="args"
v-model="value"
placeholder="Try 'America', 'Britain', or 'Deutschland'..."
/>
</div>
`,
}),
args: {
items: itemsWithKeywords,
modelValue: undefined,
},
parameters: {
docs: {
description: {
story:
'Items can include `keywords` — extra strings matched during filtering (e.g. synonyms) without changing the displayed label. Try searching for "America", "Britain", or "Deutschland".',
},
},
},
} satisfies Story;
export const WithIcons = {
render: (args) => ({
components: { Combobox, N8nIcon },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyContainerStyle}; display: flex; gap: 16px; align-items: center;">
<Combobox v-bind="args" v-model="value">
<template #item-leading="{ item, ui }">
<N8nIcon :icon="item.icon" color="primary" v-bind="ui" />
</template>
</Combobox>
</div>
`,
}),
args: {
items: iconItems,
modelValue: 'light',
},
} satisfies Story;
export const WithSlots = {
render: (args) => ({
components: { Combobox, N8nIcon },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyContainerStyle}">
<Combobox v-bind="args" v-model="value">
<template #item-leading="{ item, ui }">
<N8nIcon :icon="item.icon" color="primary" v-bind="ui" />
</template>
<template #item-label="{ item }">
Custom label: {{ item.label }}
</template>
<template #item-trailing="{ item, ui }">
<N8nIcon :icon="item.icon" color="secondary" v-bind="ui" />
</template>
</Combobox>
</div>
`,
}),
args: {
items: slotItems,
modelValue: undefined,
},
} satisfies Story;
const CREATE_FRUIT_VALUE = '__create_fruit__';
export const WithHeaderAndFooterActions = {
name: 'With Header And Footer Actions',
render: () => ({
components: { Combobox, N8nDialog, N8nDialogFooter, N8nDialogClose, N8nButton },
setup() {
const value = ref<string | undefined>();
const open = ref(false);
const createOpen = ref(false);
const options = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Orange', value: 'orange' },
];
const items = computed(() => [
{
type: 'group' as const,
label: 'Suggested',
items: options,
},
{ type: 'separator' as const },
{
label: 'Create new fruit',
value: CREATE_FRUIT_VALUE,
icon: 'plus' as const,
onSelect: (event: Event) => {
event.preventDefault();
open.value = false;
createOpen.value = true;
},
},
]);
return { value, open, createOpen, items };
},
template: `
<div style="${storyContainerStyle}">
<Combobox
v-model="value"
v-model:open="open"
:items="items"
placeholder="Select a fruit..."
/>
<N8nDialog
v-model:open="createOpen"
header="Create new fruit"
description="This action item uses onSelect with preventDefault so it never becomes the Combobox value."
size="small"
>
<N8nDialogFooter>
<N8nDialogClose as-child>
<N8nButton label="Cancel" variant="outline" />
</N8nDialogClose>
<N8nDialogClose as-child>
<N8nButton label="Create" variant="solid" />
</N8nDialogClose>
</N8nDialogFooter>
</N8nDialog>
</div>
`,
}),
parameters: {
docs: {
description: {
story:
'Footer actions stay inside `role="listbox"` as options. Use `onSelect` with `event.preventDefault()` so choosing the action closes/opens a modal without updating `modelValue`.',
},
},
},
} satisfies Story;
export const WithForm = {
name: 'With Form',
render: () => ({
components: { Combobox, N8nButton, N8nInput },
setup() {
const name = ref('');
const status = ref<string | undefined>();
const fruits = ref<string[]>(['apple', 'banana']);
const notes = ref('');
return { name, status, fruits, notes, statusItems, fruitItems };
},
template: `
<form
style="${storyContainerStyle}; display: flex; flex-direction: column; gap: var(--spacing--md);"
@submit.prevent
>
<label style="display: flex; flex-direction: column; gap: var(--spacing--2xs); font-size: var(--font-size--sm);">
Name
<N8nInput v-model="name" placeholder="Enter name..." />
</label>
<label style="display: flex; flex-direction: column; gap: var(--spacing--2xs); font-size: var(--font-size--sm);">
Status
<Combobox
:items="statusItems"
v-model="status"
placeholder="Search status..."
/>
</label>
<label style="display: flex; flex-direction: column; gap: var(--spacing--2xs); font-size: var(--font-size--sm);">
Fruits
<Combobox
:items="fruitItems"
v-model="fruits"
multiple
clearable
placeholder="Select fruits..."
/>
</label>
<label style="display: flex; flex-direction: column; gap: var(--spacing--2xs); font-size: var(--font-size--sm);">
Notes
<N8nInput v-model="notes" placeholder="Enter notes..." />
</label>
<N8nButton type="submit" variant="solid" label="Submit" style="width: 100%;" />
</form>
`,
}),
parameters: {
docs: {
description: {
story:
'Single and multiple Comboboxes between N8nInput fields. Use Tab / Shift+Tab to verify focus moves correctly into and out of each combobox.',
},
},
},
} satisfies Story;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,85 @@
import type { ComboboxContentProps, ComboboxRootEmits, ComboboxRootProps } from './reka-ui';
import type { IconName } from '../../../components/N8nIcon/icons';
import type { InputSize } from '../../../components/N8nInput/Input.types';
export type ComboboxValue = string;
export type ComboboxOptionBase<TValue extends ComboboxValue = ComboboxValue> = {
type?: 'item';
value: TValue;
label: string;
icon?: IconName;
disabled?: boolean;
/**
* String used for typeahead filtering. Defaults to `label`.
* Set this when the filter text should differ from the displayed label
* (e.g. a slot-rendered label).
*/
textValue?: string;
/**
* Extra strings matched during typeahead filtering (e.g. synonyms).
* Does not replace `textValue` — both the base filter text and these
* keywords are checked.
*/
keywords?: string[];
/**
* Called when the item is chosen. Call `event.preventDefault()` to keep the
* selection from updating (e.g. footer actions that open a modal).
*/
onSelect?: (event: Event) => void;
};
export type ComboboxGroupItem<TValue extends ComboboxValue = ComboboxValue> = {
type: 'group';
label?: string;
items: Array<ComboboxOptionBase<TValue>>;
};
export type ComboboxSeparatorItem = {
type: 'separator';
};
export type ComboboxStructuralItem<TValue extends ComboboxValue = ComboboxValue> =
| ComboboxGroupItem<TValue>
| ComboboxSeparatorItem;
export type ComboboxItem<TValue extends ComboboxValue = ComboboxValue> =
| ComboboxOptionBase<TValue>
| ComboboxStructuralItem<TValue>;
export type ComboboxProps = Omit<ComboboxRootProps<ComboboxValue>, 'dir' | 'openOnFocus'> &
Pick<ComboboxContentProps, 'side' | 'sideOffset' | 'align'> & {
size?: ComboboxSizes;
icon?: IconName;
placeholder?: string;
autoFocus?: boolean;
emptyText?: string;
items?: ComboboxItem[];
contentClass?: string;
id?: string;
clearable?: boolean;
teleported?: boolean;
portalTarget?: string | HTMLElement;
};
export type ComboboxEmits = ComboboxRootEmits<ComboboxValue | ComboboxValue[] | undefined>;
export type ComboboxSizes = InputSize;
type ComboboxItemUi = { class: string };
type SlotProps = (props: { item: ComboboxOptionBase; ui: ComboboxItemUi }) => unknown;
export type ComboboxItemSlots = {
['item-leading']?: SlotProps;
['item-label']?: (props: { item: ComboboxOptionBase }) => unknown;
['item-trailing']?: SlotProps;
};
export type ComboboxSlots = {
item?: (props: { item: ComboboxOptionBase }) => unknown;
label?: (props: { item: ComboboxGroupItem }) => unknown;
['item-leading']?: SlotProps;
['item-label']?: (props: { item: ComboboxOptionBase }) => unknown;
['item-trailing']?: SlotProps;
};

View File

@ -0,0 +1,757 @@
<script setup lang="ts">
import { reactivePick } from '@vueuse/core';
import {
computed,
getCurrentInstance,
nextTick,
ref,
useAttrs,
useCssModule,
useId,
useTemplateRef,
watch,
} from 'vue';
import Icon from '@n8n/design-system/components/N8nIcon/Icon.vue';
import { useI18n } from '@n8n/design-system/composables/useI18n';
import { N8nTagsInput2, TagsInputInput, type TagsInputValue } from '../TagsInput';
import type {
ComboboxEmits,
ComboboxGroupItem,
ComboboxItem,
ComboboxOptionBase,
ComboboxProps,
ComboboxSizes,
ComboboxSlots,
ComboboxValue,
} from './Combobox.types';
import N8nComboboxItem from './ComboboxItem.vue';
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxLabel,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport,
useForwardPropsEmits,
} from './reka-ui';
defineOptions({ inheritAttrs: false });
defineSlots<ComboboxSlots>();
const emit = defineEmits<ComboboxEmits>();
const $style = useCssModule();
const attrs = useAttrs();
const props = withDefaults(defineProps<ComboboxProps>(), {
size: 'large',
side: 'bottom',
sideOffset: 4,
align: 'start',
clearable: false,
teleported: true,
});
const { t } = useI18n();
const placeholder = computed(() => props.placeholder ?? t('combobox.placeholder'));
const emptyText = computed(() => props.emptyText ?? t('combobox.emptyText'));
const generatedId = useId();
const inputId = computed(() => props.id ?? generatedId);
const inputAttrs = computed(() =>
Object.fromEntries(Object.entries(attrs).filter(([key]) => key.startsWith('aria-'))),
);
const anchorAttrs = computed(() =>
Object.fromEntries(Object.entries(attrs).filter(([key]) => !key.startsWith('aria-'))),
);
const rootProps = useForwardPropsEmits(
reactivePick(
props,
'open',
'defaultOpen',
'disabled',
'required',
'multiple',
'ignoreFilter',
'resetSearchTermOnBlur',
'resetSearchTermOnSelect',
'openOnClick',
'highlightOnHover',
),
);
const vnodeProps = getCurrentInstance()?.vnode.props ?? {};
const isModelControlled = 'modelValue' in vnodeProps || 'model-value' in vnodeProps;
const internalValue = ref<ComboboxValue | ComboboxValue[] | undefined>(props.defaultValue);
const rootModelValue = computed(() => (isModelControlled ? props.modelValue : internalValue.value));
const inputRef = useTemplateRef<
InstanceType<typeof ComboboxInput> | InstanceType<typeof TagsInputInput>
>('input');
function getInputElement(): HTMLInputElement | undefined {
const element: unknown = inputRef.value?.$el;
return element instanceof HTMLInputElement ? element : undefined;
}
function clearSearchInput() {
void nextTick(() => {
const element = getInputElement();
if (!element) {
return;
}
element.value = '';
element.dispatchEvent(new InputEvent('input', { bubbles: true, data: null }));
});
}
function onModelValueUpdate(value: ComboboxValue | ComboboxValue[] | undefined) {
if (!isModelControlled) {
internalValue.value = value;
}
emit('update:modelValue', value);
if (props.multiple) {
clearSearchInput();
}
}
function setSelectedValue(value: ComboboxValue | ComboboxValue[] | undefined) {
onModelValueUpdate(value);
}
function isGroupItem(item: ComboboxItem): item is ComboboxGroupItem {
return item.type === 'group';
}
function isSeparatorItem(item: ComboboxItem): item is { type: 'separator' } {
return item.type === 'separator';
}
function warnInvalidItem(message: string, item: ComboboxItem) {
if (!import.meta.env.DEV) {
return;
}
// eslint-disable-next-line no-console
console.warn(`[N8nCombobox2] ${message}`, item);
}
function normaliseOption(item: ComboboxOptionBase): ComboboxOptionBase | undefined {
if (item.value === '') {
warnInvalidItem(
'Skipping item: "value" is missing or empty. Every selectable item needs a non-empty value.',
item,
);
return undefined;
}
if (!item.label) {
warnInvalidItem(
'Skipping item: "label" is missing or empty. Every selectable item needs a label.',
item,
);
return undefined;
}
return {
...item,
textValue: item.textValue ?? item.label,
};
}
type ComboboxSection = {
group?: ComboboxGroupItem;
label?: string;
items: ComboboxOptionBase[];
};
const sections = computed<ComboboxSection[]>(() => {
if (!props.items?.length) return [];
const result: ComboboxSection[] = [];
let pendingOptions: ComboboxOptionBase[] = [];
const flushPendingOptions = () => {
if (pendingOptions.length === 0) {
return;
}
result.push({ items: pendingOptions });
pendingOptions = [];
};
for (const item of props.items) {
if (isGroupItem(item)) {
flushPendingOptions();
const groupItems: ComboboxOptionBase[] = [];
for (const child of item.items) {
const normalised = normaliseOption(child);
if (normalised) {
groupItems.push(normalised);
}
}
const label = item.label || undefined;
if (item.label !== undefined && !item.label) {
warnInvalidItem('Skipping group label: "label" is empty.', item);
}
result.push({
group: item,
label,
items: groupItems,
});
continue;
}
if (isSeparatorItem(item)) {
flushPendingOptions();
continue;
}
const normalised = normaliseOption(item);
if (normalised) {
pendingOptions.push(normalised);
}
}
flushPendingOptions();
return result;
});
const optionItems = computed(() => sections.value.flatMap((section) => section.items));
const anchorRef = useTemplateRef<InstanceType<typeof ComboboxAnchor>>('anchor');
defineExpose({
anchorRef,
});
const sizes: Record<ComboboxSizes, string> = {
mini: $style.mini,
small: $style.small,
medium: $style.medium,
large: $style.large,
xlarge: $style.xlarge,
};
const sizeClass = computed(() => sizes[props.size]);
function getDisplayValue(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
if (Array.isArray(value)) {
return '';
}
const matchedItem = optionItems.value.find((item) => item.value === value);
if (matchedItem) {
return matchedItem.label;
}
if (typeof value === 'string') {
return value;
}
return '';
}
const searchTerm = ref(getDisplayValue(rootModelValue.value));
function hasValue(value: ComboboxValue | ComboboxValue[] | undefined): boolean {
if (props.multiple) {
return Array.isArray(value) && value.length > 0;
}
return value !== undefined && value !== null && value !== '';
}
function getSelectedItem(
value: ComboboxValue | ComboboxValue[] | undefined,
): ComboboxOptionBase | undefined {
if (props.multiple) {
return undefined;
}
if (value === undefined || value === null || Array.isArray(value)) {
return undefined;
}
return optionItems.value.find((item) => item.value === value);
}
/**
* Reka only reapplies `displayValue` when `modelValue` changes, not when `items`
* do. Sync the closed input once a matching label becomes available so a value
* set before async items load does not stay as the raw id.
*/
watch(
optionItems,
() => {
const label = getSelectedItem(rootModelValue.value)?.label;
if (!label) {
return;
}
const element = getInputElement();
if ((element && document.activeElement === element) || searchTerm.value === label) {
return;
}
searchTerm.value = label;
},
{ flush: 'post' },
);
function showClearButton(value: ComboboxValue | ComboboxValue[] | undefined): boolean {
return props.clearable && !props.disabled && hasValue(value);
}
function onClear() {
setSelectedValue(props.multiple ? [] : undefined);
void nextTick(() => {
const element = getInputElement();
if (!element) {
return;
}
element.value = '';
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
element.focus();
});
}
function onTagsUpdate(value: TagsInputValue[]) {
setSelectedValue(value.filter((tag): tag is string => typeof tag === 'string'));
}
</script>
<template>
<ComboboxRoot
v-slot="{ modelValue: selectedValue }"
:name="props.name"
v-bind="rootProps"
:model-value="rootModelValue"
:default-value="isModelControlled ? props.defaultValue : undefined"
:open-on-focus="true"
@update:model-value="onModelValueUpdate"
@update:open="emit('update:open', $event)"
@highlight="emit('highlight', $event)"
>
<ComboboxAnchor
ref="anchor"
data-test-id="combobox"
v-bind="anchorAttrs"
:class="[$style.comboboxAnchor, sizeClass, props.multiple && $style.multiple]"
:data-disabled="props.disabled || undefined"
:data-multiple="props.multiple || undefined"
:data-empty="hasValue(selectedValue) ? undefined : true"
>
<template
v-for="selectedItem in [getSelectedItem(selectedValue)]"
:key="selectedItem?.value ?? 'none'"
>
<span v-if="!props.multiple && selectedItem?.icon" :class="$style.leadingIcon">
<slot name="item-leading" :item="selectedItem" :ui="{ class: $style.leadingIconGlyph }">
<Icon :icon="selectedItem.icon" :class="$style.leadingIconGlyph" size="large" />
</slot>
</span>
<span v-else-if="!props.multiple && props.icon" :class="$style.leadingIcon">
<Icon :icon="props.icon" :class="$style.leadingIconGlyph" size="large" />
</span>
</template>
<N8nTagsInput2
v-if="props.multiple"
:id="inputId"
:embedded="true"
:model-value="Array.isArray(selectedValue) ? selectedValue : []"
:size="props.size"
:disabled="props.disabled"
:display-value="getDisplayValue"
:placeholder="placeholder"
:auto-focus="props.autoFocus"
@update:model-value="onTagsUpdate"
>
<template #input="inputProps">
<ComboboxInput
:id="inputId"
as-child
:display-value="getDisplayValue"
v-bind="inputAttrs"
>
<TagsInputInput
:id="inputProps.id"
ref="input"
:class="inputProps.class"
:placeholder="inputProps.placeholder"
:auto-focus="inputProps.autoFocus"
:disabled="inputProps.disabled"
@keydown.enter.prevent
/>
</ComboboxInput>
</template>
</N8nTagsInput2>
<ComboboxInput
v-else
:id="inputId"
ref="input"
v-model="searchTerm"
:class="$style.comboboxInput"
:placeholder="placeholder"
:auto-focus="props.autoFocus"
:display-value="getDisplayValue"
v-bind="inputAttrs"
/>
<button
v-if="showClearButton(selectedValue)"
type="button"
:class="$style.clearButton"
:aria-label="t('combobox.clearSelection')"
@mousedown.prevent
@click.stop="onClear"
>
<Icon icon="x" size="small" />
</button>
<ComboboxTrigger as-child>
<button
type="button"
:class="$style.comboboxTrigger"
tabindex="-1"
:aria-label="t('combobox.showPopup')"
@mousedown.prevent
>
<Icon icon="chevron-down" :class="$style.trailingIcon" />
</button>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxPortal
:disabled="!props.teleported && !props.portalTarget"
v-bind="props.portalTarget ? { to: props.portalTarget } : {}"
>
<ComboboxContent
position="popper"
:class="[$style.comboboxContent, props.contentClass]"
:align="props.align"
:side="props.side"
:side-offset="props.sideOffset"
>
<ComboboxViewport :class="$style.comboboxViewport">
<ComboboxEmpty :class="$style.comboboxEmpty" role="status">
{{ emptyText }}
</ComboboxEmpty>
<ComboboxGroup
v-for="(section, sectionIndex) in sections"
:key="`section-${sectionIndex}`"
:class="$style.comboboxGroup"
>
<ComboboxLabel v-if="section.label && section.group" :class="$style.comboboxLabel">
<slot name="label" :item="section.group">
{{ section.label }}
</slot>
</ComboboxLabel>
<template
v-for="item in section.items"
:key="`section-${sectionIndex}-item-${String(item.value)}`"
>
<slot name="item" :item="item">
<N8nComboboxItem v-bind="item">
<template v-if="$slots['item-leading']" #item-leading="{ ui }">
<slot name="item-leading" :item="item" :ui="ui" />
</template>
<template #item-label>
<slot name="item-label" :item="item">
{{ item.label }}
</slot>
</template>
<template v-if="$slots['item-trailing']" #item-trailing="{ ui }">
<slot name="item-trailing" :item="item" :ui="ui" />
</template>
</N8nComboboxItem>
</slot>
</template>
</ComboboxGroup>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/focus';
@use '@n8n/design-system/css/mixins/input' as input-mixin;
@use '@n8n/design-system/css/mixins/popover' as popover;
.comboboxAnchor {
@include input-mixin.size-variables('large');
@include input-mixin.theme-variables(var(--border-color));
display: inline-flex;
align-items: center;
justify-content: flex-start;
position: relative;
gap: var(--spacing--3xs);
width: 100%;
border-radius: var(--input--radius--top-left, var(--input--radius))
var(--input--radius--top-right, var(--input--radius))
var(--input--radius--bottom-right, var(--input--radius))
var(--input--radius--bottom-left, var(--input--radius));
font-size: var(--input--font-size);
background-color: var(--input--color--background);
box-shadow:
var(--input--shadow),
inset var(--input--border--shadow);
min-height: var(--input--height);
padding: 0 var(--input--padding);
color: var(--input--color--text);
@include focus.focus-within-ring;
&:not([data-disabled]):hover:not(:focus-within):not(:has(.clearButton:hover)) {
cursor: text;
box-shadow:
var(--input--shadow--hover),
inset var(--input--border--shadow--hover);
}
&:focus-within {
box-shadow:
var(--input--shadow--focus),
inset var(--input--border--shadow--focus);
}
&[data-disabled] {
cursor: not-allowed;
opacity: 0.6;
box-shadow:
var(--input--shadow),
inset var(--input--border--shadow);
&:focus-within {
outline: none;
box-shadow:
var(--input--shadow),
inset var(--input--border--shadow);
}
}
&.multiple {
--tags-input--padding: var(--spacing--4xs);
padding: var(--tags-input--padding);
padding-inline-end: var(--input--padding);
}
}
.mini {
@include input-mixin.size-variables('mini');
}
.small {
@include input-mixin.size-variables('small');
}
.medium {
@include input-mixin.size-variables('medium');
}
.large {
@include input-mixin.size-variables('large');
}
.xlarge {
@include input-mixin.size-variables('xlarge');
}
.leadingIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
line-height: 0;
}
.leadingIconGlyph {
display: block;
flex-shrink: 0;
}
.comboboxInput {
flex: 1;
align-self: stretch;
min-width: 0;
width: 100%;
min-height: var(--input--height);
padding: 0;
border: none;
background: transparent;
outline: none;
font-size: inherit;
color: inherit;
&::placeholder {
color: var(--input--placeholder--color);
}
&:focus,
&:focus-visible {
outline: none;
}
&:disabled {
cursor: not-allowed;
color: var(--input--color--disabled);
&::placeholder {
color: var(--input--placeholder--color--disabled);
}
}
}
.comboboxTrigger {
position: relative;
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
&::after {
content: '';
position: absolute;
width: var(--input--height);
height: var(--input--height);
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
}
&:disabled {
cursor: not-allowed;
}
}
.clearButton {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: var(--spacing--sm);
height: var(--spacing--sm);
padding: 0;
border: none;
border-radius: var(--radius--full);
background: transparent;
color: var(--text-color--subtle);
cursor: pointer;
&:hover {
color: var(--color--text--shade-1);
}
&:focus {
outline: none;
background-color: var(--background--hover);
color: var(--color--text--shade-1);
}
}
.trailingIcon {
flex-shrink: 0;
color: var(--input--color--text);
}
.comboboxAnchor[data-empty] .trailingIcon {
color: var(--input--placeholder--color);
}
.comboboxAnchor:not([data-disabled])[data-empty] .comboboxTrigger:hover .trailingIcon {
color: var(--color--text--shade-1);
}
.comboboxAnchor[data-disabled][data-empty] .trailingIcon {
color: var(--input--placeholder--color--disabled);
}
.comboboxAnchor[data-disabled]:not([data-empty]) .trailingIcon {
color: var(--input--color--disabled);
}
.comboboxContent {
--combobox-content--radius: var(--radius--xs);
--popover-content--radius: var(--combobox-content--radius);
@include popover.popover-surface;
@include popover.popover-placement-offsets;
--combobox-content--max-height: 500px;
display: flex;
flex-direction: column;
min-width: var(--reka-combobox-trigger-width);
max-height: min(
var(--combobox-content--max-height),
var(--reka-combobox-content-available-height, 75dvh)
);
}
.comboboxViewport {
--combobox-viewport--padding: var(--spacing--4xs);
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: var(--combobox-viewport--padding);
}
.comboboxEmpty {
padding: var(--spacing--xs) var(--spacing--sm);
font-size: var(--font-size--xs);
color: var(--text-color--subtler);
text-align: center;
}
.comboboxLabel {
padding: var(--spacing--3xs) var(--spacing--2xs) var(--spacing--4xs);
color: var(--text-color--subtler);
font-size: var(--font-size--2xs);
}
.comboboxGroup {
--combobox-separator-outline-inset: 1px;
&:not([hidden]) ~ &:not([hidden]) {
border-top: 1px solid var(--border-color);
margin-block-start: var(--combobox-viewport--padding);
margin-inline: calc(
-1 * var(--combobox-viewport--padding) + var(--combobox-separator-outline-inset)
);
padding-block-start: var(--combobox-viewport--padding);
padding-inline: calc(
var(--combobox-viewport--padding) - var(--combobox-separator-outline-inset)
);
}
}
</style>

View File

@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, useCssModule } from 'vue';
import Icon from '@n8n/design-system/components/N8nIcon/Icon.vue';
import type { ComboboxItemSlots, ComboboxOptionBase } from './Combobox.types';
import { ComboboxItem, ComboboxItemIndicator } from './reka-ui';
defineOptions({ inheritAttrs: false });
defineSlots<ComboboxItemSlots>();
const props = defineProps<ComboboxOptionBase>();
const $style = useCssModule();
/** Reka filters against a single string — join base text with optional keywords. */
const filterText = computed(() =>
[props.textValue ?? props.label, ...(props.keywords ?? [])].join(' '),
);
</script>
<template>
<ComboboxItem
v-if="props.value !== ''"
:class="$style.root"
:disabled="props.disabled"
:value="props.value"
:text-value="filterText"
:data-disabled="props.disabled || undefined"
@mousedown.prevent
@select="props.onSelect?.($event)"
>
<slot name="item-leading" :item="props" :ui="{ class: $style.itemLeading }">
<Icon v-if="props.icon" :icon="props.icon" :class="$style.itemLeading" size="large" />
</slot>
<span :class="$style.itemText">
<slot name="item-label" :item="props">
{{ props.label }}
</slot>
</span>
<slot name="item-trailing" :item="props" :ui="{ class: $style.itemTrailing }" />
<ComboboxItemIndicator as-child>
<Icon icon="check" :class="$style.itemIndicator" />
</ComboboxItemIndicator>
</ComboboxItem>
</template>
<style module lang="scss">
@use '@n8n/design-system/css/mixins/floating-item' as floating-item;
.root {
@include floating-item.floating-item;
width: 100%;
min-width: 0;
color: var(--text-color);
&:not([data-disabled]) {
&[data-highlighted] {
background-color: var(--background--hover);
cursor: pointer;
}
&:hover {
cursor: pointer;
}
}
&[data-disabled] {
color: var(--text-color--disabled);
cursor: not-allowed;
}
}
.itemLeading {
flex-shrink: 0;
}
.itemText {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemIndicator,
.itemTrailing {
margin-left: auto;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,389 @@
# Component specification
Allows users to search and choose one or more options from a list. All comboboxes filter as the user types and open the dropdown on input focus. Supports single and multiple selection, grouped lists (groups and separators), and is suited to larger datasets where a plain Select is not enough.
- **Component Name:** N8nCombobox2
- **Figma Component:** [ComboBox](https://www.figma.com/design/8zib7Trf2D2CHYXrEGPHkg/n8n-Design-System-V3?node-id=2631-7139&m=dev)
- **Reka UI Component:** [Combobox](https://reka-ui.com/docs/components/combobox)
## Component structure
```
Combobox (N8nCombobox2)
├── ComboboxAnchor (trigger chrome)
│ ├── Single: leading icon + ComboboxInput + clear + chevron
│ └── Multiple: N8nTagsInput2 (embedded) + clear + chevron
│ └── #input slot: ComboboxInput as-child → TagsInputInput
└── ComboboxContent (portaled dropdown, role="listbox", max-height 500px by default)
└── ComboboxViewport (scrollable list area)
├── ComboboxEmpty
└── ComboboxGroup (one per `type: 'group'`, or batched top-level options)
├── ComboboxLabel (optional section heading)
└── N8nCombobox2Item (reka-ui ComboboxItem + default row: icon, label, check)
```
There are no header/footer slots. Reka applies `role="listbox"` to `ComboboxContent`, so non-option content (buttons, links, freeform markup) would break the ARIA listbox pattern. For section headings use `type: 'group'` with an optional `label`; for actionable chrome (e.g. “Create new…”) add a selectable option — see [Header and footer actions](#header-and-footer-actions).
When `multiple` is true, selected values render via embedded `N8nTagsInput2` (shared chip/layout styles; Combobox keeps the field chrome). Freeform tag creation is disabled — values are only added from the dropdown.
Use `type: 'group'` for sections. The `label` is optional. Each group maps to its own reka `ComboboxGroup`, so heading DOM `id`s stay unique and `aria-labelledby` points at the correct label. Top-level options (outside any group) are batched into an unlabeled group. Dividers are CSS between consecutive *visible* groups (`:not([hidden]) ~ :not([hidden])`), so they disappear when Reka hides a group that has no filter matches. `{ type: 'separator' }` is only a split marker for unlabeled options — it is not rendered as a DOM node (Reka would not filter a separator element).
## Public API Definition
**Props**
- `id?: string` — Applied to the combobox input. Falls back to a generated id when omitted so the field always has an `id` for label association and browser a11y checks
- `name?: string` — Form field name passed to reka-ui root
- `placeholder?: string` — Shown in the input when no value is selected | `default: t('combobox.placeholder')`
- `emptyText?: string` — Shown when filtering returns no matches | `default: t('combobox.emptyText')`
- `autoFocus?: boolean` — Focus the input on mount
- `items?: ComboboxItem[]` — Array of options to render (see [Item shapes](#item-shapes) below). If `modelValue` is set before items resolve, the input shows the raw value until a matching item is available, then updates to that item's `label`. There is no built-in pending/skeleton state — wrap the field at the call site if you need one.
- `defaultValue?: ComboboxValue | ComboboxValue[]` — Initial value when uncontrolled. Seeds the selection used for tags, the clear button, leading icon, and `data-empty` (same as a controlled `modelValue`).
- `modelValue?: ComboboxValue | ComboboxValue[]` — Controlled value. Bind with `v-model`
- `multiple?: boolean` — Allow selecting multiple options. Selected values render as removable tags via embedded `N8nTagsInput2` (search input stays empty; filtering still works while typing)
- `open?: boolean` — Controlled open state. Bind with `v-model:open`
- `defaultOpen?: boolean` — Initial open state when uncontrolled
- `disabled?: boolean` — Disable interaction
- `required?: boolean` — Mark the field as required (reka-ui root)
- `icon?: IconName` — Leading icon in the trigger when no selected item icon is available
- `ignoreFilter?: boolean` — Disable built-in filtering
- `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`
- `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`
- `portalTarget?: string | HTMLElement` — Portal target element (e.g. pop-out window's `document.body`). When set, portals content to the specified element.
**UI Props**
- `size?`: `'mini' | 'small' | 'medium' | 'large' | 'xlarge'` | `default: 'large'` — Applies to the trigger/input only. Dropdown items use a fixed size aligned with `DropdownMenu`.
- `side?`: `'top' | 'right' | 'bottom' | 'left'` | `default: 'bottom'`
- `sideOffset?`: `number` | `default: 4`
- `align?`: `'start' | 'center' | 'end'` | `default: 'start'`
- `contentClass?`: `string` — Additional class(es) on the portaled dropdown content
The dropdown content defaults to a max height of **500px** with vertical scrolling on the viewport. Override by setting the CSS variable on a custom class:
```css
.my-combobox-content {
--combobox-content--max-height: 300px;
}
```
**Events**
- `update:modelValue(value: ComboboxValue | ComboboxValue[])` — For single selection, clearing emits `undefined`. For multiple selection, clearing emits `[]`.
- `update:open(value: boolean)`
- `highlight(payload: { ref: HTMLElement; value: ComboboxValue } | undefined)` — reka-ui root
**Exposed**
- `anchorRef` — Ref to the `ComboboxAnchor` element
**Attributes**
- `aria-*` fallthrough attributes are forwarded to the combobox input. The placeholder is never used as an accessible name — associate a `<label>` via the input `id` (generated when omitted, or pass an explicit `id`), or pass `aria-label` / `aria-labelledby`.
- Other non-prop attributes (e.g. `data-test-id`) fall through to `ComboboxAnchor`, not the root.
**Slots**
- `item`: `{ item: ComboboxOptionBase }` — Custom item rendering (replaces default `N8nCombobox2Item`). Prefer `#item-leading` / `#item-label` / `#item-trailing` for content customization. If you use `#item`, you must re-render `N8nCombobox2Item` (or an equivalent Reka `ComboboxItem`) yourself — otherwise the option loses selection, highlighting, filtering, and accessibility semantics.
- `item-leading`: `{ item: ComboboxOptionBase; ui: { class: string } }` — Pass-through to `N8nCombobox2Item`
- `item-label`: `{ item: ComboboxOptionBase }` — Pass-through to `N8nCombobox2Item`
- `item-trailing`: `{ item: ComboboxOptionBase; ui: { class: string } }` — Pass-through to `N8nCombobox2Item`
- `label`: `{ item: ComboboxGroupItem }` — Section heading for `type: 'group'` entries that have a `label`
### Item shapes
Selectable items must include a non-empty `label` and `value`. Map source data at the call site; the component does not support `valueKey` / `labelKey` or primitive string items. In development, missing/empty `value` or `label` are skipped with a console warning; in production builds they are dropped silently.
```typescript
type ComboboxValue = string;
type ComboboxOptionBase<TValue extends ComboboxValue = ComboboxValue> = {
type?: 'item';
value: TValue; // required — must not be ''
label: string; // required
icon?: IconName;
disabled?: boolean;
/** Filter text when it should differ from `label` (e.g. slot-rendered label). Defaults to `label`. */
textValue?: string;
/** Extra strings matched during filtering (e.g. synonyms). Checked alongside `textValue` / `label`. */
keywords?: string[];
/** Call `event.preventDefault()` to run an action without updating the selection. */
onSelect?: (event: Event) => void;
};
type ComboboxItem =
| ComboboxOptionBase
| { type: 'group'; label?: string; items: ComboboxOptionBase[] }
| { type: 'separator' };
```
Consumers that need extra fields can extend the base type:
```typescript
interface CustomOption extends ComboboxOptionBase<string> {
description: string;
}
```
- **Object items** (e.g. `{ label: 'Option 1', value: 'option1' }`) — `modelValue` stores `value`; the input displays `label`. Missing/empty `value` or `label` are skipped (with a console warning in development) — empty string values are never passed to reka (they would kill the dropdown).
- **Groups** — `{ type: 'group', label?: 'Fruits', items: [...] }` — optional section heading plus nested options. Label may be omitted for an unlabeled group. Consecutive visible groups always get a decorative divider.
- **Separators** — `{ type: 'separator' }` — splits unlabeled top-level options into sibling groups (so they get a divider). Not a DOM node; two explicit `type: 'group'` entries already divide.
Object items may also include an `icon` property. When no custom `#item-leading` slot is provided, icons on items are rendered automatically. The same `#item-leading` slot (or default icon) is also used for the selected value in the trigger.
### Template usage examples
**Object items**
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { N8nCombobox2 } from '@n8n/design-system';
const items = ref([
{ label: 'Backlog', value: 'backlog' },
{ label: 'Todo', value: 'todo' },
{ label: 'In Progress', value: 'in_progress' },
{ label: 'Done', value: 'done' },
]);
const value = ref('backlog');
</script>
<template>
<N8nCombobox2 v-model="value" :items="items" placeholder="Search status..." />
</template>
```
**Object items with icons**
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { N8nCombobox2, N8nIcon } from '@n8n/design-system';
const items = ref([
{ value: 'light', label: 'Light', icon: 'wrench' },
{ value: 'dark', label: 'Dark', icon: 'filled-square' },
]);
const value = ref('light');
</script>
<template>
<N8nCombobox2 v-model="value" :items="items">
<template #item-leading="{ item, ui }">
<N8nIcon :icon="item.icon" color="primary" v-bind="ui" />
</template>
</N8nCombobox2>
</template>
```
**Using N8nCombobox2Item for full control**
`#item` replaces the default item renderer entirely (same escape hatch as `N8nSelect2` / `N8nDropdownMenu`). Re-render `N8nCombobox2Item` so the option keeps Reka semantics — use this when you need to compose around the item (e.g. wrap it in a popover), not for ordinary content customization.
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { N8nCombobox2, N8nCombobox2Item, N8nBadge } from '@n8n/design-system';
const items = ref([
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
]);
const value = ref<string | undefined>();
</script>
<template>
<N8nCombobox2 v-model="value" :items="items">
<template #item="{ item }">
<N8nCombobox2Item v-bind="item">
<template #item-trailing="{ ui }">
<N8nBadge :class="ui.class">Custom</N8nBadge>
</template>
</N8nCombobox2Item>
</template>
</N8nCombobox2>
</template>
```
**Grouped list with groups and separators**
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { N8nCombobox2 } from '@n8n/design-system';
const items = ref([
{
type: 'group',
label: 'Fruits',
items: [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
],
},
{ type: 'separator' },
{
type: 'group',
label: 'Vegetables',
items: [{ label: 'Carrot', value: 'carrot' }],
},
]);
const value = ref<string | undefined>();
</script>
<template>
<N8nCombobox2 v-model="value" :items="items" placeholder="Select a food..." />
</template>
```
**Header and footer actions**
Do not place buttons or other freeform interactive controls in the popup — they sit inside `role="listbox"` and break the ARIA pattern. Model actions as selectable options instead: use `type: 'group'` for labeled sections, `{ type: 'separator' }` (or a second group) to visually pin an action, and `onSelect` with `event.preventDefault()` so the action does not become the field value.
```vue
<script setup lang="ts">
import { computed, ref } from 'vue';
import { N8nCombobox2 } from '@n8n/design-system';
const options = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Orange', value: 'orange' },
];
const open = ref(false);
const createOpen = ref(false);
const value = ref<string | undefined>();
const items = computed(() => [
{
type: 'group' as const,
label: 'Fruits',
items: options,
},
{ type: 'separator' as const },
{
label: 'Create new fruit',
value: '__create__',
icon: 'plus' as const,
onSelect: (event: Event) => {
event.preventDefault();
open.value = false;
createOpen.value = true;
},
},
]);
</script>
<template>
<N8nCombobox2
v-model="value"
v-model:open="open"
:items="items"
placeholder="Select a fruit..."
/>
</template>
```
**Controlled value and open state**
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { N8nCombobox2 } from '@n8n/design-system';
const items = [
{ label: 'Workflows', value: 'workflows' },
{ label: 'Credentials', value: 'credentials' },
];
const value = ref<string | undefined>('workflows');
const open = ref(false);
</script>
<template>
<N8nCombobox2
v-model="value"
v-model:open="open"
:items="items"
placeholder="Search..."
/>
</template>
```
**Clearable selection**
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { N8nCombobox2 } from '@n8n/design-system';
const items = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
];
const value = ref<string | undefined>('option1');
</script>
<template>
<N8nCombobox2
v-model="value"
:items="items"
clearable
placeholder="Search..."
/>
</template>
```
When the clear button is clicked, single selection emits `undefined`, multiple selection emits `[]`, and the input is refocused. Clearing the input text to search again does **not** clear the committed selection — abandon the search (Escape / blur) to restore the display via `resetSearchTermOnBlur`.
**Multiple selection (tags)**
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { N8nCombobox2 } from '@n8n/design-system';
const items = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Orange', value: 'orange' },
];
const value = ref<string[]>(['apple']);
</script>
<template>
<N8nCombobox2
v-model="value"
:items="items"
multiple
clearable
placeholder="Select fruits..."
/>
</template>
```
**Custom dropdown max height**
```vue
<N8nCombobox2
:items="items"
content-class="narrow-combobox"
/>
```
```css
.narrow-combobox {
--combobox-content--max-height: 300px;
}
```
## Related components
- **N8nSelect2** — Use for short static lists without search (fewer than ~10 items).
- **N8nCombobox2Item** — Reka-ui combobox item with the default row layout (icon, label, check indicator). Used inside `N8nCombobox2`.

View File

@ -0,0 +1,15 @@
export { default as N8nCombobox2 } from './Combobox.vue';
export { default as N8nCombobox2Item } from './ComboboxItem.vue';
export type {
ComboboxEmits,
ComboboxGroupItem,
ComboboxItem,
ComboboxItemSlots,
ComboboxOptionBase,
ComboboxProps,
ComboboxSeparatorItem,
ComboboxSizes,
ComboboxSlots,
ComboboxStructuralItem,
ComboboxValue,
} from './Combobox.types';

View File

@ -0,0 +1,35 @@
/**
* Local re-export shim for reka-ui.
*
* Combobox.vue and ComboboxItem.vue import reka-ui through this shim.
* Storybook's vue-docgen-plugin resolves package imports from this folder to a
* broken relative path:
*
* src/v2/components/Combobox/ → ../../node_modules/reka-ui/dist/index.js
* (resolves to src/v2/node_modules/…, which does not exist)
*
* Values and types are re-exported here so files in this folder never
* import 'reka-ui' directly.
*/
export {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxLabel,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport,
useForwardPropsEmits,
} from 'reka-ui';
export type {
ComboboxContentProps,
ComboboxItemProps,
ComboboxRootEmits,
ComboboxRootProps,
} from 'reka-ui';

View File

@ -150,6 +150,50 @@ export const Disabled = {
},
} satisfies Story;
export const Embedded = {
render: (args) => ({
components: { TagsInput },
setup() {
const value = ref(args.modelValue ?? ['workflow', 'production']);
return { args, value };
},
template: `
<div style="${storyContainerStyle}; display: flex; flex-direction: column; gap: var(--spacing--md);">
<p style="margin: 0; font-size: var(--font-size--2xs); color: var(--color--text--tint-1);">
Layout-only mode for composition inside another field (e.g. Combobox).
The host owns border, background, and focus styles.
</p>
<div
style="
--tags-input--padding: var(--spacing--4xs);
display: flex;
align-items: center;
width: 100%;
min-height: var(--height--lg);
padding: var(--tags-input--padding);
padding-inline-end: var(--spacing--xs);
border-radius: var(--radius--2xs);
background-color: light-dark(var(--color--neutral-white), var(--color--neutral-950));
box-shadow: inset 0 0 0 1px var(--border-color);
color: var(--color--text--shade-1);
"
>
<TagsInput
v-bind="args"
v-model="value"
embedded
placeholder="Add tags..."
/>
</div>
</div>
`,
}),
args: {
modelValue: ['workflow', 'production'],
embedded: true,
},
} satisfies Story;
export const Multiline = {
render: (args) => ({
components: { TagsInput },
@ -324,7 +368,7 @@ export const CustomTags = {
:display-value="displayValue"
:convert-value="convertValue"
>
<template #tag="{ value: tag, disabled, ui }">
<template #tag="{ value: tag, displayValue, disabled, ui }">
<span
aria-hidden="true"
:style="{
@ -338,8 +382,17 @@ export const CustomTags = {
}"
/>
<TagsInputItemText :class="ui.text" />
<TagsInputItemDelete :class="ui.delete" :disabled="disabled">
<N8nIcon icon="x" size="small" />
<TagsInputItemDelete as-child :disabled="disabled" @mousedown.prevent>
<button
type="button"
:class="ui.delete"
tabindex="-1"
:disabled="disabled"
aria-labelledby=""
:aria-label="\`Remove \${displayValue}\`"
>
<N8nIcon icon="x" size="small" />
</button>
</TagsInputItemDelete>
</template>
</TagsInput>

View File

@ -74,6 +74,16 @@ describe('v2/components/TagsInput', () => {
expect(wrapper.getByText('billing')).toBeVisible();
});
it('should name the remove button with the tag label', () => {
const wrapper = render(TagsInput, {
props: {
modelValue: ['Apple'],
},
});
expect(wrapper.getByRole('button', { name: 'Remove Apple' })).toBeVisible();
});
});
describe('sizes', () => {
@ -105,13 +115,33 @@ describe('v2/components/TagsInput', () => {
},
});
await userEvent.click(wrapper.getByRole('button', { name: 'alpha' }));
await userEvent.click(wrapper.getByRole('button', { name: 'Remove alpha' }));
await waitFor(() => {
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['beta']]);
});
});
it('should not focus the input when removing a tag while unfocused', async () => {
const wrapper = render(TagsInput, {
props: {
modelValue: ['alpha', 'beta'],
placeholder: 'Add tags...',
},
});
const input = wrapper.getByRole('textbox');
expect(input).not.toHaveFocus();
await userEvent.click(wrapper.getByRole('button', { name: 'Remove alpha' }));
await waitFor(() => {
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['beta']]);
});
expect(input).not.toHaveFocus();
});
it('should add a tag on Enter', async () => {
const wrapper = render(TagsInput, {
props: {

View File

@ -1,19 +1,18 @@
import type {
AcceptableInputValue,
TagsInputInputProps,
TagsInputRootEmits,
TagsInputRootProps,
} from './reka-ui';
import type { AcceptableInputValue, TagsInputRootEmits, TagsInputRootProps } from './reka-ui';
import type { InputSize } from '../../../components/N8nInput/Input.types';
export type TagsInputValue = AcceptableInputValue;
export type TagsInputSizes = InputSize;
export type TagsInputProps = TagsInputRootProps<TagsInputValue> &
Pick<TagsInputInputProps, 'placeholder' | 'autoFocus'> & {
size?: TagsInputSizes;
};
export interface TagsInputOwnProps {
size?: TagsInputSizes;
embedded?: boolean;
placeholder?: string;
autoFocus?: boolean;
}
export type TagsInputProps = TagsInputRootProps<TagsInputValue> & TagsInputOwnProps;
export type TagsInputEmits = TagsInputRootEmits<TagsInputValue>;

View File

@ -2,6 +2,10 @@
import { reactivePick } from '@vueuse/core';
import { computed, useCssModule, useTemplateRef } from 'vue';
import Icon from '@n8n/design-system/components/N8nIcon/Icon.vue';
import { useI18n } from '@n8n/design-system/composables/useI18n';
import type { IconSize } from '@n8n/design-system/types/icon';
import {
TagsInputInput,
TagsInputItem,
@ -9,26 +13,27 @@ import {
TagsInputItemText,
TagsInputRoot,
useForwardPropsEmits,
type TagsInputRootProps,
} from './reka-ui';
import type {
TagsInputEmits,
TagsInputProps,
TagsInputOwnProps,
TagsInputSizes,
TagsInputSlots,
TagsInputValue,
} from './TagsInput.types';
import Icon from '../../../components/N8nIcon/Icon.vue';
import type { IconSize } from '../../../types/icon';
defineOptions({ inheritAttrs: false });
const $style = useCssModule();
const { t } = useI18n();
const props = withDefaults(defineProps<TagsInputProps>(), {
const props = withDefaults(defineProps<TagsInputOwnProps & TagsInputRootProps<TagsInputValue>>(), {
placeholder: 'Add a tag...',
size: 'large',
delimiter: ',',
disabled: false,
embedded: false,
});
const emit = defineEmits<TagsInputEmits>();
@ -157,19 +162,20 @@ function getInputClass(isEmpty: boolean): string {
</script>
<template>
<div ref="root" :class="[$style.root, sizes[props.size]]">
<div ref="root" :class="[props.embedded ? $style.embedded : $style.root, sizes[props.size]]">
<TagsInputRoot
v-bind="{ ...$attrs, ...rootProps }"
:display-value="getDisplayValue"
:class="$style.tags"
:class="[$style.tags, props.embedded && $style.tagsEmbedded]"
@invalid="onInvalid"
>
<template #default="{ modelValue }">
<template #default="{ modelValue: tags }">
<TagsInputItem
v-for="(tag, index) in modelValue"
v-for="(tag, index) in tags"
:key="getTagKey(tag, index)"
:value="tag"
:class="$style.tag"
data-test-id="tags-input-tag"
>
<slot
name="tag"
@ -180,8 +186,17 @@ function getInputClass(isEmpty: boolean): string {
:ui="{ text: $style.tagText, delete: $style.tagDelete }"
>
<TagsInputItemText :class="$style.tagText" />
<TagsInputItemDelete :class="$style.tagDelete" :disabled="props.disabled">
<Icon icon="x" :size="deleteIconSize" />
<TagsInputItemDelete as-child :disabled="props.disabled" @mousedown.prevent>
<button
type="button"
:class="$style.tagDelete"
tabindex="-1"
:disabled="props.disabled"
aria-labelledby=""
:aria-label="t('tagsInput.removeTag', { tag: getDisplayValue(tag) })"
>
<Icon icon="x" :size="deleteIconSize" />
</button>
</TagsInputItemDelete>
</slot>
</TagsInputItem>
@ -189,15 +204,15 @@ function getInputClass(isEmpty: boolean): string {
<slot
:id="props.id"
name="input"
:placeholder="modelValue.length ? '' : props.placeholder"
:placeholder="tags.length ? '' : props.placeholder"
:auto-focus="props.autoFocus"
:disabled="props.disabled"
:class="getInputClass(modelValue.length === 0)"
:class="getInputClass(tags.length === 0)"
>
<TagsInputInput
:id="props.id"
:class="getInputClass(modelValue.length === 0)"
:placeholder="modelValue.length ? '' : props.placeholder"
:class="getInputClass(tags.length === 0)"
:placeholder="tags.length ? '' : props.placeholder"
:auto-focus="props.autoFocus"
:disabled="props.disabled"
/>
@ -211,7 +226,8 @@ function getInputClass(isEmpty: boolean): string {
@use '@n8n/design-system/css/mixins/focus';
@use '@n8n/design-system/css/mixins/input' as input-mixin;
.root {
.root,
.embedded {
@include input-mixin.size-variables('large');
@include input-mixin.theme-variables(var(--border-color));
@ -220,7 +236,9 @@ function getInputClass(isEmpty: boolean): string {
--tag--gap: var(--spacing--4xs);
--tag--delete--size: max(var(--height--4xs), calc(var(--tag--height) - 2 * var(--spacing--4xs)));
--tag--padding-inline-end: calc((var(--tag--height) - var(--tag--delete--size) - 2px) / 2);
}
.root {
display: flex;
flex: 1;
width: 100%;
@ -257,6 +275,10 @@ function getInputClass(isEmpty: boolean): string {
}
}
.embedded {
width: 100%;
}
.mini {
@include input-mixin.size-variables('mini');
@ -326,6 +348,10 @@ function getInputClass(isEmpty: boolean): string {
overflow: auto;
}
.tagsEmbedded {
padding: 0;
}
.tag {
display: inline-flex;
align-items: center;

View File

@ -11,16 +11,17 @@ Freeform tag entry field. Users type values and create tags via delimiter (defau
**Props**
- `modelValue?: TagsInputValue[]` - Controlled tags. Bind with `v-model`
- `modelValue?: TagsInputValue[]` - Controlled tags. Bind with `v-model`. `TagsInputValue` is `string | number | bigint | Record | null`
- `defaultValue?: TagsInputValue[]` - Initial tags when uncontrolled
- `placeholder?: string` - Shown when empty. Default: `'Add a tag...'`
- `disabled?: boolean` - When `true`, prevents interaction and dims the field. Default: `false`
- `id?: string` - Applied to the text input
- `autoFocus?: boolean` - Focus the input on mount
- `delimiter?: string | RegExp` - Creates a tag from typed input. Default: `','`. Pass `''` to disable delimiter splitting
- `displayValue?: (value: TagsInputValue) => string` - Label for object tags. Falls back to string value or `value.label`
- `displayValue?: (value: TagsInputValue) => string` - Label for tags. Falls back to string/number/bigint/`'null'`, or `value.label` for objects
- `convertValue?: (value: string) => TagsInputValue` - Required when tags are objects
- `size?: 'mini' | 'small' | 'medium' | 'large' | 'xlarge'` - Sets `--input--height`. Tag height is `height − 2× inset`; inset/gap share `--tags-input--padding`. Default: `'large'`
- `embedded?: boolean` - When `true`, drops the field chrome so a parent (e.g. Combobox) owns the border/background. Default: `false`
- `addOnPaste?: boolean` / `addOnBlur?: boolean` / `addOnTab?: boolean` - Extra ways to commit a tag
- `max?: number` - Max tags. Reka treats `0` as unlimited
- `duplicate?: boolean` - When `true`, allow duplicate tags. When `false` (default), adding an existing tag moves it to the end of the list
@ -42,7 +43,7 @@ The draft input grows with typed text (`field-sizing: content`) so it wraps to t
**Slots**
- `input`: `{ id?, placeholder, autoFocus?, disabled?, class }` - Replace the default text input. Apply `class` so the field keeps TagsInput input styles. Re-exported `TagsInputInput` can be used when composing a custom input.
- `tag`: `{ value, displayValue, index, disabled, ui }` - Replace tag content inside the item chrome. Keep using `TagsInputItemText` / `TagsInputItemDelete` (re-exported from `@n8n/design-system`) for label + remove a11y. `ui.text` / `ui.delete` are the default class names
- `tag`: `{ value, displayValue, index, disabled, ui }` - Replace tag content inside the item chrome. Keep using `TagsInputItemText` / `TagsInputItemDelete` (re-exported from `@n8n/design-system`) for label + remove a11y. Put `@mousedown.prevent` on `TagsInputItemDelete` so removing a tag does not steal focus. Prefer `as-child` with an explicit `aria-label` (e.g. “Remove {tag}”) and clear `aria-labelledby` — reka’s default names the delete control after the tag text alone. `ui.text` / `ui.delete` are the default class names
### Template usage example
@ -85,7 +86,7 @@ const tags = ref([
:display-value="(t) => t.label"
:convert-value="(input) => ({ label: input, color: 'var(--color--text--tint-1)' })"
>
<template #tag="{ value: tag, disabled, ui }">
<template #tag="{ value: tag, displayValue, disabled, ui }">
<span
aria-hidden="true"
:style="{
@ -99,8 +100,17 @@ const tags = ref([
}"
/>
<TagsInputItemText :class="ui.text" />
<TagsInputItemDelete :class="ui.delete" :disabled="disabled">
<N8nIcon icon="x" size="small" />
<TagsInputItemDelete as-child :disabled="disabled" @mousedown.prevent>
<button
type="button"
:class="ui.delete"
tabindex="-1"
:disabled="disabled"
aria-labelledby=""
:aria-label="`Remove ${displayValue}`"
>
<N8nIcon icon="x" size="small" />
</button>
</TagsInputItemDelete>
</template>
</N8nTagsInput2>

View File

@ -52,6 +52,7 @@ export const parameters = {
},
options: {
storySort: {
method: 'alphabetical',
order: ['Style guide', 'Core', 'Areas', 'Experimental'],
},
},

View File

@ -13,6 +13,8 @@
body {
padding: 0 !important;
/* Keep the Storybook canvas stable while body data-theme flips for teleported floating UI. */
background-color: var(--color--neutral-50) !important;
}
.story {

View File

@ -0,0 +1,75 @@
diff --git a/dist/Combobox/ComboboxRoot.cjs b/dist/Combobox/ComboboxRoot.cjs
index 024be3d3c606330d644aa9bad79c4ffa5893edc5..3e4234f28e51cd328d35d5b59c310b0057c9191a 100644
--- a/dist/Combobox/ComboboxRoot.cjs
+++ b/dist/Combobox/ComboboxRoot.cjs
@@ -118,11 +118,14 @@ var ComboboxRoot_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ (
await (0, vue.nextTick)();
primitiveElement.value?.highlightSelected();
isUserInputted.value = true;
- } else isUserInputted.value = false;
- inputElement.value?.focus();
- setTimeout(() => {
- if (!val && props.resetSearchTermOnBlur) resetSearchTerm.trigger();
- }, 1);
+ // Only focus when opening — focusing on close steals Tab (reka-ui#2284)
+ inputElement.value?.focus();
+ } else {
+ isUserInputted.value = false;
+ setTimeout(() => {
+ if (!val && props.resetSearchTermOnBlur) resetSearchTerm.trigger();
+ }, 1);
+ }
}
const resetSearchTerm = (0, __vueuse_core.createEventHook)();
const isUserInputted = (0, vue.ref)(false);
diff --git a/dist/Combobox/ComboboxRoot.js b/dist/Combobox/ComboboxRoot.js
index ab18626e4d2e4de85df95dc2cc348ec2fea15271..de5b9c3bb3f95fe2bc6249a49363d5de17df69db 100644
--- a/dist/Combobox/ComboboxRoot.js
+++ b/dist/Combobox/ComboboxRoot.js
@@ -117,11 +117,14 @@ var ComboboxRoot_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ d
await nextTick();
primitiveElement.value?.highlightSelected();
isUserInputted.value = true;
- } else isUserInputted.value = false;
- inputElement.value?.focus();
- setTimeout(() => {
- if (!val && props.resetSearchTermOnBlur) resetSearchTerm.trigger();
- }, 1);
+ // Only focus when opening — focusing on close steals Tab (reka-ui#2284)
+ inputElement.value?.focus();
+ } else {
+ isUserInputted.value = false;
+ setTimeout(() => {
+ if (!val && props.resetSearchTermOnBlur) resetSearchTerm.trigger();
+ }, 1);
+ }
}
const resetSearchTerm = createEventHook();
const isUserInputted = ref(false);
diff --git a/src/Combobox/ComboboxRoot.vue b/src/Combobox/ComboboxRoot.vue
index ad142f07d8110fbef8f8793c54540e48cfec456d..8e74b2144596ce3cd71475e704b6818360d64cb6 100644
--- a/src/Combobox/ComboboxRoot.vue
+++ b/src/Combobox/ComboboxRoot.vue
@@ -125,16 +125,16 @@ async function onOpenChange(val: boolean) {
await nextTick()
primitiveElement.value?.highlightSelected()
isUserInputted.value = true
+ // Only focus when opening — focusing on close steals Tab (reka-ui#2284)
+ inputElement.value?.focus()
}
else {
isUserInputted.value = false
+ setTimeout(() => {
+ if (!val && props.resetSearchTermOnBlur)
+ resetSearchTerm.trigger()
+ }, 1)
}
-
- inputElement.value?.focus()
- setTimeout(() => {
- if (!val && props.resetSearchTermOnBlur)
- resetSearchTerm.trigger()
- }, 1)
}
const resetSearchTerm = createEventHook()

View File

@ -866,6 +866,9 @@ patchedDependencies:
pkce-challenge@5.0.0:
hash: 651e785d0b7bbf5be9210e1e895c39a16dc3ce8a5a3843b4819565fb6e175b90
path: patches/pkce-challenge@5.0.0.patch
reka-ui@2.5.0:
hash: f1cec654cb01a5751dfc1de06604bb22f9518860ea9c72fb685e770509923a2f
path: patches/reka-ui@2.5.0.patch
v-code-diff:
hash: 21588de80e591bbc1e5a068d9bce311db5254686443652945d2c7887fdafe9d9
path: patches/v-code-diff.patch
@ -5282,7 +5285,7 @@ importers:
version: 0.11.1
reka-ui:
specifier: ^2.5.0
version: 2.5.0(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2))
version: 2.5.0(patch_hash=f1cec654cb01a5751dfc1de06604bb22f9518860ea9c72fb685e770509923a2f)(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2))
sanitize-html:
specifier: 'catalog:'
version: 2.17.5
@ -6142,7 +6145,7 @@ importers:
version: 0.14.1
reka-ui:
specifier: ^2.5.0
version: 2.5.0(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2))
version: 2.5.0(patch_hash=f1cec654cb01a5751dfc1de06604bb22f9518860ea9c72fb685e770509923a2f)(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2))
sanitize-html:
specifier: 'catalog:'
version: 2.17.5
@ -41016,7 +41019,7 @@ snapshots:
reinterval@1.1.0: {}
reka-ui@2.5.0(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2)):
reka-ui@2.5.0(patch_hash=f1cec654cb01a5751dfc1de06604bb22f9518860ea9c72fb685e770509923a2f)(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2)):
dependencies:
'@floating-ui/dom': 1.7.0
'@floating-ui/vue': 1.1.6(vue@3.5.26(typescript@6.0.2))