srcdev-nuxt-components 9.6.10 → 9.6.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 (125) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +4 -2
  2. package/.claude/skills/component-compliance-checklist.md +14 -3
  3. package/.claude/skills/component-inline-action-button.md +5 -4
  4. package/.claude/skills/component-naming.md +2 -1
  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/form-field.md +15 -0
  10. package/.claude/skills/components/form-fieldset.md +18 -2
  11. package/.claude/skills/components/input-button.md +26 -7
  12. package/.claude/skills/components/input-checkbox-radio.md +2 -0
  13. package/.claude/skills/components/input-description.md +5 -0
  14. package/.claude/skills/components/input-error.md +9 -2
  15. package/.claude/skills/components/input-label.md +8 -2
  16. package/.claude/skills/components/input-number.md +2 -0
  17. package/.claude/skills/components/input-otp.md +2 -0
  18. package/.claude/skills/components/input-range.md +2 -0
  19. package/.claude/skills/components/input-select.md +2 -0
  20. package/.claude/skills/components/input-text.md +2 -0
  21. package/.claude/skills/components/input-textarea.md +2 -0
  22. package/.claude/skills/components/marquee-scroller.md +51 -14
  23. package/.claude/skills/components/pending-effect.md +5 -4
  24. package/.claude/skills/components/{triple-toggle-switch.md → segmented-control.md} +34 -19
  25. package/.claude/skills/components/services-card.md +1 -1
  26. package/.claude/skills/components/tabbed-content.md +1 -1
  27. package/.claude/skills/components/toggle-switch.md +2 -0
  28. package/.claude/skills/index.md +5 -5
  29. package/.claude/skills/storybook-add-story.md +3 -1
  30. package/.claude/skills/theming-component-token-pattern.md +24 -0
  31. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  32. package/.claude/skills/theming-partial-override.md +1 -1
  33. package/.vscode/srcdev-component-alert-mask.code-snippets +41 -0
  34. package/.vscode/srcdev-component-form-field.code-snippets +7 -6
  35. package/.vscode/srcdev-component-form-fieldset.code-snippets +12 -12
  36. package/.vscode/srcdev-component-marquee-scroller.code-snippets +31 -28
  37. package/.vscode/{srcdev-component-triple-toggle-switch.code-snippets → srcdev-component-segmented-control.code-snippets} +6 -7
  38. package/app/assets/styles/setup/03.theming/_theme-slots.css +1 -1
  39. package/app/assets/styles/setup/04.elements/forms/03.generic-input-geometry.css +4 -0
  40. package/app/components/01.atoms/alert-mask/AlertMask.vue +194 -0
  41. package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +57 -42
  42. package/app/components/01.atoms/alert-mask/stories/AlertMask.stories.ts +198 -0
  43. package/app/components/01.atoms/alert-mask/tests/AlertMask.spec.ts +233 -0
  44. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +77 -54
  45. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +103 -91
  46. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +132 -3
  47. package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +190 -102
  48. package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +17 -13
  49. package/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue +2 -2
  50. package/app/components/02.molecules/alert-masked-content/CONSUMER-STYLING.md +4 -4
  51. package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +60 -0
  52. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +12 -12
  53. package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +3 -3
  54. package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +1 -1
  55. package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +1 -1
  56. package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +9 -9
  57. package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +16 -16
  58. package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +53 -1
  59. package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +8 -8
  60. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +4 -4
  61. package/app/components/02.molecules/input-copy/InputCopy.vue +2 -2
  62. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +2 -2
  63. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +4 -4
  64. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +2 -2
  65. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +2 -2
  66. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +6 -6
  67. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +8 -0
  68. package/app/components/05.forms/form-errors/InputError.vue +43 -37
  69. package/app/components/05.forms/form-errors/stories/InputError.stories.ts +79 -1
  70. package/app/components/05.forms/form-errors/tests/InputError.spec.ts +29 -1
  71. package/app/components/05.forms/form-field/CONSUMER-STYLING.md +7 -0
  72. package/app/components/05.forms/form-field/stories/FormField.stories.ts +116 -2
  73. package/app/components/05.forms/form-fieldset/CONSUMER-STYLING.md +11 -1
  74. package/app/components/05.forms/form-fieldset/FormFieldset.vue +4 -2
  75. package/app/components/05.forms/form-fieldset/stories/FormFieldset.stories.ts +68 -4
  76. package/app/components/05.forms/form-fieldset/tests/FormFieldset.spec.ts +10 -0
  77. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +80 -47
  78. package/app/components/05.forms/input-button/InputButton.vue +52 -100
  79. package/app/components/05.forms/input-button/playwright/input-button.playwright.ts +1 -1
  80. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +145 -8
  81. package/app/components/05.forms/input-button/tests/InputButton.spec.ts +76 -46
  82. package/app/components/05.forms/input-button/tests/__snapshots__/InputButton.spec.ts.snap +17 -17
  83. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +9 -1
  84. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +8 -0
  85. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +2 -2
  86. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +14 -2
  87. package/app/components/05.forms/input-description/InputDescription.vue +2 -1
  88. package/app/components/05.forms/input-description/stories/InputDescription.stories.ts +70 -4
  89. package/app/components/05.forms/input-description/tests/InputDescription.spec.ts +2 -1
  90. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +8 -0
  91. package/app/components/05.forms/input-label/InputLabel.vue +1 -0
  92. package/app/components/05.forms/input-label/stories/InputLabel.stories.ts +68 -2
  93. package/app/components/05.forms/input-label/tests/InputLabel.spec.ts +13 -3
  94. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +7 -1
  95. package/app/components/05.forms/input-number/InputNumber.vue +4 -5
  96. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +7 -1
  97. package/app/components/05.forms/input-otp/InputOtp.vue +2 -2
  98. package/app/components/05.forms/input-range/InputRange.vue +1 -1
  99. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +7 -1
  100. package/app/components/05.forms/input-select/InputSelect.vue +14 -5
  101. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +7 -1
  102. package/app/components/05.forms/input-text/InputText.vue +4 -4
  103. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +7 -1
  104. package/app/components/05.forms/input-textarea/InputTextarea.vue +2 -3
  105. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +4 -4
  106. package/app/components/05.forms/pending-effect/CONSUMER-STYLING.md +7 -3
  107. package/app/components/05.forms/pending-effect/PendingEffect.vue +3 -3
  108. package/app/components/05.forms/pending-effect/tests/PendingEffect.spec.ts +1 -1
  109. package/app/components/05.forms/segmented-control/CONSUMER-STYLING.md +125 -0
  110. package/app/components/05.forms/segmented-control/SegmentedControl.vue +231 -0
  111. package/app/components/05.forms/segmented-control/stories/SegmentedControl.stories.ts +215 -0
  112. package/app/components/05.forms/segmented-control/tests/SegmentedControl.spec.ts +202 -0
  113. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +7 -0
  114. package/app/components/05.forms/toggle-switch/ToggleSwitch.vue +2 -0
  115. package/app/types/components/index.ts +1 -1
  116. package/package.json +1 -1
  117. package/.vscode/srcdev-component-alert-mask-core.code-snippets +0 -31
  118. package/app/components/01.atoms/alert-mask/AlertMaskCore.vue +0 -166
  119. package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +0 -105
  120. package/app/components/01.atoms/alert-mask/tests/AlertMaskCore.spec.ts +0 -342
  121. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +0 -99
  122. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +0 -251
  123. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitch.stories.ts +0 -104
  124. package/app/components/05.forms/triple-toggle-switch/tests/TripleToggleSwitch.spec.ts +0 -138
  125. /package/app/types/components/{alert-mask-core.d.ts → alert-mask.d.ts} +0 -0
@@ -1,13 +1,16 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
- import { ref } from "vue";
2
+ import { computed, ref } from "vue";
3
3
  import StorybookComponent from "../InputButton.vue";
4
4
  import InputText from "../../input-text/InputText.vue";
5
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
6
+ import type { MediaCanvas } from "~/types/components";
5
7
 
6
8
  interface InputButtonStoryArgs {
7
9
  type: "submit" | "button" | "reset";
8
10
  theme: "default" | "success" | "error" | "warning";
9
11
  variant: "primary" | "secondary" | "tertiary" | "inline";
10
12
  buttonText: string;
13
+ isPill: boolean;
11
14
  styleClassPassthrough: string[];
12
15
  isPending: boolean;
13
16
  hasPendingEffect: boolean;
@@ -75,7 +78,7 @@ export default {
75
78
  variant: {
76
79
  control: { type: "select" },
77
80
  options: ["primary", "secondary", "tertiary", "inline"],
78
- description: "Button variant/style",
81
+ description: "primary: filled. secondary: outlined. tertiary: text-only. inline: no colours, for wrapper components that style the button themselves",
79
82
  table: {
80
83
  category: "Styling",
81
84
  },
@@ -201,7 +204,7 @@ export default {
201
204
  isPending: false,
202
205
  hasPendingEffect: false,
203
206
  readonly: false,
204
- isPill: false, // Ensure isPill is present and defaulted
207
+ isPill: false,
205
208
  useLeftSlot: false,
206
209
  useRightSlot: false,
207
210
  useIconOnlySlot: false,
@@ -215,7 +218,45 @@ export default {
215
218
  rightIconName: "mdi:arrow-right",
216
219
  iconOnlyName: "mdi:flash",
217
220
  },
218
- } as Meta<typeof StorybookComponent>;
221
+ parameters: {
222
+ docs: {
223
+ description: {
224
+ component:
225
+ "The library's button. Renders a `<button>`, or a link when `href` is set (`NuxtLink` for a `/` path unless `external`). `variant` (primary, secondary, tertiary, inline) and `theme` pick the colours, `is-pill` rounds it, `readonly` dims it and blocks clicks, and `is-pending` with `has-pending-effect` animates a border while work runs. Label copy is `button-text`; the `left`/`right` slots add icons beside it, and `iconOnly` replaces it (the text stays as screen-reader text). The Slots and Icons controls are story-only. It's also the action button inside [InputCopy](?path=/story/molecules-input-copy--default), [CookieConsentBanner](?path=/story/molecules-cookieconsentbanner--default) and the step buttons of [InputNumberField](?path=/story/components-forms-input-number-inputnumberfield--default).",
226
+ },
227
+ },
228
+ },
229
+ } as Meta<InputButtonStoryArgs>;
230
+
231
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
232
+ const storyNoteStyle = "margin: 0; font-size: 1.4rem;";
233
+ const usedByNote = `
234
+ <p style="${storyNoteStyle}">
235
+ Also the action button inside
236
+ <a href="/?path=/story/molecules-input-copy--default" target="_top">InputCopy</a>,
237
+ <a href="/?path=/story/molecules-cookieconsentbanner--default" target="_top">CookieConsentBanner</a>
238
+ (variant inline, styled by the banner's tokens) and the step buttons of
239
+ <a href="/?path=/story/components-forms-input-number-inputnumberfield--default" target="_top">InputNumberField</a>.
240
+ </p>
241
+ `;
242
+
243
+ const storyOnlyArgs = [
244
+ "useLeftSlot",
245
+ "useRightSlot",
246
+ "useIconOnlySlot",
247
+ "leftSlotContent",
248
+ "rightSlotContent",
249
+ "iconOnlyContent",
250
+ "leftIconName",
251
+ "rightIconName",
252
+ "iconOnlyName",
253
+ "useLeftIcon",
254
+ "useRightIcon",
255
+ "useIconOnly",
256
+ ] as const;
257
+
258
+ const stripStoryArgs = (args: InputButtonStoryArgs) =>
259
+ Object.fromEntries(Object.entries(args).filter(([key]) => !(storyOnlyArgs as readonly string[]).includes(key)));
219
260
 
220
261
  const Template: StoryFn<InputButtonStoryArgs> = (args) => ({
221
262
  components: { StorybookComponent },
@@ -228,14 +269,18 @@ const Template: StoryFn<InputButtonStoryArgs> = (args) => ({
228
269
  }
229
270
  };
230
271
 
272
+ const componentArgs = computed(() => stripStoryArgs(args));
273
+
231
274
  return {
232
275
  args,
276
+ componentArgs,
233
277
  clickCount,
234
278
  handleClick,
235
279
  };
236
280
  },
237
281
  template: `
238
- <div style="display: flex; flex-direction: column; gap: 1rem; align-items: flex-start;margin: 36px;">
282
+ <div style="display: flex; flex-direction: column; gap: 1rem; align-items: flex-start; margin: 3.6rem;">
283
+ ${usedByNote}
239
284
  <!-- :key forces a remount when any slot-visibility arg changes. Verified the args
240
285
  themselves ARE reactive here (a plain {{ args.useLeftSlot }} interpolation updates
241
286
  live on toggle) — the bug is narrower: Vue's compiled-at-runtime string template
@@ -244,10 +289,11 @@ const Template: StoryFn<InputButtonStoryArgs> = (args) => ({
244
289
  Toggling a control changed the fallthrough attribute (confirmed via useleftslot="true"
245
290
  in the rendered DOM) but never actually added/removed the slot content until a hard
246
291
  refresh. Remounting sidesteps whatever the underlying slot-stability quirk is, rather
247
- than chasing it further. -->
292
+ than chasing it further. The story-only args are stripped before v-bind, so they no
293
+ longer land on the button as attributes. -->
248
294
  <StorybookComponent
249
295
  :key="[args.useLeftSlot, args.useLeftIcon, args.useRightSlot, args.useRightIcon, args.useIconOnlySlot, args.useIconOnly].join(',')"
250
- v-bind="args"
296
+ v-bind="componentArgs"
251
297
  @click="handleClick"
252
298
  >
253
299
  <template v-if="args.useLeftSlot || args.useLeftIcon" #left>
@@ -276,6 +322,9 @@ Default.args = {
276
322
  useLeftSlot: false,
277
323
  useRightSlot: false,
278
324
  };
325
+ Default.parameters = {
326
+ docs: { description: { story: "Text only. Every prop is live in Controls, including the pending, pill and readonly states." } },
327
+ };
279
328
 
280
329
  export const WithBothEmojiIcons = Template.bind({});
281
330
  WithBothEmojiIcons.args = {
@@ -285,6 +334,9 @@ WithBothEmojiIcons.args = {
285
334
  leftSlotContent: "💸",
286
335
  rightSlotContent: "✅",
287
336
  };
337
+ WithBothEmojiIcons.parameters = {
338
+ docs: { description: { story: "Plain content in the `left` and `right` slots." } },
339
+ };
288
340
 
289
341
  export const WithBothNuxtIconComponents = Template.bind({});
290
342
  WithBothNuxtIconComponents.args = {
@@ -294,6 +346,9 @@ WithBothNuxtIconComponents.args = {
294
346
  leftIconName: "mdi:arrow-left",
295
347
  rightIconName: "mdi:arrow-right",
296
348
  };
349
+ WithBothNuxtIconComponents.parameters = {
350
+ docs: { description: { story: "`<Icon class=\"icon\">` in both slots, sized by `--input-icon-size`." } },
351
+ };
297
352
 
298
353
  export const EmojiIconOnly = Template.bind({});
299
354
  EmojiIconOnly.args = {
@@ -301,6 +356,9 @@ EmojiIconOnly.args = {
301
356
  useIconOnlySlot: true,
302
357
  iconOnlyContent: "⚡",
303
358
  };
359
+ EmojiIconOnly.parameters = {
360
+ docs: { description: { story: "The `iconOnly` slot makes the button square (`data-icon-only`) and keeps `button-text` as screen-reader text." } },
361
+ };
304
362
 
305
363
  export const NuxtIconOnlyComponent = Template.bind({});
306
364
  NuxtIconOnlyComponent.args = {
@@ -308,6 +366,9 @@ NuxtIconOnlyComponent.args = {
308
366
  useIconOnly: true,
309
367
  iconOnlyName: "mdi:chevron-right-circle-outline",
310
368
  };
369
+ NuxtIconOnlyComponent.parameters = {
370
+ docs: { description: { story: "Icon-only with an `<Icon>`; the accessible name still comes from `button-text`." } },
371
+ };
311
372
 
312
373
  // Composition check, not a props demo: confirms InputButton's --button-min-height (defaults
313
374
  // to var(--input-min-height)) actually keeps it height-aligned with an inline text input, the
@@ -334,4 +395,80 @@ const InlineTemplate: StoryFn = () => ({
334
395
  export const InlineWithTextInput = InlineTemplate.bind({});
335
396
 
336
397
  // Fixed showcase: hardcoded values, so Controls would do nothing here.
337
- InlineWithTextInput.parameters = { controls: { disable: true } };
398
+ InlineWithTextInput.parameters = {
399
+ controls: { disable: true },
400
+ docs: {
401
+ description: {
402
+ story: "Composition check: `--button-min-height` defaults to `--input-min-height`, so the button lines up with an inline text input. Controls do nothing here.",
403
+ },
404
+ },
405
+ };
406
+
407
+ const stressLabels = [
408
+ "Jetzt unverbindlich einen kostenlosen Beratungstermin vereinbaren",
409
+ "Datenschutzgrundverordnungskonformitätsbestätigung",
410
+ "https://example.com/a/very/long/unbroken/url/for/the/button/label",
411
+ "<b>not bold</b>",
412
+ "🎉 Book now",
413
+ "احجز الآن",
414
+ "x",
415
+ "",
416
+ ];
417
+
418
+ const StressTemplate: StoryFn<InputButtonStoryArgs> = (args) => ({
419
+ components: { StorybookComponent },
420
+ setup() {
421
+ const componentArgs = computed(() => {
422
+ const { buttonText: _buttonText, ...rest } = stripStoryArgs(args);
423
+ return rest;
424
+ });
425
+ return { args, componentArgs, stressLabels };
426
+ },
427
+ template: `
428
+ <div style="display: grid; gap: 2.4rem;">
429
+ <div style="display: flex; flex-direction: column; align-items: flex-start; gap: 1.2rem;">
430
+ <StorybookComponent v-for="(label, index) in stressLabels" :key="index" v-bind="componentArgs" :button-text="label" />
431
+ <StorybookComponent v-bind="componentArgs" :button-text="stressLabels[0]">
432
+ <template #left><Icon name="mdi:calendar" class="icon" /></template>
433
+ <template #right><Icon name="mdi:arrow-right" class="icon" /></template>
434
+ </StorybookComponent>
435
+ </div>
436
+ <div style="display: flex; flex-wrap: wrap; gap: 1.2rem;">
437
+ <StorybookComponent v-bind="componentArgs" variant="secondary" button-text="Zurück zur vorherigen Seite" />
438
+ <StorybookComponent v-bind="componentArgs" :button-text="stressLabels[0]" />
439
+ </div>
440
+ </div>
441
+ `,
442
+ });
443
+
444
+ export const StressTest = StressTemplate.bind({});
445
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
446
+ StressTest.args = { isPill: true };
447
+ StressTest.decorators = [
448
+ (story, context) => ({
449
+ components: { story, CanvasSwitcher },
450
+ setup() {
451
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
452
+ return { canvasName };
453
+ },
454
+ template: `
455
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
456
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
457
+ </div>
458
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
459
+ <story />
460
+ </div>
461
+ `,
462
+ }),
463
+ ];
464
+ StressTest.parameters = {
465
+ layout: "fullscreen",
466
+ initialCanvas: "mobileCanvas",
467
+ docs: {
468
+ story: { inline: false, height: "900px" },
469
+ description: {
470
+ story:
471
+ "Long German copy, an unbroken compound word, a long URL, HTML-like text, emoji, right-to-left text, a single character and an empty label, then a long label with icons on both sides and a wrapping row of two buttons. Check at every canvas width: no button pushes past the dashed outline, long labels wrap inside the button (`--input-button-text-white-space` defaults to `normal`), icons keep their size, markup renders as text, and the pill shape still reads with several lines. Toggle variant, pill and pending in Controls. Button text is never line-clamped: a cut-off action label can't be trusted.",
472
+ },
473
+ },
474
+ };
@@ -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);