diff --git a/packages/frontend/@n8n/design-system/src/index.ts b/packages/frontend/@n8n/design-system/src/index.ts index 5fdb87d90cf..54210adb393 100644 --- a/packages/frontend/@n8n/design-system/src/index.ts +++ b/packages/frontend/@n8n/design-system/src/index.ts @@ -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'; diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Listbox/Listbox.stories.ts b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/Listbox.stories.ts new file mode 100644 index 00000000000..c5344285519 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/Listbox.stories.ts @@ -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; + +export default meta; + +type Story = StoryObj; + +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> = [ + { 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: ` +
+ + + +

Selected: {{ selected }}

+
+ `, +}); + +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: ` +
+
+

small

+ + + +
+
+

default

+ + + +
+
+

medium

+ + + +
+
+ `, +}); + +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: ` +
+
+

boxed

+

+ Bordered container for standalone lists. +

+ + + +
+
+

flush

+

+ Labels align with this copy; hover uses a rounded surface behind each item. +

+ + + +
+
+ `, +}); + +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: ` +
+ + + + + +

Selected: {{ selected }}

+
+ `, +}); + +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: ` +
+

+ Some rows have a trailing menu; others are plain selectable items. +

+ + + + + +
+ `, +}); + +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: ` +
+
+

Controlled

+

+ Parent-controlled via v-model. Use the buttons to set the value externally. +

+ + + +
+ +
+

Selected: {{ selected }}

+
+
+

Uncontrolled

+

+ Initial selection via default-value only. Parent does not track changes. +

+ + + +
+
+ `, +}); + +const ListboxMultipleDemo = defineComponent({ + name: 'ListboxMultipleDemo', + components: { Listbox, ListboxItem }, + setup() { + const selected = ref(['production', 'staging']); + return { + selected, + sampleItems: sampleItems.filter((item) => !item.disabled), + onUpdate: action('update:modelValue'), + }; + }, + template: ` +
+ + + +

+ Selected: {{ selected.join(', ') || 'none' }} +

+
+ `, +}); + +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: ` +
+

+ {{ VIRTUAL_ITEM_COUNT }} items via Reka ListboxVirtualizer. + Only visible rows are mounted. +

+ + + + + +

+ Selected: {{ selected?.label ?? 'none' }} +

+
+ `, +}); + +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: ` +
+

+ Fully custom item via the default slot — avatar, meta, and status replace the built-in label/description layout. +

+ + + + + + +

Selected: {{ selected }}

+
+ `, +}); + +export const Default: Story = { + name: 'Without dropdown', + render: (args) => ({ + components: { ListboxBasicDemo }, + setup: () => ({ args }), + template: '', + }), + args: { + size: 'default', + variant: 'boxed', + disabled: false, + highlightOnHover: true, + }, +}; + +export const Sizes: Story = { + name: 'Sizes', + render: () => ({ + components: { ListboxSizesDemo }, + template: '', + }), +}; + +export const Variants: Story = { + name: 'Variants', + render: () => ({ + components: { ListboxVariantsDemo }, + template: '', + }), +}; + +export const WithDropdown: Story = { + name: 'With dropdown', + render: () => ({ + components: { ListboxWithDropdownDemo }, + template: '', + }), +}; + +export const MixedTrailing: Story = { + name: 'Mixed trailing actions', + render: () => ({ + components: { ListboxMixedTrailingDemo }, + template: '', + }), +}; + +export const ControlledUncontrolled: Story = { + name: 'Controlled / Uncontrolled', + render: () => ({ + components: { ListboxControlledUncontrolledDemo }, + template: '', + }), +}; + +export const Multiple: Story = { + name: 'Multiple selection', + render: () => ({ + components: { ListboxMultipleDemo }, + template: '', + }), +}; + +export const CustomItem: Story = { + name: 'Custom item', + render: () => ({ + components: { ListboxCustomItemDemo }, + template: '', + }), +}; + +export const Virtualized: Story = { + name: 'Virtualized', + render: () => ({ + components: { ListboxVirtualizedDemo }, + template: '', + }), +}; diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Listbox/Listbox.types.ts b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/Listbox.types.ts new file mode 100644 index 00000000000..2a43fb76078 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/Listbox.types.ts @@ -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 & { + /** + * 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 & { + /** 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 }; diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Listbox/Listbox.vue b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/Listbox.vue new file mode 100644 index 00000000000..9569e53617c --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/Listbox.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Listbox/ListboxItem.vue b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/ListboxItem.vue new file mode 100644 index 00000000000..c96f934072b --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/ListboxItem.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Listbox/ListboxItemDefault.vue b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/ListboxItemDefault.vue new file mode 100644 index 00000000000..5b4baf0ede7 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/ListboxItemDefault.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Listbox/component-listbox.md b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/component-listbox.md new file mode 100644 index 00000000000..a40843e6890 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/component-listbox.md @@ -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 + + + +``` + +```vue + + + +``` + + +## 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 + + + +``` diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Listbox/index.ts b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/index.ts new file mode 100644 index 00000000000..8f8b4d21dca --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/index.ts @@ -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'; diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Listbox/listbox-context.ts b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/listbox-context.ts new file mode 100644 index 00000000000..19a15af65c3 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/listbox-context.ts @@ -0,0 +1,15 @@ +import type { InjectionKey, Ref } from 'vue'; + +import type { ListboxSizes, ListboxVariants } from './Listbox.types'; + +export type ListboxContext = { + size: Ref; + variant: Ref; + isAnyMenuOpen: Ref; + /** When true, CSS :hover styles are suppressed so keyboard highlight wins */ + isKeyboardNavigating: Ref; + registerMenuOpen: () => void; + registerMenuClose: () => void; +}; + +export const listboxContextKey: InjectionKey = Symbol('n8n-listbox'); diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Listbox/reka-ui.ts b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/reka-ui.ts new file mode 100644 index 00000000000..1fbe4b7bfe2 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Listbox/reka-ui.ts @@ -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';