srcdev-nuxt-components 9.4.10 → 9.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/.claude/commands/migrate-component.md +18 -0
  2. package/.claude/component-ledger/audit.json +1 -1
  3. package/.claude/component-ledger/output.html +1 -1
  4. package/.claude/skills/components/input-number-core.md +214 -0
  5. package/.claude/skills/components/input-select-core.md +151 -0
  6. package/.claude/skills/components/input-text-core.md +16 -0
  7. package/.claude/skills/components/input-textarea-core.md +154 -0
  8. package/.claude/skills/components/select-menu.md +195 -0
  9. package/.claude/skills/components/toggle-switch-core.md +150 -0
  10. package/.claude/skills/index.md +5 -0
  11. package/.vscode/srcdev-component-input-number.code-snippets +65 -0
  12. package/.vscode/srcdev-component-input-select.code-snippets +42 -0
  13. package/.vscode/srcdev-component-input-textarea.code-snippets +40 -0
  14. package/.vscode/srcdev-component-select-menu.code-snippets +65 -0
  15. package/.vscode/srcdev-component-toggle-switch.code-snippets +49 -0
  16. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +1 -1
  17. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +1 -1
  18. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +1 -1
  19. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +1 -1
  20. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +1 -1
  21. package/app/components/01.atoms/animations/section-parallax/CONSUMER-STYLING.md +1 -1
  22. package/app/components/01.atoms/animations/section-parallax/SectionParallax.vue +1 -1
  23. package/app/components/01.atoms/canvas-switcher/CONSUMER-STYLING.md +2 -2
  24. package/app/components/01.atoms/canvas-switcher/CanvasSwitcher.vue +2 -2
  25. package/app/components/01.atoms/card/CardCore.vue +4 -4
  26. package/app/components/01.atoms/clipped-panel/CONSUMER-STYLING.md +2 -2
  27. package/app/components/01.atoms/clipped-panel/ClippedPanel.vue +2 -2
  28. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +2 -2
  29. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue +2 -2
  30. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts +1 -1
  31. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +1 -1
  32. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +1 -1
  33. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +8 -8
  34. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +8 -8
  35. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +6 -6
  36. package/app/components/01.atoms/pop-over/PopOver.vue +6 -6
  37. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +1 -1
  38. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -1
  39. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +1 -1
  40. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +1 -1
  41. package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +11 -0
  42. package/app/components/02.molecules/pricing-card/PricingCard.vue +3 -1
  43. package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +2 -0
  44. package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +13 -0
  45. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +168 -0
  46. package/app/components/02.molecules/select-menu/SelectMenu.vue +384 -0
  47. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +359 -0
  48. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +476 -0
  49. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +42 -0
  50. package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +3 -3
  51. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +3 -3
  52. package/app/components/03.organisms/image-galleries/carousel-flip/stories/CarouselFlip.stories.ts +18 -18
  53. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +2 -7
  54. package/app/components/05.forms/input-button/InputButtonCore.vue +1 -5
  55. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +26 -6
  56. package/app/components/05.forms/input-number/InputNumberCore.vue +125 -85
  57. package/app/components/05.forms/input-number/stories/InputNumberCore.stories.ts +92 -0
  58. package/app/components/05.forms/input-number/tests/InputNumberCore.spec.ts +119 -0
  59. package/app/components/05.forms/input-number/variants/InputNumberDefault.vue +30 -22
  60. package/app/components/05.forms/input-number/variants/stories/InputNumberDefault.stories.ts +123 -0
  61. package/app/components/05.forms/input-number/variants/tests/InputNumberDefault.spec.ts +143 -0
  62. package/app/components/05.forms/input-range/CONSUMER-STYLING.md +2 -2
  63. package/app/components/05.forms/input-range/InputRangeCore.vue +2 -2
  64. package/app/components/05.forms/input-select/InputSelectCore.vue +23 -30
  65. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +2 -2
  66. package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +115 -0
  67. package/app/components/05.forms/input-select/tests/InputSelectCore.spec.ts +129 -0
  68. package/app/components/05.forms/input-select/variants/InputSelectWithLabel.vue +1 -1
  69. package/app/components/05.forms/input-select/variants/tests/InputSelectWithLabel.spec.ts +87 -0
  70. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +14 -0
  71. package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +1 -1
  72. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +55 -0
  73. package/app/components/05.forms/input-textarea/InputTextareaCore.vue +24 -16
  74. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +1 -1
  75. package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +119 -0
  76. package/app/components/05.forms/input-textarea/variants/InputTextareaWithLabel.vue +3 -13
  77. package/app/components/05.forms/input-textarea/variants/tests/InputTextareaWithLabel.spec.ts +93 -0
  78. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +95 -10
  79. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +29 -0
  80. package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +120 -119
  81. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +1 -1
  82. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +1 -1
  83. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +2 -2
  84. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +101 -0
  85. package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabel.vue +3 -5
  86. package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabelInline.vue +1 -2
  87. package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabel.spec.ts +77 -0
  88. package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabelInline.spec.ts +66 -0
  89. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +4 -4
  90. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +6 -6
  91. package/app/types/components/index.ts +1 -0
  92. package/app/types/components/select-menu.d.ts +5 -0
  93. package/package.json +1 -1
  94. package/.vscode/srcdev-component-toggle-switch-core.code-snippets +0 -13
@@ -42,7 +42,6 @@
42
42
  :maxlength
43
43
  :name
44
44
  :placeholder
45
- :label
46
45
  :field-has-error
47
46
  :required
48
47
  :style-class-passthrough
@@ -106,24 +105,15 @@ const props = withDefaults(defineProps<Props>(), {
106
105
 
107
106
  const slots = useSlots();
108
107
 
109
- const FormUiTheme = computed(() => {
110
- return props.fieldHasError ? "error" : props.theme;
111
- });
112
-
113
- // const id = `${props.name}-${useId()}`;
114
- // const errorId = `${id}-error-message`;
115
- // const ariaDescribedby = computed(() => {
116
- // const ariaDescribedbyId = slots.descriptionText || slots.descriptionHtml ? `${id}-description` : undefined;
117
- // return props.fieldHasError ? errorId : ariaDescribedbyId;
118
- // });
119
-
120
108
  const { id, errorId, descriptionId, ariaDescribedby } = useAriaDescribedById(
121
109
  props.name,
122
110
  toRef(props, "fieldHasError"),
123
111
  slots
124
112
  );
125
113
 
126
- const modelValue = defineModel<string | number | readonly string[] | null | undefined>();
114
+ const modelValue = defineModel<string | number | readonly string[] | null | undefined>({
115
+ required: true,
116
+ });
127
117
  const isActive = ref<boolean>(false);
128
118
  const isDirty = ref<boolean>(false);
129
119
 
@@ -0,0 +1,93 @@
1
+ // https://nuxt.com/docs/getting-started/testing#unit-testing
2
+ import type { VueWrapper } from "@vue/test-utils";
3
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
4
+ import ComponentUnderTest from "../InputTextareaWithLabel.vue";
5
+
6
+ const initialPropsData = {
7
+ name: "message",
8
+ label: "Message",
9
+ errorMessage: "",
10
+ modelValue: "",
11
+ };
12
+
13
+ let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
14
+ const wrapperFactory = (propsData = {}, slots = {}) => {
15
+ const mockPropsData = { ...initialPropsData, ...propsData };
16
+
17
+ return mountSuspended(ComponentUnderTest, {
18
+ attachTo: document.body,
19
+ props: mockPropsData,
20
+ slots,
21
+ });
22
+ };
23
+
24
+ describe("InputTextareaWithLabel", () => {
25
+ it("mounts without error", async () => {
26
+ wrapper = await wrapperFactory();
27
+ expect(wrapper.vm).toBeTruthy();
28
+ });
29
+
30
+ it("renders the label text", async () => {
31
+ wrapper = await wrapperFactory({ label: "Feedback" });
32
+ expect(wrapper.text()).toContain("Feedback");
33
+ });
34
+
35
+ it("renders a native textarea", async () => {
36
+ wrapper = await wrapperFactory();
37
+ expect(wrapper.find("textarea").exists()).toBe(true);
38
+ });
39
+
40
+ it("does not leak a label attribute onto InputTextareaCore's wrapper", async () => {
41
+ // Regression test: InputTextareaCore has no `label` prop (matches InputTextCore), so passing
42
+ // it down would fall through as a meaningless raw `label` HTML attribute on the wrapper div.
43
+ wrapper = await wrapperFactory({ label: "Feedback" });
44
+ expect(wrapper.find(".input-textarea-wrapper").attributes("label")).toBeUndefined();
45
+ });
46
+
47
+ it("sets data-invalid and the error class when fieldHasError is true", async () => {
48
+ wrapper = await wrapperFactory({ fieldHasError: true });
49
+ expect(wrapper.find(".input-textarea-with-label").attributes("data-invalid")).toBe("");
50
+ });
51
+
52
+ it("renders the error message when fieldHasError is true", async () => {
53
+ wrapper = await wrapperFactory({ fieldHasError: true, errorMessage: "This field is required" });
54
+ expect(wrapper.text()).toContain("This field is required");
55
+ });
56
+
57
+ it("renders the descriptionText slot", async () => {
58
+ wrapper = await wrapperFactory(
59
+ {},
60
+ { descriptionText: () => "Please provide more detail" }
61
+ );
62
+ expect(wrapper.text()).toContain("Please provide more detail");
63
+ });
64
+
65
+ it("renders the left/right slots when provided", async () => {
66
+ wrapper = await wrapperFactory(
67
+ {},
68
+ {
69
+ left: '<span data-testid="left-slot">📝</span>',
70
+ right: '<span data-testid="right-slot">✨</span>',
71
+ }
72
+ );
73
+
74
+ expect(wrapper.find('[data-testid="left-slot"]').exists()).toBe(true);
75
+ expect(wrapper.find('[data-testid="right-slot"]').exists()).toBe(true);
76
+ });
77
+
78
+ it("sets isDirty true once the value becomes non-empty", async () => {
79
+ wrapper = await wrapperFactory({ modelValue: "hello" });
80
+ const vm = wrapper.vm as unknown as { isDirty: boolean };
81
+ expect(vm.isDirty).toBe(true);
82
+ });
83
+
84
+ it("applies inputVariant as a class on the wrapper", async () => {
85
+ wrapper = await wrapperFactory({ inputVariant: "underlined" });
86
+ expect(wrapper.find(".input-textarea-with-label").classes()).toContain("underlined");
87
+ });
88
+
89
+ it("applies styleClassPassthrough", async () => {
90
+ wrapper = await wrapperFactory({ styleClassPassthrough: "custom-textarea" });
91
+ expect(wrapper.find(".input-textarea-with-label").classes()).toContain("custom-textarea");
92
+ });
93
+ });
@@ -2,10 +2,14 @@ import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
2
  import { reactive } from "vue";
3
3
  import InputTextWithLabel from "../../input-text/variants/InputTextWithLabel.vue";
4
4
  import InputRangeDefault from "../../input-range/variants/InputRangeDefault.vue";
5
+ import InputNumberDefault from "../../input-number/variants/InputNumberDefault.vue";
6
+ import InputTextareaWithLabel from "../../input-textarea/variants/InputTextareaWithLabel.vue";
7
+ import InputSelectWithLabel from "../../input-select/variants/InputSelectWithLabel.vue";
8
+ import ToggleSwitchWithLabel from "../../toggle-switch/variants/ToggleSwitchWithLabel.vue";
5
9
  import InputButtonCore from "../../input-button/InputButtonCore.vue";
6
10
  import FormField from "../../form-field/FormField.vue";
7
11
  import HeroText from "../../../01.atoms/text-blocks/hero-text/HeroText.vue";
8
- import type { InputUiVariant } from "~/types/forms/types.forms.d";
12
+ import type { InputUiVariant, IFormMultipleOptions } from "~/types/forms/types.forms.d";
9
13
 
10
14
  interface MigratedFieldsFormStoryArgs {
11
15
  inputVariant: InputUiVariant;
@@ -13,9 +17,11 @@ interface MigratedFieldsFormStoryArgs {
13
17
 
14
18
  // Living reference, not a component of its own — one field per 05.forms component group that
15
19
  // currently scores 5/5 in the Component Ledger (.claude/component-ledger/audit.json: tier folder,
16
- // tests, story, skill doc, CONSUMER-STYLING.md, VS Code snippet). As of 2026-09-21 that's
17
- // InputTextCore (via InputTextWithLabel) and InputRangeCore (via InputRangeDefault) — every other
18
- // 05.forms component (select, number, checkbox, radio, toggle-switch, ...) is still mid-migration.
20
+ // tests, story, skill doc, CONSUMER-STYLING.md, VS Code snippet). As of 2026-09-23 that's
21
+ // InputTextCore (via InputTextWithLabel), InputRangeCore (via InputRangeDefault), InputNumberCore
22
+ // (via InputNumberDefault), InputTextareaCore (via InputTextareaWithLabel), InputSelectCore
23
+ // (via InputSelectWithLabel), and ToggleSwitchCore (via ToggleSwitchWithLabel) — every other
24
+ // 05.forms component (checkbox, radio, ...) is still mid-migration.
19
25
  // Add a field here each time /migrate-component brings another 05.forms component up to 5/5, so
20
26
  // this story doubles as a visible migration-progress tracker rather than living only in the
21
27
  // ledger's HTML output. No validation wiring (useZodValidation/zod) here — that's the consuming
@@ -39,6 +45,10 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
39
45
  components: {
40
46
  InputTextWithLabel,
41
47
  InputRangeDefault,
48
+ InputNumberDefault,
49
+ InputTextareaWithLabel,
50
+ InputSelectWithLabel,
51
+ ToggleSwitchWithLabel,
42
52
  InputButtonCore,
43
53
  FormField,
44
54
  HeroText,
@@ -47,28 +57,53 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
47
57
  const state = reactive({
48
58
  fullName: "",
49
59
  budget: 50,
60
+ quantity: 1,
61
+ notes: "",
62
+ colour: "",
63
+ subscribe: false,
64
+ });
65
+
66
+ const colourOptions = reactive<IFormMultipleOptions>({
67
+ data: [
68
+ { id: "1", name: "red", value: "red", label: "Red" },
69
+ { id: "2", name: "blue", value: "blue", label: "Blue" },
70
+ { id: "3", name: "green", value: "green", label: "Green" },
71
+ ],
72
+ total: 3,
73
+ skip: 0,
74
+ limit: 10,
50
75
  });
51
76
 
52
77
  // Demo-only "validation" — a real consuming app would wire this through something like
53
78
  // useZodValidation instead (see this file's top comment). Kept deliberately simple so the
54
- // Continue button has an obvious, reliable way to trigger both fields' error states for
55
- // exercising InputTextWithLabel/InputRangeDefault's error UI in Storybook.
79
+ // Continue button has an obvious, reliable way to trigger each field's error state for
80
+ // exercising InputTextWithLabel/InputRangeDefault/InputNumberDefault/InputTextareaWithLabel/
81
+ // InputSelectWithLabel's error UI in Storybook.
56
82
  const errors = reactive({
57
83
  fullName: "",
58
84
  budget: "",
85
+ quantity: "",
86
+ notes: "",
87
+ colour: "",
59
88
  });
60
89
 
61
90
  const validate = () => {
62
91
  errors.fullName = state.fullName.trim() ? "" : "Full name is required";
63
92
  errors.budget = state.budget >= 100 ? "" : "Budget must be at least £100";
93
+ errors.quantity = state.quantity >= 1 ? "" : "Quantity must be at least 1";
94
+ errors.notes = state.notes.trim() ? "" : "Notes are required";
95
+ errors.colour = state.colour ? "" : "Please choose a colour";
64
96
  };
65
97
 
66
98
  const clearErrors = () => {
67
99
  errors.fullName = "";
68
100
  errors.budget = "";
101
+ errors.quantity = "";
102
+ errors.notes = "";
103
+ errors.colour = "";
69
104
  };
70
105
 
71
- return { args, state, errors, validate, clearErrors };
106
+ return { args, state, errors, validate, clearErrors, colourOptions };
72
107
  },
73
108
  template: `
74
109
  <div style="margin: 36px; max-width: 480px;">
@@ -78,9 +113,10 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
78
113
  :text-content="[{ text: 'Migrated fields', styleClass: 'normal' }]"
79
114
  :style-class-passthrough="['mbe-20']"
80
115
  />
81
- <p style="margin: 0 0 2rem 0; color: light-dark(#475569, #94a3b8); font-size: 1.4rem;">
116
+ <p style="margin: 0 0 2rem 0; color: #475569; font-size: 1.4rem;">
82
117
  One field per 05.forms component that's fully migrated (5/5 on the Component Ledger).
83
- Click Continue with an empty name or a budget under £100 to see the error states.
118
+ Click Continue with an empty name, a budget under £100, a quantity under 1, empty notes,
119
+ or no colour chosen to see the error states.
84
120
  </p>
85
121
 
86
122
  <form novalidate @submit.prevent="validate">
@@ -114,6 +150,51 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
114
150
  </InputRangeDefault>
115
151
  </FormField>
116
152
 
153
+ <FormField width="wide" :has-gutter="false">
154
+ <InputNumberDefault
155
+ v-model="state.quantity"
156
+ name="quantity"
157
+ label="Quantity"
158
+ :min="1"
159
+ :max="10"
160
+ :error-message="errors.quantity"
161
+ :field-has-error="!!errors.quantity"
162
+ :input-variant="args.inputVariant"
163
+ >
164
+ <template #left><span aria-hidden="true">−</span></template>
165
+ <template #right><span aria-hidden="true">+</span></template>
166
+ </InputNumberDefault>
167
+ </FormField>
168
+
169
+ <FormField width="wide" :has-gutter="false">
170
+ <InputTextareaWithLabel
171
+ v-model="state.notes"
172
+ name="notes"
173
+ label="Notes"
174
+ placeholder="Anything else we should know?"
175
+ :error-message="errors.notes"
176
+ :field-has-error="!!errors.notes"
177
+ :input-variant="args.inputVariant"
178
+ />
179
+ </FormField>
180
+
181
+ <FormField width="wide" :has-gutter="false">
182
+ <InputSelectWithLabel
183
+ v-model="state.colour"
184
+ v-model:field-data="colourOptions"
185
+ name="colour"
186
+ label="Favourite colour"
187
+ placeholder="Choose a colour"
188
+ :error-message="errors.colour"
189
+ :field-has-error="!!errors.colour"
190
+ :input-variant="args.inputVariant"
191
+ />
192
+ </FormField>
193
+
194
+ <FormField width="wide" :has-gutter="false">
195
+ <ToggleSwitchWithLabel v-model="state.subscribe" name="subscribe" label="Subscribe to updates" />
196
+ </FormField>
197
+
117
198
  <FormField width="wide" :has-gutter="false">
118
199
  <div style="display: flex; gap: 1.2rem;">
119
200
  <InputButtonCore type="submit" variant="primary" button-text="Continue" />
@@ -123,10 +204,14 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
123
204
  </form>
124
205
 
125
206
  <div
126
- style="margin-top: 2rem; padding: 1.6rem; border-radius: 0.8rem; background: light-dark(#f8fafc, #1e293b); font-family: monospace; font-size: 1.3rem;"
207
+ style="margin-top: 2rem; padding: 1.6rem; border-radius: 0.8rem; background: #f8fafc; font-family: monospace; font-size: 1.3rem;"
127
208
  >
128
209
  <div>fullName: {{ state.fullName || '""' }}</div>
129
210
  <div>budget: £{{ state.budget }}</div>
211
+ <div>quantity: {{ state.quantity }}</div>
212
+ <div>notes: {{ state.notes || '""' }}</div>
213
+ <div>colour: {{ state.colour || '""' }}</div>
214
+ <div>subscribe: {{ state.subscribe }}</div>
130
215
  </div>
131
216
  </div>
132
217
  `,
@@ -23,6 +23,13 @@ token-rename pass, not a redesign.
23
23
  Geometry (`--input-toggle-*`, `--form-element-*`) is shared across every `05.forms` component and
24
24
  untouched by this — see `theming-form-geometry-tokens.md`.
25
25
 
26
+ ### Shape (`round` prop)
27
+
28
+ `--toggle-switch-border-radius` controls the track and thumb's shared `border-radius`. Left unset,
29
+ it defaults to `100vw` (pill shape) when the `round` prop is `true` (the default), or `0.4rem`
30
+ (a rounded-square shape) when `round` is `false` — see the `SquareShape` story. Set the token
31
+ directly to override with an exact value the two-state default doesn't produce.
32
+
26
33
  ### Track width
27
34
 
28
35
  `--toggle-switch-track-width` overrides the track's width directly. Left unset, the track derives
@@ -64,3 +71,25 @@ fixed width independent of symbol size).
64
71
  ```vue
65
72
  <ToggleSwitchCore id="notifications" name="notifications" style="--toggle-switch-symbol-surface-on: var(--gold-06);" />
66
73
  ```
74
+
75
+ ---
76
+
77
+ ## Migration notes (2026-09-23)
78
+
79
+ - **`round` prop was previously dead** — `ToggleSwitchWithLabel`/`ToggleSwitchWithLabelInline`
80
+ both accepted and forwarded it, but `ToggleSwitchCore` never declared the prop or read it, so
81
+ the Storybook `SquareShape` story rendered identically to `Default`. Fixed by adding `round` to
82
+ `ToggleSwitchCore` and wiring it to the new `--toggle-switch-border-radius` token (see Shape
83
+ above).
84
+ - **`fieldHasError` didn't visually theme the component** — `ToggleSwitchCore` and
85
+ `ToggleSwitchWithLabel` both computed an error-aware theme value but bound `data-theme` to the
86
+ raw `theme` prop instead, so setting `fieldHasError` never applied this library's
87
+ `[data-theme="error"]` theme-token overrides (`_error.css`). Fixed by binding `data-theme` to the
88
+ computed value.
89
+ - Custom `iconOn`/`iconOff` slot content could render as a non-square box (e.g. a 16×16 SVG
90
+ rendering as ~16×20), throwing off centering inside the thumb — fixed by adding
91
+ `line-height: 0` to `.symbol-icon`, which collapses inline leading for any slotted content.
92
+ - Two dead, unread `--_transition-duration` private locals were removed from
93
+ `ToggleSwitchWithLabel`/`ToggleSwitchWithLabelInline` — nothing in either file's CSS or
94
+ `ToggleSwitchCore`'s ever read them (the actual transition durations are hardcoded `0.4s`
95
+ literals in `ToggleSwitchCore`).
@@ -1,6 +1,6 @@
1
1
  <template>
2
- <div class="toggle-switch-core" :class="elementClasses" :data-theme="theme">
3
- <div class="toggle-switch-wrapper" :class="wrapperClasses" :for="inputId" @click="toggleSwitchValue">
2
+ <div class="toggle-switch-core" :class="elementClasses" :data-theme="formUiTheme">
3
+ <div class="toggle-switch-wrapper" :class="wrapperClasses" @click="toggleSwitchValue">
4
4
  <input
5
5
  :id="inputId"
6
6
  v-model="modelValue"
@@ -44,6 +44,7 @@ interface Props {
44
44
  falseValue?: string | number | boolean;
45
45
  styleClassPassthrough?: string | string[];
46
46
  theme?: FormUiTheme;
47
+ round?: boolean;
47
48
  ariaDescribedby?: string;
48
49
  }
49
50
 
@@ -54,17 +55,20 @@ const props = withDefaults(defineProps<Props>(), {
54
55
  falseValue: false,
55
56
  styleClassPassthrough: () => [],
56
57
  theme: "default",
58
+ round: true,
57
59
  ariaDescribedby: "",
58
60
  });
59
61
 
60
62
  const slots = useSlots();
61
63
  const useDefaultIcons = computed(() => !slots.iconOn && !slots.iconOff);
62
64
 
63
- const FormUiTheme = computed(() => {
65
+ const formUiTheme = computed(() => {
64
66
  return props.fieldHasError ? "error" : props.theme;
65
67
  });
66
68
 
67
- const modelValue = defineModel<string | number | boolean>();
69
+ const roundedRadiusDefault = computed(() => (props.round ? "100vw" : "0.4rem"));
70
+
71
+ const modelValue = defineModel<string | number | boolean>({ required: true });
68
72
  const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
69
73
 
70
74
  const inputId = computed(() => `toggle-switch-${props.id}`);
@@ -90,156 +94,153 @@ const toggleSwitchValue = () => {
90
94
 
91
95
  <style lang="css">
92
96
  @layer components {
93
- .toggle-switch-core {
94
- .toggle-switch-label {
95
- display: block;
96
- }
97
+ .toggle-switch-core {
98
+ .toggle-switch-label {
99
+ display: block;
100
+ }
97
101
 
98
- .toggle-switch-wrapper {
99
- /* --_icon-font-size, --_switch-padding, --_toggle-symbol-border-width are the only locals
102
+ .toggle-switch-wrapper {
103
+ /* --_icon-font-size, --_switch-padding, --_toggle-symbol-border-width are the only locals
100
104
  actually read anywhere below — --_icon-on-opacity, --_icon-off-opacity,
101
105
  --_symbol-background-color, --_symbol-margin-inline-start, --_symbol-checked-offset were
102
106
  declared here but never referenced by any property in this file; removed as dead code
103
107
  rather than migrated, since migrating an unread token would just create a new unread
104
108
  public one. */
105
- --_icon-font-size: 2.4rem;
106
- --_switch-padding: 0.2rem;
107
- --_toggle-symbol-border-width: 0.1rem;
109
+ --_icon-font-size: 2.4rem;
110
+ --_switch-padding: 0.2rem;
111
+ --_toggle-symbol-border-width: 0.1rem;
108
112
 
109
- /* Public --toggle-switch-* tokens, inline-fallback to the shared --theme-* tokens (see
113
+ /* Public --toggle-switch-* tokens, inline-fallback to the shared --theme-* tokens (see
110
114
  theming-component-token-pattern.md) — overriding one here doesn't touch every other
111
115
  themed input/control that also reads --theme-checkbox-symbol-surface/--theme-border. */
112
- --_surface: var(--toggle-switch-surface, var(--theme-checkbox-symbol-surface));
113
- --_surface-hover: var(--toggle-switch-surface-hover, var(--theme-surface-subtle));
114
- --_border: var(--toggle-switch-border, var(--theme-border));
115
- --_border-focus: var(--toggle-switch-border-focus, var(--theme-border-focus));
116
-
117
- display: flex;
118
- flex-direction: column;
119
-
120
- input {
121
- height: 0;
122
- width: 0;
123
- opacity: 0;
124
-
125
- &:focus-visible {
126
- + .symbol-wrapper {
127
- outline: var(--form-element-outline-width-focus) solid var(--_border-focus);
128
- outline-offset: var(--form-element-outline-offset-focus);
116
+ --_surface: var(--toggle-switch-surface, var(--theme-checkbox-symbol-surface));
117
+ --_surface-hover: var(--toggle-switch-surface-hover, var(--theme-surface-subtle));
118
+ --_border: var(--toggle-switch-border, var(--theme-border));
119
+ --_border-focus: var(--toggle-switch-border-focus, var(--theme-border-focus));
120
+ --_border-radius: var(--toggle-switch-border-radius, v-bind(roundedRadiusDefault));
121
+
122
+ display: flex;
123
+ flex-direction: column;
124
+
125
+ input {
126
+ height: 0;
127
+ width: 0;
128
+ opacity: 0;
129
+
130
+ &:focus-visible {
131
+ + .symbol-wrapper {
132
+ outline: var(--form-element-outline-width-focus) solid var(--_border-focus);
133
+ outline-offset: var(--form-element-outline-offset-focus);
134
+ }
129
135
  }
130
136
  }
131
- }
132
137
 
133
- .symbol-wrapper {
134
- /* Geometry */
138
+ .symbol-wrapper {
139
+ /* Geometry */
135
140
 
136
- /* Track width has no direct public token — it's derived from the same geometry tokens
141
+ /* Track width has no direct public token — it's derived from the same geometry tokens
137
142
  that size the thumb, so the track always scales with --input-toggle-symbol-size instead
138
143
  of clipping/floating it. Breakdown at the default 0.1rem tokens (= 72px total):
139
144
  2×symbol-size (6rem) + 2×form-element-border-width (0.2rem, wrapper border) +
140
145
  2×wrapper-padding (0.2rem) + 3×symbol-outline-width (0.3rem) +
141
146
  3×symbol-border-width (0.3rem) + 2×symbol-margin (0.2rem). Override
142
147
  --toggle-switch-track-width directly if you need a value the formula doesn't produce. */
143
- --_track-width: var(
144
- --toggle-switch-track-width,
145
- calc(
146
- (2 * var(--input-toggle-symbol-size)) + (2 * var(--form-element-border-width)) +
147
- (2 * var(--input-toggle-wrapper-padding)) + (3 * var(--input-toggle-symbol-outline-width)) +
148
- (3 * var(--input-toggle-symbol-border-width)) + (2 * var(--input-toggle-symbol-margin))
149
- )
150
- );
151
-
152
- display: inline-flex;
153
- align-items: center;
154
- justify-content: start;
155
- position: relative;
156
- transition:
157
- background-color var(--theme-form-transition-duration) linear,
158
- border-color var(--theme-form-transition-duration) linear,
159
- outline var(--theme-form-transition-duration) linear,
160
- outline-offset var(--theme-form-transition-duration) linear,
161
- outline var(--theme-form-transition-duration) linear;
162
-
163
- /* UI */
164
- background-color: var(--_surface);
165
- border: var(--form-element-border-width) solid var(--_border);
166
- outline: var(--form-element-outline-width) solid transparent;
167
- border-radius: 100vw;
168
- width: var(--_track-width);
169
- padding: calc(var(--input-toggle-wrapper-padding) + var(--input-toggle-symbol-outline-width));
170
-
171
- &:hover {
172
- background-color: var(--_surface-hover);
173
- cursor: pointer;
174
- }
175
-
176
- .symbol {
177
- /* Geometry */
178
-
179
- display: inline-grid;
180
- grid-template-areas: "icon";
181
- place-content: center;
182
- /* position: absolute; */
183
- overflow: hidden;
184
- translate: 0 0;
185
-
186
- aspect-ratio: 1/1;
148
+ --_track-width: var(
149
+ --toggle-switch-track-width,
150
+ calc(
151
+ (2 * var(--input-toggle-symbol-size)) + (2 * var(--form-element-border-width)) +
152
+ (2 * var(--input-toggle-wrapper-padding)) + (3 * var(--input-toggle-symbol-outline-width)) +
153
+ (3 * var(--input-toggle-symbol-border-width)) + (2 * var(--input-toggle-symbol-margin))
154
+ )
155
+ );
156
+
157
+ display: inline-flex;
158
+ align-items: center;
159
+ justify-content: start;
160
+ position: relative;
161
+ transition:
162
+ background-color var(--theme-form-transition-duration) linear,
163
+ border-color var(--theme-form-transition-duration) linear,
164
+ outline var(--theme-form-transition-duration) linear,
165
+ outline-offset var(--theme-form-transition-duration) linear,
166
+ outline var(--theme-form-transition-duration) linear;
187
167
 
188
168
  /* UI */
189
- /* padding: calc(calc(var(--input-toggle-symbol-size) - var(--_icon-font-size)) / 2); */
190
- border: var(--input-toggle-symbol-border-width) solid var(--toggle-switch-symbol-border, var(--theme-text));
191
- outline: var(--input-toggle-symbol-outline-width) solid transparent;
192
- border-radius: 100vw;
193
- background-color: var(--toggle-switch-symbol-surface-off, var(--theme-on-surface));
194
- transition:
195
- translate 0.4s ease,
196
- background-color 0.4s linear;
197
-
198
- &.checked {
199
- background-color: var(--toggle-switch-symbol-surface-on, var(--theme-surface));
200
- /* left: calc(
201
- 100% - var(--input-toggle-symbol-size) - var(--_switch-padding) - (2 * var(--_toggle-symbol-border-width))
202
- ); */
203
- /* translate: calc(var(--input-toggle-symbol-size) / 2) 0; */
204
- /* translate: 1.6rem 0; */
205
- translate: calc(
206
- var(--input-toggle-symbol-size) + var(--input-toggle-symbol-border-width) +
207
- var(--input-toggle-symbol-outline-width)
208
- )
209
- 0;
169
+ background-color: var(--_surface);
170
+ border: var(--form-element-border-width) solid var(--_border);
171
+ outline: var(--form-element-outline-width) solid transparent;
172
+ border-radius: var(--_border-radius);
173
+ width: var(--_track-width);
174
+ padding: calc(var(--input-toggle-wrapper-padding) + var(--input-toggle-symbol-outline-width));
175
+
176
+ &:hover {
177
+ background-color: var(--_surface-hover);
178
+ cursor: pointer;
210
179
  }
211
180
 
212
- .symbol-icon {
181
+ .symbol {
213
182
  /* Geometry */
214
183
 
215
- display: grid;
216
- grid-area: icon;
184
+ display: inline-grid;
185
+ grid-template-areas: "icon";
217
186
  place-content: center;
218
- opacity: 0;
187
+ overflow: hidden;
188
+ translate: 0 0;
189
+
190
+ aspect-ratio: 1/1;
219
191
 
220
192
  /* UI */
221
- width: var(--input-toggle-symbol-size);
222
- height: var(--input-toggle-symbol-size);
223
- margin: var(--input-toggle-symbol-margin);
193
+ /* padding: calc(calc(var(--input-toggle-symbol-size) - var(--_icon-font-size)) / 2); */
194
+ border: var(--input-toggle-symbol-border-width) solid var(--toggle-switch-symbol-border, var(--theme-text));
195
+ outline: var(--input-toggle-symbol-outline-width) solid transparent;
196
+ border-radius: var(--_border-radius);
197
+ background-color: var(--toggle-switch-symbol-surface-off, var(--theme-on-surface));
224
198
  transition:
225
- opacity var(--theme-form-transition-duration),
226
- color var(--theme-form-transition-duration);
227
-
228
- &.icon-on {
229
- color: var(--toggle-switch-icon-on-color, var(--theme-on-surface));
230
- }
231
-
232
- &.icon-off {
233
- color: var(--toggle-switch-icon-off-color, var(--theme-surface));
199
+ translate 0.4s ease,
200
+ background-color 0.4s linear;
201
+
202
+ &.checked {
203
+ background-color: var(--toggle-switch-symbol-surface-on, var(--theme-surface));
204
+ translate: calc(
205
+ var(--input-toggle-symbol-size) + var(--input-toggle-symbol-border-width) +
206
+ var(--input-toggle-symbol-outline-width)
207
+ )
208
+ 0;
234
209
  }
235
210
 
236
- &.active {
237
- opacity: 1;
211
+ .symbol-icon {
212
+ /* Geometry */
213
+
214
+ display: grid;
215
+ grid-area: icon;
216
+ place-content: center;
217
+ /* Removes line-box leading so slotted icon content sizes to its own intrinsic height. */
218
+ line-height: 0;
219
+ opacity: 0;
220
+
221
+ /* UI */
222
+ width: var(--input-toggle-symbol-size);
223
+ height: var(--input-toggle-symbol-size);
224
+ margin: var(--input-toggle-symbol-margin);
225
+ transition:
226
+ opacity var(--theme-form-transition-duration),
227
+ color var(--theme-form-transition-duration);
228
+
229
+ &.icon-on {
230
+ color: var(--toggle-switch-icon-on-color, var(--theme-on-surface));
231
+ }
232
+
233
+ &.icon-off {
234
+ color: var(--toggle-switch-icon-off-color, var(--theme-surface));
235
+ }
236
+
237
+ &.active {
238
+ opacity: 1;
239
+ }
238
240
  }
239
241
  }
240
242
  }
241
243
  }
242
244
  }
243
245
  }
244
- }
245
246
  </style>
@@ -75,7 +75,7 @@ export default {
75
75
  // Styling
76
76
  theme: {
77
77
  control: { type: "select" },
78
- options: ["primary", "secondary", "tertiary", "ghost", "error", "success", "warning"],
78
+ options: ["default", "success", "error", "warning"],
79
79
  description: "Toggle switch theme",
80
80
  table: {
81
81
  category: "Styling",