From 108faa10f675cd4f542f1138c313345a6b7b3d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaque=20B=C3=B6ck?= Date: Wed, 24 Jun 2026 11:34:21 -0300 Subject: [PATCH 1/5] feat: refine input-switch and field-switch with documented stories Tighten the InputSwitch / FieldSwitch / FieldSwitchBlock visuals against the Figma spec, and align the Storybook stories with the InputSelect pattern (Show code panel open by default, hand-crafted source snippets, `## Usage` block in autodocs). --- .specs/field-switch-block.md | 9 +- .specs/field-switch.md | 12 +- .specs/input-switch.md | 77 +++++--- .../webkit/inputs/FieldSwitch.stories.js | 140 +++++++++++++-- .../webkit/inputs/FieldSwitchBlock.stories.js | 3 - .../webkit/inputs/InputSwitch.stories.js | 165 +++++++++++++----- .../field-switch-block/field-switch-block.vue | 25 +-- .../inputs/field-switch/field-switch.vue | 67 ++++--- .../inputs/input-switch/input-switch.vue | 105 +++++------ 9 files changed, 390 insertions(+), 213 deletions(-) diff --git a/.specs/field-switch-block.md b/.specs/field-switch-block.md index 6d5549b5d..04eb878c2 100644 --- a/.specs/field-switch-block.md +++ b/.specs/field-switch-block.md @@ -3,11 +3,11 @@ name: field-switch-block category: inputs structure: monolithic status: implemented -spec_version: 1 +spec_version: 2 figma: url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=2027-7168 node_id: 2027:7168 -checksum: 87064b25e5372edfbcc9cde6a5457e986ff36bf7ff44f0c7c501356d2a76de15 +checksum: 3836c2dacdd7b10a46bf71775974946bafbb088f4a1cf137545903ddfe192eea created: 2026-05-23 last_updated: 2026-05-23 --- @@ -23,10 +23,7 @@ Card-style boolean toggle with switch, label, description, and optional disabled | Prop | Type | Default | Required | JSDoc | | ------------- | --------- | ----------- | -------- | ------------------------------------------------- | | `modelValue` | `boolean` | `undefined` | no | Selected value for v-model. | -| `trueValue` | `boolean` | `true` | no | Value emitted when toggled on. | -| `falseValue` | `boolean` | `false` | no | Value emitted when toggled off. | | `disabled` | `boolean` | `false` | no | Disables interaction and applies disabled tokens. | -| `inputId` | `string` | `undefined` | no | id for the switch button; links label to control. | | `label` | `string` | `''` | no | Primary label text. | | `description` | `string` | `''` | no | Secondary description. | | `helperText` | `string` | `''` | no | Helper badge text shown when disabled. | @@ -83,7 +80,7 @@ Card-style boolean toggle with switch, label, description, and optional disabled - Visible focus: delegated to nested InputSwitch. - Keyboard map: `Tab` focuses switch; `Space` / `Enter` toggles. -- ARIA: label associated via `for` / `inputId`. +- ARIA: label associated via `for` pointing to an internally generated `id` injected on the `InputSwitch` button. - Contrast ≥4.5:1 (text) / ≥3:1 (large + icons), including disabled state. - Touch target ≥40×40 px via card hit area. diff --git a/.specs/field-switch.md b/.specs/field-switch.md index 5fe728a61..f038430b7 100644 --- a/.specs/field-switch.md +++ b/.specs/field-switch.md @@ -3,13 +3,13 @@ name: field-switch category: inputs structure: monolithic status: implemented -spec_version: 1 +spec_version: 2 figma: url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=542-105 node_id: 542:105 -checksum: 4e4186ce0355f5ccf2dedf95daa1efd33eeea62a8691b7e60c525bf863375831 +checksum: 05fd4e71e09aa76a93643985f33870adddf259c3367ce6640b8f3923f5fb6ec4 created: 2026-05-23 -last_updated: 2026-05-23 +last_updated: 2026-06-23 --- # Field Switch — Component Spec @@ -23,10 +23,7 @@ Inline boolean toggle with switch on the leading edge, label, optional descripti | Prop | Type | Default | Required | JSDoc | | ------------- | --------- | ----------- | -------- | ------------------------------------------------- | | `modelValue` | `boolean` | `undefined` | no | Selected value for v-model. | -| `trueValue` | `boolean` | `true` | no | Value emitted when toggled on. | -| `falseValue` | `boolean` | `false` | no | Value emitted when toggled off. | | `disabled` | `boolean` | `false` | no | Disables interaction and applies disabled tokens. | -| `inputId` | `string` | `undefined` | no | id for the switch button; links label to control. | | `label` | `string` | `''` | no | Primary label text. | | `description` | `string` | `''` | no | Secondary description. | | `helperText` | `string` | `''` | no | Helper badge text shown when disabled. | @@ -76,13 +73,14 @@ _none_ - Visible focus: delegated to nested InputSwitch. - Keyboard map: `Tab` focuses switch; `Space` / `Enter` toggles. -- ARIA: label associated via `for` / `inputId`. +- ARIA: label associated to the switch via `for` bound to an internally generated id (`useId`). - Contrast ≥4.5:1 (text) / ≥3:1 (large + icons), including disabled state. - Touch target ≥40×40 px via label hit area. ## Stories (Storybook) - Default +- States - Disabled ## Constraints — DO NOT diff --git a/.specs/input-switch.md b/.specs/input-switch.md index a03d98bb2..3ba7186ae 100644 --- a/.specs/input-switch.md +++ b/.specs/input-switch.md @@ -3,35 +3,49 @@ name: input-switch category: inputs structure: monolithic status: implemented -spec_version: 2 +spec_version: 8 figma: url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=2027-1247 node_id: 2027:1247 -checksum: aa1341a65323b931b7e05f8da735f05933747534396ce03a1f29b8aaf029642b +checksum: 7ead84aaacaae84b9d90c4e3d537d104522de6858d6444ce7ea56159ff87a7af created: 2026-05-22 -last_updated: 2026-05-23 +last_updated: 2026-06-23 --- + # Input Switch — Component Spec ## Purpose -Control only — the pill toggle from Figma `_Switch` (36×20 px). No label or description. Use `FieldSwitch` or `FieldSwitchBlock` for labeled layouts. +Control-only pill toggle `InputSwitch` (36×20 px). Two visual types: `default` (plain handle) and `privacy` (handle carries a `pi-lock` / `pi-lock-open` icon mirroring the toggled state). No label or description — use `FieldSwitch` / `FieldSwitchBlock` for labeled layouts. + +## Usage + +```vue + + + +``` ## Props | Prop | Type | Default | Required | JSDoc | |---|---|---|---|---| -| `modelValue` | `boolean` | `undefined` | no | Selected value for v-model. | -| `trueValue` | `boolean` | `true` | no | Value emitted when toggled on. | -| `falseValue` | `boolean` | `false` | no | Value emitted when toggled off. | -| `disabled` | `boolean` | `false` | no | Disables interaction and applies disabled tokens. | -| `inputId` | `string` | `undefined` | no | id for the switch button; associate an external label via htmlFor. | +| `isToggled` | `boolean` | `false` | no | Toggled-on state. Bind with `v-model:isToggled="value"`. Mirrors the Figma `isToggled` variant. | +| `type` | `'default' \| 'privacy'` | `'default'` | no | Visual variant. `privacy` renders a lock icon inside the handle (closed when off, open when on). | +| `isFocused` | `boolean` | `false` | no | Forces the focused visual state regardless of keyboard focus. Mirrors the Figma `isFocused` variant. | ## Events | Event | Payload | Notes | |---|---|---| -| `update:modelValue` | `boolean` | v-model. | +| `update:isToggled` | `boolean` | Emitted when the user toggles the switch. Paired with `v-model:isToggled`. | ## Slots @@ -39,45 +53,53 @@ Control only — the pill toggle from Figma `_Switch` (36×20 px). No label or d ## States -- Visual states: `default`, `hover`, `focus-visible`, `active`, `disabled`, `checked` -- `data-disabled` mirrors the `disabled` prop -- `data-checked` mirrors toggled-on state +- Visual states: `default`, `hover`, `focus-visible`, `active`, `checked` +- `data-checked` mirrors the `isToggled` prop (toggled-on state) +- `data-focused` mirrors the `isFocused` prop and applies the same ring tokens as `:focus-visible` +- `data-type` mirrors the `type` prop (`default` | `privacy`) +- Hover applies an inset `var(--bg-hover)` overlay on both off and on tracks ## 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` | -| handle move | `transition-transform duration-150 ease-out` | inline | `motion-reduce:transition-none` | +| track color change | `transition-colors duration-150 ease-out` | inline (matches catalog) | `motion-reduce:transition-none` | +| handle slide | `transition-transform duration-150 ease-out` | inline (matches catalog) | `motion-reduce:transition-none motion-reduce:transform-none` | ## Tokens | Region | Token (DESIGN.md) | |---|---| -| track (off) | `var(--bg-disabled)` | -| track (on) | `var(--primary)` | -| handle | `var(--bg-surface)` | -| ring | `var(--ring-color)` | +| track (off) — background | `var(--bg-surface)` | +| track (off) — border | `var(--border-default)` | +| track (on) — background | `var(--success-contrast)` | +| track (hover) — overlay | `var(--bg-hover)` (applies to both off and on tracks) | +| focus-visible / `data-focused` ring | `var(--ring-color)` | +| shape | `rounded-full` (Tailwind native; pill — DESIGN.md § Shapes does not gate `rounded-full`) | ## Theme gaps | Figma variable | Temporary primitive | Follow-up | |---|---|---| -| _none_ | — | — | +| handle fill (off) — Figma `--surface-300` (#b2b2b2) | inline `bg-[var(--text-muted)]` (closest semantic) | `TODO: introduce semantic --fg-handle (or equivalent) in DESIGN.md` | +| handle fill (on) — dark contrast over `--success-contrast` | inline `bg-[var(--bg-canvas)]` (closest semantic) | `TODO: introduce semantic --fg-handle-on in DESIGN.md` | ## 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)]` +- Visible focus: `focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]`. The same ring is applied when `isFocused` is `true` (`data-[focused]` mirror). - Keyboard map: `Tab` focuses; `Space` / `Enter` toggles. -- ARIA: `role="switch"` with `aria-checked`. -- Contrast ≥4.5:1 (text) / ≥3:1 (large + icons), including disabled state. +- ARIA: `role="switch"` on the root; `aria-checked` mirrors `data-checked`. The lock icon in `type="privacy"` is decorative — `aria-hidden="true"`. +- Contrast ≥4.5:1 (text) / ≥3:1 (large + icons). - `motion-reduce:transition-none motion-reduce:transform-none` on animated states. -- Touch target ≥40×40 px where the control is interactive. +- Touch target: the control itself is 36×20 — the consumer is responsible for placing it inside a ≥40×40 hit area (typically via `FieldSwitch`). The component still exposes a clickable root. ## Stories (Storybook) - Default -- Disabled +- Types — composite story rendering `type='default'` and `type='privacy'` side-by-side, each in both off and on states. + + + ## Constraints — DO NOT @@ -94,7 +116,8 @@ Control only — the pill toggle from Figma `_Switch` (36×20 px). No label or d - 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 + 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. diff --git a/apps/storybook/src/stories/webkit/inputs/FieldSwitch.stories.js b/apps/storybook/src/stories/webkit/inputs/FieldSwitch.stories.js index 1d6db0283..7d976bc1b 100644 --- a/apps/storybook/src/stories/webkit/inputs/FieldSwitch.stories.js +++ b/apps/storybook/src/stories/webkit/inputs/FieldSwitch.stories.js @@ -2,6 +2,22 @@ import { ref } from 'vue' import FieldSwitch from '@aziontech/webkit/inputs/field-switch' +const CORE_IMPORT = "import FieldSwitch from '@aziontech/webkit/inputs/field-switch'" + +const basicSource = ({ initial = 'false', bind = '' } = {}) => + [ + '', + '', + '' + ].join('\n') + /** @type {import('@storybook/vue3').Meta} */ const meta = { title: 'Webkit/Inputs/Field Switch', @@ -20,8 +36,35 @@ const meta = { }, docs: { description: { - component: - 'Inline boolean toggle with switch on the leading edge, label, optional description, and optional disabled helper badge.' + component: [ + 'Inline boolean toggle with switch on the leading edge, label, optional description, and optional disabled helper badge.', + '', + '## Usage', + '', + '```vue', + '', + '', + '', + '```' + ].join('\n') + }, + source: { + type: 'dynamic', + excludeDecorators: true + }, + canvas: { + sourceState: 'shown' } } }, @@ -58,14 +101,42 @@ const meta = { } }, args: { + modelValue: false, label: 'Switch label', - description: 'Switch description' + description: 'Switch description', + helperText: '', + disabled: false } } export default meta export const Default = { + render: (args) => ({ + components: { FieldSwitch }, + setup() { + const model = ref(args.modelValue) + return { args, model } + }, + template: ` + + ` + }), + parameters: { + docs: { + source: { + code: basicSource({ + bind: 'label="Switch label" description="Switch description"' + }) + } + } + } +} + +export const States = { render: () => ({ components: { FieldSwitch }, setup() { @@ -79,35 +150,72 @@ export const Default = { v-model="off" label="Switch label" description="Switch description" - input-id="webkit-field-switch-off" /> ` - }) + }), + parameters: { + docs: { + source: { + code: [ + '', + '', + '' + ].join('\n') + } + } + } } export const Disabled = { - render: () => ({ + args: { + modelValue: true, + helperText: 'Helper Text', + disabled: true + }, + render: (args) => ({ components: { FieldSwitch }, setup() { - const value = ref(true) - return { value } + const model = ref(args.modelValue) + return { args, model } }, template: ` ` - }) + }), + parameters: { + docs: { + source: { + code: basicSource({ + initial: 'true', + bind: 'label="Switch label" description="Switch description" helperText="Helper Text" disabled' + }) + } + } + } } diff --git a/apps/storybook/src/stories/webkit/inputs/FieldSwitchBlock.stories.js b/apps/storybook/src/stories/webkit/inputs/FieldSwitchBlock.stories.js index a8a973fad..e68d1ee0f 100644 --- a/apps/storybook/src/stories/webkit/inputs/FieldSwitchBlock.stories.js +++ b/apps/storybook/src/stories/webkit/inputs/FieldSwitchBlock.stories.js @@ -79,13 +79,11 @@ export const Default = { v-model="off" label="Switch label" description="Switch description" - input-id="webkit-field-switch-block-off" /> ` @@ -106,7 +104,6 @@ export const Disabled = { description="Switch description" helper-text="Helper Text" disabled - input-id="webkit-field-switch-block-disabled" /> ` }) diff --git a/apps/storybook/src/stories/webkit/inputs/InputSwitch.stories.js b/apps/storybook/src/stories/webkit/inputs/InputSwitch.stories.js index e2f3e5ec4..12806bda5 100644 --- a/apps/storybook/src/stories/webkit/inputs/InputSwitch.stories.js +++ b/apps/storybook/src/stories/webkit/inputs/InputSwitch.stories.js @@ -2,6 +2,22 @@ import { ref } from 'vue' import InputSwitch from '@aziontech/webkit/inputs/input-switch' +const CORE_IMPORT = "import InputSwitch from '@aziontech/webkit/inputs/input-switch'" + +const basicSource = ({ initial = 'false', bind = '' } = {}) => + [ + '', + '', + '' + ].join('\n') + /** @type {import('@storybook/vue3').Meta} */ const meta = { title: 'Webkit/Inputs/Input Switch', @@ -20,40 +36,58 @@ const meta = { }, docs: { description: { - component: - 'Control only — the pill toggle with no label or description. Use FieldSwitch or FieldSwitchBlock for built-in text.' + component: [ + 'Control-only pill toggle `InputSwitch` (36×20 px). Two visual types: `default` (plain handle) and `privacy` (handle carries a lock icon mirroring the toggled state). No label or description — use `FieldSwitch` / `FieldSwitchBlock` for labeled layouts.', + '', + '## Usage', + '', + '```vue', + '', + '', + '', + '```' + ].join('\n') + }, + source: { + type: 'dynamic', + excludeDecorators: true + }, + canvas: { + sourceState: 'shown' } } }, argTypes: { - modelValue: { - control: 'boolean', - description: 'Selected value for v-model.', - table: { type: { summary: 'boolean' }, category: 'props' } - }, - trueValue: { - control: 'boolean', - description: 'Value emitted when toggled on.', - table: { type: { summary: 'boolean' }, defaultValue: { summary: 'true' }, category: 'props' } - }, - falseValue: { + isToggled: { control: 'boolean', - description: 'Value emitted when toggled off.', + description: 'Toggled-on state. Bind with `v-model:isToggled="value"`.', table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'props' } }, - disabled: { + type: { + control: 'select', + options: ['default', 'privacy'], + description: 'Visual variant. Privacy renders a lock icon inside the handle.', + table: { + type: { summary: "'default' | 'privacy'" }, + defaultValue: { summary: "'default'" }, + category: 'props' + } + }, + isFocused: { control: 'boolean', - description: 'Disables interaction and applies disabled tokens.', + description: 'Forces the focused visual state regardless of keyboard focus.', table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'props' } }, - inputId: { - control: 'text', - description: 'id for the switch button; associate an external label via htmlFor.', - table: { type: { summary: 'string' }, category: 'props' } - }, - 'onUpdate:modelValue': { - action: 'update:modelValue', - description: 'Emitted when the selected value changes.', + 'onUpdate:isToggled': { + action: 'update:isToggled', + description: 'Emitted when the user toggles the switch.', table: { type: { summary: 'boolean' }, category: 'events' } } } @@ -61,46 +95,81 @@ const meta = { export default meta +/** @type {import('@storybook/vue3').StoryObj} */ export const Default = { - render: () => ({ + args: { + isToggled: false, + type: 'default', + isFocused: false + }, + render: (args) => ({ components: { InputSwitch }, setup() { - const value = ref(false) - return { value } + return { args } }, template: ` ` - }) + }), + parameters: { + docs: { + description: { story: 'Default switch. Use the Controls panel to flip isToggled, type, and isFocused.' }, + source: { code: basicSource() } + } + } } -export const Disabled = { +/** @type {import('@storybook/vue3').StoryObj} */ +export const Types = { render: () => ({ components: { InputSwitch }, setup() { - const on = ref(true) - const off = ref(false) - return { on, off } + const defaultOff = ref(false) + const defaultOn = ref(true) + const privacyOff = ref(false) + const privacyOn = ref(true) + return { defaultOff, defaultOn, privacyOff, privacyOn } }, template: ` -
- - +
+ + + +
` - }) + }), + parameters: { + docs: { + description: { + story: + 'Both type variants in off and on states. Privacy adds a lock (off) / lock-open (on) icon inside the handle.' + }, + source: { + code: [ + '', + '', + '' + ].join('\n') + } + } + } } diff --git a/packages/webkit/src/components/inputs/field-switch-block/field-switch-block.vue b/packages/webkit/src/components/inputs/field-switch-block/field-switch-block.vue index 193f14d7d..fe5488ea7 100644 --- a/packages/webkit/src/components/inputs/field-switch-block/field-switch-block.vue +++ b/packages/webkit/src/components/inputs/field-switch-block/field-switch-block.vue @@ -13,14 +13,8 @@ interface Props { /** Selected value for v-model. */ modelValue?: boolean - /** Value emitted when toggled on. */ - trueValue?: boolean - /** Value emitted when toggled off. */ - falseValue?: boolean /** Disables interaction and applies disabled tokens. */ disabled?: boolean - /** id for the switch button; links label to control. */ - inputId?: string /** Primary label text. */ label?: string /** Secondary description. */ @@ -31,10 +25,7 @@ const props = withDefaults(defineProps(), { modelValue: undefined, - trueValue: true, - falseValue: false, disabled: false, - inputId: undefined, label: '', description: '', helperText: '' @@ -51,9 +42,7 @@ () => (attrs['data-testid'] as string | undefined) ?? 'input-field-switch-block' ) - const resolvedInputId = computed(() => props.inputId ?? generatedId) - - const isChecked = computed(() => props.modelValue === props.trueValue) + const isChecked = computed(() => props.modelValue === true) const isHighlighted = computed( () => (isChecked.value && !props.disabled) || (!isChecked.value && props.disabled) @@ -61,7 +50,7 @@ const model = computed({ get: () => props.modelValue, - set: (next) => emit('update:modelValue', next ?? props.falseValue) + set: (next) => emit('update:modelValue', next ?? false) }) const sharedClasses = 'block data-[disabled]:cursor-not-allowed' @@ -88,7 +77,7 @@