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
@@ -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", () => {
@@ -132,3 +132,11 @@ ancestor of the label).
132
132
  reachable when you render `InputLabel` directly in a custom field wrapper: the library's wrappers
133
133
  pass their own hook classes (`input-text-label`, `input-number-label`, `input-range-label`, `input-select-label`,
134
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);
@@ -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 "../InputLabel.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
4
5
  import type { InputUiVariant, InputLabelIndicator } from "~/types/forms/types.forms";
6
+ import type { MediaCanvas } from "~/types/components";
5
7
 
6
8
  interface InputLabelStoryArgs {
7
9
  id: string;
@@ -109,8 +111,43 @@ export default {
109
111
  textLabel: "Email address",
110
112
  htmlLabel: "",
111
113
  },
114
+ decorators: [
115
+ (story, context) => ({
116
+ components: { story, CanvasSwitcher },
117
+ setup() {
118
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
119
+ return { canvasName };
120
+ },
121
+ template: `
122
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
123
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
124
+ </div>
125
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
126
+ <story />
127
+ </div>
128
+ `,
129
+ }),
130
+ ],
131
+ parameters: {
132
+ layout: "fullscreen",
133
+ docs: {
134
+ description: {
135
+ component:
136
+ "The `<label>` every labelled field wrapper renders. Label copy goes in the `textLabel` and `htmlLabel` slots (html first), `id` becomes its `for`, and `indicator` with `required` adds a required or optional marker (falling back to `app.config` `srcdev.inputLabel`). `field-has-error` and `input-variant` are styling hooks only. The control in these stories is a plain input; see [InputTextField](?path=/story/components-forms-input-text-inputtextfield--required) for it inside a real field.",
137
+ },
138
+ },
139
+ },
112
140
  } as Meta<InputLabelStoryArgs>;
113
141
 
142
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
143
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
144
+ const fieldNote = `
145
+ <p style="${storyNoteStyle}">
146
+ Every labelled field wrapper renders this label and forwards its own <code>required</code> prop. See it in a real field in the
147
+ <a href="/?path=/story/components-forms-input-text-inputtextfield--required" target="_top">InputTextField</a> story.
148
+ </p>
149
+ `;
150
+
114
151
  const Template: StoryFn<InputLabelStoryArgs> = (args) => ({
115
152
  components: { StorybookComponent },
116
153
  setup() {
@@ -133,7 +170,8 @@ const Template: StoryFn<InputLabelStoryArgs> = (args) => ({
133
170
  return { args, componentArgs, tokenStyles };
134
171
  },
135
172
  template: `
136
- <div style="max-width: 48rem; margin: 3.6rem; font-size: 1.6rem;" :style="tokenStyles">
173
+ <div style="max-width: 48rem; font-size: 1.6rem;" :style="tokenStyles">
174
+ ${fieldNote}
137
175
  <StorybookComponent v-bind="componentArgs">
138
176
  <template v-if="args.htmlLabel" #htmlLabel><span v-html="args.htmlLabel"></span></template>
139
177
  <template v-if="args.textLabel" #textLabel>{{ args.textLabel }}</template>
@@ -150,17 +188,26 @@ const Template: StoryFn<InputLabelStoryArgs> = (args) => ({
150
188
  });
151
189
 
152
190
  export const TextLabel = Template.bind({});
191
+ TextLabel.parameters = {
192
+ docs: { description: { story: "Plain label text through the `textLabel` slot." } },
193
+ };
153
194
 
154
195
  export const HtmlLabel = Template.bind({});
155
196
  HtmlLabel.args = {
156
197
  textLabel: "",
157
198
  htmlLabel: 'Email address <small style="font-weight: normal;">(work or personal)</small>',
158
199
  };
200
+ HtmlLabel.parameters = {
201
+ docs: { description: { story: "Rich markup through the `htmlLabel` slot." } },
202
+ };
159
203
 
160
204
  export const TextAndHtml = Template.bind({});
161
205
  TextAndHtml.args = {
162
206
  htmlLabel: "<strong>Work</strong> ",
163
207
  };
208
+ TextAndHtml.parameters = {
209
+ docs: { description: { story: "Both slots filled: `htmlLabel` renders first, then `textLabel`." } },
210
+ };
164
211
 
165
212
  export const MarkRequired = Template.bind({});
166
213
  MarkRequired.args = { indicator: "required", required: true };
@@ -234,3 +281,22 @@ CustomTokens.decorators = [
234
281
  `,
235
282
  }),
236
283
  ];
284
+
285
+ export const StressTest = Template.bind({});
286
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
287
+ StressTest.args = {
288
+ textLabel:
289
+ "📧 Elektronischepostadressefürdiebenachrichtigungüberbestellungen https://example.com/a/very/long/unbroken/url/for/the/label <b>not bold</b> البريد الإلكتروني",
290
+ indicator: "optional",
291
+ required: false,
292
+ optionalText: "(freiwilligeAngabekeinePflichtfeldbitteleerlassenwennnichtzutreffend)",
293
+ };
294
+ StressTest.parameters = {
295
+ initialCanvas: "mobileCanvas",
296
+ docs: {
297
+ description: {
298
+ story:
299
+ "Long unbroken German, a long URL, emoji, HTML-like and right-to-left text in the label, plus an unbroken optional marker. Check at every canvas width: the label and the marker wrap inside the dashed outline, markup renders as text, and the marker stays after the text in RTL. Switch `indicator` to required and turn `required` on to check the required marker. Label copy arrives through slots, so there is no line-clamp token: clamp your own slot markup if you need to.",
300
+ },
301
+ },
302
+ };
@@ -1,14 +1,24 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import InputLabel from "../InputLabel.vue";
4
4
 
5
- const createWrapper = (props = {}, slots = {}) =>
6
- mountSuspended(InputLabel, {
5
+ const mounted: { unmount: () => void }[] = [];
6
+
7
+ const createWrapper = async (props = {}, slots = {}) => {
8
+ const wrapper = await mountSuspended(InputLabel, {
7
9
  props: { id: "test-input", ...props },
8
10
  slots,
9
11
  });
12
+ mounted.push(wrapper);
13
+ return wrapper;
14
+ };
10
15
 
11
16
  describe("InputLabel", () => {
17
+ afterEach(() => {
18
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
19
+ vi.restoreAllMocks();
20
+ });
21
+
12
22
  describe("rendering", () => {
13
23
  it("renders a label linked to the control id", async () => {
14
24
  const wrapper = await createWrapper({ id: "email-input" });
@@ -18,7 +18,8 @@ token, same as `InputText`.
18
18
  | `--input-number-surface` | `var(--theme-input-surface)` | Wrapper, input, and embedded-button background |
19
19
  | `--input-number-surface-hover` | `var(--theme-input-surface-hover)` | Embedded left/right button background on hover |
20
20
  | `--input-number-border` | `var(--theme-border)` | Border/divider colour (`normal`/`underlined` variant border, and the divider between the input and an embedded button) |
21
- | `--input-number-border-hover` | `var(--theme-border-focus)` | Wrapper outline colour on mouse hover over the input or either step button (`normal` variant only) |
21
+ | `--input-number-border-hover` | the resolved border | 1px wrapper outline colour on mouse hover over the input or either step button (`normal` variant only) |
22
+ | `--input-number-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (`normal` only). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
22
23
  | `--input-number-border-focus` | `var(--theme-border-focus)` | Wrapper outline colour on `:focus-visible` (`normal` variant), and the embedded button's own `:focus-visible` outline |
23
24
  | `--input-number-text-color` | `var(--theme-input-text-color-normal)` | Input text colour |
24
25
  | `--input-number-placeholder-color` | `var(--theme-input-placeholder)` | Placeholder text colour |
@@ -33,6 +34,11 @@ and `step` and capped at `12em`).
33
34
 
34
35
  ---
35
36
 
37
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
38
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
39
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
40
+ > unchanged and sits just outside the ring.
41
+
36
42
  ## State hooks
37
43
 
38
44
  | Attribute | On | When |
@@ -123,11 +123,10 @@ const inputmode = computed(() => {
123
123
  border: var(--form-element-border-width) solid var(--_border);
124
124
  border-radius: var(--form-input-border-radius);
125
125
  outline: var(--form-element-outline-width) solid transparent;
126
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--input-number-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
126
127
 
127
128
  &:has(input:hover, button:hover) {
128
- outline: var(--form-element-outline-width-focus) solid
129
- var(--input-number-border-hover, var(--theme-border-focus));
130
- outline-offset: var(--form-element-outline-offset-focus);
129
+ outline-color: var(--input-number-border-hover, var(--_border));
131
130
  }
132
131
 
133
132
  &:has(input:focus-visible, button:focus-visible) {
@@ -167,8 +166,8 @@ const inputmode = computed(() => {
167
166
  outline-offset: -4px;
168
167
  }
169
168
 
170
- &.icon-only {
171
- .btn-icon {
169
+ &[data-icon-only] {
170
+ & .input-button-icon {
172
171
  margin: 0;
173
172
  }
174
173
  }
@@ -18,7 +18,8 @@ for the two-tier shape.
18
18
  | `--input-otp-surface` | `var(--theme-input-surface)` | Box background |
19
19
  | `--input-otp-text-color` | `var(--theme-input-text-color-normal)` | Digit colour |
20
20
  | `--input-otp-border` | `var(--theme-border)` | Box border (`normal`) or bottom border (`underlined`) |
21
- | `--input-otp-border-hover` | `var(--input-otp-border-focus)` | Outline colour on hover (`normal` variant) |
21
+ | `--input-otp-border-hover` | the resolved border | 1px outline colour on hover (`normal` variant) |
22
+ | `--input-otp-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (each box, `normal` only). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
22
23
  | `--input-otp-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible`, and the bottom border colour on focus (`underlined`) |
23
24
 
24
25
  ### Field (`InputOtpField`)
@@ -35,6 +36,11 @@ Border/outline widths and transition duration are the shared form geometry token
35
36
  Private tokens (not public API): `--_border` and `--_border-focus` on `.input-otp`, which only
36
37
  resolve the two public border tokens once for reuse across the variant rules.
37
38
 
39
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
40
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
41
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
42
+ > unchanged and sits just outside the ring.
43
+
38
44
  ## State hooks
39
45
 
40
46
  | Hook | Where | Meaning |
@@ -230,10 +230,10 @@ function onFocus(event: FocusEvent) {
230
230
  border: var(--form-element-border-width) solid var(--_border);
231
231
  border-radius: var(--input-otp-border-radius, var(--form-input-border-radius));
232
232
  outline: var(--form-element-outline-width) solid transparent;
233
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--input-otp-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
233
234
 
234
235
  &:hover {
235
- outline: var(--form-element-outline-width-focus) solid var(--input-otp-border-hover, var(--_border-focus));
236
- outline-offset: var(--form-element-outline-offset-focus);
236
+ outline-color: var(--input-otp-border-hover, var(--_border));
237
237
  }
238
238
 
239
239
  &:focus-visible {
@@ -103,7 +103,7 @@ watch(
103
103
  width: var(--input-range-button-size);
104
104
  height: var(--input-range-button-size);
105
105
 
106
- .btn-icon {
106
+ & .input-button-icon {
107
107
  margin: initial;
108
108
  }
109
109
  }
@@ -17,7 +17,8 @@ to override just this component; leave it unset and it inherits the shared `--th
17
17
  | `--input-select-surface` | `var(--theme-input-surface)` | Wrapper/select background |
18
18
  | `--input-select-text-color` | `var(--theme-input-text-color-normal)` | Select text colour (added 2026-10-16; it inherited the page colour before) |
19
19
  | `--input-select-border` | `var(--theme-border)` | Border colour (`normal`/`underlined` variants and the open picker, resting) |
20
- | `--input-select-border-hover` | `var(--theme-border-focus)` | Outline colour on hover (mouse) |
20
+ | `--input-select-border-hover` | the resolved border (`normal`), `var(--theme-border-focus)` (`underlined`) | Outline colour on hover (mouse): 1px with no offset on `normal`, 2px at a 2px offset on `underlined` |
21
+ | `--input-select-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (`normal` only). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
21
22
  | `--input-select-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible` (keyboard/assistive), and the open picker's outline |
22
23
 
23
24
  ### Options
@@ -41,6 +42,11 @@ shows and ignores them.
41
42
 
42
43
  ---
43
44
 
45
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
46
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
47
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
48
+ > unchanged and sits just outside the ring.
49
+
44
50
  ## State hooks
45
51
 
46
52
  | Attribute | On | When |
@@ -112,16 +112,25 @@ watch(
112
112
  border: var(--form-element-border-width) solid var(--_border);
113
113
  border-radius: var(--form-input-border-radius);
114
114
  outline: var(--form-element-outline-width) solid transparent;
115
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--input-select-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
116
+
117
+ &:has(:hover) {
118
+ outline-color: var(--input-select-border-hover, var(--_border));
119
+ }
120
+
121
+ &:has(select:focus-visible) {
122
+ outline-color: var(--_border-focus);
123
+ }
115
124
  }
116
125
 
117
126
  &[data-input-variant="underlined"] {
118
127
  border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--_border);
119
- }
120
128
 
121
- &:has(:hover) {
122
- outline: var(--form-element-outline-width-focus) solid
123
- var(--input-select-border-hover, var(--theme-border-focus));
124
- outline-offset: var(--form-element-outline-offset-focus);
129
+ &:has(:hover) {
130
+ outline: var(--form-element-outline-width-focus) solid
131
+ var(--input-select-border-hover, var(--theme-border-focus));
132
+ outline-offset: var(--form-element-outline-offset-focus);
133
+ }
125
134
  }
126
135
 
127
136
  &:has(select:focus-visible) {
@@ -17,7 +17,8 @@ override just this component; leave it unset and it inherits the shared `--theme
17
17
  | `--input-text-surface` | `var(--theme-input-surface)` | Wrapper background, and slot `InputButton` background |
18
18
  | `--input-text-surface-hover` | `var(--theme-input-surface-hover)` | Slot `InputButton` background on hover |
19
19
  | `--input-text-border` | `var(--theme-border)` | Border colour (`normal`/`underlined`, resting), and the stepper's button dividers |
20
- | `--input-text-border-hover` | `var(--theme-border-focus)` | Outline colour on hover (`normal` only) |
20
+ | `--input-text-border-hover` | the resolved border | 1px outline colour on hover (`normal` only) |
21
+ | `--input-text-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (`normal` only). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
21
22
  | `--input-text-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible` (`normal` only), and a slot button's own focus outline |
22
23
  | `--input-text-color` | `var(--theme-input-text-color-normal)` | Input text and slot button colour, and autofilled text (global rule) |
23
24
  | `--input-text-placeholder-color` | `var(--theme-input-placeholder)` | Placeholder text colour |
@@ -31,6 +32,11 @@ read in several places.
31
32
 
32
33
  ---
33
34
 
35
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
36
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
37
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
38
+ > unchanged and sits just outside the ring.
39
+
34
40
  ## State hooks
35
41
 
36
42
  | Attribute | On | When |
@@ -131,12 +131,12 @@ watch(
131
131
  border: var(--form-element-border-width) solid var(--_border);
132
132
  border-radius: var(--form-input-border-radius);
133
133
  outline: var(--form-element-outline-width) solid transparent;
134
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--input-text-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
134
135
 
135
136
  padding-inline: var(--input-padding-inline);
136
137
 
137
138
  &:has(input:is(:hover), button:is(:hover)) {
138
- outline: var(--form-element-outline-width-focus) solid var(--input-text-border-hover, var(--theme-border-focus));
139
- outline-offset: var(--form-element-outline-offset-focus);
139
+ outline-color: var(--input-text-border-hover, var(--_border));
140
140
  }
141
141
 
142
142
  &:has(input:focus-visible, button:focus-visible) {
@@ -180,8 +180,8 @@ watch(
180
180
  outline-offset: -4px;
181
181
  }
182
182
 
183
- &.icon-only {
184
- .btn-icon {
183
+ &[data-icon-only] {
184
+ & .input-button-icon {
185
185
  margin: 0;
186
186
  }
187
187
  }
@@ -18,7 +18,8 @@ This shape mirrors `InputText`'s token set (see its own CONSUMER-STYLING.md).
18
18
  |---|---|---|
19
19
  | `--input-textarea-surface` | `var(--theme-input-surface)` | Wrapper background |
20
20
  | `--input-textarea-border` | `var(--theme-border)` | Border colour (`normal`/`underlined` variant border) |
21
- | `--input-textarea-border-hover` | `var(--theme-border-focus)` | Wrapper outline colour on mouse hover (`normal` only) |
21
+ | `--input-textarea-border-hover` | the resolved border | 1px wrapper outline colour on mouse hover (`normal` only) |
22
+ | `--input-textarea-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (`normal` only). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
22
23
  | `--input-textarea-border-focus` | `var(--theme-border-focus)` | Wrapper outline colour on `:focus-visible` (`normal` only) |
23
24
  | `--input-textarea-text-color` | `var(--theme-input-text-color-normal)` | Textarea text colour |
24
25
  | `--input-textarea-placeholder-color` | `var(--theme-input-placeholder)` | Placeholder text colour |
@@ -41,6 +42,11 @@ Private (not public API): `--_border`, resolved once and read by both border var
41
42
 
42
43
  ---
43
44
 
45
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
46
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
47
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
48
+ > unchanged and sits just outside the ring.
49
+
44
50
  ## State hooks
45
51
 
46
52
  | Attribute | On | When |
@@ -105,11 +105,10 @@ watch(
105
105
  border: var(--form-element-border-width) solid var(--_border);
106
106
  border-radius: var(--form-input-border-radius);
107
107
  outline: var(--form-element-outline-width) solid transparent;
108
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--input-textarea-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
108
109
 
109
110
  &:has(textarea:is(:hover)) {
110
- outline: var(--form-element-outline-width-focus) solid
111
- var(--input-textarea-border-hover, var(--theme-border-focus));
112
- outline-offset: var(--form-element-outline-offset-focus);
111
+ outline-color: var(--input-textarea-border-hover, var(--_border));
113
112
  }
114
113
 
115
114
  &:has(textarea:focus-visible) {