mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
WIP
This commit is contained in:
parent
f62cc6319a
commit
fbb105c165
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 />',
|
||||
}),
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
```
|
||||
|
|
@ -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';
|
||||
|
|
@ -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');
|
||||
|
|
@ -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';
|
||||
Loading…
Reference in New Issue
Block a user