Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions .specs/pick-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
---
name: pick-list
category: data
structure: monolithic
status: approved
spec_version: 1
checksum: 99e777f34e83355b73625e5cecba7c8ac182a1b3e972993e812cf3052a906117
created: 2026-06-25
last_updated: 2026-06-25
---

# Pick List — Component Spec

## Purpose

Dual-list transfer control: a labelled **source** listbox and a labelled **target** listbox with controls to move items between them and (optionally) reorder items within a list. Use it when the consumer needs to build an ordered subset from a pool of options where both the chosen set and the remaining pool stay visible. Derived from the PrimeVue PickList behavior but rewritten to our conventions (no Figma); the visual surface is a best-effort first draft to validate.

## Usage

```vue
<script setup>
import PickList from '@aziontech/webkit/pick-list'
import { ref } from 'vue'

const model = ref([
[
{ id: 1, label: 'Edge Functions' },
{ id: 2, label: 'WAF' }
],
[{ id: 3, label: 'Cache' }]
])
</script>

<template>
<PickList
v-model="model"
data-key="id"
source-header="Available"
target-header="Selected"
reorderable
>
<template #item="{ item }">{{ item.label }}</template>
</PickList>
</template>
```

## Props

| Prop | Type | Default | Required | JSDoc |
|---|---|---|---|---|
| `modelValue` | `[unknown[], unknown[]]` | `() => [[], []]` | no | Bound pair of lists as `[sourceItems, targetItems]` (v-model). |
| `dataKey` | `string` | `''` | no | Item field that uniquely identifies a record; enables selection tracking and stable keys. |
| `sourceHeader` | `string` | `''` | no | Heading text for the source list; also its accessible name. |
| `targetHeader` | `string` | `''` | no | Heading text for the target list; also its accessible name. |
| `disabled` | `boolean` | `false` | no | Disables all selection and move controls and applies disabled tokens. |
| `reorderable` | `boolean` | `false` | no | Shows up/down reorder controls that move selected items within their own list. |

## Events

| Event | Payload | Notes |
|---|---|---|
| `update:modelValue` | `[unknown[], unknown[]]` | v-model update with the new `[sourceItems, targetItems]` pair. |
| `move` | `{ direction: 'to-target' \| 'to-source'; items: unknown[] }` | Fired after items move between lists, with the moved items. |
| `reorder` | `{ list: 'source' \| 'target'; items: unknown[] }` | Fired after a reorder, with the items in the affected list. |

## Slots

| Slot | Scope | Notes |
|---|---|---|
| `item` | `{ item: unknown; index: number; list: 'source' \| 'target' }` | Renders a single row; receives the item, its index, and which list it belongs to. |
| `sourceHeader` | `—` | Overrides the source heading content. |
| `targetHeader` | `—` | Overrides the target heading content. |

## States

- Visual states: `default`, `hover` (option), `focus-visible` (option / control), `selected` (option), `disabled`
- `data-disabled` mirrors the `disabled` prop
- `data-reorderable` mirrors the `reorderable` prop
- Each option carries `data-selected` mirroring its selection and `aria-selected`

## Motion & Animations

_none_

## Tokens

| Region | Token (DESIGN.md) |
|---|---|
| typography (header) | `.text-label-lg` |
| typography (option) | `.text-body-md` |
| surface (list) | `var(--bg-surface)` |
| surface (option hover) | `var(--bg-hover)` |
| surface (option selected) | `var(--bg-selected)` |
| text | `var(--text-default)` |
| text (header) | `var(--text-muted)` |
| text (disabled) | `var(--text-disabled)` |
| surface (disabled) | `var(--bg-disabled)` |
| border | `var(--border-default)` |
| ring | `var(--ring-color)` |
| spacing (list padding) | `var(--spacing-xs)` |
| spacing (option padding) | `var(--spacing-sm)` |
| gap (lists + controls) | `var(--spacing-sm)` |
| shape (list) | `var(--shape-card)` |
| shape (option) | `var(--shape-elements)` |

## Theme gaps

| Figma variable | Temporary primitive | Follow-up |
|---|---|---|
| _none_ | — | — |

## Accessibility (WCAG 2.1 AA)

- Visible focus: `focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]` on each option and on every move/reorder control.
- Keyboard map: `Tab` moves between the two listboxes and the control column; within a listbox `ArrowUp`/`ArrowDown` move the active option, `Space`/`Enter` toggle its selection; move and reorder controls are activated with `Enter`/`Space`.
- ARIA: each list is `role="listbox"` with `aria-label` from its header text; options are `role="option"` with `aria-selected`; the source/target listboxes set `aria-multiselectable="true"`; every move/reorder control is an icon-only button with an explicit `aria-label`; `aria-disabled` mirrors `disabled`.
- Contrast ≥4.5:1 (text) / ≥3:1 (borders + icons), including the disabled state.
- No motion in this component, so no `motion-reduce:*` is required.
- Touch target ≥40×40 px for every move/reorder control (medium icon-button height).

## Stories (Storybook)

- Default
- Disabled
- WithReorder — justified: the `reorderable` prop adds an extra control column (up/down) that is hidden by default; the story documents the reorder controls and within-list ordering behavior in isolation.

## Constraints — DO NOT

<!-- This block is injected VERBATIM into every sub-agent prompt.
spec-validator rejects the spec if this block is missing or shorter than the template. -->

- Do not add props beyond the Props table above. If you need a prop that is not listed, emit `BLOCKED: missing prop <name>` and stop — do not invent.
- Do not add events beyond the Events table above. Same rule for slots and sub-components.
- Do not invent imports. Every `@aziontech/webkit/*` path must exist in `packages/webkit/package.json#exports`. Every relative import must resolve to a real file. Every npm package must be installed.
- Do not use HEX/RGB/HSL colors, Tailwind palette names (e.g. `bg-blue-500`), raw typography classes (e.g. `text-sm`), `any`, `@ts-ignore`, or `class` inside `defineProps`.
- Do not install or import positioning/animation libraries (`@floating-ui/*`, `popper.js`, `tippy.js`, `gsap`, `framer-motion`, `motion`, `@vueuse/motion`, `@formkit/auto-animate`, drag-drop runtimes, scroll virtualization libs). Use CSS + Vue primitives (`<Teleport>`, `<Transition>`). See `.claude/rules/dependencies.md`.
- Do not improvise animations. Every `animate-*` / `transition-*` class must come from `packages/theme/src/tokens/semantic/animations.js`; every motion-bearing class pairs with `motion-reduce:*` on the same class string; no component-local `@keyframes`.
- Do not create class presets in JavaScript (`const kindClasses = {...}`, `const sharedClasses = [...]`, `const sizeClasses = {...}`, `const rootClasses = computed(...)`). Variants live on `data-*` attributes consumed by Tailwind `data-[attr=value]:`. All utilities live inline on the root element's `class` attribute. No `<style>` block, no component-local `.css`/`.scss`. See `.claude/rules/styling.md`.
- Do not inherit artifacts as-is from another design system, Figma file, library, or pre-existing `CONTRACT.md` / `README.md`. Rewrite to our conventions. See `.claude/rules/migration.md`.
- Do not add Figma references to Storybook stories. No `parameters.design`, no `parameters.figma`, no Figma URLs in `docs.description.*`, no `@storybook/addon-designs` import. The Figma link is owned by `<name>.figma.ts` (Code Connect). See `.claude/docs/COMPONENT_REQUIREMENTS.md`.
- Do not use `parameters.actions.argTypesRegex` (deprecated in Storybook 8 and silently misroutes Vue 3 emits) or `parameters.actions.handles` (DOM-only). Declare every event explicitly in `argTypes` with a camelCase `on<Event>` key and `{ action: '<emitted-name>' }`. Do not use the legacy CSF2 `Name.args = {...}` form — always object-style CSF3.
- Do not add bespoke Storybook stories beyond Default + Types + Sizes + state stories (`Loading`, `Disabled`) for the props the component actually declares, unless the spec's "Stories (Storybook)" section explicitly justifies the addition. Do not split Types/Sizes into one-story-per-variant — the composite stories are the canonical pattern.
- Do not duplicate the `## Usage` block from the spec inside the Storybook story body. The block is injected once into `parameters.docs.description.component` by the storybook-write skill; copy it nowhere else.
- Do not edit `.claude/docs/DESIGN.md`, `.claude/docs/COMPONENT_REQUIREMENTS.md`, or `.claude/docs/PRIMEVUE_ABSTRACTION.md`.
- Do not edit the root `package.json` or `.github/workflows/*`.
- Do not change `structure` after `status: approved`. To change structure, bump `spec_version` and re-author the spec.
- Do not create files outside the paths declared by your task (the orchestrator tells you exactly which files to write).
- Do not run `git` commands, `pnpm install`, or any command that changes the lockfile.
- If anything in the spec is ambiguous or contradicts the rules, emit `BLOCKED: <one-sentence reason>` and write nothing.
199 changes: 199 additions & 0 deletions apps/storybook/src/stories/components/data/PickList.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import PickList from '@aziontech/webkit/pick-list'
import { ref } from 'vue'

const sampleModel = () => [
[
{ id: 1, label: 'Edge Functions' },
{ id: 2, label: 'WAF' },
{ id: 3, label: 'Image Processor' }
],
[{ id: 4, label: 'Cache' }]
]

/** @type {import('@storybook/vue3').Meta<typeof PickList>} */
const meta = {
title: 'Components/Data/PickList',
component: PickList,
tags: ['autodocs'],
parameters: {
layout: 'padded',
backgrounds: { default: 'dark' },
a11y: {
config: {
rules: [
{ id: 'color-contrast', enabled: true },
{ id: 'focus-order-semantics', enabled: true }
]
}
},
docs: {
description: {
component: [
'Dual-list transfer control: a labelled source listbox and a labelled target listbox with controls to move items between them and (optionally) reorder items within a list. Use it when the consumer needs to build an ordered subset from a pool of options where both the chosen set and the remaining pool stay visible.',
'',
'## Usage',
'',
'```vue',
'<script setup>',
"import PickList from '@aziontech/webkit/pick-list'",
"import { ref } from 'vue'",
'',
'const model = ref([',
' [',
" { id: 1, label: 'Edge Functions' },",
" { id: 2, label: 'WAF' }",
' ],',
" [{ id: 3, label: 'Cache' }]",
'])',
'</script>',
'',
'<template>',
' <PickList',
' v-model="model"',
' data-key="id"',
' source-header="Available"',
' target-header="Selected"',
' reorderable',
' >',
' <template #item="{ item }">{{ item.label }}</template>',
' </PickList>',
'</template>',
'```'
].join('\n')
}
}
},
argTypes: {
modelValue: {
control: false,
description: 'Bound pair of lists as [sourceItems, targetItems] (v-model).',
table: { type: { summary: '[unknown[], unknown[]]' }, category: 'props' }
},
dataKey: {
control: 'text',
description: 'Item field that uniquely identifies a record.',
table: { type: { summary: 'string' }, defaultValue: { summary: "''" }, category: 'props' }
},
sourceHeader: {
control: 'text',
description: 'Heading text for the source list; also its accessible name.',
table: { type: { summary: 'string' }, defaultValue: { summary: "''" }, category: 'props' }
},
targetHeader: {
control: 'text',
description: 'Heading text for the target list; also its accessible name.',
table: { type: { summary: 'string' }, defaultValue: { summary: "''" }, category: 'props' }
},
disabled: {
control: 'boolean',
description: 'Disables all selection and move controls and applies disabled tokens.',
table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'props' }
},
reorderable: {
control: 'boolean',
description: 'Shows up/down reorder controls that move selected items within their own list.',
table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'props' }
},
'onUpdate:modelValue': {
action: 'update:modelValue',
description: 'Emitted with the new [sourceItems, targetItems] pair.',
table: { type: { summary: '[unknown[], unknown[]]' }, category: 'events' }
},
onMove: {
action: 'move',
description: 'Fired after items move between lists, with the moved items.',
table: {
type: { summary: "{ direction: 'to-target' | 'to-source'; items: unknown[] }" },
category: 'events'
}
},
onReorder: {
action: 'reorder',
description: 'Fired after a reorder, with the items in the affected list.',
table: {
type: { summary: "{ list: 'source' | 'target'; items: unknown[] }" },
category: 'events'
}
},
item: {
control: false,
description: 'Renders a single row; receives the item, its index, and which list it belongs to.',
table: { type: { summary: 'slot' }, category: 'slots' }
},
sourceHeaderSlot: {
control: false,
name: 'sourceHeader',
description: 'Overrides the source heading content.',
table: { type: { summary: 'slot' }, category: 'slots' }
},
targetHeaderSlot: {
control: false,
name: 'targetHeader',
description: 'Overrides the target heading content.',
table: { type: { summary: 'slot' }, category: 'slots' }
}
},
args: {
dataKey: 'id',
sourceHeader: 'Available',
targetHeader: 'Selected',
disabled: false,
reorderable: false
}
}

export default meta

const Template = (args) => ({
components: { PickList },
setup() {
const value = ref(args.modelValue ?? sampleModel())
const onUpdate = (next) => {
value.value = next
args['onUpdate:modelValue']?.(next)
}
return { args, value, onUpdate }
},
template: `
<div class="max-w-[var(--container-xl)]">
<PickList
v-bind="args"
:model-value="value"
@update:model-value="onUpdate"
@move="args.onMove"
@reorder="args.onReorder"
>
<template #item="{ item }">{{ item.label }}</template>
</PickList>
</div>
`
})

/** @type {import('@storybook/vue3').StoryObj<typeof PickList>} */
export const Default = {
render: Template,
parameters: {
docs: { description: { story: 'Two lists with select-and-move and move-all controls.' } }
}
}

export const Disabled = {
args: { disabled: true },
render: Template,
parameters: {
docs: { description: { story: 'Disabled state: selection and all move controls are inert.' } }
}
}

export const WithReorder = {
args: { reorderable: true },
render: Template,
parameters: {
docs: {
description: {
story:
'Reorder controls (up/down) appear above the target list and move the selected items within their own list.'
}
}
}
}
1 change: 1 addition & 0 deletions packages/webkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"./data-table-toolbar": "./src/components/data/data-table/data-table-toolbar.vue",
"./data-table-view-all-footer": "./src/components/data/data-table/data-table-view-all-footer.vue",
"./data-table-view-toggle": "./src/components/data/data-table/data-table-view-toggle.vue",
"./pick-list": "./src/components/data/pick-list/pick-list.vue",
"./message": "./src/components/feedback/message/message.vue",
"./status-indicator": "./src/components/feedback/status-indicator/status-indicator.vue",
"./skeleton": "./src/components/feedback/skeleton/skeleton.vue",
Expand Down
12 changes: 12 additions & 0 deletions packages/webkit/src/components/data/pick-list/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@aziontech/webkit-data-pick-list",
"main": "./pick-list.vue",
"module": "./pick-list.vue",
"types": "./pick-list.vue.d.ts",
"browser": {
"./sfc": "./pick-list.vue"
},
"sideEffects": [
"*.vue"
]
}
Loading
Loading