Skip to content
Open
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
81 changes: 54 additions & 27 deletions .specs/checkbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,62 +2,88 @@
name: checkbox
category: inputs
structure: monolithic
status: implemented
spec_version: 2
checksum: d0a27b8ca2563f33c09954c4657f76af2b4edab6e87bb5367d0ab69d366a2acb
status: approved
spec_version: 3
checksum: 2fd0e74289221cb07d676ea9cecad5c547ffedc51452a69e0e9e552b05ab2cb3
created: 2026-05-22
last_updated: 2026-05-22
last_updated: 2026-06-24
---

# Checkbox — Component Spec

## Purpose

Collects or edits user input. Migrated from the existing implementation at `packages/webkit/src/components/webkit/inputs/checkbox/`.
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
<script setup>
import { ref } from 'vue'
import Checkbox from '@aziontech/webkit/checkbox'

const accepted = ref(false)
</script>

<template>
<label class="inline-flex items-center gap-[var(--spacing-2)]">
<Checkbox v-model="accepted" binary input-id="accept-terms" />
Accept terms
</label>
</template>
```

## Props

| Prop | Type | Default | Required | JSDoc |
|---|---|---|---|---|
| `modelValue` | `string` | `'undefined'` | no | model Value. |
| `value` | `string` | `'undefined'` | no | value. |
| `binary` | `boolean` | `false` | no | binary. |
| `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`. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After update Seems it's updating the type to any and writing 'undefined' inside quotes indicating undefined string.

Type string should default value with '' (empty string).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the spec_version 3 rewrite: value/modelValue are now typed unknown (not any — our spec Constraints forbid any) with an unquoted undefined default. I kept unknown rather than string because the checkbox is polymorphic — the model is a boolean in binary mode and an array otherwise — so an empty-string default doesn't apply; undefined (no preset value) is correct for a control-only checkbox. The quoted 'undefined' strings are gone across the table.

| `binary` | `boolean` | `false` | no | Toggles a single boolean instead of collecting `value` into an array. |
| `disabled` | `boolean` | `false` | no | Disables interaction and applies disabled tokens. |
| `readonly` | `boolean` | `false` | no | readonly. |
| `inputId` | `string` | `'undefined'` | no | input Id. |
| `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'` | no | tabindex. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the type if not current default tabindex 0 should be configured to both types number|undefined but the case it is writing undefined inside string.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1b96920: tabindex is now typed number | undefined with an unquoted undefined default. I didn't default it to 0 so the control keeps the browser's native tab ordering unless a consumer opts in.

| `tabindex` | `number \| undefined` | `undefined` | no | Tab order for the native input. |

## Events

| Event | Payload | Notes |
|---|---|---|
| `update:modelValue` | `unknown` | |
| `update:modelValue` | `unknown` | Emitted on toggle; payload is the next boolean (`binary`) or the updated value array. |

## Slots

| _none_ | — | — |

## States

- Visual states: `default`, `hover`, `focus-visible`, `active`, `disabled`
- Visual states: `default`, `hover`, `focus-visible`, `active`, `disabled`, `readonly`, `checked`
- `data-checked` mirrors the checked state
- `data-disabled` mirrors the `disabled` prop
- `data-readonly` mirrors the `readonly` prop

## Motion & Animations

| Trigger | Animation / Transition | Token | Reduced-motion fallback |
| Trigger | Animation / Transition | Token (see `.claude/docs/DESIGN.md` § Animations) | Reduced-motion fallback |
|---|---|---|---|
| state change | `transition-colors duration-150 ease-out` | inline | `motion-reduce:transition-none` |
| 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` |

## Tokens

| Region | Token (DESIGN.md) |
|---|---|
| typography | .text-body-sm |
| surface | `var(--bg-surface)` |
| text | `var(--text-default)` |
| spacing | `var(--spacing-3)` |
| shape | `var(--shape-elements)` |
| 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)` |

## Theme gaps

Expand All @@ -67,17 +93,17 @@ Collects or edits user input. Migrated from the existing implementation at `pack

## 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)]`
- Keyboard map: `Tab` focuses; `Enter`/`Space` activates; `Escape` closes overlays where applicable.
- ARIA: root uses appropriate roles (`button`, `dialog`, `status`, etc.) per sub-component.
- Contrast ≥4.5:1 (text) / ≥3:1 (large + icons), including disabled state.
- `motion-reduce:transition-none motion-reduce:transform-none` on animated states.
- Touch target ≥40×40 px where the control is interactive.
- 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 `<input type="checkbox">` 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.

## Stories (Storybook)

- Default
- Disabled
- Disabled — demonstrates the `disabled` prop (checked and unchecked).

## Constraints — DO NOT

Expand All @@ -94,7 +120,8 @@ Collects or edits user input. Migrated from the existing implementation at `pack
- 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 + per `kind` + per `size` + Disabled, unless the spec's "Stories (Storybook)" section explicitly justifies the addition.
- 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.
Expand Down
124 changes: 67 additions & 57 deletions apps/storybook/src/stories/components/inputs/Checkbox.stories.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { ref } from 'vue'

import Checkbox from '@aziontech/webkit/checkbox'
import { ref, watch } from 'vue'

/** @type {import('@storybook/vue3').Meta<typeof Checkbox>} */
const meta = {
title: 'Components/Inputs/Checkbox',
title: 'Components/Inputs/Checkbox',
component: Checkbox,
tags: ['autodocs'],
parameters: {
layout: 'padded',
layout: 'centered',
backgrounds: { default: 'dark' },
a11y: {
config: {
Expand All @@ -20,25 +19,44 @@ const meta = {
},
docs: {
description: {
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.'
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',
'<script setup>',
"import { ref } from 'vue'",
"import Checkbox from '@aziontech/webkit/checkbox'",
'',
'const accepted = ref(false)',
'</script>',
'',
'<template>',
' <label class="inline-flex items-center gap-[var(--spacing-2)]">',
' <Checkbox v-model="accepted" binary input-id="accept-terms" />',
' Accept terms',
' </label>',
'</template>',
'```'
].join('\n')
}
}
},
argTypes: {
modelValue: {
control: 'boolean',
description: 'model Value.',
description: 'Bound value: a boolean in binary mode, otherwise the array/value the control reflects.',
table: { type: { summary: 'unknown' }, category: 'props' }
},
value: {
control: false,
description: 'value.',
description: "This checkbox's value, added to or removed from the model array when not binary.",
table: { type: { summary: 'unknown' }, category: 'props' }
},
binary: {
control: 'boolean',
description: 'binary.',
description: 'Toggles a single boolean instead of collecting value into an array.',
table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'props' }
},
disabled: {
Expand All @@ -48,12 +66,12 @@ const meta = {
},
readonly: {
control: 'boolean',
description: 'readonly.',
description: 'Prevents changes while the control stays focusable.',
table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'props' }
},
inputId: {
control: 'text',
description: 'input Id.',
description: 'Forwarded to the native input id; pair with an external label for attribute.',
table: { type: { summary: 'string' }, category: 'props' }
},
name: {
Expand All @@ -63,76 +81,68 @@ const meta = {
},
tabindex: {
control: 'number',
description: 'tabindex.',
table: { type: { summary: 'number' }, category: 'props' }
description: 'Tab order for the native input.',
table: { type: { summary: 'number | undefined' }, category: 'props' }
},
'onUpdate:modelValue': {
action: 'update:modelValue',
description: 'Emitted when the value changes.',
description: 'Emitted on toggle; payload is the next boolean (binary) or the updated value array.',
table: { type: { summary: 'unknown' }, category: 'events' }
}
},
args: {
binary: true,
disabled: false,
readonly: false
}
}

export default meta

export const Default = {
render: () => ({
components: { Checkbox },
setup() {
const value = ref(false)
return { value }
},
template: `
<Checkbox
v-model="value"
binary
input-id="webkit-checkbox-default"
aria-label="Toggle option"
/>
`
}),
parameters: {
docs: {
description: {
story: 'Binary control only — associate copy via an external label or FieldCheckbox.'
const Template = (args) => ({
components: { Checkbox },
setup() {
const value = ref(args.modelValue ?? false)

watch(
() => args.modelValue,
(next) => {
value.value = next ?? false
}
)

const onUpdate = (next) => {
value.value = next
args['onUpdate:modelValue']?.(next)
}

return { args, value, onUpdate }
},
template: '<Checkbox v-bind="args" :model-value="value" @update:model-value="onUpdate" />'
})

/** @type {import('@storybook/vue3').StoryObj<typeof Checkbox>} */
export const Default = {
render: Template,
parameters: {
docs: { description: { story: 'Binary control only — associate copy via an external label or FieldCheckbox.' } }
}
}

export const Disabled = {
render: () => ({
render: (args) => ({
components: { Checkbox },
setup() {
const checked = ref(true)
const unchecked = ref(false)
return { checked, unchecked }
return { args }
},
template: `
<div class="flex items-center gap-[var(--spacing-4)]">
<Checkbox
v-model="checked"
binary
disabled
input-id="webkit-checkbox-disabled-on"
aria-label="Disabled checked"
/>
<Checkbox
v-model="unchecked"
binary
disabled
input-id="webkit-checkbox-disabled-off"
aria-label="Disabled unchecked"
/>
<Checkbox v-bind="args" :model-value="true" disabled aria-label="Disabled checked" />
<Checkbox v-bind="args" :model-value="false" disabled aria-label="Disabled unchecked" />
</div>
`
}),
parameters: {
docs: {
description: {
story: 'Disabled checked and unchecked controls without label chrome.'
}
}
docs: { description: { story: 'Disabled control in both checked and unchecked states.' } }
}
}
Loading
Loading