diff --git a/.specs/checkbox.md b/.specs/checkbox.md index 3ff9fb8dc..9fc7bf241 100644 --- a/.specs/checkbox.md +++ b/.specs/checkbox.md @@ -2,34 +2,29 @@ name: checkbox category: inputs structure: monolithic -status: approved +status: implemented spec_version: 3 -checksum: 2fd0e74289221cb07d676ea9cecad5c547ffedc51452a69e0e9e552b05ab2cb3 +checksum: 1f6e19b3c0419970b778bd61a4316627e13e43607aea53264f41d45acce8642f created: 2026-05-22 -last_updated: 2026-06-24 +last_updated: 2026-06-25 --- - # Checkbox — Component Spec ## Purpose -Square checkbox control only — the box, the native input, and the check glyph, with no label or description chrome. Use it when you compose your own label, or reach for `FieldCheckbox` / `FieldCheckboxBlock` when you want built-in text. Supports a single boolean (`binary`) or collecting a `value` into an array model. +Binary selection control. Supports single boolean (`binary: true`), single-value selection (matches `value` against `modelValue`), and array-based multi-select (used by `MultiSelect` and `DataTable` to drive bulk selection). The new `indeterminate` prop renders a third visual state (horizontal bar) without flipping `aria-checked` — used by parents that own a partial-selection summary (e.g. "select all" rows where some children are selected). Aligned with Figma frame `2027:7311`. ## Usage ```vue ``` @@ -37,73 +32,80 @@ const accepted = ref(false) | Prop | Type | Default | Required | JSDoc | |---|---|---|---|---| -| `modelValue` | `unknown` | `undefined` | no | Bound value: a boolean in `binary` mode, otherwise the array/value the control reflects. | -| `value` | `unknown` | `undefined` | no | This checkbox's value, added to or removed from the model array when not `binary`. | -| `binary` | `boolean` | `false` | no | Toggles a single boolean instead of collecting `value` into an array. | +| `modelValue` | `unknown` | `undefined` | no | Two-way bound value. Boolean in `binary` mode, scalar when paired with `value`, array when multi-selecting. | +| `value` | `unknown` | `undefined` | no | Identifier for this checkbox in non-binary mode. Compared against `modelValue` (or included in the array). | +| `binary` | `boolean` | `false` | no | When true, the checkbox toggles `modelValue` as a boolean (no `value` pairing). | +| `indeterminate` | `boolean` | `false` | no | Renders the indeterminate visual (horizontal bar). Does not affect `modelValue`; the parent owns the tri-state logic. | | `disabled` | `boolean` | `false` | no | Disables interaction and applies disabled tokens. | -| `readonly` | `boolean` | `false` | no | Prevents changes while the control stays focusable. | -| `inputId` | `string` | `undefined` | no | Forwarded to the native input `id`; pair with an external label's `for`. | -| `name` | `string` | `undefined` | no | HTML name for form submission. | -| `tabindex` | `number \| undefined` | `undefined` | no | Tab order for the native input. | +| `readonly` | `boolean` | `false` | no | Marks the field read-only; value is visible but cannot change via interaction. | +| `inputId` | `string` | `''` | no | Forwarded to the inner `` for label association. | +| `name` | `string` | `''` | no | HTML name for form submission. | +| `tabindex` | `number` | `0` | no | Forwarded to the inner ``. | ## Events | Event | Payload | Notes | |---|---|---| -| `update:modelValue` | `unknown` | Emitted on toggle; payload is the next boolean (`binary`) or the updated value array. | +| `update:modelValue` | `unknown` | Fires on activation. Payload is the next boolean (binary mode) or the next array (multi mode) or the matched value (single-value mode). | ## Slots -| _none_ | — | — | +The checkbox is a leaf primitive — no slots. Label/description live in `FieldCheckbox` wrappers. + +| Slot | Scope | Notes | +|---|---|---| ## States -- Visual states: `default`, `hover`, `focus-visible`, `active`, `disabled`, `readonly`, `checked` -- `data-checked` mirrors the checked state +- Visual states: `default`, `hover`, `focus-visible`, `active`, `checked`, `indeterminate`, `disabled`, `readonly` +- `data-state` on the root: `unchecked` | `checked` | `indeterminate` +- `data-checked` mirrors `isChecked` (legacy; kept for backwards compatibility with existing `data-[checked]:` styling) +- `data-indeterminate` mirrors the `indeterminate` prop - `data-disabled` mirrors the `disabled` prop - `data-readonly` mirrors the `readonly` prop ## Motion & Animations -| Trigger | Animation / Transition | Token (see `.claude/docs/DESIGN.md` § Animations) | Reduced-motion fallback | +| Trigger | Animation / Transition | Token | Reduced-motion fallback | |---|---|---|---| -| hover / active state change | `transition-opacity duration-fast-02 ease-productive-entrance` on the hover/active ghost overlays | semantic (matches catalog) | `motion-reduce:transition-none` | +| state change (border / bg / color) | `transition-colors duration-150 ease-out` | inline (matches catalog) | `motion-reduce:transition-none` | ## Tokens | Region | Token (DESIGN.md) | |---|---| -| surface | `var(--bg-surface)` | -| border | `var(--border-default)` | -| checked surface | `var(--primary)` | -| checked glyph | `var(--primary-contrast)` | -| shape | `var(--shape-button)` | -| hover overlay | `var(--bg-hover)` | -| active overlay | `var(--bg-active)` | -| disabled surface | `var(--bg-disabled)` | -| disabled glyph | `var(--text-disabled)` | -| ring | `var(--ring-color)` | -| ring offset | `var(--bg-canvas)` | +| surface (unchecked) | `var(--bg-surface)` | +| surface (checked) | `var(--primary)` | +| surface (indeterminate) | `var(--primary)` | +| surface (disabled) | `var(--bg-disabled)` | +| icon (checked / indeterminate) | `var(--primary-contrast)` | +| icon (disabled) | `var(--text-disabled)` | +| border (default) | `var(--border-default)` | +| border (checked) | `var(--primary)` | +| ring (focus) | `var(--ring-color)` | +| ring offset (focus) | `var(--bg-canvas)` | +| shape | `var(--shape-elements)` | ## Theme gaps | Figma variable | Temporary primitive | Follow-up | |---|---|---| -| _none_ | — | — | +| `--border-width-default` (0.8 px) | `border-[0.8px]` (Tailwind arbitrary) | TODO: catalog `--border-width-default` in DESIGN.md as a semantic primitive shared with Select / MultiSelect / Checkbox. | ## Accessibility (WCAG 2.1 AA) -- Visible focus: descendant native input drives the ring — `has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-[var(--ring-color)] has-[:focus-visible]:ring-offset-1 has-[:focus-visible]:ring-offset-[var(--bg-canvas)]` -- Keyboard map: `Tab` focuses the native input; `Space` toggles checked state (native checkbox behavior). -- ARIA: renders a real `` with `aria-checked`; control-only, so associate copy via `inputId` + an external label `for`, or use `FieldCheckbox`. -- Contrast ≥4.5:1 (text) / ≥3:1 (large + icons), including the disabled state. -- `motion-reduce:transition-none` on the animated ghost overlays. -- Touch target: the box is 1.125rem (18px) — justified deviation for a control-only primitive; the larger ≥40×40 px hit area is provided by the field wrappers (`FieldCheckbox` / `FieldCheckboxBlock`) or the consumer's label. +- Visible focus: `focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]` +- Keyboard map: `Tab` focuses; `Space` toggles. (The native `` carries the semantics.) +- ARIA: native `` is the source of truth. `aria-checked` is set to `'mixed'` when `indeterminate` is true (with `indeterminate` DOM property also set on the input). The wrapping `` is decorative. +- Contrast ≥4.5:1 (text) / ≥3:1 (icons + borders), including disabled state. +- `motion-reduce:transition-none` on every transition-bearing class. +- Touch target — the `1.125rem` size is below 40×40 px; consumers should wrap with `FieldCheckbox` or expand the parent click area when the control sits on its own. ## Stories (Storybook) - Default -- Disabled — demonstrates the `disabled` prop (checked and unchecked). +- Indeterminate — justification: documents the tri-state visual (horizontal bar) and the `aria-checked="mixed"` semantics added in `spec_version: 3`. Not reachable from any other story. +- Disabled ## Constraints — DO NOT @@ -120,8 +122,7 @@ const accepted = ref(false) - 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 `.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` key and `{ action: '' }`. 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 add bespoke Storybook stories beyond Default + per `kind` + per `size` + Disabled, unless the spec's "Stories (Storybook)" section explicitly justifies the addition. - 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. diff --git a/apps/storybook/src/stories/components/inputs/Checkbox.stories.js b/apps/storybook/src/stories/components/inputs/Checkbox.stories.js index 013409dc6..02d668b3f 100644 --- a/apps/storybook/src/stories/components/inputs/Checkbox.stories.js +++ b/apps/storybook/src/stories/components/inputs/Checkbox.stories.js @@ -1,5 +1,6 @@ +import { ref } from 'vue' + import Checkbox from '@aziontech/webkit/checkbox' -import { ref, watch } from 'vue' /** @type {import('@storybook/vue3').Meta} */ const meta = { @@ -7,7 +8,7 @@ const meta = { component: Checkbox, tags: ['autodocs'], parameters: { - layout: 'centered', + layout: 'padded', backgrounds: { default: 'dark' }, a11y: { config: { @@ -19,44 +20,30 @@ const meta = { }, docs: { description: { - component: [ - 'Square checkbox control only — the box, the native input, and the check glyph, with no label or description chrome. Use it when you compose your own label, or reach for `FieldCheckbox` / `FieldCheckboxBlock` when you want built-in text. Supports a single boolean (`binary`) or collecting a `value` into an array model.', - '', - '## Usage', - '', - '```vue', - '', - '', - '', - '```' - ].join('\n') + component: + 'Control only — the square checkbox input with no label or description. Pair with an external label or use FieldCheckbox / FieldCheckboxBlock for built-in text.' } } }, argTypes: { modelValue: { control: 'boolean', - description: 'Bound value: a boolean in binary mode, otherwise the array/value the control reflects.', + description: 'Two-way bound value.', table: { type: { summary: 'unknown' }, category: 'props' } }, value: { control: false, - description: "This checkbox's value, added to or removed from the model array when not binary.", + description: 'Identifier for this checkbox in non-binary mode.', table: { type: { summary: 'unknown' }, category: 'props' } }, binary: { control: 'boolean', - description: 'Toggles a single boolean instead of collecting value into an array.', + description: 'When true, the checkbox toggles modelValue as a boolean.', + table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'props' } + }, + indeterminate: { + control: 'boolean', + description: 'Renders the indeterminate visual (horizontal bar). Does not affect modelValue.', table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'props' } }, disabled: { @@ -66,12 +53,12 @@ const meta = { }, readonly: { control: 'boolean', - description: 'Prevents changes while the control stays focusable.', + description: 'Marks the field read-only.', table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'props' } }, inputId: { control: 'text', - description: 'Forwarded to the native input id; pair with an external label for attribute.', + description: 'Forwarded to the inner input id for label association.', table: { type: { summary: 'string' }, category: 'props' } }, name: { @@ -81,68 +68,118 @@ const meta = { }, tabindex: { control: 'number', - description: 'Tab order for the native input.', - table: { type: { summary: 'number | undefined' }, category: 'props' } + description: 'Forwarded to the inner input tabindex.', + table: { type: { summary: 'number' }, category: 'props' } }, 'onUpdate:modelValue': { action: 'update:modelValue', - description: 'Emitted on toggle; payload is the next boolean (binary) or the updated value array.', + description: 'Emitted when the value changes.', table: { type: { summary: 'unknown' }, category: 'events' } } - }, - args: { - binary: true, - disabled: false, - readonly: false } } export default meta -const Template = (args) => ({ - components: { Checkbox }, - setup() { - const value = ref(args.modelValue ?? false) - - watch( - () => args.modelValue, - (next) => { - value.value = next ?? false +export const Default = { + args: { + binary: true, + indeterminate: false, + disabled: false, + readonly: false + }, + render: (args) => ({ + components: { Checkbox }, + setup() { + const value = ref(false) + return { args, value } + }, + template: ` + + ` + }), + parameters: { + docs: { + description: { + story: 'Binary control only — associate copy via an external label or FieldCheckbox.' } - ) - - const onUpdate = (next) => { - value.value = next - args['onUpdate:modelValue']?.(next) } + } +} - return { args, value, onUpdate } +export const Indeterminate = { + args: { + binary: true, + indeterminate: true, + disabled: false, + readonly: false }, - template: '' -}) - -/** @type {import('@storybook/vue3').StoryObj} */ -export const Default = { - render: Template, + render: (args) => ({ + components: { Checkbox }, + setup() { + const value = ref(false) + return { args, value } + }, + template: ` + + ` + }), parameters: { - docs: { description: { story: 'Binary control only — associate copy via an external label or FieldCheckbox.' } } + docs: { + description: { + story: + 'Tri-state visual. The parent owns the indeterminate logic (typical pattern: "select all" header when some rows are selected). Sets `aria-checked="mixed"` and the native input.indeterminate DOM property.' + } + } } } export const Disabled = { + args: { + binary: true, + indeterminate: false, + disabled: true, + readonly: false + }, render: (args) => ({ components: { Checkbox }, setup() { - return { args } + const checked = ref(true) + const unchecked = ref(false) + return { args, checked, unchecked } }, template: `
- - + +
` }), parameters: { - docs: { description: { story: 'Disabled control in both checked and unchecked states.' } } + docs: { + description: { + story: 'Disabled checked and unchecked controls without label chrome.' + } + } } } diff --git a/packages/webkit/src/components/inputs/checkbox/checkbox.vue b/packages/webkit/src/components/inputs/checkbox/checkbox.vue index 69a1e919b..d74ca1f55 100644 --- a/packages/webkit/src/components/inputs/checkbox/checkbox.vue +++ b/packages/webkit/src/components/inputs/checkbox/checkbox.vue @@ -1,5 +1,5 @@