srcdev-nuxt-components 9.6.13 → 9.6.15

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 (74) hide show
  1. package/.claude/rules/consumer/story-register-components.md +1 -1
  2. package/.claude/skills/component-compliance-checklist.md +5 -0
  3. package/.claude/skills/components/display-qr-code.md +22 -4
  4. package/.claude/skills/components/eyebrow-text.md +9 -5
  5. package/.claude/skills/components/header-block.md +13 -5
  6. package/.claude/skills/components/hero-text.md +13 -9
  7. package/.claude/skills/components/link-text.md +8 -5
  8. package/.claude/skills/components/pop-over.md +17 -3
  9. package/.claude/skills/components/sliding-toggle.md +108 -0
  10. package/.claude/skills/components/tabbed-content.md +8 -3
  11. package/.claude/skills/composable-sliding-indicator.md +60 -0
  12. package/.claude/skills/index.md +2 -0
  13. package/.claude/skills/storybook-add-story.md +22 -2
  14. package/.claude/skills/testing-add-playwright.md +12 -7
  15. package/.claude/skills/testing-add-unit-test.md +25 -0
  16. package/.claude/skills/theming-component-token-pattern.md +18 -0
  17. package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
  18. package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
  19. package/.vscode/srcdev-component-link-text.code-snippets +3 -3
  20. package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
  21. package/.vscode/srcdev-component-sliding-toggle.code-snippets +37 -0
  22. package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -1
  23. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
  24. package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
  25. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
  26. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
  27. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
  28. package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
  29. package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
  30. package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
  31. package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
  32. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +15 -2
  33. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +4 -4
  34. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +4 -4
  35. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
  36. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
  37. package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
  38. package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
  39. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
  40. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
  41. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
  42. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
  43. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
  44. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
  45. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
  46. package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
  47. package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
  48. package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
  49. package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
  50. package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
  51. package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
  52. package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
  53. package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
  54. package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
  55. package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
  56. package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
  57. package/app/components/02.molecules/sliding-toggle/CONSUMER-STYLING.md +109 -0
  58. package/app/components/02.molecules/sliding-toggle/SlidingToggle.vue +307 -0
  59. package/app/components/02.molecules/sliding-toggle/stories/SlidingToggle.stories.ts +357 -0
  60. package/app/components/02.molecules/sliding-toggle/tests/SlidingToggle.spec.ts +335 -0
  61. package/app/components/02.molecules/sliding-toggle/tests/__snapshots__/SlidingToggle.spec.ts.snap +3 -0
  62. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
  63. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
  64. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
  65. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
  66. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
  67. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
  68. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
  69. package/app/composables/tests/useSlidingIndicator.spec.ts +143 -0
  70. package/app/composables/useSlidingIndicator.ts +56 -0
  71. package/app/composables/useTabs.ts +4 -31
  72. package/app/types/components/index.ts +1 -0
  73. package/app/types/components/sliding-toggle.d.ts +5 -0
  74. package/package.json +1 -1
@@ -0,0 +1,307 @@
1
+ <template>
2
+ <div
3
+ ref="trackRef"
4
+ class="sliding-toggle"
5
+ :class="elementClasses"
6
+ role="radiogroup"
7
+ :aria-label="ariaLabel || undefined"
8
+ :aria-labelledby="ariaLabelledby || undefined"
9
+ :aria-disabled="disabled ? 'true' : undefined"
10
+ :data-ready="isReady ? '' : undefined"
11
+ :data-has-selection="checkedIndex >= 0 ? '' : undefined"
12
+ :data-hovering="hoveredIndex !== null ? '' : undefined"
13
+ @mouseleave="setHovered(null)"
14
+ @keydown="handleKeydown"
15
+ >
16
+ <span class="sliding-toggle-indicator sliding-toggle-indicator-hover" aria-hidden="true"></span>
17
+ <span class="sliding-toggle-indicator sliding-toggle-indicator-active" aria-hidden="true"></span>
18
+ <button
19
+ v-for="(option, index) in options"
20
+ :key="`${index}-${option.value}`"
21
+ type="button"
22
+ role="radio"
23
+ class="sliding-toggle-option"
24
+ :data-index="index"
25
+ :aria-checked="index === checkedIndex ? 'true' : 'false'"
26
+ :tabindex="index === tabStopIndex ? 0 : -1"
27
+ :disabled="isOptionDisabled(index)"
28
+ @mouseenter="setHovered(index)"
29
+ @click="select(index)"
30
+ >
31
+ {{ option.label }}
32
+ </button>
33
+ </div>
34
+ </template>
35
+
36
+ <script setup lang="ts" generic="T extends string | number">
37
+ import type { SlidingToggleOption } from "~/types/components";
38
+
39
+ interface Props {
40
+ options: SlidingToggleOption<T>[];
41
+ ariaLabel?: string;
42
+ ariaLabelledby?: string;
43
+ disabled?: boolean;
44
+ trackHover?: boolean;
45
+ transitionDuration?: number;
46
+ styleClassPassthrough?: string | string[];
47
+ }
48
+
49
+ const props = withDefaults(defineProps<Props>(), {
50
+ ariaLabel: "",
51
+ ariaLabelledby: "",
52
+ disabled: false,
53
+ trackHover: true,
54
+ transitionDuration: 200,
55
+ styleClassPassthrough: () => [],
56
+ });
57
+
58
+ const modelValue = defineModel<T>({ required: true });
59
+
60
+ const trackRef = ref<HTMLElement | null>(null);
61
+ const isReady = ref(false);
62
+ const hoveredIndex = ref<number | null>(null);
63
+
64
+ const checkedIndex = computed(() => props.options.findIndex((option) => option.value === modelValue.value));
65
+
66
+ const isOptionDisabled = (index: number) => props.disabled || Boolean(props.options[index]?.disabled);
67
+
68
+ const enabledIndexes = computed(() =>
69
+ props.options.map((_, index) => index).filter((index) => !isOptionDisabled(index))
70
+ );
71
+
72
+ // The checked option takes the tab stop; with nothing checked, the first enabled one does.
73
+ const tabStopIndex = computed(() =>
74
+ checkedIndex.value >= 0 && !isOptionDisabled(checkedIndex.value) ? checkedIndex.value : (enabledIndexes.value[0] ?? -1)
75
+ );
76
+
77
+ const optionElement = (index: number) =>
78
+ index >= 0 ? (trackRef.value?.querySelector<HTMLElement>(`[data-index="${index}"]`) ?? null) : null;
79
+
80
+ const { place, reset } = useSlidingIndicator(trackRef, { duration: () => props.transitionDuration });
81
+
82
+ const placeActive = (animate: boolean) => {
83
+ place("sliding-toggle-active", optionElement(checkedIndex.value), animate);
84
+ };
85
+
86
+ const placeHover = (animate: boolean) => {
87
+ const index = hoveredIndex.value ?? checkedIndex.value;
88
+ if (index < 0) {
89
+ reset("sliding-toggle-hover");
90
+ return;
91
+ }
92
+ place("sliding-toggle-hover", optionElement(index), animate);
93
+ };
94
+
95
+ const placeAll = (animate: boolean) => {
96
+ placeActive(animate);
97
+ placeHover(animate);
98
+ };
99
+
100
+ const setHovered = (index: number | null) => {
101
+ if (!props.trackHover) return;
102
+ const next = index !== null && isOptionDisabled(index) ? null : index;
103
+ if (hoveredIndex.value === next) return;
104
+ hoveredIndex.value = next;
105
+ placeHover(true);
106
+ };
107
+
108
+ const select = (index: number) => {
109
+ const option = props.options[index];
110
+ if (!option || isOptionDisabled(index)) return;
111
+ modelValue.value = option.value;
112
+ optionElement(index)?.focus();
113
+ };
114
+
115
+ const isRtl = () => trackRef.value?.closest("[dir]")?.getAttribute("dir") === "rtl";
116
+
117
+ // WAI-ARIA radio group: arrows move focus and select, wrapping and skipping disabled options.
118
+ const handleKeydown = (event: KeyboardEvent) => {
119
+ const option = (event.target as HTMLElement).closest<HTMLElement>("[data-index]");
120
+ const enabled = enabledIndexes.value;
121
+ if (!option || enabled.length === 0) return;
122
+
123
+ const position = enabled.indexOf(Number(option.dataset.index));
124
+ const nextKeys = ["ArrowDown", isRtl() ? "ArrowLeft" : "ArrowRight"];
125
+ const previousKeys = ["ArrowUp", isRtl() ? "ArrowRight" : "ArrowLeft"];
126
+
127
+ let target: number | undefined;
128
+ if (nextKeys.includes(event.key)) {
129
+ target = enabled[(position + 1) % enabled.length];
130
+ } else if (previousKeys.includes(event.key)) {
131
+ target = enabled[position <= 0 ? enabled.length - 1 : position - 1];
132
+ } else if (event.key === "Home") {
133
+ target = enabled[0];
134
+ } else if (event.key === "End") {
135
+ target = enabled[enabled.length - 1];
136
+ }
137
+
138
+ if (target === undefined) return;
139
+ event.preventDefault();
140
+ select(target);
141
+ };
142
+
143
+ watch(checkedIndex, () =>
144
+ nextTick(() => {
145
+ placeActive(true);
146
+ if (hoveredIndex.value === null) placeHover(true);
147
+ })
148
+ );
149
+
150
+ let resizeObserver: ResizeObserver | null = null;
151
+ let mutationObserver: MutationObserver | null = null;
152
+
153
+ // Re-measure when the track or any single option resizes (a late web font changes label widths
154
+ // without resizing the track), and when options are added, removed or restyled.
155
+ const observeOptions = () => {
156
+ if (!resizeObserver || !trackRef.value) return;
157
+ resizeObserver.disconnect();
158
+ resizeObserver.observe(trackRef.value);
159
+ trackRef.value.querySelectorAll<HTMLElement>("[data-index]").forEach((element) => resizeObserver?.observe(element));
160
+ };
161
+
162
+ onMounted(() => {
163
+ placeAll(false);
164
+ isReady.value = true;
165
+
166
+ if (typeof ResizeObserver !== "undefined") {
167
+ resizeObserver = new ResizeObserver(() => placeAll(false));
168
+ observeOptions();
169
+ }
170
+ if (typeof MutationObserver !== "undefined" && trackRef.value) {
171
+ mutationObserver = new MutationObserver(() => {
172
+ observeOptions();
173
+ placeAll(false);
174
+ });
175
+ mutationObserver.observe(trackRef.value, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
176
+ }
177
+ });
178
+
179
+ onBeforeUnmount(() => {
180
+ resizeObserver?.disconnect();
181
+ mutationObserver?.disconnect();
182
+ });
183
+
184
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
185
+
186
+ watch(
187
+ () => props.styleClassPassthrough,
188
+ () => resetElementClasses(props.styleClassPassthrough)
189
+ );
190
+ </script>
191
+
192
+ <style lang="css">
193
+ @layer components {
194
+ .sliding-toggle {
195
+ position: relative;
196
+ display: flex;
197
+ inline-size: fit-content;
198
+ max-inline-size: 100%;
199
+ gap: var(--sliding-toggle-gap, 0.4rem);
200
+ padding: var(--sliding-toggle-padding, 0.4rem);
201
+ background-color: var(--sliding-toggle-surface, var(--theme-surface-subtle));
202
+ border-radius: var(--sliding-toggle-border-radius, 0.8rem);
203
+ corner-shape: var(--sliding-toggle-corner-shape, round);
204
+
205
+ .sliding-toggle-indicator {
206
+ position: absolute;
207
+ inset-block: var(--sliding-toggle-padding, 0.4rem);
208
+ border-radius: var(--sliding-toggle-option-border-radius, 0.6rem);
209
+ corner-shape: var(--sliding-toggle-corner-shape, round);
210
+ opacity: 0;
211
+ pointer-events: none;
212
+ }
213
+
214
+ .sliding-toggle-indicator-hover {
215
+ left: var(--_sliding-toggle-hover-start, 0px);
216
+ right: var(--_sliding-toggle-hover-end, 100%);
217
+ transition:
218
+ left var(--_sliding-toggle-hover-duration, 0ms) ease
219
+ calc(var(--_sliding-toggle-hover-start-delay, 0ms) * var(--sliding-toggle-indicator-stretch, 1)),
220
+ right var(--_sliding-toggle-hover-duration, 0ms) ease
221
+ calc(var(--_sliding-toggle-hover-end-delay, 0ms) * var(--sliding-toggle-indicator-stretch, 1));
222
+ background-color: var(
223
+ --sliding-toggle-hover-indicator-surface,
224
+ color-mix(in oklch, var(--theme-surface-subtle), var(--theme-text) 6%)
225
+ );
226
+ }
227
+
228
+ .sliding-toggle-indicator-active {
229
+ left: var(--_sliding-toggle-active-start, 0px);
230
+ right: var(--_sliding-toggle-active-end, 100%);
231
+ transition:
232
+ left var(--_sliding-toggle-active-duration, 0ms) ease
233
+ calc(var(--_sliding-toggle-active-start-delay, 0ms) * var(--sliding-toggle-indicator-stretch, 1)),
234
+ right var(--_sliding-toggle-active-duration, 0ms) ease
235
+ calc(var(--_sliding-toggle-active-end-delay, 0ms) * var(--sliding-toggle-indicator-stretch, 1));
236
+ background-color: var(--sliding-toggle-indicator-surface, var(--theme-on-surface));
237
+ box-shadow: var(--sliding-toggle-indicator-shadow, 0 0.1rem 0.3rem rgb(0 0 0 / 0.1));
238
+ overflow: hidden;
239
+
240
+ &::after {
241
+ content: "";
242
+ position: absolute;
243
+ inset-inline: 0;
244
+ inset-block-end: 0;
245
+ block-size: var(--sliding-toggle-marker-height, 0.2rem);
246
+ background-color: var(--sliding-toggle-marker-colour, var(--theme-accent));
247
+ }
248
+ }
249
+
250
+ &[data-ready][data-has-selection] .sliding-toggle-indicator-active,
251
+ &[data-ready][data-has-selection] .sliding-toggle-indicator-hover,
252
+ &[data-ready][data-hovering] .sliding-toggle-indicator-hover {
253
+ opacity: 1;
254
+ }
255
+
256
+ .sliding-toggle-option {
257
+ appearance: none;
258
+ position: relative;
259
+ z-index: 1;
260
+ flex: var(--sliding-toggle-option-flex, 0 1 auto);
261
+ min-inline-size: 0;
262
+ margin: 0;
263
+ padding: var(--sliding-toggle-option-padding, 0.8rem 1.6rem);
264
+ border: 0;
265
+ border-radius: var(--sliding-toggle-option-border-radius, 0.6rem);
266
+ corner-shape: var(--sliding-toggle-corner-shape, round);
267
+ background-color: transparent;
268
+ font: inherit;
269
+ font-size: var(--sliding-toggle-option-font-size, inherit);
270
+ font-weight: var(--sliding-toggle-option-font-weight, 500);
271
+ line-height: 1.2;
272
+ white-space: var(--sliding-toggle-option-white-space, normal);
273
+ overflow-wrap: anywhere;
274
+ color: var(--sliding-toggle-option-colour, color-mix(in oklch, var(--theme-text) 70%, transparent));
275
+ cursor: pointer;
276
+ transition: color var(--sliding-toggle-transition-duration, 200ms) ease;
277
+
278
+ &[aria-checked="true"] {
279
+ color: var(--sliding-toggle-option-colour-active, var(--theme-text));
280
+ }
281
+
282
+ &:focus-visible {
283
+ outline: var(--sliding-toggle-focus-ring-width, 0.2rem) solid
284
+ var(--sliding-toggle-focus-ring-colour, var(--theme-border-focus));
285
+ outline-offset: 0.1rem;
286
+ }
287
+
288
+ &:disabled {
289
+ cursor: not-allowed;
290
+ opacity: 0.5;
291
+ }
292
+
293
+ @media (prefers-reduced-motion: reduce) {
294
+ transition: none;
295
+ }
296
+ }
297
+
298
+ &:not([data-ready]) .sliding-toggle-option[aria-checked="true"] {
299
+ background-color: var(--sliding-toggle-indicator-surface, var(--theme-on-surface));
300
+ box-shadow:
301
+ inset 0 calc(-1 * var(--sliding-toggle-marker-height, 0.2rem)) 0
302
+ var(--sliding-toggle-marker-colour, var(--theme-accent)),
303
+ var(--sliding-toggle-indicator-shadow, 0 0.1rem 0.3rem rgb(0 0 0 / 0.1));
304
+ }
305
+ }
306
+ }
307
+ </style>
@@ -0,0 +1,357 @@
1
+ import { computed, ref } from "vue";
2
+ import type { ConcreteComponent } from "vue";
3
+ import SlidingToggle from "../SlidingToggle.vue";
4
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
5
+ import type { SlidingToggleOption } from "~/types/components";
6
+
7
+ interface StoryArgs {
8
+ modelValue: string;
9
+ options: SlidingToggleOption[];
10
+ ariaLabel: string;
11
+ disabled: boolean;
12
+ trackHover: boolean;
13
+ transitionDuration: number;
14
+ cornerShape: string;
15
+ optionFlex: string;
16
+ stretch: number;
17
+ }
18
+
19
+ const tokenCategory = "CSS tokens (story only, set in your CSS)";
20
+
21
+ const meta: Meta<StoryArgs> = {
22
+ title: "Molecules/SlidingToggle",
23
+ // Generic components don't fit Meta's ConcreteComponent<Args>; the props table still generates.
24
+ component: SlidingToggle as unknown as ConcreteComponent<StoryArgs>,
25
+ argTypes: {
26
+ modelValue: {
27
+ control: "text",
28
+ description: "The selected option's `value` (`v-model`).",
29
+ table: { category: "Model" },
30
+ },
31
+ options: {
32
+ control: "object",
33
+ description: "Options: `{ value, label, disabled? }[]`.",
34
+ table: { category: "Content" },
35
+ },
36
+ ariaLabel: {
37
+ control: "text",
38
+ description: "Names the radiogroup. Set this or `ariaLabelledby`.",
39
+ table: { category: "Accessibility" },
40
+ },
41
+ disabled: {
42
+ control: "boolean",
43
+ description: "Disables the whole group.",
44
+ table: { category: "Basic" },
45
+ },
46
+ trackHover: {
47
+ control: "boolean",
48
+ description: "Show the hover indicator that follows the pointer.",
49
+ table: { category: "Basic" },
50
+ },
51
+ transitionDuration: {
52
+ control: { type: "range", min: 0, max: 1000, step: 50 },
53
+ description: "Length of each edge's slide, in ms. Reduced motion snaps instead.",
54
+ table: { category: "Basic" },
55
+ },
56
+ cornerShape: {
57
+ control: { type: "select" },
58
+ options: ["round", "squircle", "superellipse(3)", "bevel", "scoop", "notch"],
59
+ description:
60
+ "**Story control, not a prop.** Sets `--sliding-toggle-corner-shape` on a wrapper. In your CSS: `.billing { --sliding-toggle-corner-shape: squircle; }`. Chromium 139+ only; other browsers keep rounded corners.",
61
+ table: { category: tokenCategory },
62
+ },
63
+ optionFlex: {
64
+ control: { type: "select" },
65
+ options: ["0 1 auto", "1 1 0"],
66
+ description:
67
+ "**Story control, not a prop.** Sets `--sliding-toggle-option-flex`. `1 1 0` gives equal widths: `.billing { --sliding-toggle-option-flex: 1 1 0; }`.",
68
+ table: { category: tokenCategory },
69
+ },
70
+ stretch: {
71
+ control: { type: "range", min: 0, max: 1, step: 0.25 },
72
+ description:
73
+ "**Story control, not a prop.** Sets `--sliding-toggle-indicator-stretch`. `0` slides both indicators' edges together instead of stretching: `.billing { --sliding-toggle-indicator-stretch: 0; }`.",
74
+ table: { category: tokenCategory },
75
+ },
76
+ ariaLabelledby: { table: { disable: true } },
77
+ styleClassPassthrough: { table: { disable: true } },
78
+ } as Meta<StoryArgs>["argTypes"],
79
+ args: {
80
+ modelValue: "monthly",
81
+ options: [
82
+ { value: "monthly", label: "Monthly" },
83
+ { value: "yearly", label: "Yearly (save 20%)" },
84
+ { value: "lifetime", label: "Lifetime" },
85
+ ],
86
+ ariaLabel: "Billing period",
87
+ disabled: false,
88
+ trackHover: true,
89
+ transitionDuration: 200,
90
+ cornerShape: "round",
91
+ optionFlex: "0 1 auto",
92
+ stretch: 1,
93
+ },
94
+ parameters: {
95
+ docs: {
96
+ description: {
97
+ component:
98
+ "A single-choice segmented control whose selected background slides between options of different widths, with the same stretch-then-catch-up motion as TabbedContent's indicator. A second indicator follows the pointer. It's a radio group: arrow keys move and select, and only the selected option is a tab stop. `options` and `v-model` drive it; name it with `ariaLabel` or `ariaLabelledby`.",
99
+ },
100
+ },
101
+ },
102
+ };
103
+
104
+ export default meta;
105
+ type Story = StoryObj<StoryArgs>;
106
+
107
+ const storySetup = (args: StoryArgs) => {
108
+ const componentArgs = computed(() => {
109
+ const { modelValue: _modelValue, cornerShape: _cornerShape, optionFlex: _optionFlex, stretch: _stretch, ...rest } = args;
110
+ return rest;
111
+ });
112
+ const tokenStyle = computed(() => ({
113
+ "--sliding-toggle-corner-shape": args.cornerShape,
114
+ "--sliding-toggle-option-flex": args.optionFlex,
115
+ "--sliding-toggle-indicator-stretch": String(args.stretch),
116
+ }));
117
+ return { args, componentArgs, tokenStyle };
118
+ };
119
+
120
+ export const Default: Story = {
121
+ render: (args) => ({
122
+ components: { SlidingToggle },
123
+ setup: () => storySetup(args),
124
+ template: `
125
+ <div :style="tokenStyle" style="display: grid; gap: 1.6rem; justify-items: start;">
126
+ <SlidingToggle v-bind="componentArgs" v-model="args.modelValue" />
127
+ <p style="margin: 0; font-size: 1.4rem;">Selected: <code>{{ args.modelValue }}</code></p>
128
+ </div>
129
+ `,
130
+ }),
131
+ parameters: {
132
+ docs: {
133
+ description: {
134
+ story:
135
+ "Click an option, or Tab in and use the arrow keys, Home and End. Hover across the options to see the hover indicator. The corner-shape, option-flex and stretch controls are CSS tokens, not props: set the stretch to 0 to see both indicators slide without stretching.",
136
+ },
137
+ },
138
+ },
139
+ };
140
+
141
+ export const EqualWidths: Story = {
142
+ args: { optionFlex: "1 1 0" },
143
+ render: (args) => ({
144
+ components: { SlidingToggle },
145
+ setup: () => storySetup(args),
146
+ template: `
147
+ <div :style="tokenStyle" style="inline-size: 40rem; max-inline-size: 100%;">
148
+ <SlidingToggle v-bind="componentArgs" v-model="args.modelValue" style="inline-size: 100%;" />
149
+ </div>
150
+ `,
151
+ }),
152
+ parameters: {
153
+ docs: {
154
+ description: {
155
+ story:
156
+ "`--sliding-toggle-option-flex: 1 1 0` with the toggle stretched to its container: every option takes an equal share, the usual look for a two-option billing switch.",
157
+ },
158
+ },
159
+ },
160
+ };
161
+
162
+ export const CornerShapes: Story = {
163
+ render: (args) => ({
164
+ components: { SlidingToggle },
165
+ setup() {
166
+ const shapes = ["round", "squircle", "superellipse(3)", "bevel", "scoop", "notch"];
167
+ const values = ref<Record<string, string>>(Object.fromEntries(shapes.map((shape) => [shape, "yearly"])));
168
+ return { args, shapes, values };
169
+ },
170
+ template: `
171
+ <div style="display: grid; gap: 1.6rem; justify-items: start;">
172
+ <p style="margin: 0; font-size: 1.4rem;">
173
+ <code>--sliding-toggle-corner-shape</code> applies to the track, both indicators and the options.
174
+ It needs Chromium 139+; elsewhere every row shows plain rounded corners.
175
+ </p>
176
+ <div v-for="shape in shapes" :key="shape" :style="{ '--sliding-toggle-corner-shape': shape, '--sliding-toggle-border-radius': '1.6rem', '--sliding-toggle-option-border-radius': '1.2rem' }" style="display: flex; gap: 1.6rem; align-items: center;">
177
+ <code style="inline-size: 14rem; font-size: 1.3rem;">{{ shape }}</code>
178
+ <SlidingToggle :options="args.options" :aria-label="shape + ' corners'" v-model="values[shape]" />
179
+ </div>
180
+ </div>
181
+ `,
182
+ }),
183
+ parameters: {
184
+ controls: { disable: true },
185
+ docs: {
186
+ description: {
187
+ story:
188
+ "Each `corner-shape` value with larger radii so the difference shows. A progressive enhancement: browsers without `corner-shape` ignore it and keep `border-radius`.",
189
+ },
190
+ },
191
+ },
192
+ };
193
+
194
+ export const CustomColours: Story = {
195
+ render: (args) => ({
196
+ components: { SlidingToggle },
197
+ setup: () => storySetup(args),
198
+ template: `
199
+ <div
200
+ :style="tokenStyle"
201
+ style="--sliding-toggle-surface: #1f2937; --sliding-toggle-indicator-surface: #f9fafb; --sliding-toggle-hover-indicator-surface: #374151; --sliding-toggle-marker-colour: #f59e0b; --sliding-toggle-option-colour: #d1d5db; --sliding-toggle-option-colour-active: #111827;"
202
+ >
203
+ <SlidingToggle v-bind="componentArgs" v-model="args.modelValue" />
204
+ </div>
205
+ `,
206
+ }),
207
+ parameters: {
208
+ docs: {
209
+ description: {
210
+ story: "Every colour overridden with public tokens set on a wrapper.",
211
+ },
212
+ },
213
+ },
214
+ };
215
+
216
+ export const Disabled: Story = {
217
+ render: (args) => ({
218
+ components: { SlidingToggle },
219
+ setup() {
220
+ const partial = ref("monthly");
221
+ const options = [
222
+ { value: "monthly", label: "Monthly" },
223
+ { value: "yearly", label: "Yearly", disabled: true },
224
+ { value: "lifetime", label: "Lifetime" },
225
+ ];
226
+ return { args, partial, options };
227
+ },
228
+ template: `
229
+ <div style="display: grid; gap: 2.4rem; justify-items: start;">
230
+ <p style="margin: 0; font-size: 1.4rem;">One disabled option: the arrow keys skip it and the hover indicator ignores it.</p>
231
+ <SlidingToggle v-model="partial" :options="options" aria-label="Billing period, yearly unavailable" />
232
+ <p style="margin: 0; font-size: 1.4rem;">The whole group disabled.</p>
233
+ <SlidingToggle v-model="partial" :options="args.options" aria-label="Billing period (disabled)" disabled />
234
+ </div>
235
+ `,
236
+ }),
237
+ parameters: {
238
+ controls: { disable: true },
239
+ docs: {
240
+ description: {
241
+ story: "A single disabled option (skipped by the keyboard) and a fully `disabled` group.",
242
+ },
243
+ },
244
+ },
245
+ };
246
+
247
+ const longGerman = "Jährliche Abrechnung mit zwanzig Prozent Rabatt";
248
+
249
+ export const StressTest: Story = {
250
+ name: "Stress Test (Worst-Case Data)",
251
+ render: () => ({
252
+ components: { SlidingToggle },
253
+ setup() {
254
+ const cases = [
255
+ {
256
+ note: "Long German labels in an 18rem column: labels wrap, the indicator stays on the selected option",
257
+ width: "18rem",
258
+ model: ref("b"),
259
+ options: [
260
+ { value: "a", label: "Monatliche Abrechnung" },
261
+ { value: "b", label: longGerman },
262
+ ],
263
+ },
264
+ {
265
+ note: "An unbroken label and a URL",
266
+ width: "24rem",
267
+ model: ref("a"),
268
+ options: [
269
+ { value: "a", label: "Monatlicheabrechnungohnemindestlaufzeit" },
270
+ { value: "b", label: "https://example.invalid/a/very/long/url" },
271
+ ],
272
+ },
273
+ {
274
+ note: "Seven options in a 24rem column",
275
+ width: "24rem",
276
+ model: ref("4"),
277
+ options: Array.from({ length: 7 }, (_, index) => ({ value: String(index), label: `Option ${index + 1}` })),
278
+ },
279
+ {
280
+ note: "Emoji first, HTML-like text (shown as text) and duplicate labels",
281
+ width: "auto",
282
+ model: ref("c"),
283
+ options: [
284
+ { value: "a", label: "😀 Happy" },
285
+ { value: "b", label: "<b>not bold</b>" },
286
+ { value: "c", label: "Same" },
287
+ { value: "d", label: "Same" },
288
+ ],
289
+ },
290
+ {
291
+ note: "A single option",
292
+ width: "auto",
293
+ model: ref("only"),
294
+ options: [{ value: "only", label: "Only choice" }],
295
+ },
296
+ {
297
+ note: "A value matching no option: nothing selected, no indicator, the first option is the tab stop",
298
+ width: "auto",
299
+ model: ref("missing"),
300
+ options: [
301
+ { value: "a", label: "First" },
302
+ { value: "b", label: "Second" },
303
+ ],
304
+ },
305
+ {
306
+ note: "Every option disabled",
307
+ width: "auto",
308
+ model: ref("a"),
309
+ options: [
310
+ { value: "a", label: "First", disabled: true },
311
+ { value: "b", label: "Second", disabled: true },
312
+ ],
313
+ },
314
+ {
315
+ note: "An empty options array: an empty track, no errors",
316
+ width: "auto",
317
+ model: ref("a"),
318
+ options: [],
319
+ },
320
+ ];
321
+ const rtlValue = ref("b");
322
+ return { cases, rtlValue };
323
+ },
324
+ template: `
325
+ <div style="display: grid; gap: 2.4rem; padding: 2.4rem;">
326
+ <template v-for="(item, index) in cases" :key="index">
327
+ <div style="display: grid; gap: 0.8rem; justify-items: start;">
328
+ <p style="margin: 0; font-size: 1.4rem;">{{ item.note }}</p>
329
+ <div :style="{ inlineSize: item.width }" style="outline: 0.1rem dashed currentColor;">
330
+ <SlidingToggle v-model="item.model.value" :options="item.options" :aria-label="item.note" />
331
+ </div>
332
+ </div>
333
+ </template>
334
+
335
+ <div style="display: grid; gap: 0.8rem; justify-items: start;">
336
+ <p style="margin: 0; font-size: 1.4rem;">Right-to-left: options run right to left, and ArrowLeft moves to the next one.</p>
337
+ <SlidingToggle
338
+ v-model="rtlValue"
339
+ dir="rtl"
340
+ aria-label="فترة الفوترة"
341
+ :options="[{ value: 'a', label: 'شهري' }, { value: 'b', label: 'سنوي مع خصم' }, { value: 'c', label: 'مدى الحياة' }]"
342
+ />
343
+ </div>
344
+ </div>
345
+ `,
346
+ }),
347
+ parameters: {
348
+ controls: { disable: true },
349
+ docs: {
350
+ story: { inline: false, height: "1300px" },
351
+ description: {
352
+ story:
353
+ "Worst-case options. Check: long and unbroken labels wrap inside each dashed column and the indicator still covers the selected option (re-measured as labels wrap); seven options shrink rather than overflow; emoji, HTML-like and duplicate labels render as text; a lone option and an unmatched value behave; a fully disabled group can't be focused; an empty `options` array leaves just the track; right-to-left reverses the order and the arrow keys.",
354
+ },
355
+ },
356
+ },
357
+ };