srcdev-nuxt-components 9.6.10 → 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 (108) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +4 -2
  2. package/.claude/skills/component-compliance-checklist.md +9 -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/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/index.md +5 -5
  26. package/.claude/skills/storybook-add-story.md +3 -1
  27. package/.claude/skills/theming-component-token-pattern.md +24 -0
  28. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  29. package/.claude/skills/theming-partial-override.md +1 -1
  30. package/.vscode/srcdev-component-alert-mask.code-snippets +41 -0
  31. package/.vscode/{srcdev-component-triple-toggle-switch.code-snippets → srcdev-component-segmented-control.code-snippets} +6 -7
  32. package/app/assets/styles/setup/03.theming/_theme-slots.css +1 -1
  33. package/app/assets/styles/setup/04.elements/forms/03.generic-input-geometry.css +4 -0
  34. package/app/components/01.atoms/alert-mask/AlertMask.vue +194 -0
  35. package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +57 -42
  36. package/app/components/01.atoms/alert-mask/stories/AlertMask.stories.ts +198 -0
  37. package/app/components/01.atoms/alert-mask/tests/AlertMask.spec.ts +233 -0
  38. package/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue +2 -2
  39. package/app/components/02.molecules/alert-masked-content/CONSUMER-STYLING.md +4 -4
  40. package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +60 -0
  41. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +12 -12
  42. package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +3 -3
  43. package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +1 -1
  44. package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +1 -1
  45. package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +9 -9
  46. package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +16 -16
  47. package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +53 -1
  48. package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +8 -8
  49. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +4 -4
  50. package/app/components/02.molecules/input-copy/InputCopy.vue +2 -2
  51. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +2 -2
  52. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +4 -4
  53. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +2 -2
  54. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +2 -2
  55. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +6 -6
  56. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +8 -0
  57. package/app/components/05.forms/form-errors/InputError.vue +43 -37
  58. package/app/components/05.forms/form-errors/stories/InputError.stories.ts +79 -1
  59. package/app/components/05.forms/form-errors/tests/InputError.spec.ts +29 -1
  60. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +80 -47
  61. package/app/components/05.forms/input-button/InputButton.vue +52 -100
  62. package/app/components/05.forms/input-button/playwright/input-button.playwright.ts +1 -1
  63. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +145 -8
  64. package/app/components/05.forms/input-button/tests/InputButton.spec.ts +76 -46
  65. package/app/components/05.forms/input-button/tests/__snapshots__/InputButton.spec.ts.snap +17 -17
  66. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +9 -1
  67. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +8 -0
  68. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +2 -2
  69. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +14 -2
  70. package/app/components/05.forms/input-description/InputDescription.vue +2 -1
  71. package/app/components/05.forms/input-description/stories/InputDescription.stories.ts +70 -4
  72. package/app/components/05.forms/input-description/tests/InputDescription.spec.ts +2 -1
  73. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +8 -0
  74. package/app/components/05.forms/input-label/InputLabel.vue +1 -0
  75. package/app/components/05.forms/input-label/stories/InputLabel.stories.ts +68 -2
  76. package/app/components/05.forms/input-label/tests/InputLabel.spec.ts +13 -3
  77. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +7 -1
  78. package/app/components/05.forms/input-number/InputNumber.vue +4 -5
  79. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +7 -1
  80. package/app/components/05.forms/input-otp/InputOtp.vue +2 -2
  81. package/app/components/05.forms/input-range/InputRange.vue +1 -1
  82. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +7 -1
  83. package/app/components/05.forms/input-select/InputSelect.vue +14 -5
  84. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +7 -1
  85. package/app/components/05.forms/input-text/InputText.vue +4 -4
  86. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +7 -1
  87. package/app/components/05.forms/input-textarea/InputTextarea.vue +2 -3
  88. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +4 -4
  89. package/app/components/05.forms/pending-effect/CONSUMER-STYLING.md +7 -3
  90. package/app/components/05.forms/pending-effect/PendingEffect.vue +3 -3
  91. package/app/components/05.forms/pending-effect/tests/PendingEffect.spec.ts +1 -1
  92. package/app/components/05.forms/segmented-control/CONSUMER-STYLING.md +125 -0
  93. package/app/components/05.forms/segmented-control/SegmentedControl.vue +231 -0
  94. package/app/components/05.forms/segmented-control/stories/SegmentedControl.stories.ts +215 -0
  95. package/app/components/05.forms/segmented-control/tests/SegmentedControl.spec.ts +202 -0
  96. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +7 -0
  97. package/app/components/05.forms/toggle-switch/ToggleSwitch.vue +2 -0
  98. package/app/types/components/index.ts +1 -1
  99. package/package.json +1 -1
  100. package/.vscode/srcdev-component-alert-mask-core.code-snippets +0 -31
  101. package/app/components/01.atoms/alert-mask/AlertMaskCore.vue +0 -166
  102. package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +0 -105
  103. package/app/components/01.atoms/alert-mask/tests/AlertMaskCore.spec.ts +0 -342
  104. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +0 -99
  105. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +0 -251
  106. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitch.stories.ts +0 -104
  107. package/app/components/05.forms/triple-toggle-switch/tests/TripleToggleSwitch.spec.ts +0 -138
  108. /package/app/types/components/{alert-mask-core.d.ts → alert-mask.d.ts} +0 -0
@@ -1,342 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { mountSuspended } from "@nuxt/test-utils/runtime";
3
- import AlertMaskCore from "../AlertMaskCore.vue";
4
- import type { AlertMaskConfig } from "../../../../types/components/alert-mask-core.d";
5
-
6
- // Type interface for AlertMaskCore component instance
7
- interface AlertMaskCoreInstance {
8
- cfg: {
9
- backgroundColour: string;
10
- borderColour: string;
11
- radiusLeft: number;
12
- radiusRight: number;
13
- borderLeft: number;
14
- borderTop: number;
15
- borderRight: number;
16
- borderBottom: number;
17
- };
18
- alertContentRef: HTMLElement | null;
19
- svgWidth: number;
20
- svgHeight: number;
21
- outerPath: string;
22
- innerPath: string;
23
- }
24
-
25
- // Mock ResizeObserver
26
- // Vitest 4: a mock's implementation must be a regular function (not an
27
- // arrow function) to remain constructible via `new`.
28
- const mockResizeObserver = vi.fn(function () {
29
- return {
30
- observe: vi.fn(),
31
- unobserve: vi.fn(),
32
- disconnect: vi.fn(),
33
- };
34
- });
35
-
36
- vi.stubGlobal("ResizeObserver", mockResizeObserver);
37
-
38
- describe("AlertMaskCore", () => {
39
- beforeEach(() => {
40
- vi.clearAllMocks();
41
- });
42
-
43
- afterEach(() => {
44
- vi.restoreAllMocks();
45
- });
46
-
47
- it("mounts without error", async () => {
48
- const wrapper = await mountSuspended(AlertMaskCore);
49
- expect(wrapper.vm).toBeTruthy();
50
- });
51
-
52
- it("renders base structure correctly", async () => {
53
- const wrapper = await mountSuspended(AlertMaskCore);
54
-
55
- expect(wrapper.find(".alert-mask-core").exists()).toBe(true);
56
- expect(wrapper.find(".alert-mask-decorator").exists()).toBe(true);
57
- expect(wrapper.find(".alert-mask-content").exists()).toBe(true);
58
- expect(wrapper.find(".alert-mask-content-slot").exists()).toBe(true);
59
- });
60
-
61
- it("renders default slot content", async () => {
62
- const wrapper = await mountSuspended(AlertMaskCore, {
63
- slots: {
64
- default: "<p>Test alert content</p>",
65
- },
66
- });
67
-
68
- expect(wrapper.find(".alert-mask-content-slot p").text()).toBe("Test alert content");
69
- });
70
-
71
- it("applies style class passthrough correctly", async () => {
72
- const wrapper = await mountSuspended(AlertMaskCore, {
73
- props: {
74
- styleClassPassthrough: ["custom-class", "another-class"],
75
- },
76
- });
77
-
78
- const alertMask = wrapper.find(".alert-mask-core");
79
- expect(alertMask.classes()).toContain("custom-class");
80
- expect(alertMask.classes()).toContain("another-class");
81
- });
82
-
83
- it("applies style class passthrough as string", async () => {
84
- const wrapper = await mountSuspended(AlertMaskCore, {
85
- props: {
86
- styleClassPassthrough: "single-class",
87
- },
88
- });
89
-
90
- const alertMask = wrapper.find(".alert-mask-core");
91
- expect(alertMask.classes()).toContain("single-class");
92
- });
93
-
94
- it("uses default configuration when no config provided", async () => {
95
- const wrapper = await mountSuspended(AlertMaskCore);
96
-
97
- // Check that computed config uses defaults
98
- const vm = wrapper.vm as unknown as AlertMaskCoreInstance;
99
- expect(vm.cfg.backgroundColour).toBe("rgba(0,0,0,0.25)");
100
- expect(vm.cfg.borderColour).toBe("var(--orange-08)");
101
- expect(vm.cfg.radiusLeft).toBe(12);
102
- expect(vm.cfg.radiusRight).toBe(12);
103
- expect(vm.cfg.borderLeft).toBe(8);
104
- expect(vm.cfg.borderTop).toBe(8);
105
- expect(vm.cfg.borderRight).toBe(8);
106
- expect(vm.cfg.borderBottom).toBe(8);
107
- });
108
-
109
- it("applies custom configuration correctly", async () => {
110
- const config: AlertMaskConfig = {
111
- backgroundColour: "rgba(255,0,0,0.5)",
112
- borderColour: "#ff0000",
113
- radiusLeft: 20,
114
- radiusRight: 15,
115
- borderLeft: 12,
116
- borderTop: 10,
117
- borderRight: 8,
118
- borderBottom: 6,
119
- };
120
-
121
- const wrapper = await mountSuspended(AlertMaskCore, {
122
- props: { config },
123
- });
124
-
125
- const vm = wrapper.vm as unknown as AlertMaskCoreInstance;
126
- expect(vm.cfg.backgroundColour).toBe("rgba(255,0,0,0.5)");
127
- expect(vm.cfg.borderColour).toBe("#ff0000");
128
- expect(vm.cfg.radiusLeft).toBe(20);
129
- expect(vm.cfg.radiusRight).toBe(15);
130
- expect(vm.cfg.borderLeft).toBe(12);
131
- expect(vm.cfg.borderTop).toBe(10);
132
- expect(vm.cfg.borderRight).toBe(8);
133
- expect(vm.cfg.borderBottom).toBe(6);
134
- });
135
-
136
- it("applies partial configuration with defaults", async () => {
137
- const config: AlertMaskConfig = {
138
- backgroundColour: "blue",
139
- radiusLeft: 25,
140
- };
141
-
142
- const wrapper = await mountSuspended(AlertMaskCore, {
143
- props: { config },
144
- });
145
-
146
- const vm = wrapper.vm as unknown as AlertMaskCoreInstance;
147
- expect(vm.cfg.backgroundColour).toBe("blue");
148
- expect(vm.cfg.radiusLeft).toBe(25);
149
- // Check defaults are maintained for other values
150
- expect(vm.cfg.borderColour).toBe("var(--orange-08)");
151
- expect(vm.cfg.radiusRight).toBe(12);
152
- });
153
-
154
- it("sets up template ref for content element", async () => {
155
- const wrapper = await mountSuspended(AlertMaskCore, {
156
- slots: {
157
- default: "<div>Content</div>",
158
- },
159
- });
160
-
161
- await nextTick();
162
- const vm = wrapper.vm as unknown as AlertMaskCoreInstance;
163
- expect(vm.alertContentRef).toBeTruthy();
164
- });
165
-
166
- it("creates SVG with proper structure", async () => {
167
- const wrapper = await mountSuspended(AlertMaskCore);
168
-
169
- const svg = wrapper.find(".alert-mask-decorator");
170
- expect(svg.element.tagName).toBe("svg");
171
-
172
- // Check for defs and mask
173
- expect(wrapper.find("defs").exists()).toBe(true);
174
- const mask = wrapper.find("mask");
175
- expect(mask.exists()).toBe(true);
176
- expect(wrapper.find("path[mask]").attributes("mask")).toBe(`url(#${mask.attributes("id")})`);
177
-
178
- // Check for paths
179
- const paths = wrapper.findAll("path");
180
- expect(paths.length).toBe(4); // 2 in mask, 2 visible paths
181
- });
182
-
183
- it("gives each instance its own mask id so multiple masks on a page don't share one shape", async () => {
184
- const wrapper = await mountSuspended({
185
- components: { AlertMaskCore },
186
- template: "<div><AlertMaskCore></AlertMaskCore><AlertMaskCore></AlertMaskCore></div>",
187
- });
188
- const ids = wrapper.findAll("mask").map((mask) => mask.attributes("id"));
189
- expect(ids).toHaveLength(2);
190
- expect(ids[0]).toBeTruthy();
191
- expect(ids[0]).not.toBe(ids[1]);
192
- });
193
-
194
- it("hides the decorative SVG from assistive technology", async () => {
195
- const wrapper = await mountSuspended(AlertMaskCore);
196
- expect(wrapper.find(".alert-mask-decorator").attributes("aria-hidden")).toBe("true");
197
- });
198
-
199
- it("applies config colours as fill styles so CSS var() values resolve", async () => {
200
- const wrapper = await mountSuspended(AlertMaskCore, {
201
- props: { config: { borderColour: "var(--my-border)", backgroundColour: "var(--my-bg)" } },
202
- });
203
- const paths = wrapper.findAll(".alert-mask-decorator > path");
204
- expect((paths[0]!.element as unknown as SVGElement).style.fill).toBe("var(--my-border)");
205
- expect((paths[1]!.element as unknown as SVGElement).style.fill).toBe("var(--my-bg)");
206
- });
207
-
208
- it("disconnects its ResizeObserver on unmount", async () => {
209
- const wrapper = await mountSuspended(AlertMaskCore);
210
- const observer = mockResizeObserver.mock.results.at(-1)?.value as { disconnect: ReturnType<typeof vi.fn> };
211
- wrapper.unmount();
212
- expect(observer.disconnect).toHaveBeenCalled();
213
- });
214
-
215
- it("applies CSS custom properties for content positioning", async () => {
216
- const config: AlertMaskConfig = {
217
- borderLeft: 15,
218
- borderTop: 20,
219
- borderRight: 25,
220
- borderBottom: 30,
221
- };
222
-
223
- const wrapper = await mountSuspended(AlertMaskCore, {
224
- props: { config },
225
- });
226
-
227
- const contentEl = wrapper.find(".alert-mask-content");
228
- const style = (contentEl.element as HTMLElement).style;
229
-
230
- expect(style.getPropertyValue("--_inset-inline-start")).toBe("15px");
231
- expect(style.getPropertyValue("--_inset-block-start")).toBe("20px");
232
- expect(style.getPropertyValue("--_inset-inline-end")).toBe("25px");
233
- expect(style.getPropertyValue("--_inset-block-end")).toBe("30px");
234
- });
235
-
236
- it("handles zero border values correctly", async () => {
237
- const config: AlertMaskConfig = {
238
- borderLeft: 0,
239
- borderTop: 0,
240
- borderRight: 0,
241
- borderBottom: 0,
242
- };
243
-
244
- const wrapper = await mountSuspended(AlertMaskCore, {
245
- props: { config },
246
- });
247
-
248
- const contentEl = wrapper.find(".alert-mask-content");
249
- const style = (contentEl.element as HTMLElement).style;
250
-
251
- expect(style.getPropertyValue("--_inset-inline-start")).toBe("0px");
252
- expect(style.getPropertyValue("--_inset-block-start")).toBe("0px");
253
- expect(style.getPropertyValue("--_inset-inline-end")).toBe("0px");
254
- expect(style.getPropertyValue("--_inset-block-end")).toBe("0px");
255
- });
256
-
257
- it("sets up ResizeObserver on mount", async () => {
258
- await mountSuspended(AlertMaskCore, {
259
- slots: {
260
- default: "<div>Content to observe</div>",
261
- },
262
- });
263
-
264
- // ResizeObserver should be instantiated
265
- expect(mockResizeObserver).toHaveBeenCalled();
266
- });
267
-
268
- it("generates SVG paths when dimensions are available", async () => {
269
- const wrapper = await mountSuspended(AlertMaskCore);
270
- const vm = wrapper.vm as unknown as AlertMaskCoreInstance;
271
-
272
- // Set dimensions
273
- vm.svgWidth = 200;
274
- vm.svgHeight = 100;
275
-
276
- await nextTick();
277
-
278
- // Check that paths are generated (not empty)
279
- expect(vm.outerPath).toBeTruthy();
280
- expect(vm.innerPath).toBeTruthy();
281
- expect(vm.outerPath.length).toBeGreaterThan(0);
282
- expect(vm.innerPath.length).toBeGreaterThan(0);
283
- });
284
-
285
- it("returns empty paths when dimensions are zero", async () => {
286
- const wrapper = await mountSuspended(AlertMaskCore);
287
- const vm = wrapper.vm as unknown as AlertMaskCoreInstance;
288
-
289
- // Explicitly set dimensions to zero to test the condition
290
- vm.svgWidth = 0;
291
- vm.svgHeight = 0;
292
-
293
- await nextTick();
294
-
295
- expect(vm.outerPath).toBe("");
296
- expect(vm.innerPath).toBe("");
297
- });
298
-
299
- it("updates SVG height CSS custom property", async () => {
300
- const wrapper = await mountSuspended(AlertMaskCore);
301
- const vm = wrapper.vm as unknown as AlertMaskCoreInstance;
302
-
303
- vm.svgHeight = 150;
304
- await nextTick();
305
-
306
- const svg = wrapper.find(".alert-mask-decorator");
307
- const style = (svg.element as HTMLElement).style;
308
- expect(style.getPropertyValue("--_height")).toBe("150px");
309
- });
310
-
311
- it("handles empty configuration object", async () => {
312
- const wrapper = await mountSuspended(AlertMaskCore, {
313
- props: {
314
- config: {},
315
- },
316
- });
317
-
318
- // Should use all defaults when empty config provided
319
- const vm = wrapper.vm as unknown as AlertMaskCoreInstance;
320
- expect(vm.cfg.backgroundColour).toBe("rgba(0,0,0,0.25)");
321
- expect(vm.cfg.borderColour).toBe("var(--orange-08)");
322
- });
323
-
324
- it("maintains reactivity with config changes", async () => {
325
- const wrapper = await mountSuspended(AlertMaskCore, {
326
- props: {
327
- config: { backgroundColour: "red" },
328
- },
329
- });
330
-
331
- let vm = wrapper.vm as unknown as AlertMaskCoreInstance;
332
- expect(vm.cfg.backgroundColour).toBe("red");
333
-
334
- // Update props
335
- await wrapper.setProps({
336
- config: { backgroundColour: "blue" },
337
- });
338
-
339
- vm = wrapper.vm as unknown as AlertMaskCoreInstance;
340
- expect(vm.cfg.backgroundColour).toBe("blue");
341
- });
342
- });
@@ -1,99 +0,0 @@
1
- # TripleToggleSwitch — Consumer Styling Guide
2
-
3
- > **Renamed 2026-09-27**: `TripleToggleSwitchCore` → `TripleToggleSwitch`. The root class was
4
- > already `.triple-toggle-switch`, and no token names changed.
5
-
6
- ## Public token API
7
-
8
- Colour tokens fall back to the shared `--theme-*` tokens every other themed component uses, so
9
- leave them unset to follow the site theme. See `theming-component-token-pattern.md`.
10
-
11
- ### Wrapper
12
-
13
- | Token | Default | Controls |
14
- |---|---|---|
15
- | `--triple-toggle-switch-surface` | `var(--theme-input-surface)` | Wrapper background |
16
- | `--triple-toggle-switch-border` | `var(--theme-border)` | Wrapper border colour |
17
- | `--triple-toggle-switch-ring-focus` | `var(--theme-ring)` | Wrapper outline colour while a radio has `:focus-visible` |
18
- | `--triple-toggle-switch-padding` | `0.6rem` | Wrapper padding |
19
- | `--triple-toggle-switch-gap` | `1rem` | Gap between the three options (also drives the marker's position) |
20
-
21
- ### Selected-option marker
22
-
23
- | Token | Default | Controls |
24
- |---|---|---|
25
- | `--triple-toggle-switch-marker-surface` | `var(--theme-input-surface)` | Marker background when the selected value isn't `system`/`light`/`dark` |
26
- | `--triple-toggle-switch-marker-border` | `var(--slate-10)` | Marker border colour |
27
- | `--triple-toggle-switch-marker-gradient-system` | green `radial-gradient(...)` | Marker `background-image` when `system` is selected |
28
- | `--triple-toggle-switch-marker-gradient-light` | red/orange `radial-gradient(...)` | Marker `background-image` when `light` is selected |
29
- | `--triple-toggle-switch-marker-gradient-dark` | black/grey `radial-gradient(...)` | Marker `background-image` when `dark` is selected |
30
-
31
- > **Changed 2026-09-27**: the three marker gradients were hardcoded literals with no override hook.
32
- > They're now public tokens; the defaults are unchanged. Any `<image>` value works
33
- > (`linear-gradient(...)`, `url(...)`, `none`).
34
-
35
- ### Options
36
-
37
- | Token | Default | Controls |
38
- |---|---|---|
39
- | `--triple-toggle-switch-option-padding` | `0.5rem` | Padding inside each option circle |
40
- | `--triple-toggle-switch-icon-size` | `2rem` | Icon `font-size` (and the hidden radio's hit area) |
41
- | `--triple-toggle-switch-option-border` | `#00000025` | Each option's resting border colour |
42
- | `--triple-toggle-switch-option-border-hover` | `var(--slate-10)` | Option outline colour on icon hover |
43
- | `--triple-toggle-switch-option-ring-focus` | `var(--theme-ring)` | Option outline colour on `:focus-visible` |
44
- | `--triple-toggle-switch-option-icon-color` | `var(--slate-10)` | Icon colour, resting |
45
- | `--triple-toggle-switch-option-icon-color-active` | `var(--slate-00)` | Icon colour, selected |
46
-
47
- Also read (shared, not component-specific): `--form-element-border-width`,
48
- `--form-element-outline-width`, `--theme-form-transition-duration`. See
49
- `theming-form-geometry-tokens.md`.
50
-
51
- Private tokens (not public API): `--_form-items-gap`, `--_scheme-icon-font-size` (each used twice,
52
- fed by the public gap/icon-size tokens) and `--_select-scheme-group-background-color`/
53
- `--_select-scheme-group-background-image` (swapped per selected value).
54
-
55
- ## State hooks
56
-
57
- - **Selected value**: the marker gradients key off `:has(input[value="system" | "light" | "dark"]:checked)`
58
- on the root. These three values are hardcoded, a legacy of the one real consumer
59
- (`DisplayThemeSwitch`). Any other value shows `--triple-toggle-switch-marker-surface` instead.
60
- - **Option icon classes**: each `.option-icon` carries the option's `id` as a class (e.g.
61
- `.option-icon.light`) plus `.active` when selected.
62
- - **Marker**: `.selected-option-marker` gains `.show` roughly 250ms after mount (it's positioned
63
- from a measured option width, so it stays hidden until measured).
64
- - **Theme**: `data-theme` on the root, from the `theme` prop.
65
- - **Inner classes**: `.triple-toggle-switch-wrapper`, `.selected-option-marker-wrapper`,
66
- `.selected-option-marker`, `.option-group-wrapper` (the `role="radiogroup"`), `.option-group`
67
- (each `<label>`), `.option-icon`, `.option-input`.
68
-
69
- ## Motion
70
-
71
- The marker slides between options over the `stepAnimationDuration` prop (default `250ms`). Under
72
- `prefers-reduced-motion: reduce` the slide transition is removed and the marker jumps.
73
-
74
- ## Global theming
75
-
76
- ```css
77
- :where(html) {
78
- --triple-toggle-switch-surface: var(--rose-09);
79
- --triple-toggle-switch-option-ring-focus: var(--rose-04);
80
- }
81
- ```
82
-
83
- ## Local overrides
84
-
85
- Set the tokens above on an element you own (a page or section class, or a class added with
86
- `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
87
- `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
88
- `<style scoped>`, tokens set on your element still work, but selectors that reach inside the
89
- component need `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
90
-
91
- **Caveat:** when used through `DisplayThemeSwitch`, that wrapper re-declares the four sizing tokens
92
- (`--triple-toggle-switch-gap`, `-padding`, `-option-padding`, `-icon-size`) on the root element, so
93
- setting them on an ancestor has no effect there. Set them on the component itself (via
94
- `style` or a passthrough class) instead. Used directly, `TripleToggleSwitch` re-declares nothing.
95
-
96
- ## Class passthrough
97
-
98
- `style-class-passthrough` adds classes to the root `.triple-toggle-switch` element, alongside
99
- `data-theme`. `DisplayThemeSwitch` uses it for its `colour-scheme-select` and `small` classes.
@@ -1,251 +0,0 @@
1
- <template>
2
- <div class="triple-toggle-switch" :class="[elementClasses]" :data-theme="theme">
3
- <div class="triple-toggle-switch-wrapper">
4
- <div class="selected-option-marker-wrapper">
5
- <div class="selected-option-marker" :class="[{ show: showMarker }]"></div>
6
- </div>
7
- <div class="option-group-wrapper" role="radiogroup" :aria-label="ariaLabel || undefined">
8
- <label
9
- v-for="option in fieldData.data"
10
- :key="option.id"
11
- ref="optionGroup"
12
- :for="option.id"
13
- class="option-group"
14
- >
15
- <span class="sr-only">{{ option.label }}</span>
16
- <Icon
17
- v-if="option.icon"
18
- :name="option.icon"
19
- class="option-icon"
20
- :class="[option.id, { active: modelValue === option.value }]"
21
- />
22
- <input
23
- :id="option.id"
24
- v-model="modelValue"
25
- type="radio"
26
- :name="name"
27
- class="option-input"
28
- :value="option.value"
29
- />
30
- </label>
31
- </div>
32
- </div>
33
- </div>
34
- </template>
35
-
36
- <script setup lang="ts">
37
- import type { FormUiTheme, IFormMultipleOptions } from "~/types/forms/types.forms";
38
-
39
- interface Props {
40
- name?: string;
41
- ariaLabel?: string;
42
- theme?: FormUiTheme;
43
- stepAnimationDuration?: string;
44
- styleClassPassthrough?: string | string[];
45
- }
46
-
47
- const props = withDefaults(defineProps<Props>(), {
48
- name: "triple-toggle-switch",
49
- ariaLabel: "",
50
- theme: "default",
51
- stepAnimationDuration: "250ms",
52
- styleClassPassthrough: () => [],
53
- });
54
-
55
- const modelValue = defineModel<string | number | boolean>({ required: true });
56
- const fieldData = defineModel<IFormMultipleOptions>("fieldData", { required: true });
57
-
58
- const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
59
-
60
- const { stepAnimationDuration } = toRefs(props);
61
-
62
- const optionGroupRefs = useTemplateRef<HTMLLabelElement[]>("optionGroup");
63
-
64
- const iconWidth = ref("0px");
65
- const showMarker = ref(false);
66
-
67
- const selectedOptionIndex = computed(() => {
68
- if (!fieldData.value?.data) return 0;
69
- const index = fieldData.value.data.findIndex((option) => option.value === modelValue.value);
70
- return Math.max(0, index);
71
- });
72
-
73
- const setupDefaults = async () => {
74
- await nextTick();
75
-
76
- const firstOption = optionGroupRefs.value?.[0];
77
- if (firstOption) {
78
- iconWidth.value = `${firstOption.getBoundingClientRect().width}px`;
79
- }
80
-
81
- await new Promise((resolve) => {
82
- requestAnimationFrame(() => {
83
- setTimeout(resolve, 250);
84
- });
85
- });
86
-
87
- showMarker.value = true;
88
- };
89
-
90
- onMounted(() => {
91
- setupDefaults();
92
- });
93
- </script>
94
-
95
- <style lang="css">
96
- @layer components {
97
- .triple-toggle-switch {
98
- --_form-items-gap: var(--triple-toggle-switch-gap, 1rem);
99
- --_select-scheme-group-background-color: var(--triple-toggle-switch-marker-surface, var(--theme-input-surface));
100
- --_select-scheme-group-background-image: none;
101
- --_scheme-icon-font-size: var(--triple-toggle-switch-icon-size, 2rem);
102
-
103
- &:has(input[value="system"]:checked) {
104
- --_select-scheme-group-background-color: transparent;
105
- --_select-scheme-group-background-image: var(
106
- --triple-toggle-switch-marker-gradient-system,
107
- radial-gradient(circle, rgb(66, 180, 58) 0%, rgb(17, 199, 0) 27%, rgb(8, 117, 3) 100%)
108
- );
109
- }
110
-
111
- &:has(input[value="light"]:checked) {
112
- --_select-scheme-group-background-color: transparent;
113
- --_select-scheme-group-background-image: var(
114
- --triple-toggle-switch-marker-gradient-light,
115
- radial-gradient(circle, rgba(180, 58, 91, 1) 0%, rgba(253, 29, 29, 1) 27%, rgba(252, 176, 69, 1) 100%)
116
- );
117
- }
118
-
119
- &:has(input[value="dark"]:checked) {
120
- --_select-scheme-group-background-color: transparent;
121
- --_select-scheme-group-background-image: var(
122
- --triple-toggle-switch-marker-gradient-dark,
123
- radial-gradient(circle, rgb(50, 20, 25) 0%, rgb(0, 0, 0) 27%, rgb(100, 100, 100) 100%)
124
- );
125
- }
126
-
127
- .triple-toggle-switch-wrapper {
128
- display: inline-grid;
129
- grid-template-areas: "select-stack";
130
- width: fit-content;
131
-
132
- background-color: var(--triple-toggle-switch-surface, var(--theme-input-surface));
133
- border: var(--form-element-border-width) solid var(--triple-toggle-switch-border, var(--theme-border));
134
- outline: var(--form-element-outline-width) solid transparent;
135
- border-radius: 100vw;
136
- padding: var(--triple-toggle-switch-padding, 0.6rem);
137
-
138
- transition: all var(--theme-form-transition-duration) ease-in-out;
139
-
140
- &:has(input:focus-visible) {
141
- outline: var(--form-element-outline-width) solid var(--triple-toggle-switch-ring-focus, var(--theme-ring));
142
- outline-offset: 0.2rem;
143
- }
144
-
145
- .selected-option-marker-wrapper {
146
- grid-area: select-stack;
147
- z-index: 1;
148
- display: flex;
149
- align-items: center;
150
- position: relative;
151
-
152
- .selected-option-marker {
153
- aspect-ratio: 1;
154
- width: v-bind(iconWidth);
155
- transition: all v-bind(stepAnimationDuration) ease-in-out;
156
- background-color: var(--_select-scheme-group-background-color);
157
- background-image: var(--_select-scheme-group-background-image);
158
- border: var(--form-element-border-width) solid var(--triple-toggle-switch-marker-border, var(--slate-10));
159
- border-radius: 50%;
160
-
161
- position: absolute;
162
- left: calc(
163
- v-bind(selectedOptionIndex) * v-bind(iconWidth) + (var(--_form-items-gap) * v-bind(selectedOptionIndex))
164
- );
165
-
166
- opacity: 0;
167
-
168
- &.show {
169
- opacity: 1;
170
- }
171
-
172
- @media (prefers-reduced-motion: reduce) {
173
- transition: none;
174
- }
175
- }
176
- }
177
-
178
- .option-group-wrapper {
179
- display: grid;
180
- grid-area: select-stack;
181
- grid-template-columns: repeat(3, 1fr);
182
- align-items: center;
183
- width: fit-content;
184
- z-index: 2;
185
- gap: var(--_form-items-gap);
186
- position: relative;
187
-
188
- .option-group {
189
- display: grid;
190
- grid-template-areas: "icon-stack";
191
- place-items: center;
192
- background: transparent;
193
- border: var(--form-element-border-width) solid var(--triple-toggle-switch-option-border, #00000025);
194
- outline: var(--form-element-outline-width) solid transparent;
195
- border-radius: 50%;
196
- padding: var(--triple-toggle-switch-option-padding, 0.5rem);
197
- overflow: hidden;
198
-
199
- transition: all calc(var(--theme-form-transition-duration) / 3);
200
-
201
- &:has(.option-icon:hover) {
202
- outline: var(--form-element-outline-width) solid
203
- var(--triple-toggle-switch-option-border-hover, var(--slate-10));
204
- }
205
-
206
- &:has(input:focus-visible) {
207
- outline: var(--form-element-outline-width) solid
208
- var(--triple-toggle-switch-option-ring-focus, var(--theme-ring));
209
- outline-offset: 0.2rem;
210
- }
211
-
212
- .option-icon {
213
- grid-area: icon-stack;
214
- display: block;
215
- font-size: var(--_scheme-icon-font-size);
216
- width: 1em;
217
- height: 1em;
218
- color: var(--triple-toggle-switch-option-icon-color, var(--slate-10));
219
- transition: color calc(v-bind(stepAnimationDuration) / 2) ease-in-out;
220
-
221
- &.active {
222
- color: var(--triple-toggle-switch-option-icon-color-active, var(--slate-00));
223
- transition-delay: calc(v-bind(stepAnimationDuration) / 2);
224
- }
225
-
226
- @media (prefers-reduced-motion: reduce) {
227
- transition: none;
228
- }
229
-
230
- &:hover {
231
- cursor: pointer;
232
- }
233
- }
234
-
235
- .option-input {
236
- grid-area: icon-stack;
237
- appearance: none;
238
- opacity: 0;
239
- width: var(--_scheme-icon-font-size);
240
- height: var(--_scheme-icon-font-size);
241
-
242
- &:hover {
243
- cursor: pointer;
244
- }
245
- }
246
- }
247
- }
248
- }
249
- }
250
- }
251
- </style>