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
@@ -12,7 +12,7 @@ import ToggleSwitchField from "../../toggle-switch/ToggleSwitchField.vue";
12
12
  import MultipleCheckboxes from "../../input-checkbox/MultipleCheckboxes.vue";
13
13
  import SingleCheckbox from "../../input-checkbox/SingleCheckbox.vue";
14
14
  import MultipleRadiobuttons from "../../input-radio/MultipleRadiobuttons.vue";
15
- import TripleToggleSwitch from "../../triple-toggle-switch/TripleToggleSwitch.vue";
15
+ import SegmentedControl from "../../segmented-control/SegmentedControl.vue";
16
16
  import InputButton from "../../input-button/InputButton.vue";
17
17
  import FormField from "../../form-field/FormField.vue";
18
18
  import FormWrapper from "../../form-wrapper/FormWrapper.vue";
@@ -31,7 +31,7 @@ interface MigratedFieldsFormStoryArgs {
31
31
  // InputTextAsNumberField, InputRangeField (InputRange), InputNumberField
32
32
  // (InputNumber), InputOtpField (InputOtp), InputTextareaField (InputTextarea), InputSelectField
33
33
  // (InputSelect), ToggleSwitchField, stacked and inline (ToggleSwitch),
34
- // MultipleCheckboxes, SingleCheckbox, MultipleRadiobuttons, TripleToggleSwitch ("Theme
34
+ // MultipleCheckboxes, SingleCheckbox, MultipleRadiobuttons, SegmentedControl ("Theme
35
35
  // preference": no Field wrapper or error state, so it sits under a plain caption).
36
36
  // - Rendered inside other fields: FormWrapper (wraps the form), FormField (wraps every field),
37
37
  // FormFieldset (checkbox/radio groups), InputLabel, InputDescription (the "Full name" and
@@ -71,7 +71,7 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
71
71
  MultipleCheckboxes,
72
72
  SingleCheckbox,
73
73
  MultipleRadiobuttons,
74
- TripleToggleSwitch,
74
+ SegmentedControl,
75
75
  InputButton,
76
76
  FormField,
77
77
  FormWrapper,
@@ -418,7 +418,7 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
418
418
  <FormField width="wide" :has-gutter="false">
419
419
  <div style="display: grid; gap: 0.8rem; justify-items: start;">
420
420
  <span>Theme preference</span>
421
- <TripleToggleSwitch
421
+ <SegmentedControl
422
422
  v-model="state.themePreference"
423
423
  v-model:field-data="themeOptions"
424
424
  name="themePreference"
@@ -8,8 +8,12 @@ directly: `InputButton` renders it when `has-pending-effect` is set, and it anim
8
8
  <InputButton button-text="Save" has-pending-effect :is-pending="saving" />
9
9
  ```
10
10
 
11
- The CSS keys off the host button's classes: `.pending-effect` (added by `has-pending-effect`),
12
- `.is-pending`, and `.icon-only`. The SVG itself is `.pending-effect-container`.
11
+ The CSS keys off the host button's state attributes: `.input-button[data-pending-effect]` (added by
12
+ `has-pending-effect`), `[data-pending]`, and `[data-icon-only]`. The SVG itself is
13
+ `.pending-effect-container`.
14
+
15
+ > **Changed 2026-10-17**: these were the classes `.pending-effect`, `.is-pending` and `.icon-only`
16
+ > until `InputButton` moved its state to `data-*` attributes.
13
17
 
14
18
  The line sits outside the button's edge, so nothing around it may clip overflow. `InputButton`
15
19
  sets `overflow: visible` on itself when `has-pending-effect` is on, but an ancestor with
@@ -50,7 +54,7 @@ positioning maths) and `--_offset` (the resolved `--pending-effect-offset`).
50
54
  ## Reduced motion
51
55
 
52
56
  Under `prefers-reduced-motion: reduce` the effect stays hidden. The host button still gets
53
- `pointer-events: none` while `.is-pending` is set, from this component's CSS, so it can't be
57
+ `pointer-events: none` while `[data-pending]` is set, from this component's CSS, so it can't be
54
58
  clicked twice.
55
59
 
56
60
  ---
@@ -34,7 +34,7 @@ watch(
34
34
 
35
35
  <style lang="css">
36
36
  @layer components {
37
- .pending-effect {
37
+ .input-button[data-pending-effect] {
38
38
  position: relative;
39
39
 
40
40
  .pending-effect-container {
@@ -72,14 +72,14 @@ watch(
72
72
  }
73
73
  }
74
74
 
75
- &.icon-only .pending-effect-container {
75
+ &[data-icon-only] .pending-effect-container {
76
76
  .pending-effect-blur,
77
77
  .pending-effect-line {
78
78
  rx: var(--pending-effect-border-radius-icon-only, 100vw);
79
79
  }
80
80
  }
81
81
 
82
- &.is-pending {
82
+ &[data-pending] {
83
83
  pointer-events: none;
84
84
  cursor: wait;
85
85
 
@@ -54,7 +54,7 @@ describe("PendingEffect", () => {
54
54
  props: { buttonText: "Save", hasPendingEffect: true },
55
55
  });
56
56
  expect(withEffect.find(".pending-effect-container").exists()).toBe(true);
57
- expect(withEffect.find("button").classes()).toContain("pending-effect");
57
+ expect(withEffect.find("button").attributes("data-pending-effect")).toBe("");
58
58
  });
59
59
 
60
60
  it("passes the button theme through", async () => {
@@ -0,0 +1,125 @@
1
+ # SegmentedControl — Consumer Styling Guide
2
+
3
+ > **Renamed 2026-10-17**: `TripleToggleSwitch` → `SegmentedControl`, since it handles any number
4
+ > of options. Every class and token changed prefix: `.triple-toggle-switch*` → `.segmented-control*`
5
+ > and `--triple-toggle-switch-*` → `--segmented-control-*`, with the rest of each name unchanged.
6
+ >
7
+ > **Renamed 2026-09-27**: `TripleToggleSwitchCore` → `TripleToggleSwitch`. The root class was
8
+ > already `.triple-toggle-switch`, and no token names changed.
9
+
10
+ ## Public token API
11
+
12
+ Colour tokens fall back to the shared `--theme-*` tokens every other themed component uses, so
13
+ leave them unset to follow the site theme. See `theming-component-token-pattern.md`.
14
+
15
+ ### Wrapper
16
+
17
+ | Token | Default | Controls |
18
+ |---|---|---|
19
+ | `--segmented-control-surface` | `var(--theme-input-surface)` | Wrapper background |
20
+ | `--segmented-control-border` | `var(--theme-border)` | Wrapper border colour |
21
+ | `--segmented-control-border-hover` | the resolved border | 1px wrapper outline colour on hover |
22
+ | `--segmented-control-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (on the wrapper). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
23
+ | `--segmented-control-ring-focus` | `var(--theme-ring)` | Wrapper outline colour while a radio has `:focus-visible` |
24
+ | `--segmented-control-padding` | `0.6rem` | Wrapper padding |
25
+ | `--segmented-control-gap` | `1rem` | Gap between the options (also drives the marker's position) |
26
+
27
+ ### Selected-option marker
28
+
29
+ | Token | Default | Controls |
30
+ |---|---|---|
31
+ | `--segmented-control-marker-surface` | `var(--theme-surface)` | Marker background when the selected value isn't `system`/`light`/`dark` |
32
+ | `--segmented-control-marker-border` | `var(--theme-text)` | Marker border colour |
33
+ | `--segmented-control-marker-gradient-system` | green `radial-gradient(...)` | Marker `background-image` when `system` is selected |
34
+ | `--segmented-control-marker-gradient-light` | red/orange `radial-gradient(...)` | Marker `background-image` when `light` is selected |
35
+ | `--segmented-control-marker-gradient-dark` | black/grey `radial-gradient(...)` | Marker `background-image` when `dark` is selected |
36
+
37
+ > **Changed 2026-09-27**: the three marker gradients were hardcoded literals with no override hook.
38
+ > They're now public tokens; the defaults are unchanged. Any `<image>` value works
39
+ > (`linear-gradient(...)`, `url(...)`, `none`).
40
+
41
+ ### Options
42
+
43
+ | Token | Default | Controls |
44
+ |---|---|---|
45
+ | `--segmented-control-option-padding` | `0.5rem` | Padding inside each option circle |
46
+ | `--segmented-control-icon-size` | `2rem` | Icon `font-size` (and the hidden radio's hit area) |
47
+ | `--segmented-control-option-border` | `#00000025` | Each option's resting border colour |
48
+ | `--segmented-control-option-border-hover` | `var(--theme-text)` | Option outline colour on hover |
49
+ | `--segmented-control-option-ring-focus` | `var(--theme-ring)` | Option outline colour on `:focus-visible` |
50
+ | `--segmented-control-option-icon-color` | `var(--theme-text)` | Icon colour, resting |
51
+ | `--segmented-control-option-icon-color-active` | `var(--theme-on-surface)` | Icon colour, selected |
52
+
53
+ > **Changed 2026-10-17**: the marker surface defaulted to `--theme-input-surface` (white) while
54
+ > the selected icon defaulted to white too, so any value other than `system`/`light`/`dark` showed
55
+ > a white icon on a white marker. The marker now defaults to `--theme-surface` and the icons to
56
+ > `--theme-text`/`--theme-on-surface`, replacing the raw `--slate-10`/`--slate-00` palette tokens
57
+ > (close to the same colours on the default theme).
58
+
59
+ Also read (shared, not component-specific): `--form-element-border-width`,
60
+ `--form-element-outline-width`, `--theme-form-transition-duration`. See
61
+ `theming-form-geometry-tokens.md`.
62
+
63
+ Private tokens (not public API): `--_form-items-gap`, `--_scheme-icon-font-size` (each read in
64
+ several places, fed by the public gap/icon-size tokens) and `--_select-scheme-group-background-color`/
65
+ `--_select-scheme-group-background-image` (swapped per selected value).
66
+
67
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
68
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
69
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
70
+ > unchanged and sits just outside the ring.
71
+
72
+ ## State hooks
73
+
74
+ - **Selected value**: the marker gradients key off `:has(input[value="system" | "light" | "dark"]:checked)`
75
+ on the root. These three values are hardcoded, a legacy of the one real consumer
76
+ (`DisplayThemeSwitch`). Any other value shows `--segmented-control-marker-surface` instead.
77
+ - **Each option** (`.segmented-control-option`, the `<label>`) carries `data-value` (the
78
+ option's value) and `data-checked` when selected. Target one with
79
+ `.segmented-control-option[data-value="light"]`.
80
+ - **Root** has `data-has-selection` when the value matches an option; without it the marker is
81
+ hidden.
82
+ - **Theme**: `data-theme` on the root, from the `theme` prop.
83
+ - **Inner classes**: `.segmented-control-wrapper`, `.segmented-control-marker-layer`,
84
+ `.segmented-control-marker`, `.segmented-control-options` (the `role="radiogroup"`),
85
+ `.segmented-control-option`, `.segmented-control-option-icon`,
86
+ `.segmented-control-option-input`.
87
+
88
+ > **Changed 2026-10-17**: the inner classes were generic (`.selected-option-marker-wrapper`,
89
+ > `.selected-option-marker`, `.option-group-wrapper`, `.option-group`, `.option-icon`,
90
+ > `.option-input`) and collided with consumer CSS. State moved too: `.option-icon.active` →
91
+ > `.segmented-control-option[data-checked]`, `.option-icon.light` (the option id as a class)
92
+ > → `.segmented-control-option[data-value="light"]`, and `.selected-option-marker.show` is gone:
93
+ > the marker is positioned in CSS from the first paint, no measuring.
94
+
95
+ ## Motion
96
+
97
+ The marker slides between options over the `stepAnimationDuration` prop (default `250ms`). Under
98
+ `prefers-reduced-motion: reduce` the slide transition is removed and the marker jumps.
99
+
100
+ ## Global theming
101
+
102
+ ```css
103
+ :where(html) {
104
+ --segmented-control-surface: var(--rose-09);
105
+ --segmented-control-option-ring-focus: var(--rose-04);
106
+ }
107
+ ```
108
+
109
+ ## Local overrides
110
+
111
+ Set the tokens above on an element you own (a page or section class, or a class added with
112
+ `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
113
+ `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
114
+ `<style scoped>`, tokens set on your element still work, but selectors that reach inside the
115
+ component need `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
116
+
117
+ **Caveat:** when used through `DisplayThemeSwitch`, that wrapper re-declares the four sizing tokens
118
+ (`--segmented-control-gap`, `-padding`, `-option-padding`, `-icon-size`) on the root element, so
119
+ setting them on an ancestor has no effect there. Set them on the component itself (via
120
+ `style` or a passthrough class) instead. Used directly, `SegmentedControl` re-declares nothing.
121
+
122
+ ## Class passthrough
123
+
124
+ `style-class-passthrough` adds classes to the root `.segmented-control` element, alongside
125
+ `data-theme`. `DisplayThemeSwitch` uses it for its `colour-scheme-select` and `small` classes.
@@ -0,0 +1,231 @@
1
+ <template>
2
+ <div
3
+ class="segmented-control"
4
+ :class="elementClasses"
5
+ :data-theme="theme"
6
+ :data-has-selection="hasSelection ? '' : undefined"
7
+ >
8
+ <div class="segmented-control-wrapper">
9
+ <div class="segmented-control-marker-layer" aria-hidden="true">
10
+ <div class="segmented-control-marker"></div>
11
+ </div>
12
+ <div class="segmented-control-options" role="radiogroup" :aria-label="ariaLabel || undefined">
13
+ <label
14
+ v-for="(option, index) in options"
15
+ :key="option.id ?? index"
16
+ :for="`${uid}-${index}`"
17
+ class="segmented-control-option"
18
+ :data-value="option.value"
19
+ :data-checked="modelValue === option.value ? '' : undefined"
20
+ >
21
+ <span class="sr-only">{{ option.label }}</span>
22
+ <Icon v-if="option.icon" :name="option.icon" class="segmented-control-option-icon" aria-hidden="true" />
23
+ <input
24
+ :id="`${uid}-${index}`"
25
+ v-model="modelValue"
26
+ type="radio"
27
+ :name="groupName"
28
+ class="segmented-control-option-input"
29
+ :value="option.value"
30
+ />
31
+ </label>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </template>
36
+
37
+ <script setup lang="ts">
38
+ import type { FormUiTheme, IFormMultipleOptions } from "~/types/forms/types.forms";
39
+
40
+ interface Props {
41
+ name?: string;
42
+ ariaLabel?: string;
43
+ theme?: FormUiTheme;
44
+ stepAnimationDuration?: string;
45
+ styleClassPassthrough?: string | string[];
46
+ }
47
+
48
+ const props = withDefaults(defineProps<Props>(), {
49
+ name: undefined,
50
+ ariaLabel: "",
51
+ theme: "default",
52
+ stepAnimationDuration: "250ms",
53
+ styleClassPassthrough: () => [],
54
+ });
55
+
56
+ const modelValue = defineModel<string | number | boolean>({ required: true });
57
+ const fieldData = defineModel<IFormMultipleOptions>("fieldData", { required: true });
58
+
59
+ const uid = useId();
60
+ const groupName = computed(() => props.name || `segmented-control-${uid}`);
61
+
62
+ const options = computed(() => fieldData.value?.data ?? []);
63
+ const optionCount = computed(() => Math.max(1, options.value.length));
64
+ const selectedOptionIndex = computed(() => options.value.findIndex((option) => option.value === modelValue.value));
65
+ const hasSelection = computed(() => selectedOptionIndex.value >= 0);
66
+ const markerIndex = computed(() => Math.max(0, selectedOptionIndex.value));
67
+
68
+ const { stepAnimationDuration } = toRefs(props);
69
+
70
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
71
+
72
+ watch(
73
+ () => props.styleClassPassthrough,
74
+ () => {
75
+ resetElementClasses(props.styleClassPassthrough);
76
+ }
77
+ );
78
+ </script>
79
+
80
+ <style lang="css">
81
+ @layer components {
82
+ .segmented-control {
83
+ --_form-items-gap: var(--segmented-control-gap, 1rem);
84
+ --_select-scheme-group-background-color: var(--segmented-control-marker-surface, var(--theme-surface));
85
+ --_select-scheme-group-background-image: none;
86
+ --_scheme-icon-font-size: var(--segmented-control-icon-size, 2rem);
87
+
88
+ &:has(input[value="system"]:checked) {
89
+ --_select-scheme-group-background-color: transparent;
90
+ --_select-scheme-group-background-image: var(
91
+ --segmented-control-marker-gradient-system,
92
+ radial-gradient(circle, rgb(66, 180, 58) 0%, rgb(17, 199, 0) 27%, rgb(8, 117, 3) 100%)
93
+ );
94
+ }
95
+
96
+ &:has(input[value="light"]:checked) {
97
+ --_select-scheme-group-background-color: transparent;
98
+ --_select-scheme-group-background-image: var(
99
+ --segmented-control-marker-gradient-light,
100
+ radial-gradient(circle, rgba(180, 58, 91, 1) 0%, rgba(253, 29, 29, 1) 27%, rgba(252, 176, 69, 1) 100%)
101
+ );
102
+ }
103
+
104
+ &:has(input[value="dark"]:checked) {
105
+ --_select-scheme-group-background-color: transparent;
106
+ --_select-scheme-group-background-image: var(
107
+ --segmented-control-marker-gradient-dark,
108
+ radial-gradient(circle, rgb(50, 20, 25) 0%, rgb(0, 0, 0) 27%, rgb(100, 100, 100) 100%)
109
+ );
110
+ }
111
+
112
+ .segmented-control-wrapper {
113
+ display: inline-grid;
114
+ grid-template-areas: "select-stack";
115
+ width: fit-content;
116
+ max-inline-size: 100%;
117
+
118
+ background-color: var(--segmented-control-surface, var(--theme-input-surface));
119
+ border: var(--form-element-border-width) solid var(--segmented-control-border, var(--theme-border));
120
+ outline: var(--form-element-outline-width) solid transparent;
121
+ box-shadow: 0 0 0 var(--form-element-shadow-width)
122
+ var(--segmented-control-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
123
+ border-radius: 100vw;
124
+ padding: var(--segmented-control-padding, 0.6rem);
125
+
126
+ transition: all var(--theme-form-transition-duration) ease-in-out;
127
+
128
+ &:hover {
129
+ outline-color: var(--segmented-control-border-hover, var(--segmented-control-border, var(--theme-border)));
130
+ }
131
+
132
+ &:has(input:focus-visible) {
133
+ outline: var(--form-element-outline-width) solid var(--segmented-control-ring-focus, var(--theme-ring));
134
+ outline-offset: 0.2rem;
135
+ }
136
+
137
+ & .segmented-control-marker-layer,
138
+ & .segmented-control-options {
139
+ grid-area: select-stack;
140
+ display: grid;
141
+ grid-template-columns: repeat(v-bind(optionCount), minmax(0, 1fr));
142
+ gap: var(--_form-items-gap);
143
+ }
144
+
145
+ & .segmented-control-marker-layer {
146
+ z-index: 1;
147
+
148
+ .segmented-control-marker {
149
+ grid-column: 1;
150
+ position: relative;
151
+ inset-inline-start: calc(v-bind(markerIndex) * (100% + var(--_form-items-gap)));
152
+ transition: inset-inline-start v-bind(stepAnimationDuration) ease-in-out;
153
+ background-color: var(--_select-scheme-group-background-color);
154
+ background-image: var(--_select-scheme-group-background-image);
155
+ border: var(--form-element-border-width) solid var(--segmented-control-marker-border, var(--theme-text));
156
+ border-radius: 50%;
157
+ opacity: 0;
158
+
159
+ @media (prefers-reduced-motion: reduce) {
160
+ transition: none;
161
+ }
162
+ }
163
+ }
164
+
165
+ & .segmented-control-options {
166
+ z-index: 2;
167
+ align-items: center;
168
+
169
+ .segmented-control-option {
170
+ display: grid;
171
+ grid-template-areas: "icon-stack";
172
+ place-items: center;
173
+ min-inline-size: 0;
174
+ background: transparent;
175
+ border: var(--form-element-border-width) solid var(--segmented-control-option-border, #00000025);
176
+ outline: var(--form-element-outline-width) solid transparent;
177
+ border-radius: 50%;
178
+ padding: var(--segmented-control-option-padding, 0.5rem);
179
+ overflow: hidden;
180
+ cursor: pointer;
181
+
182
+ transition: all calc(var(--theme-form-transition-duration) / 3);
183
+
184
+ &:hover {
185
+ outline-color: var(--segmented-control-option-border-hover, var(--theme-text));
186
+ }
187
+
188
+ &:has(input:focus-visible) {
189
+ outline: var(--form-element-outline-width) solid
190
+ var(--segmented-control-option-ring-focus, var(--theme-ring));
191
+ outline-offset: 0.2rem;
192
+ }
193
+
194
+ .segmented-control-option-icon {
195
+ grid-area: icon-stack;
196
+ display: block;
197
+ font-size: var(--_scheme-icon-font-size);
198
+ width: 1em;
199
+ height: 1em;
200
+ color: var(--segmented-control-option-icon-color, var(--theme-text));
201
+ transition: color calc(v-bind(stepAnimationDuration) / 2) ease-in-out;
202
+
203
+ @media (prefers-reduced-motion: reduce) {
204
+ transition: none;
205
+ }
206
+ }
207
+
208
+ &[data-checked] .segmented-control-option-icon {
209
+ color: var(--segmented-control-option-icon-color-active, var(--theme-on-surface));
210
+ transition-delay: calc(v-bind(stepAnimationDuration) / 2);
211
+ }
212
+
213
+ .segmented-control-option-input {
214
+ grid-area: icon-stack;
215
+ appearance: none;
216
+ margin: 0;
217
+ opacity: 0;
218
+ width: var(--_scheme-icon-font-size);
219
+ height: var(--_scheme-icon-font-size);
220
+ cursor: pointer;
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ &[data-has-selection] .segmented-control-wrapper .segmented-control-marker {
227
+ opacity: 1;
228
+ }
229
+ }
230
+ }
231
+ </style>
@@ -0,0 +1,215 @@
1
+ import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import { ref } from "vue";
3
+ import StorybookComponent from "../SegmentedControl.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
5
+ import type { IFormMultipleOptions } from "~/types/forms/types.forms.d";
6
+ import type { MediaCanvas } from "~/types/components";
7
+
8
+ interface SegmentedControlStoryArgs {
9
+ name: string;
10
+ ariaLabel: string;
11
+ stepAnimationDuration: string;
12
+ }
13
+
14
+ // The marker gradients key off three option values: "system", "light", "dark" (see
15
+ // CONSUMER-STYLING.md). Any other value falls back to --segmented-control-marker-surface.
16
+ export default {
17
+ title: "Components/Forms/Segmented Control/SegmentedControl",
18
+ component: StorybookComponent,
19
+ argTypes: {
20
+ name: {
21
+ control: "text",
22
+ description: "Radio group name. Leave empty for a unique generated name",
23
+ table: { category: "Basic" },
24
+ },
25
+ ariaLabel: {
26
+ control: "text",
27
+ description: "Accessible name for the radiogroup",
28
+ table: { category: "Accessibility" },
29
+ },
30
+ stepAnimationDuration: {
31
+ control: "text",
32
+ description: "CSS transition duration for the marker sliding between options",
33
+ table: { category: "Styling" },
34
+ },
35
+ },
36
+ args: {
37
+ name: "",
38
+ ariaLabel: "Colour scheme",
39
+ stepAnimationDuration: "250ms",
40
+ },
41
+ decorators: [
42
+ (story, context) => ({
43
+ components: { story, CanvasSwitcher },
44
+ setup() {
45
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
46
+ return { canvasName };
47
+ },
48
+ template: `
49
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
50
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
51
+ </div>
52
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
53
+ <story />
54
+ </div>
55
+ `,
56
+ }),
57
+ ],
58
+ parameters: {
59
+ layout: "fullscreen",
60
+ docs: {
61
+ description: {
62
+ component:
63
+ "A segmented radio group drawn as icons in a pill, with a marker that slides to the selected option. `v-model` is the selected value and `v-model:field-data` the options (`data[]` of `value`, `label`, `icon`); labels are screen-reader text. `aria-label` names the group, `name` defaults to a unique generated name, and `step-animation-duration` sets the slide. The values `system`, `light` and `dark` get their own marker gradients. [DisplayThemeSwitch](?path=/story/molecules-displaythemeswitch--default) wraps it as a colour-scheme switch.",
64
+ },
65
+ },
66
+ },
67
+ } as Meta<SegmentedControlStoryArgs>;
68
+
69
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
70
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
71
+ const themeSwitchNote = `
72
+ <p style="${storyNoteStyle}">
73
+ Wired to the site's colour scheme with labels, icons and a small size in the
74
+ <a href="/?path=/story/molecules-displaythemeswitch--default" target="_top">DisplayThemeSwitch</a> story.
75
+ </p>
76
+ `;
77
+
78
+ const buildOptions = (): IFormMultipleOptions => ({
79
+ data: [
80
+ { id: "system", name: "colour-scheme", value: "system", label: "System", icon: "material-symbols:night-sight-auto-sharp" },
81
+ { id: "light", name: "colour-scheme", value: "light", label: "Light", icon: "radix-icons:sun" },
82
+ { id: "dark", name: "colour-scheme", value: "dark", label: "Dark", icon: "radix-icons:moon" },
83
+ ],
84
+ total: 3,
85
+ skip: 0,
86
+ limit: 3,
87
+ });
88
+
89
+ const Template: StoryFn<SegmentedControlStoryArgs> = (args) => ({
90
+ components: { StorybookComponent },
91
+ setup() {
92
+ const modelValue = ref("system");
93
+ const fieldData = ref<IFormMultipleOptions>(buildOptions());
94
+ return { args, modelValue, fieldData };
95
+ },
96
+ template: `
97
+ <div>
98
+ ${themeSwitchNote}
99
+ <StorybookComponent
100
+ v-model="modelValue"
101
+ v-model:field-data="fieldData"
102
+ :name="args.name || undefined"
103
+ :aria-label="args.ariaLabel"
104
+ :step-animation-duration="args.stepAnimationDuration"
105
+ />
106
+ <div style="margin-top: 1.6rem; font-size: 1.4rem;">Current value: {{ modelValue }}</div>
107
+ </div>
108
+ `,
109
+ });
110
+
111
+ export const Default = Template.bind({});
112
+ Default.parameters = {
113
+ docs: { description: { story: "The three colour-scheme values, each with its own marker gradient." } },
114
+ };
115
+
116
+ const CustomTokensTemplate: StoryFn<SegmentedControlStoryArgs> = (args) => ({
117
+ components: { StorybookComponent },
118
+ setup() {
119
+ const modelValue = ref("light");
120
+ const fieldData = ref<IFormMultipleOptions>(buildOptions());
121
+ return { args, modelValue, fieldData };
122
+ },
123
+ template: `
124
+ <div
125
+ style="
126
+ --segmented-control-gap: 0.4rem;
127
+ --segmented-control-padding: 0.4rem;
128
+ --segmented-control-icon-size: 1.6rem;
129
+ --segmented-control-marker-gradient-system: linear-gradient(135deg, #0ea5e9, #6366f1);
130
+ --segmented-control-marker-gradient-light: linear-gradient(135deg, #facc15, #f97316);
131
+ --segmented-control-marker-gradient-dark: linear-gradient(135deg, #1e293b, #475569);
132
+ "
133
+ >
134
+ <StorybookComponent
135
+ v-model="modelValue"
136
+ v-model:field-data="fieldData"
137
+ :name="args.name || undefined"
138
+ :aria-label="args.ariaLabel"
139
+ :step-animation-duration="args.stepAnimationDuration"
140
+ />
141
+ <div style="margin-top: 1.6rem; font-size: 1.4rem;">Current value: {{ modelValue }}</div>
142
+ </div>
143
+ `,
144
+ });
145
+
146
+ export const CustomTokens = CustomTokensTemplate.bind({});
147
+ CustomTokens.parameters = {
148
+ docs: {
149
+ description: { story: "Smaller sizing and custom marker gradients, set as tokens on a wrapper." },
150
+ },
151
+ };
152
+
153
+ const stressOptions = (): IFormMultipleOptions => ({
154
+ data: [
155
+ { id: "a", name: "stress", value: "first", label: "Erste Option mit einer sehr langen Beschreibung für Bildschirmleser", icon: "radix-icons:sun" },
156
+ { id: "a", name: "stress", value: "second", label: "<b>not bold</b>", icon: "radix-icons:moon" },
157
+ { id: "c", name: "stress", value: "third", label: "خيار ثالث" },
158
+ { id: "d", name: "stress", value: "fourth", label: "🎉", icon: "material-symbols:night-sight-auto-sharp" },
159
+ { id: "e", name: "stress", value: "fifth", label: "x", icon: "radix-icons:gear" },
160
+ ],
161
+ total: 5,
162
+ skip: 0,
163
+ limit: 5,
164
+ });
165
+
166
+ const StressTemplate: StoryFn<SegmentedControlStoryArgs> = (args) => ({
167
+ components: { StorybookComponent },
168
+ setup() {
169
+ const fiveValue = ref("second");
170
+ const fiveOptions = ref<IFormMultipleOptions>(stressOptions());
171
+ const unknownValue = ref("not-an-option");
172
+ const unknownOptions = ref<IFormMultipleOptions>(buildOptions());
173
+ const singleValue = ref("only");
174
+ const singleOptions = ref<IFormMultipleOptions>({
175
+ data: [{ id: "only", name: "single", value: "only", label: "Only option", icon: "radix-icons:sun" }],
176
+ total: 1,
177
+ skip: 0,
178
+ limit: 1,
179
+ });
180
+ return { args, fiveValue, fiveOptions, unknownValue, unknownOptions, singleValue, singleOptions };
181
+ },
182
+ template: `
183
+ <div style="display: grid; gap: 2.4rem; justify-items: start;">
184
+ <div>
185
+ <p style="margin: 0 0 0.8rem; font-size: 1.4rem;">Five options, one without an icon, a duplicate id in the data</p>
186
+ <StorybookComponent v-model="fiveValue" v-model:field-data="fiveOptions" :aria-label="args.ariaLabel" :step-animation-duration="args.stepAnimationDuration" />
187
+ <div style="margin-top: 0.8rem; font-size: 1.4rem;">Current value: {{ fiveValue }}</div>
188
+ </div>
189
+ <div>
190
+ <p style="margin: 0 0 0.8rem; font-size: 1.4rem;">Value that matches no option (no marker)</p>
191
+ <StorybookComponent v-model="unknownValue" v-model:field-data="unknownOptions" :aria-label="args.ariaLabel" :step-animation-duration="args.stepAnimationDuration" />
192
+ </div>
193
+ <div>
194
+ <p style="margin: 0 0 0.8rem; font-size: 1.4rem;">A single option</p>
195
+ <StorybookComponent v-model="singleValue" v-model:field-data="singleOptions" :aria-label="args.ariaLabel" :step-animation-duration="args.stepAnimationDuration" />
196
+ </div>
197
+ <div dir="rtl">
198
+ <p style="margin: 0 0 0.8rem; font-size: 1.4rem;">Right-to-left</p>
199
+ <StorybookComponent v-model="fiveValue" v-model:field-data="fiveOptions" :aria-label="args.ariaLabel" :step-animation-duration="args.stepAnimationDuration" />
200
+ </div>
201
+ </div>
202
+ `,
203
+ });
204
+
205
+ export const StressTest = StressTemplate.bind({});
206
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
207
+ StressTest.parameters = {
208
+ initialCanvas: "mobileCanvas",
209
+ docs: {
210
+ description: {
211
+ story:
212
+ "Five options (one with no icon, two sharing a data id, long, HTML-like, right-to-left and emoji labels as screen-reader text), a value that matches no option, a single option, and the five-option group in a right-to-left container. Check at every canvas width: the marker lines up with the selected option and slides the right way in RTL, the pill shrinks inside the dashed outline rather than overflowing, no marker shows for the unknown value, and clicking any option in either five-option group selects it in both (they share one model) without the two groups interfering.",
213
+ },
214
+ },
215
+ };