srcdev-nuxt-components 9.6.9 → 9.6.11

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 (109) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +4 -2
  2. package/.claude/skills/component-compliance-checklist.md +24 -2
  3. package/.claude/skills/component-inline-action-button.md +5 -4
  4. package/.claude/skills/component-naming.md +8 -2
  5. package/.claude/skills/components/{alert-mask-core.md → alert-mask.md} +38 -23
  6. package/.claude/skills/components/alert-masked-content.md +3 -3
  7. package/.claude/skills/components/container-glow.md +1 -1
  8. package/.claude/skills/components/display-theme-switch.md +8 -8
  9. package/.claude/skills/components/input-button.md +26 -7
  10. package/.claude/skills/components/input-checkbox-radio.md +2 -0
  11. package/.claude/skills/components/input-description.md +5 -0
  12. package/.claude/skills/components/input-error.md +9 -2
  13. package/.claude/skills/components/input-label.md +8 -2
  14. package/.claude/skills/components/input-number.md +2 -0
  15. package/.claude/skills/components/input-otp.md +2 -0
  16. package/.claude/skills/components/input-range.md +2 -0
  17. package/.claude/skills/components/input-select.md +2 -0
  18. package/.claude/skills/components/input-text.md +2 -0
  19. package/.claude/skills/components/input-textarea.md +2 -0
  20. package/.claude/skills/components/pending-effect.md +5 -4
  21. package/.claude/skills/components/{triple-toggle-switch.md → segmented-control.md} +34 -19
  22. package/.claude/skills/components/services-card.md +1 -1
  23. package/.claude/skills/components/tabbed-content.md +1 -1
  24. package/.claude/skills/components/toggle-switch.md +2 -0
  25. package/.claude/skills/css-nesting-conventions.md +22 -1
  26. package/.claude/skills/index.md +5 -5
  27. package/.claude/skills/storybook-add-story.md +3 -1
  28. package/.claude/skills/theming-component-token-pattern.md +24 -0
  29. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  30. package/.claude/skills/theming-partial-override.md +1 -1
  31. package/.vscode/srcdev-component-alert-mask.code-snippets +41 -0
  32. package/.vscode/{srcdev-component-triple-toggle-switch.code-snippets → srcdev-component-segmented-control.code-snippets} +6 -7
  33. package/app/assets/styles/setup/03.theming/_theme-slots.css +1 -1
  34. package/app/assets/styles/setup/04.elements/forms/03.generic-input-geometry.css +4 -0
  35. package/app/components/01.atoms/alert-mask/AlertMask.vue +194 -0
  36. package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +57 -42
  37. package/app/components/01.atoms/alert-mask/stories/AlertMask.stories.ts +198 -0
  38. package/app/components/01.atoms/alert-mask/tests/AlertMask.spec.ts +233 -0
  39. package/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue +2 -2
  40. package/app/components/02.molecules/alert-masked-content/CONSUMER-STYLING.md +4 -4
  41. package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +60 -0
  42. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +12 -12
  43. package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +3 -3
  44. package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +1 -1
  45. package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +1 -1
  46. package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +9 -9
  47. package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +16 -16
  48. package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +53 -1
  49. package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +8 -8
  50. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +4 -4
  51. package/app/components/02.molecules/input-copy/InputCopy.vue +2 -2
  52. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +2 -2
  53. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +4 -4
  54. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +2 -2
  55. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +2 -2
  56. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +6 -6
  57. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +8 -0
  58. package/app/components/05.forms/form-errors/InputError.vue +43 -37
  59. package/app/components/05.forms/form-errors/stories/InputError.stories.ts +79 -1
  60. package/app/components/05.forms/form-errors/tests/InputError.spec.ts +29 -1
  61. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +80 -47
  62. package/app/components/05.forms/input-button/InputButton.vue +52 -100
  63. package/app/components/05.forms/input-button/playwright/input-button.playwright.ts +1 -1
  64. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +145 -8
  65. package/app/components/05.forms/input-button/tests/InputButton.spec.ts +76 -46
  66. package/app/components/05.forms/input-button/tests/__snapshots__/InputButton.spec.ts.snap +17 -17
  67. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +9 -1
  68. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +8 -0
  69. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +2 -2
  70. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +14 -2
  71. package/app/components/05.forms/input-description/InputDescription.vue +2 -1
  72. package/app/components/05.forms/input-description/stories/InputDescription.stories.ts +70 -4
  73. package/app/components/05.forms/input-description/tests/InputDescription.spec.ts +2 -1
  74. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +10 -2
  75. package/app/components/05.forms/input-label/InputLabel.vue +1 -0
  76. package/app/components/05.forms/input-label/stories/InputLabel.stories.ts +68 -2
  77. package/app/components/05.forms/input-label/tests/InputLabel.spec.ts +13 -3
  78. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +7 -1
  79. package/app/components/05.forms/input-number/InputNumber.vue +4 -5
  80. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +7 -1
  81. package/app/components/05.forms/input-otp/InputOtp.vue +2 -2
  82. package/app/components/05.forms/input-range/InputRange.vue +1 -1
  83. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +7 -1
  84. package/app/components/05.forms/input-select/InputSelect.vue +14 -5
  85. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +7 -1
  86. package/app/components/05.forms/input-text/InputText.vue +4 -4
  87. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +7 -1
  88. package/app/components/05.forms/input-textarea/InputTextarea.vue +2 -3
  89. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +4 -4
  90. package/app/components/05.forms/pending-effect/CONSUMER-STYLING.md +7 -3
  91. package/app/components/05.forms/pending-effect/PendingEffect.vue +3 -3
  92. package/app/components/05.forms/pending-effect/tests/PendingEffect.spec.ts +1 -1
  93. package/app/components/05.forms/segmented-control/CONSUMER-STYLING.md +125 -0
  94. package/app/components/05.forms/segmented-control/SegmentedControl.vue +231 -0
  95. package/app/components/05.forms/segmented-control/stories/SegmentedControl.stories.ts +215 -0
  96. package/app/components/05.forms/segmented-control/tests/SegmentedControl.spec.ts +202 -0
  97. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +7 -0
  98. package/app/components/05.forms/toggle-switch/ToggleSwitch.vue +2 -0
  99. package/app/types/components/index.ts +1 -1
  100. package/package.json +1 -1
  101. package/.vscode/srcdev-component-alert-mask-core.code-snippets +0 -31
  102. package/app/components/01.atoms/alert-mask/AlertMaskCore.vue +0 -166
  103. package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +0 -105
  104. package/app/components/01.atoms/alert-mask/tests/AlertMaskCore.spec.ts +0 -342
  105. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +0 -99
  106. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +0 -251
  107. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitch.stories.ts +0 -104
  108. package/app/components/05.forms/triple-toggle-switch/tests/TripleToggleSwitch.spec.ts +0 -138
  109. /package/app/types/components/{alert-mask-core.d.ts → alert-mask.d.ts} +0 -0
@@ -9,7 +9,6 @@ interface InputButtonInstance {
9
9
  hasLeftSlot: () => boolean;
10
10
  hasRightSlot: () => boolean;
11
11
  hasIconOnlySlot: () => boolean;
12
- buttonClasses: () => unknown[];
13
12
  }
14
13
 
15
14
  // --- Helpers ---
@@ -92,12 +91,12 @@ describe("InputButton", () => {
92
91
 
93
92
  it("renders button text", async () => {
94
93
  wrapper = await createWrapper({ buttonText: "Hello" });
95
- expect(wrapper.find(".button-text").text()).toBe("Hello");
94
+ expect(wrapper.find(".input-button-text").text()).toBe("Hello");
96
95
  });
97
96
 
98
97
  it("handles empty buttonText gracefully", async () => {
99
98
  wrapper = await createWrapper({ buttonText: "" });
100
- const buttonText = wrapper.find(".button-text");
99
+ const buttonText = wrapper.find(".input-button-text");
101
100
  expect(buttonText.exists()).toBe(true);
102
101
  expect(buttonText.text()).toBe("");
103
102
  });
@@ -107,31 +106,45 @@ describe("InputButton", () => {
107
106
  // Props
108
107
  // -------------------------
109
108
  describe("Props", () => {
110
- it("applies variant class", async () => {
111
- for (const variant of ["primary", "secondary", "tertiary"] as const) {
109
+ it("reflects variant as data-variant", async () => {
110
+ for (const variant of ["primary", "secondary", "tertiary", "inline"] as const) {
112
111
  const w = await mountSuspended(InputButton, {
113
112
  props: { buttonText: "Test", variant },
114
113
  });
115
- expect(w.find("button").classes()).toContain(variant);
114
+ expect(w.find("button").attributes("data-variant")).toBe(variant);
115
+ expect(w.find("button").classes()).not.toContain(variant);
116
116
  w.unmount();
117
117
  }
118
118
  });
119
119
 
120
- it("applies pill class when isPill is true", async () => {
120
+ it("sets data-pill when isPill is true", async () => {
121
121
  wrapper = await createWrapper({ isPill: true });
122
- expect(wrapper.find("button").classes()).toContain("pill");
122
+ expect(wrapper.find("button").attributes("data-pill")).toBe("");
123
123
  });
124
124
 
125
- it("does not apply pill class by default", async () => {
125
+ it("does not set data-pill by default", async () => {
126
126
  wrapper = await createWrapper();
127
- expect(wrapper.find("button").classes()).not.toContain("pill");
127
+ expect(wrapper.find("button").attributes("data-pill")).toBeUndefined();
128
128
  });
129
129
 
130
- it("applies is-pending and pending-effect classes", async () => {
130
+ it("sets data-pending and data-pending-effect", async () => {
131
131
  wrapper = await createWrapper({ isPending: true, hasPendingEffect: true });
132
+ const button = wrapper.find("button");
133
+ expect(button.attributes("data-pending")).toBe("");
134
+ expect(button.attributes("data-pending-effect")).toBe("");
135
+ });
136
+
137
+ it("does not use collision-prone bare state classes", async () => {
138
+ wrapper = await createWrapper(
139
+ { isPill: true, isPending: true, hasPendingEffect: true, variant: "secondary" },
140
+ { iconOnly: "<span>⚡</span>" }
141
+ );
132
142
  const classes = wrapper.find("button").classes();
133
- expect(classes).toContain("is-pending");
134
- expect(classes).toContain("pending-effect");
143
+ for (const name of ["secondary", "pill", "is-pending", "pending-effect", "icon-only"]) {
144
+ expect(classes).not.toContain(name);
145
+ }
146
+ expect(wrapper.find(".btn-icon").exists()).toBe(false);
147
+ expect(wrapper.find(".button-text").exists()).toBe(false);
135
148
  });
136
149
 
137
150
  it("applies correct type attribute", async () => {
@@ -168,9 +181,9 @@ describe("InputButton", () => {
168
181
  // Accessibility
169
182
  // -------------------------
170
183
  describe("Accessibility", () => {
171
- it("sets aria-disabled to false by default", async () => {
184
+ it("omits aria-disabled when not readonly", async () => {
172
185
  wrapper = await createWrapper();
173
- expect(wrapper.find("button").attributes("aria-disabled")).toBe("false");
186
+ expect(wrapper.find("button").attributes("aria-disabled")).toBeUndefined();
174
187
  });
175
188
 
176
189
  it("sets aria-disabled and readonly attr when readonly", async () => {
@@ -182,12 +195,12 @@ describe("InputButton", () => {
182
195
 
183
196
  it("keeps button text visible (not sr-only) by default", async () => {
184
197
  wrapper = await createWrapper();
185
- expect(wrapper.find(".button-text").classes()).not.toContain("sr-only");
198
+ expect(wrapper.find(".input-button-text").classes()).not.toContain("sr-only");
186
199
  });
187
200
 
188
201
  it("makes button text sr-only with iconOnly slot, but keeps it in DOM for screen readers", async () => {
189
202
  wrapper = await createWrapper({ buttonText: "Save document" }, { iconOnly: "<span>💾</span>" });
190
- const buttonText = wrapper.find(".button-text");
203
+ const buttonText = wrapper.find(".input-button-text");
191
204
  expect(buttonText.text()).toBe("Save document");
192
205
  expect(buttonText.classes()).toContain("sr-only");
193
206
  });
@@ -199,20 +212,20 @@ describe("InputButton", () => {
199
212
  describe("Slots", () => {
200
213
  it("renders left slot", async () => {
201
214
  wrapper = await createWrapper({}, { left: '<span data-testid="left-icon">←</span>' });
202
- expect(wrapper.find(".btn-icon.left").exists()).toBe(true);
215
+ expect(wrapper.find('.input-button-icon[data-slot="left"]').exists()).toBe(true);
203
216
  expect(wrapper.find('[data-testid="left-icon"]').exists()).toBe(true);
204
217
  });
205
218
 
206
219
  it("renders right slot", async () => {
207
220
  wrapper = await createWrapper({}, { right: '<span data-testid="right-icon">→</span>' });
208
- expect(wrapper.find(".btn-icon.right").exists()).toBe(true);
221
+ expect(wrapper.find('.input-button-icon[data-slot="right"]').exists()).toBe(true);
209
222
  expect(wrapper.find('[data-testid="right-icon"]').exists()).toBe(true);
210
223
  });
211
224
 
212
- it("renders iconOnly slot and adds icon-only class to button", async () => {
225
+ it("renders iconOnly slot and sets data-icon-only on the button", async () => {
213
226
  wrapper = await createWrapper({}, { iconOnly: '<span data-testid="icon-only">⚡</span>' });
214
- expect(wrapper.find(".btn-icon.icon-only").exists()).toBe(true);
215
- expect(wrapper.find("button").classes()).toContain("icon-only");
227
+ expect(wrapper.find('.input-button-icon[data-slot="icon-only"]').exists()).toBe(true);
228
+ expect(wrapper.find("button").attributes("data-icon-only")).toBe("");
216
229
  });
217
230
 
218
231
  it("iconOnly suppresses left and right slots", async () => {
@@ -224,9 +237,9 @@ describe("InputButton", () => {
224
237
  iconOnly: '<span data-testid="icon">⚡</span>',
225
238
  }
226
239
  );
227
- expect(wrapper.find(".btn-icon.left").exists()).toBe(false);
228
- expect(wrapper.find(".btn-icon.right").exists()).toBe(false);
229
- expect(wrapper.find(".btn-icon.icon-only").exists()).toBe(true);
240
+ expect(wrapper.find('.input-button-icon[data-slot="left"]').exists()).toBe(false);
241
+ expect(wrapper.find('.input-button-icon[data-slot="right"]').exists()).toBe(false);
242
+ expect(wrapper.find('.input-button-icon[data-slot="icon-only"]').exists()).toBe(true);
230
243
  });
231
244
 
232
245
  it("renders left and right slots together when no iconOnly", async () => {
@@ -237,9 +250,9 @@ describe("InputButton", () => {
237
250
  right: '<span data-testid="right">→</span>',
238
251
  }
239
252
  );
240
- expect(wrapper.find(".btn-icon.left").exists()).toBe(true);
241
- expect(wrapper.find(".btn-icon.right").exists()).toBe(true);
242
- expect(wrapper.find(".btn-icon.icon-only").exists()).toBe(false);
253
+ expect(wrapper.find('.input-button-icon[data-slot="left"]').exists()).toBe(true);
254
+ expect(wrapper.find('.input-button-icon[data-slot="right"]').exists()).toBe(true);
255
+ expect(wrapper.find('.input-button-icon[data-slot="icon-only"]').exists()).toBe(false);
243
256
  });
244
257
  });
245
258
 
@@ -265,9 +278,9 @@ describe("InputButton", () => {
265
278
  expect(link.attributes("href")).toBe("/api/auth/github");
266
279
  });
267
280
 
268
- it("applies the is-link class for a link", async () => {
281
+ it("sets data-link for a link", async () => {
269
282
  wrapper = await createWrapper({ href: "/api/auth/github", external: true });
270
- expect(wrapper.find("a").classes()).toContain("is-link");
283
+ expect(wrapper.find("a").attributes("data-link")).toBe("");
271
284
  });
272
285
  });
273
286
 
@@ -297,8 +310,19 @@ describe("InputButton", () => {
297
310
  });
298
311
  });
299
312
 
313
+ const looseMounts: { unmount: () => void }[] = [];
314
+ const track = <T extends { unmount: () => void }>(mounted: T) => {
315
+ looseMounts.push(mounted);
316
+ return mounted;
317
+ };
318
+
319
+ afterEach(() => {
320
+ looseMounts.splice(0).forEach((mounted) => mounted.unmount());
321
+ vi.restoreAllMocks();
322
+ });
323
+
300
324
  describe("InputButton slot toggling", () => {
301
- it("adds and removes the icon-only class when the iconOnly slot changes after mount", async () => {
325
+ it("adds and removes data-icon-only when the iconOnly slot changes after mount", async () => {
302
326
  const Host = defineComponent({
303
327
  components: { InputButton },
304
328
  setup() {
@@ -310,23 +334,25 @@ describe("InputButton slot toggling", () => {
310
334
  </InputButton>
311
335
  `,
312
336
  });
313
- const wrapper = await mountSuspended(Host);
337
+ const wrapper = track(await mountSuspended(Host));
314
338
  const button = () => wrapper.find(".input-button");
315
- expect(button().classes()).not.toContain("icon-only");
339
+ expect(button().attributes("data-icon-only")).toBeUndefined();
316
340
  (wrapper.vm as unknown as { iconOnly: boolean }).iconOnly = true;
317
341
  await nextTick();
318
- expect(button().classes()).toContain("icon-only");
319
- expect(wrapper.find(".button-text").classes()).toContain("sr-only");
342
+ expect(button().attributes("data-icon-only")).toBe("");
343
+ expect(wrapper.find(".input-button-text").classes()).toContain("sr-only");
320
344
  });
321
345
  });
322
346
 
323
347
  describe("InputButton readonly and reactivity", () => {
324
348
  it("does not fire a consumer click handler while readonly (keyboard activation included)", async () => {
325
349
  const onClick = vi.fn();
326
- const wrapper = await mountSuspended(InputButton, {
327
- props: { buttonText: "Save", readonly: true },
328
- attrs: { onClick },
329
- });
350
+ const wrapper = track(
351
+ await mountSuspended(InputButton, {
352
+ props: { buttonText: "Save", readonly: true },
353
+ attrs: { onClick },
354
+ })
355
+ );
330
356
  await wrapper.trigger("click");
331
357
  expect(onClick).not.toHaveBeenCalled();
332
358
  expect(wrapper.attributes("aria-disabled")).toBe("true");
@@ -334,18 +360,22 @@ describe("InputButton readonly and reactivity", () => {
334
360
 
335
361
  it("fires the click handler when not readonly", async () => {
336
362
  const onClick = vi.fn();
337
- const wrapper = await mountSuspended(InputButton, {
338
- props: { buttonText: "Save" },
339
- attrs: { onClick },
340
- });
363
+ const wrapper = track(
364
+ await mountSuspended(InputButton, {
365
+ props: { buttonText: "Save" },
366
+ attrs: { onClick },
367
+ })
368
+ );
341
369
  await wrapper.trigger("click");
342
370
  expect(onClick).toHaveBeenCalledTimes(1);
343
371
  });
344
372
 
345
373
  it("updates classes when styleClassPassthrough changes after mount", async () => {
346
- const wrapper = await mountSuspended(InputButton, {
347
- props: { buttonText: "Save", styleClassPassthrough: ["original"] },
348
- });
374
+ const wrapper = track(
375
+ await mountSuspended(InputButton, {
376
+ props: { buttonText: "Save", styleClassPassthrough: ["original"] },
377
+ })
378
+ );
349
379
  await wrapper.setProps({ styleClassPassthrough: ["updated"] });
350
380
  expect(wrapper.classes()).not.toContain("original");
351
381
  expect(wrapper.classes()).toContain("updated");
@@ -1,72 +1,72 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
3
  exports[`InputButton > Snapshots > icon-only slot 1`] = `
4
- "<button type="button" aria-disabled="false" data-testid="input-button" data-theme="default" class="input-button primary icon-only">
4
+ "<button type="button" data-testid="input-button" data-theme="default" data-variant="primary" data-icon-only="" class="input-button">
5
5
  <!--v-if-->
6
- <!--v-if--><span class="button-text sr-only">Click me</span>
7
- <!--v-if--><span class="btn-icon icon-only"><span data-testid="icon-only">⚡</span></span>
6
+ <!--v-if--><span class="input-button-text sr-only">Click me</span>
7
+ <!--v-if--><span class="input-button-icon" data-slot="icon-only"><span data-testid="icon-only">⚡</span></span>
8
8
  </button>"
9
9
  `;
10
10
 
11
11
  exports[`InputButton > Snapshots > pending state 1`] = `
12
- "<button type="button" aria-disabled="false" data-testid="input-button" data-theme="default" class="input-button primary pending-effect is-pending"><svg class="pending-effect-container" data-theme="default" aria-hidden="true" focusable="false">
12
+ "<button type="button" data-testid="input-button" data-theme="default" data-variant="primary" data-pending="" data-pending-effect="" class="input-button"><svg class="pending-effect-container" data-theme="default" aria-hidden="true" focusable="false">
13
13
  <rect pathLength="100" stroke-linecap="round" class="pending-effect-blur"></rect>
14
14
  <rect pathLength="100" stroke-linecap="round" class="pending-effect-line"></rect>
15
15
  </svg>
16
- <!--v-if--><span class="button-text">Click me</span>
16
+ <!--v-if--><span class="input-button-text">Click me</span>
17
17
  <!--v-if-->
18
18
  <!--v-if-->
19
19
  </button>"
20
20
  `;
21
21
 
22
22
  exports[`InputButton > Snapshots > pill 1`] = `
23
- "<button type="button" aria-disabled="false" data-testid="input-button" data-theme="default" class="input-button primary pill">
23
+ "<button type="button" data-testid="input-button" data-theme="default" data-variant="primary" data-pill="" class="input-button">
24
24
  <!--v-if-->
25
- <!--v-if--><span class="button-text">Click me</span>
25
+ <!--v-if--><span class="input-button-text">Click me</span>
26
26
  <!--v-if-->
27
27
  <!--v-if-->
28
28
  </button>"
29
29
  `;
30
30
 
31
31
  exports[`InputButton > Snapshots > primary (default) 1`] = `
32
- "<button type="button" aria-disabled="false" data-testid="input-button" data-theme="default" class="input-button primary">
32
+ "<button type="button" data-testid="input-button" data-theme="default" data-variant="primary" class="input-button">
33
33
  <!--v-if-->
34
- <!--v-if--><span class="button-text">Click me</span>
34
+ <!--v-if--><span class="input-button-text">Click me</span>
35
35
  <!--v-if-->
36
36
  <!--v-if-->
37
37
  </button>"
38
38
  `;
39
39
 
40
40
  exports[`InputButton > Snapshots > readonly 1`] = `
41
- "<button type="button" readonly="" aria-disabled="true" data-testid="input-button" data-theme="default" class="input-button primary">
41
+ "<button type="button" readonly="" aria-disabled="true" data-testid="input-button" data-theme="default" data-variant="primary" class="input-button">
42
42
  <!--v-if-->
43
- <!--v-if--><span class="button-text">Click me</span>
43
+ <!--v-if--><span class="input-button-text">Click me</span>
44
44
  <!--v-if-->
45
45
  <!--v-if-->
46
46
  </button>"
47
47
  `;
48
48
 
49
49
  exports[`InputButton > Snapshots > secondary variant 1`] = `
50
- "<button type="button" aria-disabled="false" data-testid="input-button" data-theme="default" class="input-button secondary">
50
+ "<button type="button" data-testid="input-button" data-theme="default" data-variant="secondary" class="input-button">
51
51
  <!--v-if-->
52
- <!--v-if--><span class="button-text">Click me</span>
52
+ <!--v-if--><span class="input-button-text">Click me</span>
53
53
  <!--v-if-->
54
54
  <!--v-if-->
55
55
  </button>"
56
56
  `;
57
57
 
58
58
  exports[`InputButton > Snapshots > tertiary variant 1`] = `
59
- "<button type="button" aria-disabled="false" data-testid="input-button" data-theme="default" class="input-button tertiary">
59
+ "<button type="button" data-testid="input-button" data-theme="default" data-variant="tertiary" class="input-button">
60
60
  <!--v-if-->
61
- <!--v-if--><span class="button-text">Click me</span>
61
+ <!--v-if--><span class="input-button-text">Click me</span>
62
62
  <!--v-if-->
63
63
  <!--v-if-->
64
64
  </button>"
65
65
  `;
66
66
 
67
67
  exports[`InputButton > Snapshots > with left and right slots 1`] = `
68
- "<button type="button" aria-disabled="false" data-testid="input-button" data-theme="default" class="input-button primary">
69
- <!--v-if--><span class="btn-icon left"><span data-testid="left-icon">←</span></span><span class="button-text">Click me</span><span class="btn-icon right"><span data-testid="right-icon">→</span></span>
68
+ "<button type="button" data-testid="input-button" data-theme="default" data-variant="primary" class="input-button">
69
+ <!--v-if--><span class="input-button-icon" data-slot="left"><span data-testid="left-icon">←</span></span><span class="input-button-text">Click me</span><span class="input-button-icon" data-slot="right"><span data-testid="right-icon">→</span></span>
70
70
  <!--v-if-->
71
71
  </button>"
72
72
  `;
@@ -25,6 +25,8 @@ declared in `04.elements/forms/03.generic-input-geometry.css`; see `theming-form
25
25
  |---|---|---|
26
26
  | `--input-checkbox-surface` | `var(--theme-checkbox-symbol-surface)` | Background |
27
27
  | `--input-checkbox-border` | `var(--theme-border)` | Border colour |
28
+ | `--input-checkbox-border-hover` | the resolved border | 1px outline colour on hover |
29
+ | `--input-checkbox-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (not inside the button style). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
28
30
  | `--input-checkbox-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible` (not in button mode, the button draws its own) |
29
31
  | `--input-checkbox-icon-color` | `var(--theme-text)` | Checked icon colour |
30
32
  | `--input-checkbox-transition-duration` | `var(--theme-form-transition-duration)` (`0.2s`) | Border/background and icon fade transitions, all three components |
@@ -50,7 +52,8 @@ Also reads `--input-checked-icon-gap` (control to label gap) and `--input-min-he
50
52
  | `--input-checkbox-button-surface` | `var(--theme-input-surface)` | Background (resting) |
51
53
  | `--input-checkbox-button-border` | `var(--theme-border)` | Border colour |
52
54
  | `--input-checkbox-button-surface-hover` | `var(--theme-input-surface-hover)` | Background on hover |
53
- | `--input-checkbox-button-ring-hover` | `var(--theme-ring)` | Outline colour on hover |
55
+ | `--input-checkbox-button-ring-hover` | `var(--input-checkbox-button-border)` | 1px outline colour on hover, no offset |
56
+ | `--input-checkbox-button-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border. Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
54
57
  | `--input-checkbox-button-surface-focus` | `var(--theme-surface-subtle)` | Background when the inner control has `:focus-visible` |
55
58
  | `--input-checkbox-button-ring-focus` | `var(--theme-border-focus)` | Outline colour when the inner control has `:focus-visible` |
56
59
  | `--input-checkbox-button-label-color` | `var(--theme-text)` | Option label colour |
@@ -90,6 +93,11 @@ transitions) and `--_icon-size` (the button shrinks the inner control's checked
90
93
 
91
94
  ---
92
95
 
96
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
97
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
98
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
99
+ > unchanged and sits just outside the ring.
100
+
93
101
  ## State hooks
94
102
 
95
103
  | Attribute | On | When |
@@ -112,6 +112,14 @@ watch(
112
112
  border-radius: 100vw;
113
113
  }
114
114
 
115
+ &:not([data-button]) {
116
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--input-checkbox-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
117
+
118
+ &:has(input:hover) {
119
+ outline-color: var(--input-checkbox-border-hover, var(--input-checkbox-border, var(--theme-border)));
120
+ }
121
+ }
122
+
115
123
  &:not([data-button]):has(input:focus-visible) {
116
124
  outline: var(--form-element-outline-width-focus) solid var(--input-checkbox-border-focus, var(--theme-border-focus));
117
125
  outline-offset: var(--form-element-outline-offset-focus);
@@ -114,6 +114,7 @@ watch(
114
114
  border: var(--form-element-border-width) solid var(--input-checkbox-button-border, var(--theme-border));
115
115
  border-radius: var(--input-checkbox-button-border-radius, 0.4rem);
116
116
  outline: var(--form-element-outline-width) solid transparent;
117
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--input-checkbox-button-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
117
118
 
118
119
  transition: all var(--input-checkbox-transition-duration, var(--theme-form-transition-duration)) ease-in-out;
119
120
 
@@ -127,8 +128,7 @@ watch(
127
128
 
128
129
  &:hover {
129
130
  background-color: var(--input-checkbox-button-surface-hover, var(--theme-input-surface-hover));
130
- outline-color: var(--input-checkbox-button-ring-hover, var(--theme-ring));
131
- outline-offset: var(--form-element-outline-offset-focus);
131
+ outline-color: var(--input-checkbox-button-ring-hover, var(--input-checkbox-button-border, var(--theme-border)));
132
132
  cursor: pointer;
133
133
  }
134
134
 
@@ -36,7 +36,7 @@ These match `FormFieldset`'s `--form-fieldset-*` panel tokens.
36
36
  | `--input-description-slot-margin-block-end` | `0.8rem` | Space below each slot's wrapper |
37
37
  | `--input-description-slot-margin-inline` | `0` | Inline margin of each slot's wrapper |
38
38
  | `--input-description-font-size` | `var(--step-4)` | Font size of the `descriptionText` paragraph |
39
- | `--input-description-line-height` | `var(--step-4)` | Line height of the `descriptionText` paragraph. The default equals the font size, which is tight for wrapping text, so `1.5` is a good override for long descriptions |
39
+ | `--input-description-line-height` | `1.5` | Line height of the `descriptionText` paragraph |
40
40
 
41
41
  With default (unpadded) panel tokens, the slot margins collapse through the root, so they are what
42
42
  spaces the description from the label and control. Beyond these the `descriptionHtml` wrapper
@@ -67,7 +67,7 @@ Every field's description at once, from an app-level stylesheet (e.g. `app/asset
67
67
  ```css
68
68
  :where(html) {
69
69
  --input-description-color: var(--theme-input-placeholder);
70
- --input-description-line-height: 1.5;
70
+ --input-description-font-size: 1.4rem;
71
71
  }
72
72
  ```
73
73
 
@@ -175,3 +175,15 @@ show as uneven space inside the panel. Zero them and space the panel with the ro
175
175
  `.input-description` element. It's only reachable when you render `InputDescription` directly in
176
176
  a custom field wrapper: the library's own wrappers pass `input-text-description` (no styles
177
177
  attached) and don't forward yours. Use a `class` on the wrapper instead (see **One field** above).
178
+
179
+ ---
180
+
181
+ ## Notes
182
+
183
+ - The root sets `overflow-wrap: anywhere`, so a long URL or unbroken word wraps inside a narrow
184
+ column. Copy arrives through slots, so there is no line-clamp token: clamp your own slot markup
185
+ if you need to.
186
+
187
+ > **Changed 2026-10-17**: `--input-description-line-height` defaults to `1.5` (was `var(--step-4)`,
188
+ > the same as the font size, so wrapped lines touched). If you relied on the tighter spacing, set
189
+ > it back with `--input-description-line-height: var(--step-4)`.
@@ -59,6 +59,7 @@ watch(
59
59
  border-radius: var(--input-description-border-radius, 0);
60
60
  outline: var(--input-description-outline, 0);
61
61
  outline-offset: var(--input-description-outline-offset, 0);
62
+ overflow-wrap: anywhere;
62
63
 
63
64
  .input-description-html,
64
65
  .input-description-text {
@@ -69,7 +70,7 @@ watch(
69
70
 
70
71
  .input-description-text {
71
72
  font-size: var(--input-description-font-size, var(--step-4));
72
- line-height: var(--input-description-line-height, var(--step-4));
73
+ line-height: var(--input-description-line-height, 1.5);
73
74
  }
74
75
  }
75
76
  }
@@ -1,7 +1,9 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
- import { computed } from "vue";
2
+ import { computed, ref } from "vue";
3
3
  import StorybookComponent from "../InputDescription.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
4
5
  import type { InputUiVariant } from "~/types/forms/types.forms";
6
+ import type { MediaCanvas } from "~/types/components";
5
7
 
6
8
  interface InputDescriptionStoryArgs {
7
9
  descriptionId: string;
@@ -52,8 +54,43 @@ export default {
52
54
  descriptionText: "We'll only use this to send your booking confirmation.",
53
55
  descriptionHtml: "",
54
56
  },
57
+ decorators: [
58
+ (story, context) => ({
59
+ components: { story, CanvasSwitcher },
60
+ setup() {
61
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
62
+ return { canvasName };
63
+ },
64
+ template: `
65
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
66
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
67
+ </div>
68
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
69
+ <story />
70
+ </div>
71
+ `,
72
+ }),
73
+ ],
74
+ parameters: {
75
+ layout: "fullscreen",
76
+ docs: {
77
+ description: {
78
+ component:
79
+ "Help text for a form field. Copy goes in the `descriptionText` slot (a paragraph) and the `descriptionHtml` slot (rich markup, rendered first); with neither filled nothing renders. `description-id` becomes the root id, for the control's `aria-describedby`. `field-has-error` and `input-variant` are styling hooks only. Every labelled field renders it from its own description slots: see [InputTextField](?path=/story/components-forms-input-text-inputtextfield--with-description).",
80
+ },
81
+ },
82
+ },
55
83
  } as Meta<InputDescriptionStoryArgs>;
56
84
 
85
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
86
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
87
+ const fieldNote = `
88
+ <p style="${storyNoteStyle}">
89
+ Every labelled field renders this from its own description slots. See it in a real field in the
90
+ <a href="/?path=/story/components-forms-input-text-inputtextfield--with-description" target="_top">InputTextField</a> story.
91
+ </p>
92
+ `;
93
+
57
94
  const Template: StoryFn<InputDescriptionStoryArgs> = (args) => ({
58
95
  components: { StorybookComponent },
59
96
  setup() {
@@ -64,7 +101,8 @@ const Template: StoryFn<InputDescriptionStoryArgs> = (args) => ({
64
101
  return { args, componentArgs };
65
102
  },
66
103
  template: `
67
- <div style="max-width: 48rem; margin: 3.6rem; font-size: 1.6rem;">
104
+ <div style="max-width: 48rem; font-size: 1.6rem;">
105
+ ${fieldNote}
68
106
  <label for="story-field" style="display: block; font-weight: 700;">Email address</label>
69
107
  <StorybookComponent v-bind="componentArgs">
70
108
  <template v-if="args.descriptionHtml" #descriptionHtml><span v-html="args.descriptionHtml"></span></template>
@@ -81,17 +119,26 @@ const Template: StoryFn<InputDescriptionStoryArgs> = (args) => ({
81
119
  });
82
120
 
83
121
  export const TextOnly = Template.bind({});
122
+ TextOnly.parameters = {
123
+ docs: { description: { story: "Plain help text in the `descriptionText` slot." } },
124
+ };
84
125
 
85
126
  export const HtmlOnly = Template.bind({});
86
127
  HtmlOnly.args = {
87
128
  descriptionText: "",
88
129
  descriptionHtml: "Password must contain:<ul><li>At least 12 characters</li><li>One number</li></ul>",
89
130
  };
131
+ HtmlOnly.parameters = {
132
+ docs: { description: { story: "Rich markup, here a list, in the `descriptionHtml` slot." } },
133
+ };
90
134
 
91
135
  export const TextAndHtml = Template.bind({});
92
136
  TextAndHtml.args = {
93
137
  descriptionHtml: "<strong>Tip:</strong> use your work address if you have one.",
94
138
  };
139
+ TextAndHtml.parameters = {
140
+ docs: { description: { story: "Both slots: `descriptionHtml` renders above `descriptionText`." } },
141
+ };
95
142
 
96
143
  export const Empty = Template.bind({});
97
144
  Empty.args = { descriptionText: "", descriptionHtml: "" };
@@ -101,6 +148,9 @@ Empty.parameters = {
101
148
 
102
149
  export const CustomTokens = Template.bind({});
103
150
  CustomTokens.args = { styleClassPassthrough: "input-description-story-tokens" };
151
+ CustomTokens.parameters = {
152
+ docs: { description: { story: "Colour, size and slot spacing set as tokens through a passthrough class." } },
153
+ };
104
154
  CustomTokens.decorators = [
105
155
  () => ({
106
156
  template: `
@@ -109,7 +159,6 @@ CustomTokens.decorators = [
109
159
  .input-description-story-tokens {
110
160
  --input-description-color: var(--theme-input-placeholder);
111
161
  --input-description-font-size: 1.4rem;
112
- --input-description-line-height: 1.5;
113
162
  --input-description-slot-margin-block-start: 0.2rem;
114
163
  --input-description-slot-margin-block-end: 1.2rem;
115
164
  }
@@ -147,7 +196,6 @@ Panel.decorators = [
147
196
  --input-description-padding-inline: 1.2rem;
148
197
  --input-description-slot-margin-block-start: 0;
149
198
  --input-description-slot-margin-block-end: 0;
150
- --input-description-line-height: 1.5;
151
199
  }
152
200
  .input-description-story-panel .input-description-html + .input-description-text {
153
201
  margin-block-start: 0.4rem;
@@ -158,3 +206,21 @@ Panel.decorators = [
158
206
  `,
159
207
  }),
160
208
  ];
209
+
210
+ export const StressTest = Template.bind({});
211
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
212
+ StressTest.args = {
213
+ descriptionText:
214
+ "📧 Wir verwenden Ihre Adresse ausschließlich für Rückfragen zu Ihrer Bestellung. Datenschutzgrundverordnungskonformitätsbestätigung https://example.com/a/very/long/unbroken/url/for/the/privacy/policy <b>not bold</b> يرجى إدخال بريدك الإلكتروني",
215
+ descriptionHtml:
216
+ "<strong>Hinweis:</strong> Elektronischepostadressefürdiebenachrichtigungüberbestellungen <a href=\"https://example.com/a/very/long/unbroken/url/for/the/terms\">https://example.com/a/very/long/unbroken/url/for/the/terms</a>",
217
+ };
218
+ StressTest.parameters = {
219
+ initialCanvas: "mobileCanvas",
220
+ docs: {
221
+ description: {
222
+ story:
223
+ "Long German copy, unbroken compound words, long URLs (one as a link), emoji, HTML-like and right-to-left text in both slots. Check at every canvas width: every line wraps inside the dashed outline and the wrapped lines don't touch. Copy arrives through slots, so there is no line-clamp token: clamp your own slot markup if you need to.",
224
+ },
225
+ },
226
+ };
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, afterEach } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import { nextTick } from "vue";
4
4
  import InputDescription from "../InputDescription.vue";
@@ -19,6 +19,7 @@ describe("InputDescription", () => {
19
19
  afterEach(() => {
20
20
  wrapper?.unmount();
21
21
  wrapper = undefined;
22
+ vi.restoreAllMocks();
22
23
  });
23
24
 
24
25
  describe("rendering", () => {
@@ -130,5 +130,13 @@ ancestor of the label).
130
130
 
131
131
  `:style-class-passthrough` (string or string array) adds classes to the `<label>`. It's only
132
132
  reachable when you render `InputLabel` directly in a custom field wrapper: the library's wrappers
133
- pass their own hook classes (`input-text-label`, `input-number-label`, `input-select-label`,
134
- `input-textarea-label`, `input-switch-label`) and don't forward yours.
133
+ pass their own hook classes (`input-text-label`, `input-number-label`, `input-range-label`, `input-select-label`,
134
+ `input-textarea-label`, `toggle-switch-label`) and don't forward yours.
135
+
136
+ ---
137
+
138
+ ## Notes
139
+
140
+ - The label sets `overflow-wrap: anywhere`, so a long URL or unbroken word (and an unbroken
141
+ marker text) wraps inside a narrow column. Label copy arrives through slots, so there is no
142
+ line-clamp token: clamp your own slot markup if you need to.
@@ -95,6 +95,7 @@ watch(
95
95
  line-height: var(--input-label-line-height, 1.5);
96
96
  margin-block: var(--input-label-margin-block, 0.8rem);
97
97
  margin-inline: var(--input-label-margin-inline, 0);
98
+ overflow-wrap: anywhere;
98
99
 
99
100
  .input-label-indicator {
100
101
  --_indicator-color: var(--input-label-indicator-color, inherit);