This commit is contained in:
Sebastien Powell 2026-07-27 16:11:57 +01:00
parent f62cc6319a
commit fbb105c165
10 changed files with 1602 additions and 0 deletions

View File

@ -35,6 +35,10 @@ export {
TagsInputItemText,
} from './v2/components/TagsInput/reka-ui';
export type * from './v2/components/TagsInput/TagsInput.types';
export { default as N8nListbox } from './v2/components/Listbox/Listbox.vue';
export { default as N8nListboxItem } from './v2/components/Listbox/ListboxItem.vue';
export { default as N8nListboxItemDefault } from './v2/components/Listbox/ListboxItemDefault.vue';
export type * from './v2/components/Listbox/Listbox.types';
export { default as N8nSwitch2 } from './components/N8nSwitch/Switch.vue';
export type * from './components/N8nSwitch/Switch.types';
export { createPasswordRules } from './components/N8nFormInput/validators';

View File

@ -0,0 +1,682 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { action } from 'storybook/actions';
import { defineComponent, ref } from 'vue';
import Listbox from './Listbox.vue';
import ListboxItem from './ListboxItem.vue';
import { ListboxVirtualizer } from './reka-ui';
import type { DropdownMenuItemProps } from '../../../components/N8nDropdownMenu/DropdownMenu.types';
import N8nDropdownMenu from '../../../components/N8nDropdownMenu/DropdownMenu.vue';
import N8nIconButton from '../../../components/N8nIconButton/IconButton.vue';
const meta = {
title: 'Experimental/Listbox',
component: Listbox,
parameters: {
docs: {
description: {
component:
'Keyboard-navigable selectable list. Put row actions in the trailing slot so dropdowns stay outside the option.',
},
source: { type: 'dynamic' },
},
layout: 'centered',
},
argTypes: {
size: {
control: 'select',
options: ['small', 'default', 'medium'],
},
variant: {
control: 'select',
options: ['boxed', 'flush'],
},
disabled: { control: 'boolean' },
highlightOnHover: { control: 'boolean' },
multiple: { control: 'boolean' },
},
} satisfies Meta<typeof Listbox>;
export default meta;
type Story = StoryObj<typeof meta>;
type DemoItem = {
id: string;
label: string;
description: string;
disabled?: boolean;
};
const sampleItems: DemoItem[] = [
{ id: 'production', label: 'production', description: '12 workflows' },
{ id: 'staging', label: 'staging', description: '4 workflows' },
{ id: 'internal', label: 'internal', description: 'Not being used' },
{ id: 'customer-facing', label: 'customer-facing', description: '7 workflows' },
{ id: 'legacy', label: 'legacy', description: '1 workflow', disabled: true },
];
const rowActions: Array<DropdownMenuItemProps<'edit' | 'delete'>> = [
{ id: 'edit', label: 'Edit', icon: { type: 'icon', value: 'pen' } },
{ id: 'delete', label: 'Delete', icon: { type: 'icon', value: 'trash-2' } },
];
const ListboxBasicDemo = defineComponent({
name: 'ListboxBasicDemo',
components: { Listbox, ListboxItem },
props: {
size: { type: String, default: 'default' },
variant: { type: String, default: 'boxed' },
disabled: { type: Boolean, default: false },
highlightOnHover: { type: Boolean, default: true },
},
setup() {
const selected = ref('production');
return {
selected,
sampleItems,
onUpdate: action('update:modelValue'),
};
},
template: `
<div style="width: 360px; padding: 24px;">
<Listbox
v-model="selected"
:size="size"
:variant="variant"
:disabled="disabled"
:highlight-on-hover="highlightOnHover"
aria-label="Tags"
@update:model-value="onUpdate"
>
<ListboxItem
v-for="item in sampleItems"
:key="item.id"
:value="item.id"
:label="item.label"
:description="item.description"
:disabled="item.disabled"
/>
</Listbox>
<p style="margin-top: 16px; font-size: 14px;">Selected: <strong>{{ selected }}</strong></p>
</div>
`,
});
const ListboxSizesDemo = defineComponent({
name: 'ListboxSizesDemo',
components: { Listbox, ListboxItem },
setup() {
const selectedSmall = ref('production');
const selectedDefault = ref('production');
const selectedMedium = ref('production');
const items = sampleItems.slice(0, 3);
return { selectedSmall, selectedDefault, selectedMedium, items };
},
template: `
<div style="width: 960px; padding: 24px; display: flex; gap: 24px;">
<section style="flex: 1; min-width: 0;">
<h3 style="margin: 0 0 8px; font-size: 14px; font-weight: 600;">small</h3>
<Listbox v-model="selectedSmall" size="small" aria-label="Small listbox">
<ListboxItem
v-for="item in items"
:key="'small-' + item.id"
:value="item.id"
:label="item.label"
:description="item.description"
/>
</Listbox>
</section>
<section style="flex: 1; min-width: 0;">
<h3 style="margin: 0 0 8px; font-size: 14px; font-weight: 600;">default</h3>
<Listbox v-model="selectedDefault" size="default" aria-label="Default listbox">
<ListboxItem
v-for="item in items"
:key="'default-' + item.id"
:value="item.id"
:label="item.label"
:description="item.description"
/>
</Listbox>
</section>
<section style="flex: 1; min-width: 0;">
<h3 style="margin: 0 0 8px; font-size: 14px; font-weight: 600;">medium</h3>
<Listbox v-model="selectedMedium" size="medium" aria-label="Medium listbox">
<ListboxItem
v-for="item in items"
:key="'medium-' + item.id"
:value="item.id"
:label="item.label"
:description="item.description"
/>
</Listbox>
</section>
</div>
`,
});
const ListboxVariantsDemo = defineComponent({
name: 'ListboxVariantsDemo',
components: { Listbox, ListboxItem },
setup() {
const selectedBoxed = ref('production');
const selectedFlush = ref('production');
const items = sampleItems.slice(0, 3);
return { selectedBoxed, selectedFlush, items };
},
template: `
<div style="width: 720px; padding: 24px; display: flex; gap: 24px;">
<section style="flex: 1; min-width: 0;">
<h3 style="margin: 0 0 8px; font-size: 14px; font-weight: 600;">boxed</h3>
<p style="margin: 0 0 12px; font-size: 13px; color: var(--text-color--subtle);">
Bordered container for standalone lists.
</p>
<Listbox v-model="selectedBoxed" variant="boxed" aria-label="Boxed listbox">
<ListboxItem
v-for="item in items"
:key="'boxed-' + item.id"
:value="item.id"
:label="item.label"
:description="item.description"
/>
</Listbox>
</section>
<section
style="
flex: 1;
min-width: 0;
padding: var(--spacing--md);
background: var(--background--surface);
border: 1px dashed var(--border-color--subtle);
border-radius: var(--radius);
"
>
<h3 style="margin: 0 0 8px; font-size: 14px; font-weight: 600;">flush</h3>
<p style="margin: 0 0 12px; font-size: 13px; color: var(--text-color--subtle);">
Labels align with this copy; hover uses a rounded surface behind each item.
</p>
<Listbox v-model="selectedFlush" variant="flush" aria-label="Flush listbox">
<ListboxItem
v-for="item in items"
:key="'flush-' + item.id"
:value="item.id"
:label="item.label"
:description="item.description"
/>
</Listbox>
</section>
</div>
`,
});
const ListboxWithDropdownDemo = defineComponent({
name: 'ListboxWithDropdownDemo',
components: { Listbox, ListboxItem, N8nDropdownMenu, N8nIconButton },
setup() {
const selected = ref('staging');
return {
selected,
sampleItems,
rowActions,
onUpdate: action('update:modelValue'),
onAction: action('row-action'),
};
},
template: `
<div style="width: 360px; padding: 24px;">
<Listbox
v-model="selected"
aria-label="Tags with actions"
@update:model-value="onUpdate"
>
<ListboxItem
v-for="item in sampleItems"
:key="item.id"
:value="item.id"
:label="item.label"
:description="item.description"
:disabled="item.disabled"
>
<template v-if="!item.disabled" #trailing="{ setMenuOpen }">
<N8nDropdownMenu
:items="rowActions"
placement="bottom-end"
width="12rem"
:modal="false"
@update:model-value="setMenuOpen"
@select="(actionId) => onAction({ tag: item.id, action: actionId })"
>
<template #trigger>
<N8nIconButton
variant="subtle"
icon="ellipsis-vertical"
aria-label="Tag actions"
/>
</template>
</N8nDropdownMenu>
</template>
</ListboxItem>
</Listbox>
<p style="margin-top: 16px; font-size: 14px;">Selected: <strong>{{ selected }}</strong></p>
</div>
`,
});
const ListboxMixedTrailingDemo = defineComponent({
name: 'ListboxMixedTrailingDemo',
components: { Listbox, ListboxItem, N8nDropdownMenu, N8nIconButton },
setup() {
const selected = ref('production');
const withActions = new Set(['production', 'customer-facing']);
return {
selected,
sampleItems,
rowActions,
withActions,
onUpdate: action('update:modelValue'),
onAction: action('row-action'),
};
},
template: `
<div style="width: 360px; padding: 24px;">
<p style="margin: 0 0 12px; font-size: 13px; color: var(--text-color--subtle);">
Some rows have a trailing menu; others are plain selectable items.
</p>
<Listbox
v-model="selected"
aria-label="Mixed trailing actions"
@update:model-value="onUpdate"
>
<ListboxItem
v-for="item in sampleItems"
:key="item.id"
:value="item.id"
:label="item.label"
:description="item.description"
:disabled="item.disabled"
>
<template v-if="withActions.has(item.id)" #trailing="{ setMenuOpen }">
<N8nDropdownMenu
:items="rowActions"
placement="bottom-end"
width="12rem"
:modal="false"
@update:model-value="setMenuOpen"
@select="(actionId) => onAction({ tag: item.id, action: actionId })"
>
<template #trigger>
<N8nIconButton
variant="subtle"
icon="ellipsis-vertical"
aria-label="Tag actions"
/>
</template>
</N8nDropdownMenu>
</template>
</ListboxItem>
</Listbox>
</div>
`,
});
const ListboxControlledUncontrolledDemo = defineComponent({
name: 'ListboxControlledUncontrolledDemo',
components: { Listbox, ListboxItem },
setup() {
const selected = ref('production');
return {
selected,
sampleItems: sampleItems.slice(0, 3),
onUpdate: action('update:modelValue'),
};
},
template: `
<div style="width: 720px; padding: 24px; display: flex; gap: 32px;">
<section style="flex: 1;">
<h3 style="margin: 0 0 8px; font-size: 14px; font-weight: 600;">Controlled</h3>
<p style="margin: 0 0 12px; font-size: 13px; color: var(--text-color--subtle);">
Parent-controlled via <code>v-model</code>. Use the buttons to set the value externally.
</p>
<Listbox
key="controlled"
v-model="selected"
aria-label="Controlled listbox"
@update:model-value="onUpdate"
>
<ListboxItem
v-for="item in sampleItems"
:key="'controlled-' + item.id"
:value="item.id"
:label="item.label"
:description="item.description"
/>
</Listbox>
<div style="display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;">
<button
v-for="item in sampleItems"
:key="item.id"
type="button"
style="padding: 4px 12px; font-size: 13px; cursor: pointer;"
@click="selected = item.id"
>
Set "{{ item.label }}"
</button>
</div>
<p style="margin-top: 12px; font-size: 14px;">Selected: <strong>{{ selected }}</strong></p>
</section>
<section style="flex: 1;">
<h3 style="margin: 0 0 8px; font-size: 14px; font-weight: 600;">Uncontrolled</h3>
<p style="margin: 0 0 12px; font-size: 13px; color: var(--text-color--subtle);">
Initial selection via <code>default-value</code> only. Parent does not track changes.
</p>
<Listbox
key="uncontrolled"
default-value="staging"
aria-label="Uncontrolled listbox"
@update:model-value="onUpdate"
>
<ListboxItem
v-for="item in sampleItems"
:key="'uncontrolled-' + item.id"
:value="item.id"
:label="item.label"
:description="item.description"
/>
</Listbox>
</section>
</div>
`,
});
const ListboxMultipleDemo = defineComponent({
name: 'ListboxMultipleDemo',
components: { Listbox, ListboxItem },
setup() {
const selected = ref<string[]>(['production', 'staging']);
return {
selected,
sampleItems: sampleItems.filter((item) => !item.disabled),
onUpdate: action('update:modelValue'),
};
},
template: `
<div style="width: 360px; padding: 24px;">
<Listbox
v-model="selected"
multiple
aria-label="Multi-select tags"
@update:model-value="onUpdate"
>
<ListboxItem
v-for="item in sampleItems"
:key="item.id"
:value="item.id"
:label="item.label"
:description="item.description"
/>
</Listbox>
<p style="margin-top: 16px; font-size: 14px;">
Selected: <strong>{{ selected.join(', ') || 'none' }}</strong>
</p>
</div>
`,
});
const VIRTUAL_ITEM_COUNT = 1000;
const VIRTUAL_ESTIMATE_SIZE = 56;
const virtualItems: DemoItem[] = Array.from({ length: VIRTUAL_ITEM_COUNT }, (_, index) => {
const id = `tag-${index + 1}`;
return {
id,
label: id,
description: index % 7 === 0 ? 'Not being used' : `${(index % 20) + 1} workflows`,
disabled: index % 47 === 0,
};
});
const ListboxVirtualizedDemo = defineComponent({
name: 'ListboxVirtualizedDemo',
components: { Listbox, ListboxItem, ListboxVirtualizer },
setup() {
const selected = ref(virtualItems[0]);
return {
selected,
virtualItems,
VIRTUAL_ITEM_COUNT,
VIRTUAL_ESTIMATE_SIZE,
textContent: (option: DemoItem) => option.label,
onUpdate: action('update:modelValue'),
};
},
template: `
<div style="width: 360px; padding: 24px;">
<p style="margin: 0 0 12px; font-size: 13px; color: var(--text-color--subtle);">
{{ VIRTUAL_ITEM_COUNT }} items via Reka <code>ListboxVirtualizer</code>.
Only visible rows are mounted.
</p>
<Listbox
v-model="selected"
by="id"
max-height="360px"
aria-label="Virtualized tags"
@update:model-value="onUpdate"
>
<ListboxVirtualizer
v-slot="{ option }"
:options="virtualItems"
:estimate-size="VIRTUAL_ESTIMATE_SIZE"
:text-content="textContent"
>
<ListboxItem
:value="option"
:label="option.label"
:description="option.description"
:disabled="option.disabled"
/>
</ListboxVirtualizer>
</Listbox>
<p style="margin-top: 16px; font-size: 14px;">
Selected: <strong>{{ selected?.label ?? 'none' }}</strong>
</p>
</div>
`,
});
const ListboxCustomItemDemo = defineComponent({
name: 'ListboxCustomItemDemo',
components: { Listbox, ListboxItem, N8nDropdownMenu, N8nIconButton },
setup() {
const selected = ref('production');
const people = [
{
id: 'production',
name: 'Production',
email: 'prod@example.com',
initials: 'PR',
status: 'Active',
},
{
id: 'staging',
name: 'Staging',
email: 'staging@example.com',
initials: 'ST',
status: 'Active',
},
{
id: 'legacy',
name: 'Legacy',
email: 'legacy@example.com',
initials: 'LG',
status: 'Archived',
},
];
return {
selected,
people,
rowActions,
onUpdate: action('update:modelValue'),
onAction: action('row-action'),
};
},
template: `
<div style="width: 420px; padding: 24px;">
<p style="margin: 0 0 12px; font-size: 13px; color: var(--text-color--subtle);">
Fully custom item via the default slot — avatar, meta, and status replace the built-in label/description layout.
</p>
<Listbox
v-model="selected"
aria-label="Custom listbox items"
@update:model-value="onUpdate"
>
<ListboxItem
v-for="person in people"
:key="person.id"
:value="person.id"
>
<template #default>
<div style="display: flex; align-items: center; gap: 12px; min-width: 0; width: 100%;">
<span
aria-hidden="true"
style="
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 999px;
background: var(--color--background--light-1);
color: var(--color--text--shade-1);
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
"
>
{{ person.initials }}
</span>
<span style="display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1;">
<span style="font-size: 14px; font-weight: 500; line-height: 1.3; color: var(--color--text--shade-1);">
{{ person.name }}
</span>
<span style="font-size: 12px; line-height: 1.25; color: var(--text-color--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ person.email }}
</span>
</span>
<span
style="
flex-shrink: 0;
font-size: 11px;
line-height: 1.25;
color: var(--text-color--subtle);
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border-color--subtle);
"
>
{{ person.status }}
</span>
</div>
</template>
<template #trailing="{ setMenuOpen }">
<N8nDropdownMenu
:items="rowActions"
placement="bottom-end"
width="12rem"
:modal="false"
@update:model-value="setMenuOpen"
@select="(actionId) => onAction({ id: person.id, action: actionId })"
>
<template #trigger>
<N8nIconButton
variant="subtle"
icon="ellipsis-vertical"
aria-label="Item actions"
/>
</template>
</N8nDropdownMenu>
</template>
</ListboxItem>
</Listbox>
<p style="margin-top: 16px; font-size: 14px;">Selected: <strong>{{ selected }}</strong></p>
</div>
`,
});
export const Default: Story = {
name: 'Without dropdown',
render: (args) => ({
components: { ListboxBasicDemo },
setup: () => ({ args }),
template: '<ListboxBasicDemo v-bind="args" />',
}),
args: {
size: 'default',
variant: 'boxed',
disabled: false,
highlightOnHover: true,
},
};
export const Sizes: Story = {
name: 'Sizes',
render: () => ({
components: { ListboxSizesDemo },
template: '<ListboxSizesDemo />',
}),
};
export const Variants: Story = {
name: 'Variants',
render: () => ({
components: { ListboxVariantsDemo },
template: '<ListboxVariantsDemo />',
}),
};
export const WithDropdown: Story = {
name: 'With dropdown',
render: () => ({
components: { ListboxWithDropdownDemo },
template: '<ListboxWithDropdownDemo />',
}),
};
export const MixedTrailing: Story = {
name: 'Mixed trailing actions',
render: () => ({
components: { ListboxMixedTrailingDemo },
template: '<ListboxMixedTrailingDemo />',
}),
};
export const ControlledUncontrolled: Story = {
name: 'Controlled / Uncontrolled',
render: () => ({
components: { ListboxControlledUncontrolledDemo },
template: '<ListboxControlledUncontrolledDemo />',
}),
};
export const Multiple: Story = {
name: 'Multiple selection',
render: () => ({
components: { ListboxMultipleDemo },
template: '<ListboxMultipleDemo />',
}),
};
export const CustomItem: Story = {
name: 'Custom item',
render: () => ({
components: { ListboxCustomItemDemo },
template: '<ListboxCustomItemDemo />',
}),
};
export const Virtualized: Story = {
name: 'Virtualized',
render: () => ({
components: { ListboxVirtualizedDemo },
template: '<ListboxVirtualizedDemo />',
}),
};

View File

@ -0,0 +1,131 @@
import type {
AcceptableValue,
RekaListboxItemEmits,
RekaListboxItemProps,
RekaListboxRootEmits,
RekaListboxRootProps,
} from './reka-ui';
export type ListboxSizes = 'small' | 'default' | 'medium';
export type ListboxVariants = 'boxed' | 'flush';
export type ListboxProps = Omit<RekaListboxRootProps, 'as' | 'asChild'> & {
/**
* Visual density of list rows.
* @defaultValue 'default'
*/
size?: ListboxSizes;
/**
* Surface treatment of the list.
* `boxed` — bordered container. `flush` — no outer border (embed in a parent surface).
* @defaultValue 'boxed'
*/
variant?: ListboxVariants;
/**
* Max height of the scrollable content area.
* @defaultValue '360px'
*/
maxHeight?: string;
};
export type ListboxEmits = RekaListboxRootEmits;
export type ListboxSlotUi = {
root: string;
content: string;
isFrozen: string;
};
export type ListboxSlots = {
default(props: { isFrozen: boolean; ui: ListboxSlotUi }): unknown;
content(props: { isFrozen: boolean; ui: ListboxSlotUi }): unknown;
};
export type ListboxItemProps = Omit<RekaListboxItemProps, 'as' | 'asChild'> & {
/** Primary text shown when the default slot is empty */
label?: string;
/** Secondary text shown under the label when the default slot is empty */
description?: string;
/**
* Controlled open state for a trailing menu. Bind with `v-model:menu-open`.
* Keeps listbox hover/pointer behavior stable while a menu is open.
*/
menuOpen?: boolean;
};
export type ListboxItemEmits = RekaListboxItemEmits & {
'update:menuOpen': [open: boolean];
};
export type ListboxItemSlotUi = {
row: string;
option: string;
trailing: string;
};
export type ListboxItemDefaultSlotUi = {
info: string;
leading: string;
text: string;
label: string;
description: string;
};
export type ListboxItemDefaultProps = {
label?: string;
description?: string;
disabled?: boolean;
};
export type ListboxItemDefaultSlots = {
leading(props: {
label?: string;
description?: string;
disabled?: boolean;
ui: ListboxItemDefaultSlotUi;
}): unknown;
label(props: {
label?: string;
disabled?: boolean;
ui: ListboxItemDefaultSlotUi;
}): unknown;
description(props: {
description?: string;
disabled?: boolean;
ui: ListboxItemDefaultSlotUi;
}): unknown;
};
export type ListboxItemSlots = {
/** Replaces the entire option body (default is ListboxItemDefault) */
default(props: {
label?: string;
description?: string;
disabled?: boolean;
ui: ListboxItemSlotUi;
}): unknown;
leading(props: {
label?: string;
description?: string;
disabled?: boolean;
ui: ListboxItemDefaultSlotUi;
}): unknown;
label(props: {
label?: string;
disabled?: boolean;
ui: ListboxItemDefaultSlotUi;
}): unknown;
description(props: {
description?: string;
disabled?: boolean;
ui: ListboxItemDefaultSlotUi;
}): unknown;
/** Row actions rendered outside the option (use for dropdown menus) */
trailing(props: {
menuOpen: boolean;
setMenuOpen: (open: boolean) => void;
ui: ListboxItemSlotUi;
}): unknown;
};
export type { AcceptableValue };

View File

@ -0,0 +1,202 @@
<script setup lang="ts">
import { reactiveOmit, reactivePick } from '@vueuse/core';
import { computed, provide, ref, toRef, useAttrs } from 'vue';
import { listboxContextKey } from './listbox-context';
import type { ListboxEmits, ListboxProps, ListboxSlots } from './Listbox.types';
import { ListboxContent, ListboxRoot, useForwardProps } from './reka-ui';
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const rootClass = computed(() => attrs.class);
const rootAttrs = computed(() => reactiveOmit(attrs, ['class']));
const props = withDefaults(defineProps<Omit<ListboxProps, 'modelValue'>>(), {
orientation: 'vertical',
highlightOnHover: true,
disabled: false,
size: 'default',
variant: 'boxed',
maxHeight: '360px',
});
const emit = defineEmits<ListboxEmits>();
defineSlots<ListboxSlots>();
const modelValue = defineModel<ListboxProps['modelValue']>();
const openMenuCount = ref(0);
const isAnyMenuOpen = computed(() => openMenuCount.value > 0);
const isKeyboardNavigating = ref(false);
// Single-select defaults to replace (click keeps selection). Multi-select defaults to
// toggle (click adds/removes). Reka's own default is always toggle, which clears
// single selection on a second click.
const resolvedSelectionBehavior = computed(() => {
if (props.selectionBehavior) {
return props.selectionBehavior;
}
return props.multiple ? 'toggle' : 'replace';
});
provide(listboxContextKey, {
size: toRef(props, 'size'),
variant: toRef(props, 'variant'),
isAnyMenuOpen,
isKeyboardNavigating,
registerMenuOpen: () => {
openMenuCount.value += 1;
},
registerMenuClose: () => {
openMenuCount.value = Math.max(0, openMenuCount.value - 1);
},
});
const rootProps = useForwardProps(
reactivePick(
props,
'disabled',
'orientation',
'multiple',
'dir',
'by',
'name',
'required',
'defaultValue',
),
);
function onKeydown(event: KeyboardEvent) {
if (
event.key === 'ArrowDown' ||
event.key === 'ArrowUp' ||
event.key === 'Home' ||
event.key === 'End' ||
event.key === 'PageDown' ||
event.key === 'PageUp'
) {
isKeyboardNavigating.value = true;
}
}
function onPointerMove() {
if (isKeyboardNavigating.value) {
isKeyboardNavigating.value = false;
}
}
</script>
<template>
<ListboxRoot
v-bind="{ ...rootProps, ...rootAttrs }"
v-model="modelValue"
:selection-behavior="resolvedSelectionBehavior"
:highlight-on-hover="highlightOnHover && !isAnyMenuOpen && !isKeyboardNavigating"
:class="[$style.root, $style[size], $style[variant], rootClass]"
data-test-id="listbox"
@keydown="onKeydown"
@pointermove="onPointerMove"
@highlight="emit('highlight', $event)"
@entry-focus="emit('entryFocus', $event)"
@leave="emit('leave', $event)"
>
<ListboxContent
:class="[
$style.content,
{ [$style.isFrozen]: isAnyMenuOpen, [$style.contentFlush]: variant === 'flush' },
]"
:style="{ maxHeight }"
data-test-id="listbox-content"
>
<slot
name="content"
:is-frozen="isAnyMenuOpen"
:ui="{ root: $style.root, content: $style.content, isFrozen: $style.isFrozen }"
>
<slot
:is-frozen="isAnyMenuOpen"
:ui="{ root: $style.root, content: $style.content, isFrozen: $style.isFrozen }"
/>
</slot>
</ListboxContent>
</ListboxRoot>
</template>
<style module>
.root {
width: 100%;
overflow: clip;
}
.boxed {
border: var(--border-width) solid var(--border-color--subtle);
border-radius: var(--radius);
background: var(--background--surface);
}
.flush {
border: none;
border-radius: 0;
background: transparent;
overflow: visible;
--listbox-flush-highlight-inset-inline: var(--spacing--md);
--listbox-flush-highlight-inset-block: var(--spacing--2xs);
}
.content {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
outline: none;
}
.contentFlush {
--_inset-x: var(--listbox-flush-highlight-inset-inline, var(--spacing--md));
--_inset-y: var(--listbox-flush-highlight-inset-block, var(--spacing--2xs));
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
/* Expand so rounded highlights can bleed while labels stay content-aligned */
width: calc(100% + 2 * var(--_inset-x));
margin-inline: calc(-1 * var(--_inset-x));
padding-inline: var(--_inset-x);
padding-block: var(--_inset-y);
box-sizing: border-box;
}
.isFrozen {
pointer-events: none;
}
.small {
--listbox-row-min-height: var(--spacing--2xl);
--listbox-row-padding-block: var(--spacing--2xs);
--listbox-row-padding-inline: var(--spacing--sm);
--listbox-label-font-size: var(--font-size--sm);
--listbox-description-font-size: var(--font-size--2xs);
--listbox-label-line-height: var(--line-height--md);
--listbox-description-line-height: var(--line-height--sm);
}
.default {
--listbox-row-min-height: var(--spacing--3xl);
--listbox-row-padding-block: var(--spacing--sm);
--listbox-row-padding-inline: var(--spacing--md);
--listbox-label-font-size: var(--font-size--sm);
--listbox-description-font-size: var(--font-size--xs);
--listbox-label-line-height: var(--line-height--md);
--listbox-description-line-height: var(--line-height--sm);
}
.medium {
--listbox-row-min-height: var(--spacing--4xl);
--listbox-row-padding-block: var(--spacing--sm);
--listbox-row-padding-inline: var(--spacing--md);
--listbox-label-font-size: var(--font-size--md);
--listbox-description-font-size: var(--font-size--sm);
--listbox-label-line-height: var(--line-height--lg);
--listbox-description-line-height: var(--line-height--md);
}
</style>

View File

@ -0,0 +1,287 @@
<script setup lang="ts">
import { reactiveOmit } from '@vueuse/core';
import { computed, inject, onBeforeUnmount, useAttrs, useCssModule, watch } from 'vue';
import type { StyleValue } from 'vue';
import { listboxContextKey } from './listbox-context';
import type { ListboxItemEmits, ListboxItemProps, ListboxItemSlots } from './Listbox.types';
import ListboxItemDefault from './ListboxItemDefault.vue';
import { ListboxItem as RekaListboxItem } from './reka-ui';
defineOptions({ inheritAttrs: false });
const props = defineProps<Omit<ListboxItemProps, 'menuOpen'>>();
const emit = defineEmits<ListboxItemEmits>();
const slots = defineSlots<ListboxItemSlots>();
const attrs = useAttrs();
const menuOpen = defineModel<boolean>('menuOpen', { default: false });
const listbox = inject(listboxContextKey, null);
const $style = useCssModule();
const isHoverSuppressed = computed(() => listbox?.isKeyboardNavigating.value ?? false);
const isFlush = computed(() => listbox?.variant.value === 'flush');
/*
ListboxVirtualizer clones this component and passes absolute positioning via
fallthrough attrs. Those must land on the row (outer root), while option attrs
like data-index stay on the Reka option for keyboard/typeahead.
*/
const rowStyle = computed<StyleValue | undefined>(() => attrs.style as StyleValue | undefined);
const optionAttrs = computed(() => reactiveOmit(attrs, 'style'));
const slotUi = {
row: $style.row,
option: $style.option,
trailing: $style.trailing,
};
watch(menuOpen, (open, previous) => {
if (!listbox) {
return;
}
if (open && !previous) {
listbox.registerMenuOpen();
return;
}
if (!open && previous) {
listbox.registerMenuClose();
}
});
onBeforeUnmount(() => {
if (menuOpen.value) {
listbox?.registerMenuClose();
}
});
function setMenuOpen(open: boolean) {
menuOpen.value = open;
}
</script>
<template>
<div
:class="{
[$style.row]: true,
[$style.rowFlush]: isFlush,
[$style.isDisabled]: props.disabled,
[$style.isMenuOpen]: menuOpen,
[$style.isHoverSuppressed]: isHoverSuppressed,
}"
:style="rowStyle"
data-test-id="listbox-item"
>
<span v-if="isFlush" :class="$style.highlight" aria-hidden="true" />
<!--
Padding lives on the option so the full visual item is the selection
hit-target. Trailing overlays as a sibling (not nested in the Reka option)
so dropdown menus are not remounted by highlight changes.
-->
<RekaListboxItem
:value="props.value"
:disabled="props.disabled"
:class="$style.option"
data-test-id="listbox-option"
v-bind="optionAttrs"
@select="emit('select', $event)"
>
<slot
:label="props.label"
:description="props.description"
:disabled="props.disabled"
:ui="slotUi"
>
<ListboxItemDefault
:label="props.label"
:description="props.description"
:disabled="props.disabled"
>
<template v-if="!!slots.leading" #leading="leadingProps">
<slot name="leading" v-bind="leadingProps" />
</template>
<template v-if="!!slots.label" #label="labelProps">
<slot name="label" v-bind="labelProps" />
</template>
<template v-if="!!slots.description" #description="descriptionProps">
<slot name="description" v-bind="descriptionProps" />
</template>
</ListboxItemDefault>
</slot>
</RekaListboxItem>
<div
v-if="!!slots.trailing"
:class="$style.trailing"
data-test-id="listbox-item-trailing"
@click.stop
@keydown.stop
@pointerdown.stop
>
<slot name="trailing" :menu-open="menuOpen" :set-menu-open="setMenuOpen" :ui="slotUi" />
</div>
</div>
</template>
<style module>
.row {
position: relative;
width: 100%;
box-sizing: border-box;
border-bottom: var(--border-width) solid var(--border-color--subtle);
cursor: pointer;
&:last-child {
border-bottom: none;
}
&:has([data-disabled]) {
cursor: default;
}
&:not(.rowFlush):not(.isHoverSuppressed):hover,
&:not(.rowFlush):has([data-highlighted]),
&:not(.rowFlush):has(:focus-visible),
&:not(.rowFlush).isMenuOpen {
background-color: var(--background--hover, var(--color--background--light-2));
}
&:not(.rowFlush):has([data-state='checked']),
&:not(.rowFlush):has([data-state='checked']):not(.isHoverSuppressed):hover,
&:not(.rowFlush):has([data-state='checked']):has([data-highlighted]),
&:not(.rowFlush).isMenuOpen:has([data-state='checked']) {
background-color: var(--background--active, var(--color--background--light-1));
}
/* Alpha hover/active backgrounds stack with the divider and look darker — clear both edges */
&:not(.rowFlush):not(.isHoverSuppressed):hover,
&:not(.rowFlush):has([data-highlighted]),
&:not(.rowFlush):has(:focus-visible),
&:not(.rowFlush).isMenuOpen,
&:not(.rowFlush):has([data-state='checked']),
&:not(.rowFlush):has(+ .row:not(.isHoverSuppressed):hover),
&:not(.rowFlush):has(+ .row:has([data-highlighted])),
&:not(.rowFlush):has(+ .row:has(:focus-visible)),
&:not(.rowFlush):has(+ .row.isMenuOpen),
&:not(.rowFlush):has(+ .row:has([data-state='checked'])) {
border-bottom-color: transparent;
}
}
.rowFlush {
isolation: isolate;
border-bottom: none;
background: transparent;
}
.highlight {
position: absolute;
z-index: 0;
inset-block: calc(-1 * var(--listbox-flush-highlight-inset-block, var(--spacing--2xs)));
inset-inline: calc(-1 * var(--listbox-flush-highlight-inset-inline, var(--spacing--md)));
border-radius: var(--radius);
background-color: var(--background--hover, var(--color--background--light-2));
opacity: 0;
pointer-events: none;
}
.rowFlush:not(.isHoverSuppressed):hover .highlight,
.rowFlush:has([data-highlighted]) .highlight,
.rowFlush:has(:focus-visible) .highlight,
.rowFlush.isMenuOpen .highlight {
opacity: 1;
}
.rowFlush:has([data-state='checked']) .highlight,
.rowFlush:has([data-state='checked']):not(.isHoverSuppressed):hover .highlight,
.rowFlush:has([data-state='checked']):has([data-highlighted]) .highlight,
.rowFlush.isMenuOpen:has([data-state='checked']) .highlight {
opacity: 1;
background-color: var(--background--active, var(--color--background--light-1));
}
.option {
position: relative;
z-index: 1;
outline: none;
min-width: 0;
display: flex;
align-items: center;
box-sizing: border-box;
width: 100%;
min-height: var(--listbox-row-min-height, var(--spacing--3xl));
padding: var(--listbox-row-padding-block, var(--spacing--sm))
var(--listbox-row-padding-inline, var(--spacing--md));
cursor: inherit;
}
.rowFlush .option {
/* Expand the hit-target to match the rounded highlight surface */
margin-block: calc(-1 * var(--listbox-flush-highlight-inset-block, var(--spacing--2xs)));
margin-inline: calc(-1 * var(--listbox-flush-highlight-inset-inline, var(--spacing--md)));
padding-block: calc(
var(--listbox-row-padding-block, var(--spacing--sm)) +
var(--listbox-flush-highlight-inset-block, var(--spacing--2xs))
);
padding-inline: var(--listbox-flush-highlight-inset-inline, var(--spacing--md));
width: auto;
}
/* Reserve space so labels don't sit under the overlaid trailing actions */
.row:has(.trailing) .option {
padding-inline-end: calc(
var(--listbox-row-padding-inline, var(--spacing--md)) + var(--spacing--2xl) + var(--spacing--sm)
);
}
.rowFlush:has(.trailing) .option {
padding-inline-end: calc(
var(--listbox-flush-highlight-inset-inline, var(--spacing--md)) + var(--spacing--2xl) +
var(--spacing--sm)
);
}
.isDisabled {
pointer-events: none;
opacity: 0.6;
cursor: default;
}
/*
Trailing overlays the option so the option stays full-width (and fully clickable).
When hidden, pointer-events are off so selection clicks reach the option underneath.
*/
.trailing {
position: absolute;
z-index: 2;
inset-block: 0;
inset-inline-end: 0;
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--spacing--2xs);
box-sizing: border-box;
padding-block: var(--listbox-row-padding-block, var(--spacing--sm));
padding-inline-end: var(--listbox-row-padding-inline, var(--spacing--md));
pointer-events: none;
opacity: 0;
}
.rowFlush .trailing {
inset-block: calc(-1 * var(--listbox-flush-highlight-inset-block, var(--spacing--2xs)));
inset-inline-end: calc(-1 * var(--listbox-flush-highlight-inset-inline, var(--spacing--md)));
padding-block: calc(
var(--listbox-row-padding-block, var(--spacing--sm)) +
var(--listbox-flush-highlight-inset-block, var(--spacing--2xs))
);
padding-inline-end: var(--listbox-flush-highlight-inset-inline, var(--spacing--md));
}
.row:not(.isHoverSuppressed):hover .trailing,
.row:has([data-highlighted]) .trailing,
.row.isMenuOpen .trailing {
opacity: 1;
pointer-events: auto;
}
</style>

View File

@ -0,0 +1,103 @@
<script setup lang="ts">
import { useCssModule } from 'vue';
import type { ListboxItemDefaultProps, ListboxItemDefaultSlots } from './Listbox.types';
defineOptions({ inheritAttrs: false });
const props = defineProps<ListboxItemDefaultProps>();
const slots = defineSlots<ListboxItemDefaultSlots>();
const $style = useCssModule();
const slotUi = {
info: $style.info,
leading: $style.leading,
text: $style.text,
label: $style.label,
description: $style.description,
};
</script>
<template>
<div :class="$style.info" data-test-id="listbox-item-default">
<div v-if="!!slots.leading" :class="$style.leading" data-test-id="listbox-item-leading">
<slot
name="leading"
:label="props.label"
:description="props.description"
:disabled="props.disabled"
:ui="slotUi"
/>
</div>
<div :class="$style.text">
<slot name="label" :label="props.label" :disabled="props.disabled" :ui="slotUi">
<span v-if="props.label" :class="$style.label" data-test-id="listbox-item-label">
{{ props.label }}
</span>
</slot>
<slot
name="description"
:description="props.description"
:disabled="props.disabled"
:ui="slotUi"
>
<span
v-if="props.description"
:class="$style.description"
data-test-id="listbox-item-description"
>
{{ props.description }}
</span>
</slot>
</div>
</div>
</template>
<style module>
.info {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing--sm);
min-width: 0;
max-width: 100%;
}
.leading {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing--3xs);
min-width: 0;
max-width: 100%;
flex: 1;
}
.label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--listbox-label-font-size, var(--font-size--sm));
font-weight: var(--font-weight--medium);
line-height: var(--listbox-label-line-height, var(--line-height--md));
color: var(--color--text--shade-1);
}
.description {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--listbox-description-font-size, var(--font-size--xs));
line-height: var(--listbox-description-line-height, var(--line-height--sm));
color: var(--text-color--subtle);
}
</style>

View File

@ -0,0 +1,158 @@
# Component specification
Keyboard-navigable list of selectable options. Supports single or multiple selection, composable item slots (full custom item, leading/label/description, trailing actions), and surface variants.
- **Component Name:** N8nListbox, N8nListboxItem, N8nListboxItemDefault
- **Figma Component:** TBD
- **Reka UI Component:** [Listbox](https://reka-ui.com/docs/components/listbox)
## Public API Definition
**Listbox props**
- `modelValue?: AcceptableValue | AcceptableValue[]` - The controlled selected value. Can be bind as `v-model`
- `defaultValue?: AcceptableValue | AcceptableValue[]` - The selected value when initially rendered. Use when you do not need to control the state
- `orientation?: 'vertical' | 'horizontal'` - Arrow-key navigation direction. Default: `'vertical'`
- `highlightOnHover?: boolean` - Highlight the option under the pointer. Default: `true`
- `multiple?: boolean` - Allow selecting more than one option
- `selectionBehavior?: 'toggle' | 'replace'` - How selection updates. Defaults to `'replace'` for single-select (keeps selection) and `'toggle'` for multi-select (click adds/removes)
- `disabled?: boolean` - When `true`, prevents interaction with the listbox
- `size?: 'small' | 'default' | 'medium'` - Row density. Default: `'default'`
- `variant?: 'boxed' | 'flush'` - `boxed` is a bordered container with full-row hover. `flush` has no outer border; labels stay content-aligned and hover/selection uses a rounded surface behind each item (bleeds into parent padding). Default: `'boxed'`
- `maxHeight?: string` - Max height of the scrollable content. Default: `'360px'`
- `name?: string` - Name for form submission
- `required?: boolean` - Whether a value is required for form submission
- `by?: string | ((a, b) => boolean)` - Compare object values by field or custom function
**Listbox events**
- `update:modelValue(value)` - Fired when the selected value updates
- `highlight(payload)` - Fired when the highlighted option changes
- `entryFocus(event)` - Fired when the listbox container receives focus
- `leave(event)` - Fired when the pointer leaves the listbox
**Listbox slots**
- `default`: `{ isFrozen, ui }` - Listbox items to render inside the list
- `content`: `{ isFrozen, ui }` - Optional wrapper around the default slot inside the scrollable content
**ListboxItem props**
- `value: AcceptableValue` - Value associated with this option
- `label?: string` - Primary text when the default slot is empty
- `description?: string` - Secondary text under the label when the default slot is empty
- `disabled?: boolean` - When `true`, prevents selecting this option
- `menuOpen?: boolean` - Controlled open state for a trailing menu (`v-model:menu-open`). Freezes list hover while open
**ListboxItem events**
- `select(event)` - Fired when the option is selected (can be prevented)
- `update:menuOpen(open)` - Fired when trailing menu open state changes
**ListboxItem slots**
- `default`: `{ label, description, disabled, ui }` - Replaces the entire option body (default renders `N8nListboxItemDefault`)
- `leading`: `{ label, description, disabled, ui }` - Content before the label (icon, avatar, etc.)
- `label`: `{ label, disabled, ui }` - Custom label content
- `description`: `{ description, disabled, ui }` - Custom description content
- `trailing`: `{ menuOpen, setMenuOpen, ui }` - Row actions rendered **outside** the option (dropdown menus). Visible on hover/highlight/open menu only — not merely because the row is selected
**ListboxItemDefault slots**
- `leading`, `label`, `description` — same as above; use when composing the default layout yourself
### Template usage example
```vue
<script setup lang="ts">
import { ref } from 'vue'
const selected = ref('production')
</script>
<template>
<N8nListbox v-model="selected" aria-label="Tags">
<N8nListboxItem value="production" label="production" description="12 workflows" />
<N8nListboxItem value="staging" label="staging" description="4 workflows" />
<N8nListboxItem value="legacy" label="legacy" description="Not being used" />
</N8nListbox>
</template>
```
```vue
<script setup lang="ts">
import { ref } from 'vue'
const selected = ref('alex')
</script>
<template>
<N8nListbox v-model="selected" aria-label="People">
<N8nListboxItem value="alex">
<template #default>
<div class="custom-row">
<span class="avatar">A</span>
<span class="meta">
<strong>Alex</strong>
<span>alex@example.com</span>
</span>
</div>
</template>
<template #trailing="{ setMenuOpen }">
<!-- overflow menu -->
</template>
</N8nListboxItem>
</N8nListbox>
</template>
```
## Accessibility notes
- Root renders as a listbox; options use `role="option"` with `aria-selected`
- Arrow keys move highlight; Space/Enter select (Reka Listbox behavior)
- Put interactive controls (menus, buttons) in the `trailing` slot so they stay outside the option and do not fight listbox highlight. Trailing overlays the option (pointer-events only when visible) so the full padded row remains selectable
## Virtualization
For long lists, compose Reka's `ListboxVirtualizer` inside `N8nListbox`. Pass the same option values you bind on each item (use `by` when values are objects) so keyboard focus can scroll to the selected row.
```vue
<script setup lang="ts">
import { ref } from 'vue'
import { ListboxVirtualizer } from 'reka-ui'
import { N8nListbox, N8nListboxItem } from '@n8n/design-system'
const items = Array.from({ length: 1000 }, (_, i) => ({
id: `tag-${i + 1}`,
label: `tag-${i + 1}`,
description: `${(i % 20) + 1} workflows`,
}))
const selected = ref(items[0])
</script>
<template>
<N8nListbox v-model="selected" by="id" max-height="360px">
<ListboxVirtualizer
v-slot="{ option }"
:options="items"
:estimate-size="56"
:text-content="(item) => item.label"
>
<N8nListboxItem
:value="option"
:label="option.label"
:description="option.description"
/>
</ListboxVirtualizer>
</N8nListbox>
</template>
```

View File

@ -0,0 +1,4 @@
export { default as N8nListbox } from './Listbox.vue';
export { default as N8nListboxItem } from './ListboxItem.vue';
export { default as N8nListboxItemDefault } from './ListboxItemDefault.vue';
export type * from './Listbox.types';

View File

@ -0,0 +1,15 @@
import type { InjectionKey, Ref } from 'vue';
import type { ListboxSizes, ListboxVariants } from './Listbox.types';
export type ListboxContext = {
size: Ref<ListboxSizes>;
variant: Ref<ListboxVariants>;
isAnyMenuOpen: Ref<boolean>;
/** When true, CSS :hover styles are suppressed so keyboard highlight wins */
isKeyboardNavigating: Ref<boolean>;
registerMenuOpen: () => void;
registerMenuClose: () => void;
};
export const listboxContextKey: InjectionKey<ListboxContext> = Symbol('n8n-listbox');

View File

@ -0,0 +1,16 @@
export {
ListboxContent,
ListboxItem,
ListboxRoot,
ListboxVirtualizer,
useForwardProps,
} from 'reka-ui';
export type {
AcceptableValue,
ListboxItemEmits as RekaListboxItemEmits,
ListboxItemProps as RekaListboxItemProps,
ListboxRootEmits as RekaListboxRootEmits,
ListboxRootProps as RekaListboxRootProps,
ListboxVirtualizerProps,
} from 'reka-ui';