srcdev-nuxt-components 9.3.0 → 9.3.2

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 (53) hide show
  1. package/.claude/hooks/check-component-update.sh +66 -0
  2. package/.claude/settings.json +1 -1
  3. package/.claude/skills/component-local-style-override.md +45 -0
  4. package/.claude/skills/components/accordian-core.md +26 -5
  5. package/.claude/skills/components/expanding-panel-classic.md +221 -0
  6. package/.claude/skills/components/expanding-panel.md +8 -8
  7. package/.claude/skills/components/navigation-items.md +51 -0
  8. package/.claude/skills/components/responsive-header.md +161 -0
  9. package/.claude/skills/components/site-header.md +98 -0
  10. package/.claude/skills/index.md +6 -1
  11. package/.claude/skills/page-transitions.md +116 -0
  12. package/.claude/skills/theming-form-geometry-tokens.md +12 -0
  13. package/.claude/skills/theming-override-default.md +14 -0
  14. package/.claude/skills/theming-partial-override.md +17 -0
  15. package/.vscode/srcdev-component-accordian-core.code-snippets +74 -0
  16. package/.vscode/srcdev-component-expanding-panel-classic.code-snippets +121 -0
  17. package/.vscode/srcdev-component-expanding-panel.code-snippets +1 -3
  18. package/.vscode/srcdev-component-responsive-header.code-snippets +46 -0
  19. package/.vscode/srcdev-component-site-header.code-snippets +74 -0
  20. package/app/components/02.molecules/expandable/accordian/AccordianCore.vue +19 -11
  21. package/app/components/02.molecules/expandable/accordian/CONSUMER-STYLING.md +93 -0
  22. package/app/components/02.molecules/expandable/accordian/stories/AccordianCore.stories.ts +31 -0
  23. package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +31 -0
  24. package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +7 -21
  25. package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +10 -10
  26. package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +35 -37
  27. package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +5 -6
  28. package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +5 -13
  29. package/app/components/02.molecules/expandable/expanding-panel-classic/CONSUMER-STYLING.md +103 -0
  30. package/app/components/02.molecules/expandable/expanding-panel-classic/ExpandingPanelClassic.vue +191 -0
  31. package/app/components/02.molecules/expandable/expanding-panel-classic/stories/ExpandingPanelClassic.stories.ts +293 -0
  32. package/app/components/02.molecules/expandable/expanding-panel-classic/tests/ExpandingPanelClassic.spec.ts +514 -0
  33. package/app/components/02.molecules/expandable/expanding-panel-classic/tests/__snapshots__/ExpandingPanelClassic.spec.ts.snap +59 -0
  34. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +66 -0
  35. package/app/components/{responsive-header → 03.organisms/responsive-header}/NavigationItems.vue +21 -28
  36. package/app/components/{responsive-header → 03.organisms/responsive-header}/ResponsiveHeader.vue +141 -43
  37. package/app/components/03.organisms/responsive-header/stories/NavigationItems.stories.ts +80 -0
  38. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +122 -0
  39. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +155 -0
  40. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +319 -0
  41. package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +34 -0
  42. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +72 -0
  43. package/app/components/03.organisms/site-header/CONSUMER-STYLING.md +60 -0
  44. package/app/components/03.organisms/site-header/SiteHeader.vue +96 -0
  45. package/app/components/03.organisms/site-header/stories/SiteHeader.stories.ts +135 -0
  46. package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +103 -0
  47. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +15 -0
  48. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +42 -0
  49. package/app/components/05.forms/input-select/InputSelectCore.vue +21 -9
  50. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +60 -0
  51. package/app/components/05.forms/input-text/InputTextCore.vue +17 -8
  52. package/app/layouts/default.vue +64 -67
  53. package/package.json +4 -1
@@ -0,0 +1,514 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { nextTick } from "vue";
3
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
4
+ import type { ComponentPublicInstance } from "vue";
5
+ import ExpandingPanelClassic from "../ExpandingPanelClassic.vue";
6
+
7
+ interface ExpandingPanelClassicInstance extends ComponentPublicInstance {
8
+ animationDurationStr: string;
9
+ open: boolean;
10
+ isPanelOpen: boolean;
11
+ }
12
+
13
+ describe("ExpandingPanelClassic", () => {
14
+ // ─── Mount ───────────────────────────────────────────────────────────────
15
+
16
+ it("mounts without error", async () => {
17
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
18
+ props: { name: "test" },
19
+ });
20
+ expect(wrapper.vm).toBeTruthy();
21
+ });
22
+
23
+ // ─── Snapshots ───────────────────────────────────────────────────────────
24
+
25
+ it("renders correct HTML structure with default props", async () => {
26
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
27
+ props: { name: "snap-default" },
28
+ });
29
+ expect(wrapper.html()).toMatchSnapshot();
30
+ });
31
+
32
+ it("renders correct HTML structure with all props set", async () => {
33
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
34
+ props: {
35
+ name: "snap-all",
36
+ animationDuration: 500,
37
+ forceOpened: true,
38
+ styleClassPassthrough: ["extra-class"],
39
+ },
40
+ });
41
+ expect(wrapper.html()).toMatchSnapshot();
42
+ });
43
+
44
+ it("renders correct HTML structure with contentIsOnTop true", async () => {
45
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
46
+ props: { name: "snap-content-on-top", contentIsOnTop: true },
47
+ });
48
+ expect(wrapper.html()).toMatchSnapshot();
49
+ });
50
+
51
+ it("renders correct HTML structure with populated slots", async () => {
52
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
53
+ props: { name: "snap-slots" },
54
+ slots: {
55
+ summary: "<span>Summary text</span>",
56
+ icon: "<span>▸</span>",
57
+ content: "<p>Content text</p>",
58
+ },
59
+ });
60
+ expect(wrapper.html()).toMatchSnapshot();
61
+ });
62
+
63
+ // ─── Root element ─────────────────────────────────────────────────────────
64
+
65
+ it("renders the root .expanding-panel-classic element", async () => {
66
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
67
+ props: { name: "root-test" },
68
+ });
69
+ expect(wrapper.find(".expanding-panel-classic").exists()).toBe(true);
70
+ });
71
+
72
+ it("renders a <details> element inside the root", async () => {
73
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
74
+ props: { name: "details-test" },
75
+ });
76
+ expect(wrapper.find("details.expanding-panel-classic-details").exists()).toBe(true);
77
+ });
78
+
79
+ it("renders a <summary> element inside <details>", async () => {
80
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
81
+ props: { name: "summary-test" },
82
+ });
83
+ expect(wrapper.find("summary.expanding-panel-classic-summary").exists()).toBe(true);
84
+ });
85
+
86
+ it("renders the content region div", async () => {
87
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
88
+ props: { name: "content-test" },
89
+ });
90
+ expect(wrapper.find(".expanding-panel-classic-content").exists()).toBe(true);
91
+ });
92
+
93
+ // ─── Default prop values ──────────────────────────────────────────────────
94
+
95
+ it("uses correct default prop values on the component instance", async () => {
96
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
97
+ props: { name: "defaults-test" },
98
+ });
99
+ expect(wrapper.props("animationDuration")).toBe(400);
100
+ expect(wrapper.props("forceOpened")).toBe(false);
101
+ expect(wrapper.props("contentIsOnTop")).toBe(false);
102
+ expect(wrapper.props("styleClassPassthrough")).toEqual([]);
103
+ });
104
+
105
+ it("defaults model value (isPanelOpen) to false", async () => {
106
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
107
+ props: { name: "model-default" },
108
+ });
109
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
110
+ expect(vm.isPanelOpen).toBe(false);
111
+ });
112
+
113
+ // ─── Computed ─────────────────────────────────────────────────────────────
114
+
115
+ it("computes animationDurationStr as '<value>ms'", async () => {
116
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
117
+ props: { name: "anim-str", animationDuration: 750 },
118
+ });
119
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
120
+ expect(vm.animationDurationStr).toBe("750ms");
121
+ });
122
+
123
+ it("computes animationDurationStr using the default duration", async () => {
124
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
125
+ props: { name: "anim-str-default" },
126
+ });
127
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
128
+ expect(vm.animationDurationStr).toBe("400ms");
129
+ });
130
+
131
+ it("open is false when forceOpened is false and model is false", async () => {
132
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
133
+ props: { name: "open-false", forceOpened: false },
134
+ });
135
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
136
+ expect(vm.open).toBe(false);
137
+ });
138
+
139
+ it("open is true when model is true", async () => {
140
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
141
+ props: { name: "open-model" },
142
+ attrs: { modelValue: true },
143
+ });
144
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
145
+ expect(vm.open).toBe(true);
146
+ });
147
+
148
+ it("open is true when forceOpened is true regardless of model", async () => {
149
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
150
+ props: { name: "open-forced", forceOpened: true },
151
+ attrs: { modelValue: false },
152
+ });
153
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
154
+ expect(vm.open).toBe(true);
155
+ });
156
+
157
+ // ─── ARIA & IDs ───────────────────────────────────────────────────────────
158
+
159
+ it("sets id on summary to id-{name}-trigger", async () => {
160
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
161
+ props: { name: "aria-panel" },
162
+ });
163
+ expect(wrapper.find("summary").attributes("id")).toBe("id-aria-panel-trigger");
164
+ });
165
+
166
+ it("sets aria-controls on summary to id-{name}-content", async () => {
167
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
168
+ props: { name: "aria-panel" },
169
+ });
170
+ expect(wrapper.find("summary").attributes("aria-controls")).toBe("id-aria-panel-content");
171
+ });
172
+
173
+ it("sets aria-expanded to false when closed", async () => {
174
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
175
+ props: { name: "aria-expanded" },
176
+ });
177
+ expect(wrapper.find("summary").attributes("aria-expanded")).toBe("false");
178
+ });
179
+
180
+ it("sets aria-expanded to true when open", async () => {
181
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
182
+ props: { name: "aria-expanded-open" },
183
+ attrs: { modelValue: true },
184
+ });
185
+ expect(wrapper.find("summary").attributes("aria-expanded")).toBe("true");
186
+ });
187
+
188
+ it("sets id on content region to id-{name}-content", async () => {
189
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
190
+ props: { name: "aria-panel" },
191
+ });
192
+ expect(wrapper.find(".expanding-panel-classic-content").attributes("id")).toBe("id-aria-panel-content");
193
+ });
194
+
195
+ it("sets aria-labelledby on content region to id-{name}-trigger", async () => {
196
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
197
+ props: { name: "aria-panel" },
198
+ });
199
+ expect(wrapper.find(".expanding-panel-classic-content").attributes("aria-labelledby")).toBe(
200
+ "id-aria-panel-trigger",
201
+ );
202
+ });
203
+
204
+ it("sets role='region' on the content div", async () => {
205
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
206
+ props: { name: "role-test" },
207
+ });
208
+ expect(wrapper.find(".expanding-panel-classic-content").attributes("role")).toBe("region");
209
+ });
210
+
211
+ // ─── Toggle behaviour ─────────────────────────────────────────────────────
212
+
213
+ it("toggles open state when summary is clicked", async () => {
214
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
215
+ props: { name: "toggle-test" },
216
+ });
217
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
218
+ expect(vm.isPanelOpen).toBe(false);
219
+
220
+ await wrapper.find("summary").trigger("click");
221
+ expect(vm.isPanelOpen).toBe(true);
222
+
223
+ await wrapper.find("summary").trigger("click");
224
+ expect(vm.isPanelOpen).toBe(false);
225
+ });
226
+
227
+ it("emits update:modelValue when summary is clicked", async () => {
228
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
229
+ props: { name: "emit-test" },
230
+ });
231
+ await wrapper.find("summary").trigger("click");
232
+ expect(wrapper.emitted("update:modelValue")).toBeTruthy();
233
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([true]);
234
+ });
235
+
236
+ it("syncs isPanelOpen to false when <details> is closed externally via toggle event", async () => {
237
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
238
+ props: { name: "external-close" },
239
+ attrs: { modelValue: true },
240
+ });
241
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
242
+ expect(vm.isPanelOpen).toBe(true);
243
+
244
+ // Simulate browser closing <details> externally (e.g. exclusive accordion name group)
245
+ const details = wrapper.find("details").element as HTMLDetailsElement;
246
+ details.open = false;
247
+ await wrapper.find("details").trigger("toggle");
248
+ await nextTick();
249
+
250
+ expect(vm.isPanelOpen).toBe(false);
251
+ });
252
+
253
+ it("syncs isPanelOpen to true when <details> is opened externally via toggle event", async () => {
254
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
255
+ props: { name: "external-open" },
256
+ });
257
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
258
+ expect(vm.isPanelOpen).toBe(false);
259
+
260
+ // Simulate browser opening <details> externally
261
+ const details = wrapper.find("details").element as HTMLDetailsElement;
262
+ details.open = true;
263
+ await wrapper.find("details").trigger("toggle");
264
+ await nextTick();
265
+
266
+ expect(vm.isPanelOpen).toBe(true);
267
+ });
268
+
269
+ it("updates aria-expanded after toggle", async () => {
270
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
271
+ props: { name: "aria-toggle" },
272
+ });
273
+ expect(wrapper.find("summary").attributes("aria-expanded")).toBe("false");
274
+
275
+ await wrapper.find("summary").trigger("click");
276
+ await nextTick();
277
+ expect(wrapper.find("summary").attributes("aria-expanded")).toBe("true");
278
+ });
279
+
280
+ it("sets open attribute on <details> when panel is open", async () => {
281
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
282
+ props: { name: "details-open" },
283
+ attrs: { modelValue: true },
284
+ });
285
+ expect(wrapper.find("details").attributes("open")).toBeDefined();
286
+ });
287
+
288
+ it("does not set open attribute on <details> when panel is closed", async () => {
289
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
290
+ props: { name: "details-closed" },
291
+ });
292
+ expect(wrapper.find("details").attributes("open")).toBeUndefined();
293
+ });
294
+
295
+ // ─── forceOpened ──────────────────────────────────────────────────────────
296
+
297
+ it("visually hides the icon-wrapper when forceOpened is true, without removing it from layout", async () => {
298
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
299
+ props: { name: "force-icon", forceOpened: true },
300
+ });
301
+ // Kept in the DOM (not v-if'd away) so the summary row height stays consistent
302
+ // with a toggleable sibling panel's — only visually hidden via the modifier class.
303
+ expect(wrapper.find(".icon-wrapper").exists()).toBe(true);
304
+ expect(wrapper.find(".icon-wrapper").classes()).toContain("icon-wrapper--hidden");
305
+ });
306
+
307
+ it("shows the icon-wrapper when forceOpened is false", async () => {
308
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
309
+ props: { name: "icon-visible", forceOpened: false },
310
+ });
311
+ expect(wrapper.find(".icon-wrapper").exists()).toBe(true);
312
+ expect(wrapper.find(".icon-wrapper").classes()).not.toContain("icon-wrapper--hidden");
313
+ });
314
+
315
+ it("keeps open true after click when forceOpened is true", async () => {
316
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
317
+ props: { name: "force-click", forceOpened: true },
318
+ });
319
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
320
+ await wrapper.find("summary").trigger("click");
321
+ expect(vm.open).toBe(true);
322
+ });
323
+
324
+ it("does not leak forceOpened into isPanelOpen, so open reverts to false when forceOpened turns back off", async () => {
325
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
326
+ props: { name: "force-then-unforce", forceOpened: true },
327
+ });
328
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
329
+ expect(vm.open).toBe(true);
330
+
331
+ // The <details> element's `open` attribute changing (driven by forceOpened) fires
332
+ // its own native 'toggle' event, independent of any user click.
333
+ await wrapper.find("details").trigger("toggle");
334
+ await nextTick();
335
+ expect(vm.isPanelOpen).toBe(false);
336
+
337
+ await wrapper.setProps({ forceOpened: false });
338
+ expect(vm.open).toBe(false);
339
+ });
340
+
341
+ // ─── Slots ────────────────────────────────────────────────────────────────
342
+
343
+ it("renders summary slot content inside .label-wrapper", async () => {
344
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
345
+ props: { name: "slot-summary" },
346
+ slots: { summary: '<span data-testid="sum">My Summary</span>' },
347
+ });
348
+ expect(wrapper.find('[data-testid="sum"]').text()).toBe("My Summary");
349
+ });
350
+
351
+ it("renders content slot content inside .inner", async () => {
352
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
353
+ props: { name: "slot-content" },
354
+ slots: { content: '<p data-testid="body">My Content</p>' },
355
+ });
356
+ expect(wrapper.find('[data-testid="body"]').text()).toBe("My Content");
357
+ });
358
+
359
+ it("renders custom icon slot content inside .icon-wrapper", async () => {
360
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
361
+ props: { name: "slot-icon" },
362
+ slots: { icon: '<span data-testid="custom-icon">▸</span>' },
363
+ });
364
+ expect(wrapper.find('[data-testid="custom-icon"]').exists()).toBe(true);
365
+ });
366
+
367
+ it("renders the default icon when no icon slot is provided", async () => {
368
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
369
+ props: { name: "default-icon" },
370
+ });
371
+ expect(wrapper.find(".icon-wrapper").exists()).toBe(true);
372
+ expect(wrapper.find(".icon-wrapper .icon").exists()).toBe(true);
373
+ });
374
+
375
+ // ─── styleClassPassthrough ────────────────────────────────────────────────
376
+
377
+ it("applies a styleClassPassthrough array to the root element", async () => {
378
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
379
+ props: {
380
+ name: "scp-array",
381
+ styleClassPassthrough: ["custom-class", "another-class"],
382
+ },
383
+ });
384
+ const root = wrapper.find(".expanding-panel-classic");
385
+ expect(root.classes()).toContain("custom-class");
386
+ expect(root.classes()).toContain("another-class");
387
+ });
388
+
389
+ it("applies a single styleClassPassthrough string to the root element", async () => {
390
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
391
+ props: { name: "scp-string", styleClassPassthrough: "single-class" },
392
+ });
393
+ expect(wrapper.find(".expanding-panel-classic").classes()).toContain("single-class");
394
+ });
395
+
396
+ // ─── contentIsOnTop ───────────────────────────────────────────────────────
397
+
398
+ it("does not apply content-is-on-top class to root by default", async () => {
399
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
400
+ props: { name: "content-on-top-default" },
401
+ });
402
+ expect(wrapper.find(".expanding-panel-classic").classes()).not.toContain("content-is-on-top");
403
+ });
404
+
405
+ it("applies content-is-on-top class to root when contentIsOnTop is true", async () => {
406
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
407
+ props: { name: "content-on-top-true", contentIsOnTop: true },
408
+ });
409
+ expect(wrapper.find(".expanding-panel-classic").classes()).toContain("content-is-on-top");
410
+ });
411
+
412
+ it("does not apply content-is-on-top class to the content region itself", async () => {
413
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
414
+ props: { name: "content-on-top-scope", contentIsOnTop: true },
415
+ });
416
+ expect(wrapper.find(".expanding-panel-classic-content").classes()).not.toContain("content-is-on-top");
417
+ });
418
+
419
+ it("still toggles open state when summary is clicked with contentIsOnTop true", async () => {
420
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
421
+ props: { name: "content-on-top-click", contentIsOnTop: true },
422
+ });
423
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
424
+ expect(vm.isPanelOpen).toBe(false);
425
+
426
+ await wrapper.find("summary").trigger("click");
427
+ expect(vm.isPanelOpen).toBe(true);
428
+ });
429
+
430
+ it("closes an open contentIsOnTop panel on outside click", async () => {
431
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
432
+ props: { name: "content-on-top-outside-click", contentIsOnTop: true },
433
+ attachTo: document.body,
434
+ });
435
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
436
+
437
+ await wrapper.find("summary").trigger("click");
438
+ expect(vm.isPanelOpen).toBe(true);
439
+
440
+ // vueuse's onClickOutside debounces re-entrant clicks via a real setTimeout(0) that
441
+ // flips an internal "isProcessingClick" flag back off. With fake timers active
442
+ // (test/vitest.setup.ts) that flag never resets on its own, so the summary click
443
+ // above would otherwise permanently suppress the very next click. Advance past it.
444
+ vi.advanceTimersByTime(0);
445
+
446
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
447
+ await nextTick();
448
+
449
+ expect(vm.isPanelOpen).toBe(false);
450
+ wrapper.unmount();
451
+ });
452
+
453
+ it("does not close on outside click when contentIsOnTop is false (ordinary accordion)", async () => {
454
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
455
+ props: { name: "content-on-top-outside-click-inline" },
456
+ attachTo: document.body,
457
+ });
458
+ const vm = wrapper.vm as unknown as ExpandingPanelClassicInstance;
459
+
460
+ await wrapper.find("summary").trigger("click");
461
+ expect(vm.isPanelOpen).toBe(true);
462
+
463
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
464
+ await nextTick();
465
+
466
+ expect(vm.isPanelOpen).toBe(true);
467
+ wrapper.unmount();
468
+ });
469
+
470
+ it("does not close a forceOpened contentIsOnTop panel on outside click", async () => {
471
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
472
+ props: { name: "content-on-top-outside-click-forced", contentIsOnTop: true, forceOpened: true },
473
+ attachTo: document.body,
474
+ });
475
+
476
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
477
+ await nextTick();
478
+
479
+ expect(wrapper.find("details").attributes("open")).toBeDefined();
480
+ wrapper.unmount();
481
+ });
482
+
483
+ // ─── name prop / useId fallback ───────────────────────────────────────────
484
+
485
+ it("uses the provided name in ARIA attributes", async () => {
486
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
487
+ props: { name: "my-panel" },
488
+ });
489
+ expect(wrapper.find("summary").attributes("id")).toBe("id-my-panel-trigger");
490
+ expect(wrapper.find(".expanding-panel-classic-content").attributes("id")).toBe("id-my-panel-content");
491
+ });
492
+
493
+ it("generates ARIA ids when no name prop is supplied", async () => {
494
+ const wrapper = await mountSuspended(ExpandingPanelClassic);
495
+ const summaryId = wrapper.find("summary").attributes("id") ?? "";
496
+ const contentId = wrapper.find(".expanding-panel-classic-content").attributes("id") ?? "";
497
+ expect(summaryId).toMatch(/^id-.+-trigger$/);
498
+ expect(contentId).toMatch(/^id-.+-content$/);
499
+ });
500
+
501
+ it("updates the details name and ARIA ids when the name prop changes after mount", async () => {
502
+ const wrapper = await mountSuspended(ExpandingPanelClassic, {
503
+ props: { name: "panel-a" },
504
+ });
505
+ expect(wrapper.find("details").attributes("name")).toBe("panel-a");
506
+ expect(wrapper.find("summary").attributes("id")).toBe("id-panel-a-trigger");
507
+
508
+ await wrapper.setProps({ name: "panel-b" });
509
+
510
+ expect(wrapper.find("details").attributes("name")).toBe("panel-b");
511
+ expect(wrapper.find("summary").attributes("id")).toBe("id-panel-b-trigger");
512
+ expect(wrapper.find(".expanding-panel-classic-content").attributes("id")).toBe("id-panel-b-content");
513
+ });
514
+ });
@@ -0,0 +1,59 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`ExpandingPanelClassic > renders correct HTML structure with all props set 1`] = `
4
+ "<div class="expanding-panel-classic extra-class">
5
+ <details class="expanding-panel-classic-details" name="snap-all" open="">
6
+ <summary id="id-snap-all-trigger" class="expanding-panel-classic-summary" aria-controls="id-snap-all-content" aria-expanded="true">
7
+ <div class="label-wrapper"></div>
8
+ <div class="icon-wrapper icon-wrapper--hidden"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
9
+ </summary>
10
+ </details>
11
+ <div id="id-snap-all-content" class="expanding-panel-classic-content" aria-labelledby="id-snap-all-trigger" role="region">
12
+ <div class="inner"></div>
13
+ </div>
14
+ </div>"
15
+ `;
16
+
17
+ exports[`ExpandingPanelClassic > renders correct HTML structure with contentIsOnTop true 1`] = `
18
+ "<div class="expanding-panel-classic content-is-on-top">
19
+ <details class="expanding-panel-classic-details" name="snap-content-on-top">
20
+ <summary id="id-snap-content-on-top-trigger" class="expanding-panel-classic-summary" aria-controls="id-snap-content-on-top-content" aria-expanded="false">
21
+ <div class="label-wrapper"></div>
22
+ <div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
23
+ </summary>
24
+ </details>
25
+ <div id="id-snap-content-on-top-content" class="expanding-panel-classic-content" aria-labelledby="id-snap-content-on-top-trigger" role="region">
26
+ <div class="inner"></div>
27
+ </div>
28
+ </div>"
29
+ `;
30
+
31
+ exports[`ExpandingPanelClassic > renders correct HTML structure with default props 1`] = `
32
+ "<div class="expanding-panel-classic">
33
+ <details class="expanding-panel-classic-details" name="snap-default">
34
+ <summary id="id-snap-default-trigger" class="expanding-panel-classic-summary" aria-controls="id-snap-default-content" aria-expanded="false">
35
+ <div class="label-wrapper"></div>
36
+ <div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
37
+ </summary>
38
+ </details>
39
+ <div id="id-snap-default-content" class="expanding-panel-classic-content" aria-labelledby="id-snap-default-trigger" role="region">
40
+ <div class="inner"></div>
41
+ </div>
42
+ </div>"
43
+ `;
44
+
45
+ exports[`ExpandingPanelClassic > renders correct HTML structure with populated slots 1`] = `
46
+ "<div class="expanding-panel-classic">
47
+ <details class="expanding-panel-classic-details" name="snap-slots">
48
+ <summary id="id-snap-slots-trigger" class="expanding-panel-classic-summary" aria-controls="id-snap-slots-content" aria-expanded="false">
49
+ <div class="label-wrapper"><span>Summary text</span></div>
50
+ <div class="icon-wrapper"><span>▸</span></div>
51
+ </summary>
52
+ </details>
53
+ <div id="id-snap-slots-content" class="expanding-panel-classic-content" aria-labelledby="id-snap-slots-trigger" role="region">
54
+ <div class="inner">
55
+ <p>Content text</p>
56
+ </div>
57
+ </div>
58
+ </div>"
59
+ `;
@@ -0,0 +1,66 @@
1
+ # ResponsiveHeader — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ All `--responsive-header-*` / `--responsive-nav-*` / `--overflow-nav-*` tokens are the stable
6
+ override surface. Set them at any scope (global, page, or instance) without touching the
7
+ component itself. `--overflow-nav-*` tokens style `NavigationItems`' overflow-panel content and
8
+ are documented alongside it since that's where they're consumed.
9
+
10
+ | Token | Default | Controls |
11
+ |---|---|---|
12
+ | `--responsive-header-link-font-size` | `inherit` | Nav-link font-size. **Always set this to a fixed value.** Leaving it `inherit` means a fluid ancestor font-size (`clamp()`/`vw`) can drift nav-item text width without ever re-triggering the overflow-collapse measurement — see the component skill doc's "measurement pipeline" section for the full mechanism. |
13
+ | `--responsive-header-link-color` | `inherit` | Link/summary text colour. |
14
+ | `--responsive-header-color` | `inherit` | Root element text colour. |
15
+ | `--responsive-header-margin` | `0` | Root element margin. |
16
+ | `--responsive-header-bg` | `transparent` | Root element background. |
17
+ | `--responsive-header-border` | `none` | Root element border. |
18
+ | `--responsive-header-border-radius` | `0` | Root element border-radius. |
19
+ | `--responsive-header-padding-block` / `--responsive-header-padding-inline` | `0` | Root element padding. |
20
+ | `--responsive-header-max-height` | `none` | Root element max-height. |
21
+ | `--responsive-header-inline-size` | `100%` | Root element inline-size. |
22
+ | `--responsive-header-sub-nav-bg` / `-border` / `-border-radius` / `-padding` | `Canvas` / `1px solid #efefef75` / `8px` / `12px` | Top-bar dropdown panel (`.main-navigation-sub-nav`). |
23
+ | `--responsive-header-overflow-btn-bg` / `-size` / `-border` / `-outline` / `-icon-color` / `-hover-outline` | `Canvas` / `20px` / `1px solid #ffffff90` / `1px solid #ffffff10` / `inherit` / `1px solid #ffffff` | Overflow burger button. |
24
+ | `--responsive-header-overflow-nav-bg` / `-border` / `-border-radius` / `-padding-block` | `Canvas` / `1px solid #ffffff90` / `8px` / `12px` | Overflow panel container. |
25
+ | `--responsive-nav-decorator-indicator-color` | `currentColor` | Active-item indicator bar/underline. |
26
+ | `--responsive-nav-decorator-hovered-indicator-color` | inherits the above | Indicator colour while hovering. |
27
+ | `--responsive-nav-decorator-hovered-bg` | `oklch(100% 0 0 / 8%)` | Hover highlight background behind the hovered item. |
28
+
29
+ ---
30
+
31
+ ## Global theming — recommended approach
32
+
33
+ Create `assets/styles/setup/07.components/responsive-header.css` in the consuming app and set
34
+ tokens on a scope class matching the one passed via `styleClassPassthrough`:
35
+
36
+ ```css
37
+ /* assets/styles/setup/07.components/responsive-header.css */
38
+ .site-header-nav {
39
+ --responsive-header-link-color: var(--slate-00);
40
+ --responsive-header-bg: #efefef05;
41
+ --responsive-header-padding-inline: 1.2rem;
42
+
43
+ /* Fixed, NOT a fluid var(--step-*) token — see the token table above. */
44
+ --responsive-header-link-font-size: 1.4rem;
45
+ @media (min-width: 1024px) {
46
+ --responsive-header-link-font-size: 1.5rem;
47
+ }
48
+ }
49
+ ```
50
+
51
+ ```vue
52
+ <ResponsiveHeader :responsive-nav-links="responsiveNavLinks" :style-class-passthrough="['site-header-nav']" />
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Notes
58
+
59
+ - Anything with async-loaded content (an `iconName` icon on a nav item, a new decorator inside
60
+ a nav link) must reserve its own `width`/`height` in CSS — an unsized element measures at
61
+ `0` during the initial geometry pass and then pops in afterward with nothing to detect it.
62
+ See `.decorator-icon`/the chevron `.icon` in the component's own `<style>` block for the
63
+ pattern (`1.35em` square, `flex-shrink: 0`).
64
+ - `NavigationItems`' own tokens (`--overflow-nav-*`) are documented in its own skill doc since
65
+ they style the overflow-panel's *content*, distinct from `ResponsiveHeader`'s own tokens
66
+ above which style the top bar and the overflow *button*/*container*.