srcdev-nuxt-components 9.6.9 → 9.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +4 -2
  2. package/.claude/skills/component-compliance-checklist.md +24 -2
  3. package/.claude/skills/component-inline-action-button.md +5 -4
  4. package/.claude/skills/component-naming.md +8 -2
  5. package/.claude/skills/components/{alert-mask-core.md → alert-mask.md} +38 -23
  6. package/.claude/skills/components/alert-masked-content.md +3 -3
  7. package/.claude/skills/components/container-glow.md +1 -1
  8. package/.claude/skills/components/display-theme-switch.md +8 -8
  9. package/.claude/skills/components/input-button.md +26 -7
  10. package/.claude/skills/components/input-checkbox-radio.md +2 -0
  11. package/.claude/skills/components/input-description.md +5 -0
  12. package/.claude/skills/components/input-error.md +9 -2
  13. package/.claude/skills/components/input-label.md +8 -2
  14. package/.claude/skills/components/input-number.md +2 -0
  15. package/.claude/skills/components/input-otp.md +2 -0
  16. package/.claude/skills/components/input-range.md +2 -0
  17. package/.claude/skills/components/input-select.md +2 -0
  18. package/.claude/skills/components/input-text.md +2 -0
  19. package/.claude/skills/components/input-textarea.md +2 -0
  20. package/.claude/skills/components/pending-effect.md +5 -4
  21. package/.claude/skills/components/{triple-toggle-switch.md → segmented-control.md} +34 -19
  22. package/.claude/skills/components/services-card.md +1 -1
  23. package/.claude/skills/components/tabbed-content.md +1 -1
  24. package/.claude/skills/components/toggle-switch.md +2 -0
  25. package/.claude/skills/css-nesting-conventions.md +22 -1
  26. package/.claude/skills/index.md +5 -5
  27. package/.claude/skills/storybook-add-story.md +3 -1
  28. package/.claude/skills/theming-component-token-pattern.md +24 -0
  29. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  30. package/.claude/skills/theming-partial-override.md +1 -1
  31. package/.vscode/srcdev-component-alert-mask.code-snippets +41 -0
  32. package/.vscode/{srcdev-component-triple-toggle-switch.code-snippets → srcdev-component-segmented-control.code-snippets} +6 -7
  33. package/app/assets/styles/setup/03.theming/_theme-slots.css +1 -1
  34. package/app/assets/styles/setup/04.elements/forms/03.generic-input-geometry.css +4 -0
  35. package/app/components/01.atoms/alert-mask/AlertMask.vue +194 -0
  36. package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +57 -42
  37. package/app/components/01.atoms/alert-mask/stories/AlertMask.stories.ts +198 -0
  38. package/app/components/01.atoms/alert-mask/tests/AlertMask.spec.ts +233 -0
  39. package/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue +2 -2
  40. package/app/components/02.molecules/alert-masked-content/CONSUMER-STYLING.md +4 -4
  41. package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +60 -0
  42. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +12 -12
  43. package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +3 -3
  44. package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +1 -1
  45. package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +1 -1
  46. package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +9 -9
  47. package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +16 -16
  48. package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +53 -1
  49. package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +8 -8
  50. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +4 -4
  51. package/app/components/02.molecules/input-copy/InputCopy.vue +2 -2
  52. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +2 -2
  53. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +4 -4
  54. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +2 -2
  55. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +2 -2
  56. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +6 -6
  57. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +8 -0
  58. package/app/components/05.forms/form-errors/InputError.vue +43 -37
  59. package/app/components/05.forms/form-errors/stories/InputError.stories.ts +79 -1
  60. package/app/components/05.forms/form-errors/tests/InputError.spec.ts +29 -1
  61. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +80 -47
  62. package/app/components/05.forms/input-button/InputButton.vue +52 -100
  63. package/app/components/05.forms/input-button/playwright/input-button.playwright.ts +1 -1
  64. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +145 -8
  65. package/app/components/05.forms/input-button/tests/InputButton.spec.ts +76 -46
  66. package/app/components/05.forms/input-button/tests/__snapshots__/InputButton.spec.ts.snap +17 -17
  67. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +9 -1
  68. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +8 -0
  69. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +2 -2
  70. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +14 -2
  71. package/app/components/05.forms/input-description/InputDescription.vue +2 -1
  72. package/app/components/05.forms/input-description/stories/InputDescription.stories.ts +70 -4
  73. package/app/components/05.forms/input-description/tests/InputDescription.spec.ts +2 -1
  74. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +10 -2
  75. package/app/components/05.forms/input-label/InputLabel.vue +1 -0
  76. package/app/components/05.forms/input-label/stories/InputLabel.stories.ts +68 -2
  77. package/app/components/05.forms/input-label/tests/InputLabel.spec.ts +13 -3
  78. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +7 -1
  79. package/app/components/05.forms/input-number/InputNumber.vue +4 -5
  80. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +7 -1
  81. package/app/components/05.forms/input-otp/InputOtp.vue +2 -2
  82. package/app/components/05.forms/input-range/InputRange.vue +1 -1
  83. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +7 -1
  84. package/app/components/05.forms/input-select/InputSelect.vue +14 -5
  85. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +7 -1
  86. package/app/components/05.forms/input-text/InputText.vue +4 -4
  87. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +7 -1
  88. package/app/components/05.forms/input-textarea/InputTextarea.vue +2 -3
  89. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +4 -4
  90. package/app/components/05.forms/pending-effect/CONSUMER-STYLING.md +7 -3
  91. package/app/components/05.forms/pending-effect/PendingEffect.vue +3 -3
  92. package/app/components/05.forms/pending-effect/tests/PendingEffect.spec.ts +1 -1
  93. package/app/components/05.forms/segmented-control/CONSUMER-STYLING.md +125 -0
  94. package/app/components/05.forms/segmented-control/SegmentedControl.vue +231 -0
  95. package/app/components/05.forms/segmented-control/stories/SegmentedControl.stories.ts +215 -0
  96. package/app/components/05.forms/segmented-control/tests/SegmentedControl.spec.ts +202 -0
  97. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +7 -0
  98. package/app/components/05.forms/toggle-switch/ToggleSwitch.vue +2 -0
  99. package/app/types/components/index.ts +1 -1
  100. package/package.json +1 -1
  101. package/.vscode/srcdev-component-alert-mask-core.code-snippets +0 -31
  102. package/app/components/01.atoms/alert-mask/AlertMaskCore.vue +0 -166
  103. package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +0 -105
  104. package/app/components/01.atoms/alert-mask/tests/AlertMaskCore.spec.ts +0 -342
  105. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +0 -99
  106. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +0 -251
  107. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitch.stories.ts +0 -104
  108. package/app/components/05.forms/triple-toggle-switch/tests/TripleToggleSwitch.spec.ts +0 -138
  109. /package/app/types/components/{alert-mask-core.d.ts → alert-mask.d.ts} +0 -0
@@ -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
+ };
@@ -0,0 +1,202 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { defineComponent, ref } from "vue";
4
+ import SegmentedControl from "../SegmentedControl.vue";
5
+ import type { IFormMultipleOptions } from "~/types/forms/types.forms";
6
+
7
+ const buildOptions = (): IFormMultipleOptions => ({
8
+ data: [
9
+ { id: "system", name: "scheme", value: "system", label: "System", icon: "radix-icons:gear" },
10
+ { id: "light", name: "scheme", value: "light", label: "Light", icon: "radix-icons:sun" },
11
+ { id: "dark", name: "scheme", value: "dark", label: "Dark", icon: "radix-icons:moon" },
12
+ ],
13
+ total: 3,
14
+ skip: 0,
15
+ limit: 3,
16
+ });
17
+
18
+ const mounted: { unmount: () => void }[] = [];
19
+
20
+ const mountSwitch = async (props: Record<string, unknown> = {}) => {
21
+ const wrapper = await mountSuspended(SegmentedControl, {
22
+ props: {
23
+ modelValue: "system",
24
+ fieldData: buildOptions(),
25
+ ...props,
26
+ },
27
+ });
28
+ mounted.push(wrapper);
29
+ return wrapper;
30
+ };
31
+
32
+ interface SegmentedControlInstance {
33
+ selectedOptionIndex: number;
34
+ markerIndex: number;
35
+ optionCount: number;
36
+ }
37
+
38
+ const inputs = (wrapper: Awaited<ReturnType<typeof mountSwitch>>) => wrapper.findAll("input.segmented-control-option-input");
39
+ const optionLabels = (wrapper: Awaited<ReturnType<typeof mountSwitch>>) => wrapper.findAll("label.segmented-control-option");
40
+
41
+ describe("SegmentedControl", () => {
42
+ afterEach(() => {
43
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
44
+ vi.restoreAllMocks();
45
+ });
46
+
47
+ it("mounts without error", async () => {
48
+ const wrapper = await mountSwitch();
49
+ expect(wrapper.vm).toBeTruthy();
50
+ expect(wrapper.find(".segmented-control").exists()).toBe(true);
51
+ });
52
+
53
+ it("renders one radio per option with its value and the shared name", async () => {
54
+ const wrapper = await mountSwitch({ name: "colour-scheme" });
55
+ expect(inputs(wrapper)).toHaveLength(3);
56
+ expect(inputs(wrapper).map((i) => (i.element as HTMLInputElement).value)).toEqual(["system", "light", "dark"]);
57
+ inputs(wrapper).forEach((i) => {
58
+ expect(i.attributes("type")).toBe("radio");
59
+ expect(i.attributes("name")).toBe("colour-scheme");
60
+ });
61
+ });
62
+
63
+ it("builds ids from useId, not from option data", async () => {
64
+ const wrapper = await mountSwitch();
65
+ const ids = inputs(wrapper).map((i) => i.attributes("id"));
66
+ expect(ids).not.toContain("system");
67
+ expect(new Set(ids).size).toBe(3);
68
+ expect(optionLabels(wrapper).map((l) => l.attributes("for"))).toEqual(ids);
69
+ });
70
+
71
+ it("gives two instances separate radio groups and ids", async () => {
72
+ const Host = defineComponent({
73
+ components: { SegmentedControl },
74
+ setup() {
75
+ return { first: ref("system"), second: ref("dark"), options: ref(buildOptions()) };
76
+ },
77
+ template: `
78
+ <div>
79
+ <SegmentedControl v-model="first" v-model:field-data="options" />
80
+ <SegmentedControl v-model="second" v-model:field-data="options" />
81
+ </div>
82
+ `,
83
+ });
84
+ const wrapper = await mountSuspended(Host);
85
+ mounted.push(wrapper);
86
+ const all = wrapper.findAll("input.segmented-control-option-input");
87
+ const names = new Set(all.map((i) => i.attributes("name")));
88
+ const ids = new Set(all.map((i) => i.attributes("id")));
89
+ expect(names.size).toBe(2);
90
+ expect(ids.size).toBe(6);
91
+ });
92
+
93
+ it("renders the label text for screen readers", async () => {
94
+ const wrapper = await mountSwitch();
95
+ expect(wrapper.findAll(".segmented-control-option .sr-only").map((s) => s.text())).toEqual([
96
+ "System",
97
+ "Light",
98
+ "Dark",
99
+ ]);
100
+ });
101
+
102
+ it("exposes the options as a radiogroup", async () => {
103
+ const wrapper = await mountSwitch();
104
+ const group = wrapper.find(".segmented-control-options");
105
+ expect(group.attributes("role")).toBe("radiogroup");
106
+ expect(group.attributes("aria-label")).toBeUndefined();
107
+ });
108
+
109
+ it("applies ariaLabel to the radiogroup", async () => {
110
+ const wrapper = await mountSwitch({ ariaLabel: "Colour scheme" });
111
+ expect(wrapper.find(".segmented-control-options").attributes("aria-label")).toBe("Colour scheme");
112
+ });
113
+
114
+ it("hides the decorative marker from assistive tech", async () => {
115
+ const wrapper = await mountSwitch();
116
+ expect(wrapper.find(".segmented-control-marker-layer").attributes("aria-hidden")).toBe("true");
117
+ });
118
+
119
+ it("checks the radio matching modelValue", async () => {
120
+ const wrapper = await mountSwitch({ modelValue: "light" });
121
+ const checked = inputs(wrapper).map((i) => (i.element as HTMLInputElement).checked);
122
+ expect(checked).toEqual([false, true, false]);
123
+ });
124
+
125
+ it("emits update:modelValue when another option is chosen", async () => {
126
+ const wrapper = await mountSwitch();
127
+ await inputs(wrapper)[2]!.setValue(true);
128
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual(["dark"]);
129
+ });
130
+
131
+ it("marks only the selected option with data-checked and exposes its value", async () => {
132
+ const wrapper = await mountSwitch({ modelValue: "dark" });
133
+ expect(optionLabels(wrapper).map((l) => l.attributes("data-checked"))).toEqual([undefined, undefined, ""]);
134
+ expect(optionLabels(wrapper).map((l) => l.attributes("data-value"))).toEqual(["system", "light", "dark"]);
135
+
136
+ await wrapper.setProps({ modelValue: "light" });
137
+ expect(optionLabels(wrapper).map((l) => l.attributes("data-checked"))).toEqual([undefined, "", undefined]);
138
+ });
139
+
140
+ it("does not use collision-prone bare class names", async () => {
141
+ const wrapper = await mountSwitch();
142
+ for (const name of [".option-group", ".option-icon", ".option-input", ".selected-option-marker", ".active", ".show"]) {
143
+ expect(wrapper.find(name).exists()).toBe(false);
144
+ }
145
+ });
146
+
147
+ it("omits the icon when an option has none", async () => {
148
+ const options = buildOptions();
149
+ delete options.data[1]!.icon;
150
+ const wrapper = await mountSwitch({ fieldData: options });
151
+ expect(wrapper.findAll(".segmented-control-option-icon")).toHaveLength(2);
152
+ expect(inputs(wrapper)).toHaveLength(3);
153
+ });
154
+
155
+ it("marks icons as decorative", async () => {
156
+ const wrapper = await mountSwitch();
157
+ wrapper.findAll(".segmented-control-option-icon").forEach((icon) => {
158
+ expect(icon.attributes("aria-hidden")).toBe("true");
159
+ });
160
+ });
161
+
162
+ it("tracks the selected index and hides the marker for an unknown value", async () => {
163
+ const wrapper = await mountSwitch({ modelValue: "dark" });
164
+ const vm = wrapper.vm as unknown as SegmentedControlInstance;
165
+ const root = () => wrapper.find(".segmented-control");
166
+ expect(vm.selectedOptionIndex).toBe(2);
167
+ expect(vm.markerIndex).toBe(2);
168
+ expect(root().attributes("data-has-selection")).toBe("");
169
+
170
+ await wrapper.setProps({ modelValue: "unknown" });
171
+ expect(vm.selectedOptionIndex).toBe(-1);
172
+ expect(vm.markerIndex).toBe(0);
173
+ expect(root().attributes("data-has-selection")).toBeUndefined();
174
+ });
175
+
176
+ it("sizes the grid from the option count, never below one column", async () => {
177
+ const four = buildOptions();
178
+ four.data.push({ id: "auto", name: "scheme", value: "auto", label: "Auto", icon: "radix-icons:gear" });
179
+ const wrapper = await mountSwitch({ fieldData: four });
180
+ expect((wrapper.vm as unknown as SegmentedControlInstance).optionCount).toBe(4);
181
+
182
+ const empty = await mountSwitch({ fieldData: { data: [], total: 0, skip: 0, limit: 0 } });
183
+ expect((empty.vm as unknown as SegmentedControlInstance).optionCount).toBe(1);
184
+ expect(empty.findAll("input")).toHaveLength(0);
185
+ });
186
+
187
+ it("applies the theme as data-theme", async () => {
188
+ const wrapper = await mountSwitch({ theme: "success" });
189
+ expect(wrapper.find(".segmented-control").attributes("data-theme")).toBe("success");
190
+ });
191
+
192
+ it("applies styleClassPassthrough classes to the root and reacts to changes", async () => {
193
+ const wrapper = await mountSwitch({ styleClassPassthrough: ["small", "extra"] });
194
+ const root = () => wrapper.find(".segmented-control");
195
+ expect(root().classes()).toContain("small");
196
+ expect(root().classes()).toContain("extra");
197
+
198
+ await wrapper.setProps({ styleClassPassthrough: ["other"] });
199
+ expect(root().classes()).toContain("other");
200
+ expect(root().classes()).not.toContain("small");
201
+ });
202
+ });
@@ -18,6 +18,8 @@ this two-tier shape exists.
18
18
  | `--toggle-switch-surface` | `var(--theme-checkbox-symbol-surface)` | Track background (resting) |
19
19
  | `--toggle-switch-surface-hover` | `var(--theme-surface-subtle)` | Track background on hover |
20
20
  | `--toggle-switch-border` | `var(--theme-border)` | Track border colour |
21
+ | `--toggle-switch-border-hover` | the resolved border | 1px track outline colour on hover |
22
+ | `--toggle-switch-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (on the track). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
21
23
  | `--toggle-switch-border-focus` | `var(--theme-border-focus)` | Track outline colour on `:focus-visible` |
22
24
  | `--toggle-switch-symbol-border` | `var(--theme-text)` | Thumb border colour |
23
25
  | `--toggle-switch-symbol-surface-off` | `var(--theme-on-surface)` | Thumb background, off |
@@ -54,6 +56,11 @@ Only set `--toggle-switch-track-width` for a width the formula doesn't produce.
54
56
 
55
57
  ---
56
58
 
59
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
60
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
61
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
62
+ > unchanged and sits just outside the ring.
63
+
57
64
  ## State hooks
58
65
 
59
66
  | Attribute | On | When |
@@ -133,12 +133,14 @@ watch(
133
133
  background-color: var(--toggle-switch-surface, var(--theme-checkbox-symbol-surface));
134
134
  border: var(--form-element-border-width) solid var(--toggle-switch-border, var(--theme-border));
135
135
  outline: var(--form-element-outline-width) solid transparent;
136
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--toggle-switch-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
136
137
  border-radius: var(--_border-radius);
137
138
  width: var(--_track-width);
138
139
  padding: calc(var(--input-toggle-wrapper-padding) + var(--input-toggle-symbol-outline-width));
139
140
 
140
141
  &:hover {
141
142
  background-color: var(--toggle-switch-surface-hover, var(--theme-surface-subtle));
143
+ outline-color: var(--toggle-switch-border-hover, var(--toggle-switch-border, var(--theme-border)));
142
144
  cursor: pointer;
143
145
  }
144
146