srcdev-nuxt-components 9.4.5 → 9.4.7

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 (104) hide show
  1. package/.claude/commands/migrate-component.md +74 -5
  2. package/.claude/component-ledger/audit.json +1 -1
  3. package/.claude/component-ledger/build.mjs +27 -0
  4. package/.claude/component-ledger/output.html +8 -3
  5. package/.claude/component-ledger/template.html +7 -2
  6. package/.claude/skills/components/breadcrumb.md +1 -0
  7. package/.claude/skills/components/column-flow-grid.md +93 -0
  8. package/.claude/skills/components/content-docs.md +2 -0
  9. package/.claude/skills/components/display-tooltip-defined.md +3 -0
  10. package/.claude/skills/components/display-tooltip.md +1 -0
  11. package/.claude/skills/components/entry-animation.md +88 -0
  12. package/.claude/skills/components/input-copy.md +2 -0
  13. package/.claude/skills/components/input-text-core.md +186 -0
  14. package/.claude/skills/components/marquee-scroller.md +130 -0
  15. package/.claude/skills/components/masonry-grid.md +153 -0
  16. package/.claude/skills/components/navigation-items.md +1 -0
  17. package/.claude/skills/components/pop-over.md +100 -0
  18. package/.claude/skills/components/responsive-header.md +3 -0
  19. package/.claude/skills/components/site-header.md +4 -0
  20. package/.claude/skills/components/site-navigation.md +1 -0
  21. package/.claude/skills/components/tab-navigation.md +1 -0
  22. package/.claude/skills/index.md +7 -1
  23. package/.vscode/srcdev-component-breadcrumb.code-snippets +13 -0
  24. package/.vscode/srcdev-component-column-flow-grid.code-snippets +39 -0
  25. package/.vscode/srcdev-component-content-docs.code-snippets +18 -0
  26. package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +15 -0
  27. package/.vscode/srcdev-component-display-tooltip.code-snippets +11 -0
  28. package/.vscode/srcdev-component-entry-animation.code-snippets +29 -0
  29. package/.vscode/srcdev-component-input-text.code-snippets +107 -0
  30. package/.vscode/srcdev-component-marquee-scroller.code-snippets +93 -0
  31. package/.vscode/srcdev-component-masonry-grid.code-snippets +51 -0
  32. package/.vscode/srcdev-component-pop-over.code-snippets +44 -0
  33. package/.vscode/srcdev-component-responsive-header.code-snippets +12 -0
  34. package/.vscode/srcdev-component-site-header.code-snippets +17 -0
  35. package/.vscode/srcdev-component-site-navigation.code-snippets +30 -0
  36. package/.vscode/srcdev-component-slider-gallery.code-snippets +38 -0
  37. package/.vscode/srcdev-component-tab-navigation.code-snippets +30 -0
  38. package/app/components/01.atoms/animations/entry/EntryAnimation.vue +7 -1
  39. package/app/components/01.atoms/animations/entry/stories/EntryAnimation.stories.ts +47 -0
  40. package/app/components/01.atoms/animations/entry/tests/EntryAnimation.spec.ts +57 -0
  41. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +94 -0
  42. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +331 -0
  43. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +151 -0
  44. package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +315 -0
  45. package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +28 -0
  46. package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +9 -10
  47. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +8 -2
  48. package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +12 -13
  49. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +4 -1
  50. package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +33 -0
  51. package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +55 -0
  52. package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +178 -0
  53. package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +75 -0
  54. package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +35 -0
  55. package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +178 -0
  56. package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +199 -0
  57. package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +158 -0
  58. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +4 -1
  59. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +54 -0
  60. package/app/components/01.atoms/pop-over/PopOver.vue +201 -0
  61. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +141 -0
  62. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +195 -0
  63. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +11 -0
  64. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +8 -2
  65. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +16 -3
  66. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +1 -1
  67. package/app/components/02.molecules/input-copy/InputCopy.vue +14 -0
  68. package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +15 -0
  69. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +41 -0
  70. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +4 -1
  71. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +4 -1
  72. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +16 -4
  73. package/app/components/03.organisms/responsive-header/NavigationItems.vue +4 -1
  74. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +16 -3
  75. package/app/components/03.organisms/site-header/SiteHeader.vue +16 -1
  76. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
  77. package/app/components/05.forms/input-select/InputSelectCore.vue +1 -1
  78. package/app/components/05.forms/input-text/InputTextCore.vue +6 -0
  79. package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +49 -20
  80. package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +41 -22
  81. package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +48 -22
  82. package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +43 -17
  83. package/app/components/05.forms/input-text/tests/InputPasswordWithLabel.spec.ts +56 -0
  84. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +61 -0
  85. package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +60 -0
  86. package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +46 -0
  87. package/app/components/05.forms/input-text/variants/InputPasswordWithLabel.vue +7 -1
  88. package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +8 -2
  89. package/app/components/05.forms/input-text/variants/InputTextWithLabel.vue +6 -0
  90. package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +20 -20
  91. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +32 -26
  92. package/app/types/components/index.ts +1 -0
  93. package/app/types/components/marquee-scroller.d.ts +10 -0
  94. package/app/types/forms/types.forms.d.ts +1 -1
  95. package/package.json +1 -1
  96. package/app/components/marquee-scroller/MarqueeScroller.vue +0 -289
  97. package/app/components/masonry-grid/MasonryGrid.vue +0 -68
  98. package/app/components/masonry-grid-ordered/MasonryGridOrdered.vue +0 -163
  99. package/app/components/masonry-grid-ordered/MasonryGridOrderedGridExperiment.vue +0 -259
  100. package/app/components/masonry-grid-ordered/stories/MasonryGridOrdered.stories.ts +0 -354
  101. package/app/components/masonry-grid-sorted/MasonryGridSorted.vue +0 -120
  102. package/app/components/pop-over/PopOver.vue +0 -90
  103. package/app/layouts/default.vue +0 -308
  104. package/app/layouts/site-navigation-demo.vue +0 -188
@@ -0,0 +1,315 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { nextTick } from "vue";
4
+ import MarqueeScroller from "../MarqueeScroller.vue";
5
+
6
+ const stubMatchMedia = (prefersReducedMotion: boolean) => {
7
+ vi.stubGlobal(
8
+ "matchMedia",
9
+ vi.fn().mockReturnValue({
10
+ matches: prefersReducedMotion,
11
+ addEventListener: vi.fn(),
12
+ })
13
+ );
14
+ };
15
+
16
+ // jsdom reports 0 for offsetWidth/scrollWidth — stub them per-element by class so
17
+ // updateRepeatCount() sees realistic container/content dimensions.
18
+ const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth");
19
+ const originalScrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollWidth");
20
+
21
+ const stubDimensions = ({ containerWidth, groupScrollWidth }: { containerWidth: number; groupScrollWidth: number }) => {
22
+ Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
23
+ configurable: true,
24
+ get() {
25
+ return this.classList.contains("marquee-scroller") ? containerWidth : 0;
26
+ },
27
+ });
28
+ Object.defineProperty(HTMLElement.prototype, "scrollWidth", {
29
+ configurable: true,
30
+ get() {
31
+ return this.classList.contains("marquee-group") ? groupScrollWidth : 0;
32
+ },
33
+ });
34
+ };
35
+
36
+ const restoreDimensions = () => {
37
+ if (originalOffsetWidth) Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth);
38
+ if (originalScrollWidth) Object.defineProperty(HTMLElement.prototype, "scrollWidth", originalScrollWidth);
39
+ };
40
+
41
+ const marqueeData = [
42
+ { id: 1, content: "logo-a" },
43
+ { id: 2, content: "logo-b" },
44
+ ];
45
+
46
+ describe("MarqueeScroller", () => {
47
+ beforeEach(() => {
48
+ stubMatchMedia(false);
49
+ });
50
+
51
+ afterEach(() => {
52
+ restoreDimensions();
53
+ });
54
+
55
+ // ─── Mount ───────────────────────────────────────────────────────────────
56
+
57
+ it("mounts without error", async () => {
58
+ const wrapper = await mountSuspended(MarqueeScroller);
59
+ expect(wrapper.vm).toBeTruthy();
60
+ });
61
+
62
+ it("renders after mount (displayComponent flips true in onMounted)", async () => {
63
+ const wrapper = await mountSuspended(MarqueeScroller);
64
+ expect(wrapper.find(".marquee-scroller").exists()).toBe(true);
65
+ });
66
+
67
+ // ─── Snapshots ───────────────────────────────────────────────────────────
68
+
69
+ it("renders correct HTML structure (default props)", async () => {
70
+ const wrapper = await mountSuspended(MarqueeScroller);
71
+ expect(wrapper.html()).toMatchSnapshot();
72
+ });
73
+
74
+ it("renders correct HTML structure (all props set)", async () => {
75
+ const wrapper = await mountSuspended(MarqueeScroller, {
76
+ props: {
77
+ animationRuntime: "20s",
78
+ reverse: true,
79
+ marqueeData,
80
+ itemConfig: { width: "80px", height: "80px", gap: "24px" },
81
+ ariaLabel: "Client logos",
82
+ ariaDescription: "Custom instructions",
83
+ showControls: true,
84
+ },
85
+ });
86
+ expect(wrapper.html()).toMatchSnapshot();
87
+ });
88
+
89
+ // ─── marqueeData / slots ─────────────────────────────────────────────────
90
+
91
+ it("renders each marqueeData item twice (main track + duplicate for looping)", async () => {
92
+ const wrapper = await mountSuspended(MarqueeScroller, {
93
+ props: { marqueeData },
94
+ slots: {
95
+ "1": "<span class='logo-a'>A</span>",
96
+ "2": "<span class='logo-b'>B</span>",
97
+ },
98
+ });
99
+ expect(wrapper.findAll(".logo-a")).toHaveLength(2);
100
+ expect(wrapper.findAll(".logo-b")).toHaveLength(2);
101
+ });
102
+
103
+ it("renders no items when marqueeData is empty", async () => {
104
+ const wrapper = await mountSuspended(MarqueeScroller);
105
+ expect(wrapper.findAll(".item")).toHaveLength(0);
106
+ });
107
+
108
+ // ─── reverse ─────────────────────────────────────────────────────────────
109
+
110
+ it("does not apply the reverse class by default", async () => {
111
+ const wrapper = await mountSuspended(MarqueeScroller);
112
+ expect(wrapper.classes()).not.toContain("reverse");
113
+ });
114
+
115
+ it("applies the reverse class when reverse is true", async () => {
116
+ const wrapper = await mountSuspended(MarqueeScroller, {
117
+ props: { reverse: true },
118
+ });
119
+ expect(wrapper.classes()).toContain("reverse");
120
+ });
121
+
122
+ // ─── Accessibility attributes ────────────────────────────────────────────
123
+
124
+ it("defaults aria-label to 'Scrolling content'", async () => {
125
+ const wrapper = await mountSuspended(MarqueeScroller);
126
+ expect(wrapper.attributes("aria-label")).toBe("Scrolling content");
127
+ });
128
+
129
+ it("uses a custom ariaLabel when provided", async () => {
130
+ const wrapper = await mountSuspended(MarqueeScroller, {
131
+ props: { ariaLabel: "Client logos" },
132
+ });
133
+ expect(wrapper.attributes("aria-label")).toBe("Client logos");
134
+ });
135
+
136
+ it("uses default screen-reader instructions when ariaDescription is not set", async () => {
137
+ const wrapper = await mountSuspended(MarqueeScroller);
138
+ expect(wrapper.find(".sr-only").text()).toContain("Use spacebar to pause or play");
139
+ });
140
+
141
+ it("uses a custom ariaDescription when provided", async () => {
142
+ const wrapper = await mountSuspended(MarqueeScroller, {
143
+ props: { ariaDescription: "Custom instructions" },
144
+ });
145
+ expect(wrapper.find(".sr-only").text()).toBe("Custom instructions");
146
+ });
147
+
148
+ it("sets aria-live to off while playing", async () => {
149
+ const wrapper = await mountSuspended(MarqueeScroller);
150
+ expect(wrapper.attributes("aria-live")).toBe("off");
151
+ });
152
+
153
+ // ─── Controls ────────────────────────────────────────────────────────────
154
+
155
+ it("does not render the control button by default", async () => {
156
+ const wrapper = await mountSuspended(MarqueeScroller);
157
+ expect(wrapper.find(".control-btn").exists()).toBe(false);
158
+ });
159
+
160
+ it("renders the control button when showControls is true", async () => {
161
+ const wrapper = await mountSuspended(MarqueeScroller, {
162
+ props: { showControls: true },
163
+ });
164
+ expect(wrapper.find(".control-btn").exists()).toBe(true);
165
+ });
166
+
167
+ it("toggles paused state and aria-live when the control button is clicked", async () => {
168
+ const wrapper = await mountSuspended(MarqueeScroller, {
169
+ props: { showControls: true },
170
+ });
171
+ await wrapper.find(".control-btn").trigger("click");
172
+ expect(wrapper.classes()).toContain("paused");
173
+ expect(wrapper.attributes("aria-live")).toBe("polite");
174
+ expect(wrapper.find(".control-btn").attributes("aria-label")).toBe("Play animation");
175
+ });
176
+
177
+ // ─── Control button icon / label customisation ──────────────────────────
178
+
179
+ it("renders the default play icon while playing", async () => {
180
+ const wrapper = await mountSuspended(MarqueeScroller, {
181
+ props: { showControls: true },
182
+ });
183
+ expect(wrapper.find(".control-btn").html()).toContain("mdi:pause");
184
+ });
185
+
186
+ it("renders the default play icon once paused", async () => {
187
+ const wrapper = await mountSuspended(MarqueeScroller, {
188
+ props: { showControls: true },
189
+ });
190
+ await wrapper.find(".control-btn").trigger("click");
191
+ expect(wrapper.find(".control-btn").html()).toContain("mdi:play");
192
+ });
193
+
194
+ it("renders a custom playIcon/pauseIcon", async () => {
195
+ const wrapper = await mountSuspended(MarqueeScroller, {
196
+ props: { showControls: true, playIcon: "mdi:play-circle", pauseIcon: "mdi:pause-circle" },
197
+ });
198
+ expect(wrapper.find(".control-btn").html()).toContain("mdi:pause-circle");
199
+ await wrapper.find(".control-btn").trigger("click");
200
+ expect(wrapper.find(".control-btn").html()).toContain("mdi:play-circle");
201
+ });
202
+
203
+ it("uses custom playLabel/pauseLabel for the control button's aria-label", async () => {
204
+ const wrapper = await mountSuspended(MarqueeScroller, {
205
+ props: { showControls: true, playLabel: "Reproduire", pauseLabel: "Suspendre" },
206
+ });
207
+ expect(wrapper.find(".control-btn").attributes("aria-label")).toBe("Suspendre");
208
+ await wrapper.find(".control-btn").trigger("click");
209
+ expect(wrapper.find(".control-btn").attributes("aria-label")).toBe("Reproduire");
210
+ });
211
+
212
+ it("replaces the toggle icon via the toggle-icon slot", async () => {
213
+ const wrapper = await mountSuspended(MarqueeScroller, {
214
+ props: { showControls: true },
215
+ slots: { "toggle-icon": "<span class='custom-icon'>custom</span>" },
216
+ });
217
+ expect(wrapper.find(".custom-icon").exists()).toBe(true);
218
+ });
219
+
220
+ it("passes isPaused to the toggle-icon slot", async () => {
221
+ const wrapper = await mountSuspended(MarqueeScroller, {
222
+ props: { showControls: true },
223
+ slots: { "toggle-icon": "<template #toggle-icon=\"{ isPaused }\"><span class='state'>{{ isPaused }}</span></template>" },
224
+ });
225
+ expect(wrapper.find(".state").text()).toBe("false");
226
+ await wrapper.find(".control-btn").trigger("click");
227
+ expect(wrapper.find(".state").text()).toBe("true");
228
+ });
229
+
230
+ // ─── Keyboard interaction ────────────────────────────────────────────────
231
+
232
+ it("toggles pause on spacebar keydown", async () => {
233
+ const wrapper = await mountSuspended(MarqueeScroller);
234
+ await wrapper.trigger("keydown", { key: " " });
235
+ expect(wrapper.classes()).toContain("paused");
236
+ await wrapper.trigger("keydown", { key: " " });
237
+ expect(wrapper.classes()).not.toContain("paused");
238
+ });
239
+
240
+ it("does not react to arrow keys (no manual-stepping behaviour is implemented)", async () => {
241
+ const wrapper = await mountSuspended(MarqueeScroller);
242
+ await wrapper.trigger("keydown", { key: "ArrowLeft" });
243
+ await wrapper.trigger("keydown", { key: "ArrowRight" });
244
+ expect(wrapper.classes()).not.toContain("paused");
245
+ });
246
+
247
+ // ─── Focus / blur ────────────────────────────────────────────────────────
248
+
249
+ it("pauses on focus when respectReducedMotion is true (default)", async () => {
250
+ const wrapper = await mountSuspended(MarqueeScroller);
251
+ await wrapper.trigger("focus");
252
+ expect(wrapper.classes()).toContain("paused");
253
+ });
254
+
255
+ it("resumes on blur when the user does not prefer reduced motion", async () => {
256
+ const wrapper = await mountSuspended(MarqueeScroller);
257
+ await wrapper.trigger("focus");
258
+ await wrapper.trigger("blur");
259
+ expect(wrapper.classes()).not.toContain("paused");
260
+ });
261
+
262
+ it("does not pause on focus when respectReducedMotion is false", async () => {
263
+ const wrapper = await mountSuspended(MarqueeScroller, {
264
+ props: { respectReducedMotion: false },
265
+ });
266
+ await wrapper.trigger("focus");
267
+ expect(wrapper.classes()).not.toContain("paused");
268
+ });
269
+
270
+ // ─── Reduced motion ──────────────────────────────────────────────────────
271
+
272
+ it("auto-pauses and applies reduced-motion class when the user prefers reduced motion", async () => {
273
+ stubMatchMedia(true);
274
+ const wrapper = await mountSuspended(MarqueeScroller);
275
+ expect(wrapper.classes()).toContain("paused");
276
+ expect(wrapper.classes()).toContain("reduced-motion");
277
+ });
278
+
279
+ it("does not check matchMedia when respectReducedMotion is false", async () => {
280
+ stubMatchMedia(false);
281
+ const matchMediaSpy = vi.fn(() => ({ matches: false, addEventListener: vi.fn() }));
282
+ vi.stubGlobal("matchMedia", matchMediaSpy);
283
+ await mountSuspended(MarqueeScroller, {
284
+ props: { respectReducedMotion: false },
285
+ });
286
+ expect(matchMediaSpy).not.toHaveBeenCalled();
287
+ });
288
+
289
+ // ─── Repeating marqueeData to fill a wide container ─────────────────────
290
+
291
+ it("repeats marqueeData enough times so one group's width covers a wide container", async () => {
292
+ stubDimensions({ containerWidth: 500, groupScrollWidth: 100 });
293
+ const wrapper = await mountSuspended(MarqueeScroller, {
294
+ props: { marqueeData },
295
+ slots: { "1": "<span class='logo-a'></span>", "2": "<span class='logo-b'></span>" },
296
+ });
297
+ await nextTick();
298
+ await nextTick();
299
+ // needs ceil(500/100) = 5 copies of the 2-item data per group
300
+ expect(wrapper.findAll(".logo-a")).toHaveLength(10);
301
+ expect(wrapper.findAll(".logo-b")).toHaveLength(10);
302
+ });
303
+
304
+ it("does not add extra copies when a single copy already covers the container", async () => {
305
+ stubDimensions({ containerWidth: 50, groupScrollWidth: 200 });
306
+ const wrapper = await mountSuspended(MarqueeScroller, {
307
+ props: { marqueeData },
308
+ slots: { "1": "<span class='logo-a'></span>", "2": "<span class='logo-b'></span>" },
309
+ });
310
+ await nextTick();
311
+ await nextTick();
312
+ expect(wrapper.findAll(".logo-a")).toHaveLength(2);
313
+ expect(wrapper.findAll(".logo-b")).toHaveLength(2);
314
+ });
315
+ });
@@ -0,0 +1,28 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`MarqueeScroller > renders correct HTML structure (all props set) 1`] = `
4
+ "<div class="marquee-scroller reverse" role="region" aria-label="Client logos" aria-live="off" tabindex="0">
5
+ <div class="sr-only">Custom instructions</div><button class="control-btn" aria-label="Pause animation" type="button"><span class="iconify i-mdi:pause" aria-hidden="true"></span></button>
6
+ <div class="marquee-track" aria-hidden="true">
7
+ <div class="marquee-group">
8
+ <div class="item"></div>
9
+ <div class="item"></div>
10
+ </div>
11
+ <div class="marquee-group" aria-hidden="true">
12
+ <div class="item"></div>
13
+ <div class="item"></div>
14
+ </div>
15
+ </div>
16
+ </div>"
17
+ `;
18
+
19
+ exports[`MarqueeScroller > renders correct HTML structure (default props) 1`] = `
20
+ "<div class="marquee-scroller" role="region" aria-label="Scrolling content" aria-live="off" tabindex="0">
21
+ <div class="sr-only">Use spacebar to pause or play the animation.</div>
22
+ <!--v-if-->
23
+ <div class="marquee-track" aria-hidden="true">
24
+ <div class="marquee-group"></div>
25
+ <div class="marquee-group" aria-hidden="true"></div>
26
+ </div>
27
+ </div>"
28
+ `;
@@ -37,17 +37,18 @@ export default {
37
37
  },
38
38
  } as Meta<typeof CanvasSwitcher>;
39
39
 
40
+ // `args` is Storybook's own reactive object — bind to it directly (`args.x`) rather than
41
+ // destructuring it into local variables/refs, which would snapshot the values once at setup()
42
+ // and stop reflecting later Controls-panel changes.
40
43
  const Template: StoryFn<CanvasSwitcherStoryArgs> = (args) => ({
41
44
  components: { CanvasSwitcher },
42
45
  setup() {
43
- const { canvasName, ...otherArgs } = args;
44
- const selected = ref(canvasName);
45
- return { selected, args: otherArgs };
46
+ return { args };
46
47
  },
47
48
  template: `
48
49
  <div>
49
- <CanvasSwitcher v-model:canvas-name="selected" v-bind="args" />
50
- <p style="margin-top: 1.6rem;">Selected: {{ selected }}</p>
50
+ <CanvasSwitcher v-model:canvas-name="args.canvasName" v-bind="args" />
51
+ <p style="margin-top: 1.6rem;">Selected: {{ args.canvasName }}</p>
51
52
  </div>
52
53
  `,
53
54
  });
@@ -60,14 +61,12 @@ PreviewWidthDemo.args = {};
60
61
  PreviewWidthDemo.render = (args) => ({
61
62
  components: { CanvasSwitcher },
62
63
  setup() {
63
- const { canvasName } = args;
64
- const selected = ref(canvasName);
65
- return { selected };
64
+ return { args };
66
65
  },
67
66
  template: `
68
67
  <div>
69
- <CanvasSwitcher v-model:canvas-name="selected" />
70
- <div :class="selected" style="border: 1px dashed currentColor; margin-top: 1.6rem; padding: 1.6rem;">
68
+ <CanvasSwitcher v-model:canvas-name="args.canvasName" />
69
+ <div :class="args.canvasName" style="border: 1px dashed currentColor; margin-top: 1.6rem; padding: 1.6rem;">
71
70
  This box is constrained by the selected canvas's utility class.
72
71
  </div>
73
72
  </div>
@@ -17,7 +17,7 @@
17
17
  <h3 class="docs-nav-heading">{{ docsNavLabel }}</h3>
18
18
  </template>
19
19
  <template #content>
20
- <nav class="docs-nav-list" aria-label="Docs navigation">
20
+ <nav class="docs-nav-list" :aria-label="docsNavAriaLabel">
21
21
  <ul>
22
22
  <li v-for="item in docsNavItems" :key="item.to">
23
23
  <component
@@ -53,7 +53,7 @@
53
53
  <h3 class="docs-page-nav-heading">{{ docsPageNavLabel }}</h3>
54
54
  </template>
55
55
  <template #content>
56
- <nav class="docs-page-nav-list" aria-label="On this page">
56
+ <nav class="docs-page-nav-list" :aria-label="docsPageNavAriaLabel">
57
57
  <ul>
58
58
  <li v-for="item in docsPageNavItems" :key="item.to">
59
59
  <component
@@ -91,6 +91,10 @@ interface Props {
91
91
  docsPageNavItems?: DocsNavItem[];
92
92
  docsNavLabel?: string;
93
93
  docsPageNavLabel?: string;
94
+ /** aria-label on the docs-nav <nav> landmark — override for localisation. */
95
+ docsNavAriaLabel?: string;
96
+ /** aria-label on the page-nav <nav> landmark — override for localisation. */
97
+ docsPageNavAriaLabel?: string;
94
98
  panelVariant?: "modern" | "classic";
95
99
  styleClassPassthrough?: string | string[];
96
100
  }
@@ -100,6 +104,8 @@ const props = withDefaults(defineProps<Props>(), {
100
104
  docsPageNavItems: () => [],
101
105
  docsNavLabel: "Navigation",
102
106
  docsPageNavLabel: "On this page",
107
+ docsNavAriaLabel: "Docs navigation",
108
+ docsPageNavAriaLabel: "On this page",
103
109
  // Defaults to "classic" — the docs/admin nav toggle relies on contentIsOnTop on mobile, and
104
110
  // ExpandingPanel's ::details-content-based positioning silently fails on WebKit (Safari/iOS),
105
111
  // making the nav unreachable there. See AccordianCore's own variant prop and CLAUDE.md
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import { computed } from "vue";
2
3
  import ContentDocs from "../ContentDocs.vue";
3
4
  import type { DocsNavItem } from "~/types/components";
4
5
 
@@ -86,19 +87,19 @@ export default {
86
87
 
87
88
  // ─── Stories ─────────────────────────────────────────────────────────────────
88
89
 
90
+ // `args` is Storybook's own reactive object — bind to it directly (`args.x`) rather than
91
+ // destructuring it into local variables/refs, which would snapshot the values once at setup()
92
+ // and stop reflecting later Controls-panel changes.
89
93
  const Template: StoryFn<ContentDocsStoryArgs> = (args) => ({
90
94
  components: { ContentDocs },
91
95
  setup() {
92
- const { activeNavItem, activePageNavItem, ...rest } = args;
93
- const activeNav = ref(activeNavItem);
94
- const activePageNav = ref(activePageNavItem);
95
- return { args: rest, activeNav, activePageNav };
96
+ return { args };
96
97
  },
97
98
  template: `
98
99
  <ContentDocs
99
100
  v-bind="args"
100
- v-model:active-nav-item="activeNav"
101
- v-model:active-page-nav-item="activePageNav"
101
+ v-model:active-nav-item="args.activeNavItem"
102
+ v-model:active-page-nav-item="args.activePageNavItem"
102
103
  >
103
104
  <template #docsContent>
104
105
  <h3 style="margin-top:0">Docs Content</h3>
@@ -140,10 +141,8 @@ CustomLabels.args = {
140
141
  export const IconAtEnd: StoryFn<ContentDocsStoryArgs> = (args) => ({
141
142
  components: { ContentDocs },
142
143
  setup() {
143
- const { activeNavItem, activePageNavItem, ...rest } = args;
144
- const activeNav = ref(activeNavItem);
145
- const activePageNav = ref(activePageNavItem);
146
- return { args: { ...rest, styleClassPassthrough: ["icon-at-end-demo"] }, activeNav, activePageNav };
144
+ const componentArgs = computed(() => ({ ...args, styleClassPassthrough: ["icon-at-end-demo"] }));
145
+ return { args, componentArgs };
147
146
  },
148
147
  template: `
149
148
  <div>
@@ -154,9 +153,9 @@ export const IconAtEnd: StoryFn<ContentDocsStoryArgs> = (args) => ({
154
153
  }
155
154
  </style>
156
155
  <ContentDocs
157
- v-bind="args"
158
- v-model:active-nav-item="activeNav"
159
- v-model:active-page-nav-item="activePageNav"
156
+ v-bind="componentArgs"
157
+ v-model:active-nav-item="args.activeNavItem"
158
+ v-model:active-page-nav-item="args.activePageNavItem"
160
159
  >
161
160
  <template #docsContent>
162
161
  <h3 style="margin-top:0">Docs Content</h3>
@@ -7,7 +7,7 @@
7
7
  popovertargetaction="toggle"
8
8
  class="display-tooltip-trigger-button"
9
9
  :class="{ hide: hideTrigger }"
10
- aria-label="Toggle the popover"
10
+ :aria-label="triggerAriaLabel"
11
11
  >
12
12
  <Icon name="fa7-solid:circle-question" class="display-tooltip-trigger-icon" aria-hidden="true" />
13
13
  </button>
@@ -24,12 +24,15 @@
24
24
  interface Props {
25
25
  tooltipId?: string;
26
26
  hideTrigger?: boolean;
27
+ /** aria-label on the trigger button — override for localisation. */
28
+ triggerAriaLabel?: string;
27
29
  styleClassPassthrough?: string | string[];
28
30
  }
29
31
 
30
32
  const props = withDefaults(defineProps<Props>(), {
31
33
  tooltipId: "",
32
34
  hideTrigger: false,
35
+ triggerAriaLabel: "Toggle the popover",
33
36
  styleClassPassthrough: () => [],
34
37
  });
35
38
 
@@ -0,0 +1,33 @@
1
+ # ColumnFlowGrid — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ | Token | Falls back to | Controls |
6
+ |---|---|---|
7
+ | `--column-flow-grid-item-border-colour` | `var(--theme-border)` | Outline colour around each item |
8
+ | `--column-flow-grid-item-padding` | `1.2rem` | Inner padding of each item |
9
+
10
+ Column width and gap are controlled via props (`item-min-width`, `gap`, `unit`), not CSS custom
11
+ properties — they drive the CSS column layout directly and need JS-computed values, so they aren't
12
+ part of the public token API. See `.claude/skills/components/column-flow-grid.md`.
13
+
14
+ ---
15
+
16
+ ## Global theming — app-level CSS file
17
+
18
+ ```css
19
+ :where(html) {
20
+ --column-flow-grid-item-border-colour: var(--rose-05);
21
+ --column-flow-grid-item-padding: 1.6rem;
22
+ }
23
+ ```
24
+
25
+ ---
26
+
27
+ ## Per-instance overrides
28
+
29
+ ```vue
30
+ <ColumnFlowGrid style="--column-flow-grid-item-border-colour: var(--gold-04);">
31
+ ...
32
+ </ColumnFlowGrid>
33
+ ```
@@ -0,0 +1,55 @@
1
+ <template>
2
+ <component
3
+ :is="tag"
4
+ class="column-flow-grid-wrapper"
5
+ :class="[elementClasses]"
6
+ :style="`--_column-flow-grid-gap: ${gap}${unit}; --_item-min-width: ${itemMinWidth}px`"
7
+ >
8
+ <div v-for="(_, name) in $slots" :key="name" class="column-flow-grid-item">
9
+ <slot :name="name"></slot>
10
+ </div>
11
+ </component>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ interface Props {
16
+ tag?: "div" | "section" | "article" | "main";
17
+ itemMinWidth?: number;
18
+ gap?: number;
19
+ unit?: string;
20
+ styleClassPassthrough?: string | string[];
21
+ }
22
+
23
+ const props = withDefaults(defineProps<Props>(), {
24
+ tag: "div",
25
+ itemMinWidth: 300,
26
+ gap: 1.2,
27
+ unit: "rem",
28
+ styleClassPassthrough: () => [],
29
+ });
30
+
31
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
32
+
33
+ watch(
34
+ () => props.styleClassPassthrough,
35
+ () => {
36
+ resetElementClasses(props.styleClassPassthrough);
37
+ }
38
+ );
39
+ </script>
40
+
41
+ <style lang="css">
42
+ @layer components {
43
+ .column-flow-grid-wrapper {
44
+ columns: auto var(--_item-min-width);
45
+ column-gap: var(--_column-flow-grid-gap);
46
+
47
+ .column-flow-grid-item {
48
+ break-inside: avoid;
49
+ outline: 0.1rem solid var(--column-flow-grid-item-border-colour, var(--theme-border));
50
+ padding: var(--column-flow-grid-item-padding, 1.2rem);
51
+ margin-block-end: var(--_column-flow-grid-gap);
52
+ }
53
+ }
54
+ }
55
+ </style>