srcdev-nuxt-components 9.6.10 → 9.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +4 -2
  2. package/.claude/skills/component-compliance-checklist.md +14 -3
  3. package/.claude/skills/component-inline-action-button.md +5 -4
  4. package/.claude/skills/component-naming.md +2 -1
  5. package/.claude/skills/components/{alert-mask-core.md → alert-mask.md} +38 -23
  6. package/.claude/skills/components/alert-masked-content.md +3 -3
  7. package/.claude/skills/components/container-glow.md +1 -1
  8. package/.claude/skills/components/display-theme-switch.md +8 -8
  9. package/.claude/skills/components/form-field.md +15 -0
  10. package/.claude/skills/components/form-fieldset.md +18 -2
  11. package/.claude/skills/components/input-button.md +26 -7
  12. package/.claude/skills/components/input-checkbox-radio.md +2 -0
  13. package/.claude/skills/components/input-description.md +5 -0
  14. package/.claude/skills/components/input-error.md +9 -2
  15. package/.claude/skills/components/input-label.md +8 -2
  16. package/.claude/skills/components/input-number.md +2 -0
  17. package/.claude/skills/components/input-otp.md +2 -0
  18. package/.claude/skills/components/input-range.md +2 -0
  19. package/.claude/skills/components/input-select.md +2 -0
  20. package/.claude/skills/components/input-text.md +2 -0
  21. package/.claude/skills/components/input-textarea.md +2 -0
  22. package/.claude/skills/components/marquee-scroller.md +51 -14
  23. package/.claude/skills/components/pending-effect.md +5 -4
  24. package/.claude/skills/components/{triple-toggle-switch.md → segmented-control.md} +34 -19
  25. package/.claude/skills/components/services-card.md +1 -1
  26. package/.claude/skills/components/tabbed-content.md +1 -1
  27. package/.claude/skills/components/toggle-switch.md +2 -0
  28. package/.claude/skills/index.md +5 -5
  29. package/.claude/skills/storybook-add-story.md +3 -1
  30. package/.claude/skills/theming-component-token-pattern.md +24 -0
  31. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  32. package/.claude/skills/theming-partial-override.md +1 -1
  33. package/.vscode/srcdev-component-alert-mask.code-snippets +41 -0
  34. package/.vscode/srcdev-component-form-field.code-snippets +7 -6
  35. package/.vscode/srcdev-component-form-fieldset.code-snippets +12 -12
  36. package/.vscode/srcdev-component-marquee-scroller.code-snippets +31 -28
  37. package/.vscode/{srcdev-component-triple-toggle-switch.code-snippets → srcdev-component-segmented-control.code-snippets} +6 -7
  38. package/app/assets/styles/setup/03.theming/_theme-slots.css +1 -1
  39. package/app/assets/styles/setup/04.elements/forms/03.generic-input-geometry.css +4 -0
  40. package/app/components/01.atoms/alert-mask/AlertMask.vue +194 -0
  41. package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +57 -42
  42. package/app/components/01.atoms/alert-mask/stories/AlertMask.stories.ts +198 -0
  43. package/app/components/01.atoms/alert-mask/tests/AlertMask.spec.ts +233 -0
  44. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +77 -54
  45. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +103 -91
  46. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +132 -3
  47. package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +190 -102
  48. package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +17 -13
  49. package/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue +2 -2
  50. package/app/components/02.molecules/alert-masked-content/CONSUMER-STYLING.md +4 -4
  51. package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +60 -0
  52. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +12 -12
  53. package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +3 -3
  54. package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +1 -1
  55. package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +1 -1
  56. package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +9 -9
  57. package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +16 -16
  58. package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +53 -1
  59. package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +8 -8
  60. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +4 -4
  61. package/app/components/02.molecules/input-copy/InputCopy.vue +2 -2
  62. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +2 -2
  63. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +4 -4
  64. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +2 -2
  65. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +2 -2
  66. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +6 -6
  67. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +8 -0
  68. package/app/components/05.forms/form-errors/InputError.vue +43 -37
  69. package/app/components/05.forms/form-errors/stories/InputError.stories.ts +79 -1
  70. package/app/components/05.forms/form-errors/tests/InputError.spec.ts +29 -1
  71. package/app/components/05.forms/form-field/CONSUMER-STYLING.md +7 -0
  72. package/app/components/05.forms/form-field/stories/FormField.stories.ts +116 -2
  73. package/app/components/05.forms/form-fieldset/CONSUMER-STYLING.md +11 -1
  74. package/app/components/05.forms/form-fieldset/FormFieldset.vue +4 -2
  75. package/app/components/05.forms/form-fieldset/stories/FormFieldset.stories.ts +68 -4
  76. package/app/components/05.forms/form-fieldset/tests/FormFieldset.spec.ts +10 -0
  77. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +80 -47
  78. package/app/components/05.forms/input-button/InputButton.vue +52 -100
  79. package/app/components/05.forms/input-button/playwright/input-button.playwright.ts +1 -1
  80. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +145 -8
  81. package/app/components/05.forms/input-button/tests/InputButton.spec.ts +76 -46
  82. package/app/components/05.forms/input-button/tests/__snapshots__/InputButton.spec.ts.snap +17 -17
  83. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +9 -1
  84. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +8 -0
  85. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +2 -2
  86. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +14 -2
  87. package/app/components/05.forms/input-description/InputDescription.vue +2 -1
  88. package/app/components/05.forms/input-description/stories/InputDescription.stories.ts +70 -4
  89. package/app/components/05.forms/input-description/tests/InputDescription.spec.ts +2 -1
  90. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +8 -0
  91. package/app/components/05.forms/input-label/InputLabel.vue +1 -0
  92. package/app/components/05.forms/input-label/stories/InputLabel.stories.ts +68 -2
  93. package/app/components/05.forms/input-label/tests/InputLabel.spec.ts +13 -3
  94. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +7 -1
  95. package/app/components/05.forms/input-number/InputNumber.vue +4 -5
  96. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +7 -1
  97. package/app/components/05.forms/input-otp/InputOtp.vue +2 -2
  98. package/app/components/05.forms/input-range/InputRange.vue +1 -1
  99. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +7 -1
  100. package/app/components/05.forms/input-select/InputSelect.vue +14 -5
  101. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +7 -1
  102. package/app/components/05.forms/input-text/InputText.vue +4 -4
  103. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +7 -1
  104. package/app/components/05.forms/input-textarea/InputTextarea.vue +2 -3
  105. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +4 -4
  106. package/app/components/05.forms/pending-effect/CONSUMER-STYLING.md +7 -3
  107. package/app/components/05.forms/pending-effect/PendingEffect.vue +3 -3
  108. package/app/components/05.forms/pending-effect/tests/PendingEffect.spec.ts +1 -1
  109. package/app/components/05.forms/segmented-control/CONSUMER-STYLING.md +125 -0
  110. package/app/components/05.forms/segmented-control/SegmentedControl.vue +231 -0
  111. package/app/components/05.forms/segmented-control/stories/SegmentedControl.stories.ts +215 -0
  112. package/app/components/05.forms/segmented-control/tests/SegmentedControl.spec.ts +202 -0
  113. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +7 -0
  114. package/app/components/05.forms/toggle-switch/ToggleSwitch.vue +2 -0
  115. package/app/types/components/index.ts +1 -1
  116. package/package.json +1 -1
  117. package/.vscode/srcdev-component-alert-mask-core.code-snippets +0 -31
  118. package/app/components/01.atoms/alert-mask/AlertMaskCore.vue +0 -166
  119. package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +0 -105
  120. package/app/components/01.atoms/alert-mask/tests/AlertMaskCore.spec.ts +0 -342
  121. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +0 -99
  122. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +0 -251
  123. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitch.stories.ts +0 -104
  124. package/app/components/05.forms/triple-toggle-switch/tests/TripleToggleSwitch.spec.ts +0 -138
  125. /package/app/types/components/{alert-mask-core.d.ts → alert-mask.d.ts} +0 -0
@@ -0,0 +1,198 @@
1
+ import { ref } from "vue";
2
+ import AlertMask from "../AlertMask.vue";
3
+ import CanvasSwitcher from "../../canvas-switcher/CanvasSwitcher.vue";
4
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
5
+ import type { MediaCanvas } from "~/types/components";
6
+
7
+ const meta: Meta<typeof AlertMask> = {
8
+ title: "Atoms/AlertMask",
9
+ component: AlertMask,
10
+ argTypes: {
11
+ config: {
12
+ control: "object",
13
+ description:
14
+ "Border/background colour and geometry: backgroundColour, borderColour, radiusLeft, radiusRight, borderLeft, borderTop, borderRight, borderBottom",
15
+ table: { category: "Basic" },
16
+ },
17
+ styleClassPassthrough: {
18
+ table: { disable: true },
19
+ },
20
+ },
21
+ parameters: {
22
+ docs: {
23
+ description: {
24
+ component:
25
+ "Draws a border and translucent fill around slotted content as an SVG path, sized to the measured content. Geometry (radii and per-side border widths) comes from the `config` prop; colours default from `--alert-mask-border-colour` and `--alert-mask-background-colour`, and `config` colours override them. [AlertMaskedContent](?path=/story/molecules-alertmaskedcontent--default) is the alert built on it.",
26
+ },
27
+ },
28
+ },
29
+ decorators: [
30
+ (story, context) => ({
31
+ components: { story, CanvasSwitcher },
32
+ setup() {
33
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
34
+ return { canvasName };
35
+ },
36
+ template: `
37
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
38
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
39
+ </div>
40
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
41
+ <p style="margin: 0 0 1.6rem; font-size: 1.4rem;">
42
+ The alert built on this mask is
43
+ <a href="/?path=/story/molecules-alertmaskedcontent--default" target="_top">AlertMaskedContent</a>.
44
+ </p>
45
+ <story />
46
+ </div>
47
+ `,
48
+ }),
49
+ ],
50
+ };
51
+
52
+ export default meta;
53
+ type Story = StoryObj<typeof AlertMask>;
54
+
55
+ // The mask is a translucent fill plus a cut-out border, so it needs something busy behind it to read at all.
56
+ const backdrop =
57
+ "background: url('/images/banners/video/lake-banner.jpg') center / cover; padding: 4rem 2.4rem; border-radius: 1.2rem; max-width: 60rem;";
58
+ const gradientBackdrop =
59
+ "background: repeating-linear-gradient(45deg, oklch(100% 0 0 / 0.18) 0 1.2rem, transparent 1.2rem 2.4rem), linear-gradient(120deg, oklch(70% 0.2 30), oklch(65% 0.2 200) 60%, oklch(55% 0.22 300)); padding: 4rem 2.4rem; border-radius: 1.2rem; max-width: 60rem;";
60
+
61
+ export const Default: Story = {
62
+ args: { config: {} },
63
+ render: (args) => ({
64
+ components: { AlertMask },
65
+ setup() {
66
+ return { args, gradientBackdrop };
67
+ },
68
+ template: `
69
+ <div data-theme="info" :style="gradientBackdrop">
70
+ <AlertMask v-bind="args">
71
+ <p style="margin: 0; padding: 1.6rem;">Default geometry and token colours: an 8px theme-accent border and a translucent subtle-surface fill.</p>
72
+ </AlertMask>
73
+ </div>
74
+ `,
75
+ }),
76
+ parameters: {
77
+ docs: { description: { story: "No config: default radii and borders, colours from the CSS tokens and the surrounding `data-theme`." } },
78
+ },
79
+ };
80
+
81
+ export const CustomConfig: Story = {
82
+ args: {
83
+ config: {
84
+ backgroundColour: "rgba(0, 0, 0, 0.3)",
85
+ borderColour: "var(--theme-accent)",
86
+ radiusLeft: 8,
87
+ radiusRight: 4,
88
+ borderLeft: 6,
89
+ borderTop: 1,
90
+ borderRight: 1,
91
+ borderBottom: 1,
92
+ },
93
+ },
94
+ render: (args) => ({
95
+ components: { AlertMask },
96
+ setup() {
97
+ return { args, backdrop };
98
+ },
99
+ template: `
100
+ <div data-theme="info" :style="backdrop">
101
+ <AlertMask v-bind="args">
102
+ <p style="margin: 0; padding: 1.6rem; color: white;">Custom accent-bar config, matching AlertMaskedContent's default shape</p>
103
+ </AlertMask>
104
+ </div>
105
+ `,
106
+ }),
107
+ parameters: {
108
+ docs: {
109
+ description: {
110
+ story: "The accent-bar shape used by AlertMaskedContent: thick left radius/border, thin elsewhere.",
111
+ },
112
+ },
113
+ },
114
+ };
115
+
116
+ export const OverGradient: Story = {
117
+ args: {
118
+ config: {
119
+ backgroundColour: "rgba(0, 0, 0, 0.3)",
120
+ borderColour: "white",
121
+ radiusLeft: 16,
122
+ radiusRight: 16,
123
+ borderLeft: 4,
124
+ borderTop: 4,
125
+ borderRight: 4,
126
+ borderBottom: 4,
127
+ },
128
+ },
129
+ render: (args) => ({
130
+ components: { AlertMask },
131
+ setup() {
132
+ return { args, gradientBackdrop };
133
+ },
134
+ template: `
135
+ <div :style="gradientBackdrop">
136
+ <AlertMask v-bind="args">
137
+ <p style="margin: 0; padding: 1.6rem; color: white;">Even border all round: the stripes show through the fill and stop at the border's inner edge.</p>
138
+ </AlertMask>
139
+ </div>
140
+ `,
141
+ }),
142
+ parameters: {
143
+ docs: {
144
+ description: {
145
+ story: "A striped gradient behind an even 4px white border, so the translucent fill and the border's cut-out are both visible.",
146
+ },
147
+ },
148
+ },
149
+ };
150
+
151
+ const stressConfigs = [
152
+ { caption: "Radii far larger than the box (500)", config: { radiusLeft: 500, radiusRight: 500 } },
153
+ { caption: "Negative borders and radius (clamped to 0)", config: { borderLeft: -10, borderTop: -4, radiusLeft: -8 } },
154
+ { caption: "Huge borders (40px) round a single word", config: { borderLeft: 40, borderTop: 40, borderRight: 40, borderBottom: 40 } },
155
+ { caption: "Fractional values", config: { borderLeft: 2.5, borderTop: 0.5, borderRight: 0.5, borderBottom: 0.5, radiusLeft: 6.25, radiusRight: 3.75 } },
156
+ { caption: "Zero borders", config: { borderLeft: 0, borderTop: 0, borderRight: 0, borderBottom: 0 } },
157
+ ];
158
+
159
+ export const StressTest: Story = {
160
+ name: "Stress Test (Worst-Case Data)",
161
+ render: () => ({
162
+ components: { AlertMask },
163
+ setup() {
164
+ return { stressConfigs, gradientBackdrop };
165
+ },
166
+ template: `
167
+ <div data-theme="info" :style="gradientBackdrop" style="display: grid; gap: 2.4rem;">
168
+ <AlertMask :config="{ borderLeft: 6, borderTop: 1, borderRight: 1, borderBottom: 1, radiusLeft: 8, radiusRight: 4 }">
169
+ <p style="margin: 0; padding: 1.6rem; overflow-wrap: anywhere;">
170
+ 📧 Datenschutzgrundverordnungskonformitätsbestätigung https://example.com/a/very/long/unbroken/url/that/should/wrap <b>not bold</b> &lt;b&gt;escaped&lt;/b&gt; يرجى إدخال بريدك الإلكتروني. Wir verwenden Ihre Adresse ausschließlich für Rückfragen zu Ihrer Bestellung bei Alex Morgan in Northtown.
171
+ </p>
172
+ </AlertMask>
173
+ <div dir="rtl">
174
+ <AlertMask :config="{ borderLeft: 6, borderTop: 1, borderRight: 1, borderBottom: 1, radiusLeft: 8, radiusRight: 4 }">
175
+ <p style="margin: 0; padding: 1.6rem;">نص من اليمين إلى اليسار داخل القناع، مع شريط التمييز على اليسار</p>
176
+ </AlertMask>
177
+ </div>
178
+ <AlertMask></AlertMask>
179
+ <div v-for="item in stressConfigs" :key="item.caption">
180
+ <p style="margin: 0 0 0.8rem; font-size: 1.4rem;">{{ item.caption }}</p>
181
+ <AlertMask :config="item.config">
182
+ <p style="margin: 0; padding: 0.8rem;">x</p>
183
+ </AlertMask>
184
+ </div>
185
+ </div>
186
+ `,
187
+ }),
188
+ parameters: {
189
+ initialCanvas: "mobileCanvas",
190
+ docs: {
191
+ story: { inline: false, height: "1100px" },
192
+ description: {
193
+ story:
194
+ "Long German copy with an unbroken compound word and URL, HTML-like and right-to-left text, an empty mask, then hostile config: oversized radii, negative and fractional values, huge and zero borders. Check at every canvas width: the frame always hugs its content inside the dashed outline, no path draws outside the box or inverts, the thick bar stays on the left in the right-to-left mask with the text clear of it, and the empty mask collapses to a border-only frame.",
195
+ },
196
+ },
197
+ },
198
+ };
@@ -0,0 +1,233 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { defineComponent, nextTick } from "vue";
4
+ import AlertMask from "../AlertMask.vue";
5
+ import type { AlertMaskConfig } from "~/types/components";
6
+
7
+ interface AlertMaskInstance {
8
+ cfg: {
9
+ backgroundColour: string | undefined;
10
+ borderColour: string | undefined;
11
+ radiusLeft: number;
12
+ radiusRight: number;
13
+ borderLeft: number;
14
+ borderTop: number;
15
+ borderRight: number;
16
+ borderBottom: number;
17
+ };
18
+ contentWidth: number;
19
+ contentHeight: number;
20
+ measured: boolean;
21
+ svgWidth: number;
22
+ svgHeight: number;
23
+ outerPath: string;
24
+ innerPath: string;
25
+ }
26
+
27
+ // Vitest 4: a mock's implementation must be a regular function (not an arrow function) to stay
28
+ // constructible via `new`.
29
+ const mockResizeObserver = vi.fn(function () {
30
+ return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() };
31
+ });
32
+ vi.stubGlobal("ResizeObserver", mockResizeObserver);
33
+
34
+ const mounted: { unmount: () => void }[] = [];
35
+
36
+ const mountMask = async (options: Parameters<typeof mountSuspended<typeof AlertMask>>[1] = {}) => {
37
+ const wrapper = await mountSuspended(AlertMask, options);
38
+ mounted.push(wrapper);
39
+ return wrapper;
40
+ };
41
+
42
+ const vmOf = (wrapper: Awaited<ReturnType<typeof mountMask>>) => wrapper.vm as unknown as AlertMaskInstance;
43
+
44
+ const setContentSize = async (vm: AlertMaskInstance, width: number, height: number) => {
45
+ vm.contentWidth = width;
46
+ vm.contentHeight = height;
47
+ vm.measured = true;
48
+ await nextTick();
49
+ };
50
+
51
+ const numbersIn = (path: string) => (path.match(/-?\d+(\.\d+)?/g) ?? []).map(Number);
52
+
53
+ describe("AlertMask", () => {
54
+ beforeEach(() => {
55
+ vi.clearAllMocks();
56
+ });
57
+
58
+ afterEach(() => {
59
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
60
+ vi.restoreAllMocks();
61
+ });
62
+
63
+ it("renders the root, decorator and content structure", async () => {
64
+ const wrapper = await mountMask();
65
+ expect(wrapper.find(".alert-mask").exists()).toBe(true);
66
+ expect(wrapper.find(".alert-mask-decorator").exists()).toBe(true);
67
+ expect(wrapper.find(".alert-mask-border").exists()).toBe(true);
68
+ expect(wrapper.find(".alert-mask-background").exists()).toBe(true);
69
+ expect(wrapper.find(".alert-mask-content-slot").exists()).toBe(true);
70
+ });
71
+
72
+ it("renders default slot content", async () => {
73
+ const wrapper = await mountMask({ slots: { default: "<p class='test-content'>Alert</p>" } });
74
+ expect(wrapper.find(".test-content").text()).toBe("Alert");
75
+ });
76
+
77
+ it("applies passthrough classes and reacts to changes", async () => {
78
+ const wrapper = await mountMask({ props: { styleClassPassthrough: ["one", "two"] } });
79
+ const root = () => wrapper.find(".alert-mask");
80
+ expect(root().classes()).toEqual(expect.arrayContaining(["one", "two"]));
81
+
82
+ await wrapper.setProps({ styleClassPassthrough: "three" });
83
+ expect(root().classes()).toContain("three");
84
+ expect(root().classes()).not.toContain("one");
85
+ });
86
+
87
+ it("uses default geometry and leaves colours to CSS tokens when no config is given", async () => {
88
+ const vm = vmOf(await mountMask());
89
+ expect(vm.cfg).toEqual({
90
+ backgroundColour: undefined,
91
+ borderColour: undefined,
92
+ radiusLeft: 12,
93
+ radiusRight: 12,
94
+ borderLeft: 8,
95
+ borderTop: 8,
96
+ borderRight: 8,
97
+ borderBottom: 8,
98
+ });
99
+ });
100
+
101
+ it("applies a full custom config", async () => {
102
+ const config: AlertMaskConfig = {
103
+ backgroundColour: "rgba(255,0,0,0.5)",
104
+ borderColour: "#ff0000",
105
+ radiusLeft: 20,
106
+ radiusRight: 15,
107
+ borderLeft: 12,
108
+ borderTop: 10,
109
+ borderRight: 8,
110
+ borderBottom: 6,
111
+ };
112
+ const vm = vmOf(await mountMask({ props: { config } }));
113
+ expect(vm.cfg).toEqual(config);
114
+ });
115
+
116
+ it("sanitises negative and non-finite sizes", async () => {
117
+ const vm = vmOf(
118
+ await mountMask({
119
+ props: { config: { borderLeft: -10, borderTop: Number.NaN, radiusLeft: -4, radiusRight: Infinity } },
120
+ })
121
+ );
122
+ expect(vm.cfg.borderLeft).toBe(0);
123
+ expect(vm.cfg.borderTop).toBe(8);
124
+ expect(vm.cfg.radiusLeft).toBe(0);
125
+ expect(vm.cfg.radiusRight).toBe(12);
126
+ });
127
+
128
+ it("only sets fill styles for colours given in config", async () => {
129
+ const plain = await mountMask();
130
+ expect(plain.find(".alert-mask-border").attributes("style")).toBeUndefined();
131
+ expect(plain.find(".alert-mask-background").attributes("style")).toBeUndefined();
132
+
133
+ const custom = await mountMask({
134
+ props: { config: { borderColour: "var(--my-border)", backgroundColour: "var(--my-bg)" } },
135
+ });
136
+ expect((custom.find(".alert-mask-border").element as unknown as SVGElement).style.fill).toBe("var(--my-border)");
137
+ expect((custom.find(".alert-mask-background").element as unknown as SVGElement).style.fill).toBe("var(--my-bg)");
138
+ });
139
+
140
+ it("gives each instance its own mask id", async () => {
141
+ const Host = defineComponent({
142
+ components: { AlertMask },
143
+ template: "<div><AlertMask>One</AlertMask><AlertMask>Two</AlertMask></div>",
144
+ });
145
+ const wrapper = await mountSuspended(Host);
146
+ mounted.push(wrapper);
147
+ const ids = wrapper.findAll("mask").map((mask) => mask.attributes("id"));
148
+ expect(new Set(ids).size).toBe(2);
149
+ expect(wrapper.findAll(".alert-mask-border")[0]!.attributes("mask")).toBe(`url(#${ids[0]})`);
150
+ });
151
+
152
+ it("hides the decorative SVG from assistive technology", async () => {
153
+ const wrapper = await mountMask();
154
+ expect(wrapper.find(".alert-mask-decorator").attributes("aria-hidden")).toBe("true");
155
+ });
156
+
157
+ it("insets the content by the configured borders", async () => {
158
+ const wrapper = await mountMask({
159
+ props: { config: { borderLeft: 15, borderTop: 20, borderRight: 25, borderBottom: 30 } },
160
+ });
161
+ const style = (wrapper.find(".alert-mask-content").element as HTMLElement).style;
162
+ expect(style.getPropertyValue("--_inset-left")).toBe("15px");
163
+ expect(style.getPropertyValue("--_inset-top")).toBe("20px");
164
+ expect(style.getPropertyValue("--_inset-right")).toBe("25px");
165
+ expect(style.getPropertyValue("--_inset-bottom")).toBe("30px");
166
+ });
167
+
168
+ it("insets the content by the default borders when config omits them", async () => {
169
+ const style = ((await mountMask()).find(".alert-mask-content").element as HTMLElement).style;
170
+ expect(style.getPropertyValue("--_inset-left")).toBe("8px");
171
+ expect(style.getPropertyValue("--_inset-bottom")).toBe("8px");
172
+ });
173
+
174
+ it("observes the content and disconnects on unmount", async () => {
175
+ const wrapper = await mountMask({ slots: { default: "<div>Content</div>" } });
176
+ expect(mockResizeObserver).toHaveBeenCalled();
177
+ const observer = mockResizeObserver.mock.results.at(-1)?.value as { disconnect: ReturnType<typeof vi.fn> };
178
+ wrapper.unmount();
179
+ mounted.splice(mounted.indexOf(wrapper), 1);
180
+ expect(observer.disconnect).toHaveBeenCalled();
181
+ });
182
+
183
+ it("draws nothing until the content is measured", async () => {
184
+ const vm = vmOf(await mountMask());
185
+ vm.measured = false;
186
+ await nextTick();
187
+ expect(vm.svgWidth).toBe(0);
188
+ expect(vm.outerPath).toBe("");
189
+ expect(vm.innerPath).toBe("");
190
+ });
191
+
192
+ it("sizes the SVG from the content plus the borders", async () => {
193
+ const wrapper = await mountMask();
194
+ const vm = vmOf(wrapper);
195
+ await setContentSize(vm, 200, 100);
196
+ expect(vm.svgWidth).toBe(216);
197
+ expect(vm.svgHeight).toBe(116);
198
+ expect((wrapper.find(".alert-mask-decorator").element as HTMLElement).style.getPropertyValue("--_height")).toBe(
199
+ "116px"
200
+ );
201
+ expect(vm.outerPath).not.toBe("");
202
+ expect(vm.innerPath).not.toBe("");
203
+ });
204
+
205
+ it("recomputes the size when the border config changes without a content resize", async () => {
206
+ const wrapper = await mountMask();
207
+ const vm = vmOf(wrapper);
208
+ await setContentSize(vm, 200, 100);
209
+
210
+ await wrapper.setProps({ config: { borderTop: 30, borderBottom: 30 } });
211
+ expect(vm.svgHeight).toBe(160);
212
+ });
213
+
214
+ it("clamps radii larger than the box so the path stays inside it", async () => {
215
+ const wrapper = await mountMask({ props: { config: { radiusLeft: 500, radiusRight: 500 } } });
216
+ const vm = vmOf(wrapper);
217
+ await setContentSize(vm, 200, 40);
218
+
219
+ const outer = numbersIn(vm.outerPath);
220
+ expect(Math.min(...outer)).toBeGreaterThanOrEqual(0);
221
+ expect(Math.max(...outer)).toBeLessThanOrEqual(216);
222
+ const inner = numbersIn(vm.innerPath);
223
+ expect(Math.min(...inner)).toBeGreaterThanOrEqual(0);
224
+ });
225
+
226
+ it("drops the inner cut-out when the borders fill the whole box", async () => {
227
+ const wrapper = await mountMask({ props: { config: { borderLeft: 100, borderRight: 100 } } });
228
+ const vm = vmOf(wrapper);
229
+ await setContentSize(vm, 0, 40);
230
+ expect(vm.outerPath).not.toBe("");
231
+ expect(vm.innerPath).toBe("");
232
+ });
233
+ });
@@ -2,17 +2,30 @@
2
2
 
3
3
  ## Public token API
4
4
 
5
+ ### Region
6
+
5
7
  | Token | Default | Controls |
6
8
  |---|---|---|
7
9
  | `--marquee-scroller-fade-width` | `10%` | Width of the transparent fade at each edge of the track (`mask-image`) |
8
- | `--marquee-scroller-focus-outline-width` | `2px` | Outline width on the root region and the control button when keyboard-focused (colour is `--theme-ring`) |
10
+ | `--marquee-scroller-focus-outline-width` | `2px` | Outline width on the root region and the control button when keyboard-focused |
11
+ | `--marquee-scroller-focus-outline-colour` | `var(--theme-ring)` | Outline colour on the same two focus states |
9
12
  | `--marquee-scroller-focus-outline-offset` | `2px` | Outline offset on the same two focus states |
13
+
14
+ ### Items
15
+
16
+ | Token | Default | Controls |
17
+ |---|---|---|
10
18
  | `--marquee-scroller-group-hover-filter` | `grayscale(1)` | Filter applied to every item when the pointer hovers anywhere over the scroller |
11
19
  | `--marquee-scroller-item-hover-filter` | `grayscale(0)` | Filter applied to a single item when it is individually hovered (overrides the group filter for that item) |
12
20
  | `--marquee-scroller-item-transition-duration` | `0.5s` | Transition duration for the item hover filter change |
13
21
  | `--marquee-scroller-item-border-width` | `1px` | Item border width |
14
22
  | `--marquee-scroller-item-border-colour` | `var(--slate-10)` | Item border colour |
15
23
  | `--marquee-scroller-item-border-radius` | `4px` | Item border radius |
24
+
25
+ ### Control button
26
+
27
+ | Token | Default | Controls |
28
+ |---|---|---|
16
29
  | `--marquee-scroller-control-offset` | `8px` | Distance of the pause/play button from the top and end edges |
17
30
  | `--marquee-scroller-control-background-colour` | `rgba(0, 0, 0, 0.7)` | Pause/play button background |
18
31
  | `--marquee-scroller-control-background-colour-hover` | `rgba(0, 0, 0, 0.9)` | Pause/play button background on hover |
@@ -21,15 +34,35 @@
21
34
  | `--marquee-scroller-control-padding` | `8px` | Pause/play button padding |
22
35
  | `--marquee-scroller-control-font-size` | `14px` | Pause/play button icon font size |
23
36
  | `--marquee-scroller-control-transition-duration` | `0.2s` | Pause/play button background-colour transition duration |
24
- | `--marquee-scroller-control-border-width` | `1px` | Pause/play button border width in forced-colors/high-contrast mode (`prefers-contrast: high`). The border colour itself stays the system `ButtonText` keyword — see note below. |
37
+ | `--marquee-scroller-control-border-width` | `1px` | Pause/play button border width under `prefers-contrast: high`. The border colour itself stays the system `ButtonText` keyword, see below. |
25
38
 
26
- ```css
27
- .my-page {
28
- --marquee-scroller-item-border-colour: #b5651d;
29
- --marquee-scroller-control-background-colour: #1a1a1a;
30
- --marquee-scroller-fade-width: 20%;
31
- }
32
- ```
39
+ Private tokens (not public API): `--_fade-width`, `--_focus-outline` (width + colour, shared by
40
+ both focus states) and `--_track-shift` (the loop distance, computed from `itemConfig`).
41
+
42
+ ---
43
+
44
+ ## State hooks
45
+
46
+ The root `.marquee-scroller` carries:
47
+
48
+ | Attribute | When |
49
+ |---|---|
50
+ | `data-paused` | The track is paused (control button, spacebar, focus inside, or reduced motion) |
51
+ | `data-reverse` | `reverse` is `true` |
52
+ | `data-reduced-motion` | The user prefers reduced motion and `respect-reduced-motion` is on |
53
+
54
+ Inner elements: `.marquee-scroller-track` (the animated row), `.marquee-scroller-group` (one copy
55
+ of the items; the second is `aria-hidden` and `inert`), `.marquee-scroller-item` (one fixed-size
56
+ item box), `.marquee-scroller-control` (the pause/play `InputButton`) and
57
+ `.marquee-scroller-description` (the visually hidden instructions).
58
+
59
+ > **Changed 2026-10-17**: the generic class names were renamed so they can't collide with consumer
60
+ > CSS. `.item` → `.marquee-scroller-item`, `.control-btn` → `.marquee-scroller-control` (now an
61
+ > `InputButton`, so it also has `.input-button`), `.marquee-track` → `.marquee-scroller-track`,
62
+ > `.marquee-group` → `.marquee-scroller-group`, `.sr-only` → `.marquee-scroller-description`. The
63
+ > bare state classes became attributes: `.reverse` → `[data-reverse]`, `.paused` →
64
+ > `[data-paused]`, `.reduced-motion` → `[data-reduced-motion]`. The keyframes are now
65
+ > `marquee-scroller-move` (was `marqueeMove`).
33
66
 
34
67
  ---
35
68
 
@@ -37,10 +70,9 @@
37
70
 
38
71
  Under `prefers-contrast: high`, the control button switches to the system colour keywords
39
72
  `ButtonFace`/`ButtonText` rather than the component's own background/text tokens. These aren't
40
- exposed as overridable tokens on purpose — they're meant to track the user's OS-level high-contrast
41
- theme, which a component-level override would defeat. Only the border *width* in that mode is a
42
- token (`--marquee-scroller-control-border-width`), since it's a plain dimension with no such
43
- constraint.
73
+ exposed as overridable tokens on purpose: they track the user's OS-level high-contrast theme, which
74
+ a component-level override would defeat. Only the border *width* in that mode is a token
75
+ (`--marquee-scroller-control-border-width`), since it's a plain dimension with no such constraint.
44
76
 
45
77
  ---
46
78
 
@@ -48,7 +80,7 @@ constraint.
48
80
 
49
81
  Item `width`/`height`/`gap` and the animation duration are set via the `itemConfig` and
50
82
  `animationRuntime` props (not CSS custom properties), since they also drive the component's own
51
- layout math (`aspect-ratio`, track width). Override those through props:
83
+ loop math (`--_track-shift`) and the repeat measurement. Override those through props:
52
84
 
53
85
  ```vue
54
86
  <MarqueeScroller
@@ -57,58 +89,49 @@ layout math (`aspect-ratio`, track width). Override those through props:
57
89
  />
58
90
  ```
59
91
 
60
- ---
92
+ Item boxes are a fixed size. Text in an item wraps (`overflow-wrap: anywhere`), but content taller
93
+ than `itemConfig.height` overflows, so size slot content to the box.
61
94
 
62
- ## Item content via slots
95
+ ---
63
96
 
64
- Each entry in `marqueeData` (`{ id, content }`) renders a slot named after its `id`. Provide the
65
- markup for each item via that dynamically-named slot:
97
+ ## Global theming
66
98
 
67
- ```vue
68
- <MarqueeScroller :marquee-data="[{ id: 1, content: 'logo-a' }, { id: 2, content: 'logo-b' }]">
69
- <template #1>
70
- <img src="/logos/a.svg" alt="Logo A" />
71
- </template>
72
- <template #2>
73
- <img src="/logos/b.svg" alt="Logo B" />
74
- </template>
75
- </MarqueeScroller>
99
+ ```css
100
+ :where(html) {
101
+ --marquee-scroller-fade-width: 15%;
102
+ --marquee-scroller-item-border-colour: var(--slate-04);
103
+ }
76
104
  ```
77
105
 
78
106
  ---
79
107
 
80
- ## Control button icon and copy
81
-
82
- The pause/play button's icon and its `aria-label` copy are all overridable — see `playIcon`/
83
- `pauseIcon`/`playLabel`/`pauseLabel` props, or the `toggle-icon` slot for a fully custom icon
84
- (scoped with `isPaused`):
108
+ ## Local overrides
85
109
 
86
- ```vue
87
- <MarqueeScroller
88
- show-controls
89
- play-icon="mdi:play-circle"
90
- pause-icon="mdi:pause-circle"
91
- play-label="Reproduire"
92
- pause-label="Suspendre"
93
- />
94
- ```
110
+ Set the tokens above on an element you own (a page or section class, or a class added with
111
+ `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
112
+ `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
113
+ `<style scoped>`, tokens set on your element still work, but selectors that reach inside the
114
+ component need `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
95
115
 
96
- ```vue
97
- <MarqueeScroller show-controls>
98
- <template #toggle-icon="{ isPaused }">
99
- <Icon :name="isPaused ? 'lucide:play' : 'lucide:pause'" />
100
- </template>
101
- </MarqueeScroller>
116
+ ```css
117
+ .partners-section {
118
+ --marquee-scroller-item-border-colour: #b5651d;
119
+ --marquee-scroller-control-background-colour: #1a1a1a;
120
+ --marquee-scroller-fade-width: 20%;
121
+ }
102
122
  ```
103
123
 
104
124
  ---
105
125
 
106
- ## Local overrides
126
+ ## Class passthrough
127
+
128
+ `:style-class-passthrough` (string or string array) adds classes to the root `.marquee-scroller`
129
+ region.
130
+
131
+ ---
107
132
 
108
- Set the tokens above on an element you own (a page or section class, or a plain `class` on the
109
- component (it falls through to the root element)): they inherit down into the component. Keep the
110
- block **unlayered** (no `@layer` wrapper) so it beats the library's `@layer components`. If your own
111
- file uses `<style scoped>`, tokens set on your element still work, but selectors that reach inside
112
- the component need `:deep()`. Patterns and examples:
113
- `.claude/skills/component-local-style-override.md`.
133
+ ## Notes
114
134
 
135
+ - Item content and the control button's icon and copy are covered in the skill doc
136
+ (`marquee-scroller.md`): one slot per `marqueeData` id, `toggle-icon` slot, and the
137
+ `playIcon`/`pauseIcon`/`playLabel`/`pauseLabel` props.