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,155 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { nextTick } from "vue";
4
+ import NavigationItems from "../NavigationItems.vue";
5
+ import type { ResponsiveHeaderState } from "../../../../types/components";
6
+
7
+ // NavigationItems renders the *complement* of what's visible in ResponsiveHeader's
8
+ // top bar: an item shows here only when its `config.visible` is false there. Mixing
9
+ // visible/hidden items (rather than the all-hidden case ResponsiveHeader's own tests
10
+ // exercise under jsdom's zero-size layout) verifies that complement logic directly.
11
+ const mainNavigationState: ResponsiveHeaderState = {
12
+ hasSecondNav: true,
13
+ navListVisibility: { firstNav: false, secondNav: true },
14
+ clonedNavLinks: {
15
+ firstNav: [
16
+ { name: "Home", path: "/", config: { left: 0, right: 0, width: 0, visible: true } },
17
+ { name: "About", path: "/about", config: { left: 0, right: 0, width: 0, visible: false } },
18
+ {
19
+ name: "Components",
20
+ childLinksTitle: "UI Components",
21
+ childLinks: [
22
+ { name: "Buttons", path: "/forms/examples/buttons" },
23
+ { name: "Tabs", path: "/ui/tabs" },
24
+ ],
25
+ config: { left: 0, right: 0, width: 0, visible: false },
26
+ },
27
+ ],
28
+ secondNav: [{ name: "Contact", path: "/contact", config: { left: 0, right: 0, width: 0, visible: true } }],
29
+ },
30
+ };
31
+
32
+ describe("NavigationItems", () => {
33
+ it("mounts without error", async () => {
34
+ const wrapper = await mountSuspended(NavigationItems, {
35
+ props: { mainNavigationState },
36
+ });
37
+ expect(wrapper.vm).toBeTruthy();
38
+ });
39
+
40
+ it("only shows the group whose navListVisibility flag is false", async () => {
41
+ const wrapper = await mountSuspended(NavigationItems, {
42
+ props: { mainNavigationState },
43
+ });
44
+ const lists = wrapper.findAll(".overflow-navigation-list");
45
+ // firstNav (navListVisibility: false) is shown; secondNav (true) is not.
46
+ expect(lists[0]?.classes()).toContain("visible");
47
+ expect(lists[1]?.classes()).not.toContain("visible");
48
+ });
49
+
50
+ it("only marks items with config.visible === false as visible in the overflow list", async () => {
51
+ const wrapper = await mountSuspended(NavigationItems, {
52
+ props: { mainNavigationState },
53
+ });
54
+ const items = wrapper.findAll('[data-group-key="firstNav"].overflow-navigation-item');
55
+ // Home (visible: true in main nav) should NOT be flagged visible here.
56
+ const homeItem = items.find((item) => item.text().includes("Home"));
57
+ expect(homeItem?.classes()).not.toContain("visible");
58
+ // About (visible: false in main nav) should be flagged visible here.
59
+ const aboutItem = items.find((item) => item.text().includes("About"));
60
+ expect(aboutItem?.classes()).toContain("visible");
61
+ });
62
+
63
+ it("renders a plain link for items with a path", async () => {
64
+ const wrapper = await mountSuspended(NavigationItems, {
65
+ props: { mainNavigationState },
66
+ });
67
+ const link = wrapper.find('a[href="/about"].overflow-navigation-link');
68
+ expect(link.exists()).toBe(true);
69
+ expect(link.text()).toBe("About");
70
+ });
71
+
72
+ it("renders an ExpandingPanel dropdown for items with childLinks", async () => {
73
+ const wrapper = await mountSuspended(NavigationItems, {
74
+ props: { mainNavigationState },
75
+ });
76
+ expect(wrapper.find(".overflow-navigation-details").exists()).toBe(true);
77
+ expect(wrapper.text()).toContain("UI Components");
78
+ const subLinks = wrapper.findAll(".overflow-navigation-sub-nav-link");
79
+ expect(subLinks).toHaveLength(2);
80
+ expect(subLinks[0]?.text()).toBe("Buttons");
81
+ expect(subLinks[1]?.text()).toBe("Tabs");
82
+ });
83
+
84
+ it("marks the item matching the current route as active", async () => {
85
+ const wrapper = await mountSuspended(NavigationItems, {
86
+ props: { mainNavigationState },
87
+ route: "/about",
88
+ });
89
+ const items = wrapper.findAll(".overflow-navigation-item");
90
+ const activeItem = items.find((item) => item.find('a[href="/about"]').exists());
91
+ expect(activeItem?.classes()).toContain("is-active");
92
+ });
93
+
94
+ it("marks a dropdown item active when one of its childLinks matches the current route", async () => {
95
+ const wrapper = await mountSuspended(NavigationItems, {
96
+ props: { mainNavigationState },
97
+ route: "/ui/tabs",
98
+ });
99
+ const dropdownItem = wrapper.find(".overflow-navigation-details").element.closest(".overflow-navigation-item");
100
+ expect(dropdownItem?.className).toContain("is-active");
101
+ });
102
+
103
+ it("sets is-hovered on a plain item on mouseenter", async () => {
104
+ const wrapper = await mountSuspended(NavigationItems, {
105
+ props: { mainNavigationState },
106
+ });
107
+ const item = wrapper.find('[data-group-key="firstNav"][data-local-index="1"]');
108
+ await item.trigger("mouseenter");
109
+ expect(item.classes()).toContain("is-hovered");
110
+ });
111
+
112
+ it("clears is-hovered on wrapper mouseleave", async () => {
113
+ const wrapper = await mountSuspended(NavigationItems, {
114
+ props: { mainNavigationState },
115
+ });
116
+ const item = wrapper.find('[data-group-key="firstNav"][data-local-index="1"]');
117
+ await item.trigger("mouseenter");
118
+ expect(item.classes()).toContain("is-hovered");
119
+
120
+ await wrapper.find(".overflow-navigation-wrapper").trigger("mouseleave");
121
+ expect(item.classes()).not.toContain("is-hovered");
122
+ });
123
+
124
+ it("applies styleClassPassthrough to the root element", async () => {
125
+ const wrapper = await mountSuspended(NavigationItems, {
126
+ props: { mainNavigationState, styleClassPassthrough: ["overflow-nav-scope"] },
127
+ });
128
+ expect(wrapper.find(".overflow-navigation-wrapper.overflow-nav-scope").exists()).toBe(true);
129
+ });
130
+
131
+ it("falls back to an empty state without throwing when mainNavigationState is omitted", async () => {
132
+ const wrapper = await mountSuspended(NavigationItems);
133
+ expect(wrapper.vm).toBeTruthy();
134
+ expect(wrapper.findAll(".overflow-navigation-item")).toHaveLength(0);
135
+ });
136
+
137
+ it("toggles a dropdown panel open on summary click", async () => {
138
+ const wrapper = await mountSuspended(NavigationItems, {
139
+ props: { mainNavigationState },
140
+ });
141
+ const details = wrapper.find(".expanding-panel-details");
142
+ expect(details.attributes("open")).toBeUndefined();
143
+
144
+ await wrapper.find(".expanding-panel-summary").trigger("click");
145
+ await nextTick();
146
+ expect(details.attributes("open")).toBe("");
147
+ });
148
+
149
+ it("renders correct HTML structure with default props", async () => {
150
+ const wrapper = await mountSuspended(NavigationItems, {
151
+ props: { mainNavigationState },
152
+ });
153
+ expect(wrapper.html()).toMatchSnapshot();
154
+ });
155
+ });
@@ -0,0 +1,319 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { nextTick } from "vue";
4
+ import ResponsiveHeader from "../ResponsiveHeader.vue";
5
+ import type { ResponsiveHeaderProp } from "../../../../types/components";
6
+
7
+ // ─── ResizeObserver mock ────────────────────────────────────────────────────
8
+ // useResizeObserver (vueuse) only constructs `new ResizeObserver(callback)` and
9
+ // calls `.observe()` — it never invokes the callback itself (that's the native
10
+ // browser's job). This mock captures the callback so tests can trigger the
11
+ // geometry pass manually and deterministically, rather than relying on real
12
+ // layout (jsdom always reports zero-size rects, which is itself a valid,
13
+ // deterministic case exercised below).
14
+ let resizeObserverCallback: ResizeObserverCallback | null = null;
15
+
16
+ class MockResizeObserver {
17
+ constructor(cb: ResizeObserverCallback) {
18
+ resizeObserverCallback = cb;
19
+ }
20
+ observe = vi.fn();
21
+ unobserve = vi.fn();
22
+ disconnect = vi.fn();
23
+ }
24
+
25
+ // runGeometryPass's internal chain is pure microtask/Promise-based (updateNavigationConfig
26
+ // resolves immediately; the only real waits are `await nextTick()`) — no real timers
27
+ // involved, so plain nextTick() cycles settle it. Do NOT use @vue/test-utils'
28
+ // flushPromises() here: it schedules its resolve via a real timer internally, which
29
+ // never fires under this repo's global vi.useFakeTimers() and hangs the test.
30
+ const triggerGeometryPass = async () => {
31
+ resizeObserverCallback?.([] as unknown as ResizeObserverEntry[], {} as ResizeObserver);
32
+ for (let i = 0; i < 8; i++) {
33
+ await nextTick();
34
+ }
35
+ };
36
+
37
+ const navLinks: ResponsiveHeaderProp = {
38
+ firstNav: [
39
+ { name: "Home", path: "/" },
40
+ { name: "About", path: "/about", iconName: "mdi:home" },
41
+ {
42
+ name: "Components",
43
+ childLinksTitle: "UI Components",
44
+ childLinks: [
45
+ { name: "Buttons", path: "/forms/examples/buttons" },
46
+ { name: "Tabs", path: "/ui/tabs" },
47
+ ],
48
+ },
49
+ ],
50
+ secondNav: [{ name: "Contact", path: "/contact" }],
51
+ };
52
+
53
+ describe("ResponsiveHeader", () => {
54
+ beforeEach(() => {
55
+ resizeObserverCallback = null;
56
+ vi.stubGlobal("ResizeObserver", MockResizeObserver);
57
+ });
58
+
59
+ it("mounts without error", async () => {
60
+ const wrapper = await mountSuspended(ResponsiveHeader, {
61
+ props: { responsiveNavLinks: navLinks },
62
+ });
63
+ expect(wrapper.vm).toBeTruthy();
64
+ });
65
+
66
+ it("renders a plain NuxtLink for items with a path", async () => {
67
+ const wrapper = await mountSuspended(ResponsiveHeader, {
68
+ props: { responsiveNavLinks: navLinks },
69
+ });
70
+ const link = wrapper.find('a[href="/"]');
71
+ expect(link.exists()).toBe(true);
72
+ expect(link.classes()).toContain("main-navigation-link");
73
+ });
74
+
75
+ it("renders a details/summary dropdown for items with childLinks", async () => {
76
+ const wrapper = await mountSuspended(ResponsiveHeader, {
77
+ props: { responsiveNavLinks: navLinks },
78
+ });
79
+ const summary = wrapper.find(".main-navigation-details-summary");
80
+ expect(summary.exists()).toBe(true);
81
+ expect(summary.text()).toContain("UI Components");
82
+ expect(wrapper.findAll(".main-navigation-sub-nav-link")).toHaveLength(2);
83
+ });
84
+
85
+ it("renders a decorator icon only for items with iconName", async () => {
86
+ const wrapper = await mountSuspended(ResponsiveHeader, {
87
+ props: { responsiveNavLinks: navLinks },
88
+ });
89
+ const homeLink = wrapper.find('a[href="/"]');
90
+ const aboutLink = wrapper.find('a[href="/about"]');
91
+ expect(homeLink.find(".decorator-icon").exists()).toBe(false);
92
+ expect(aboutLink.find(".decorator-icon").exists()).toBe(true);
93
+ });
94
+
95
+ it("marks the item matching the current route as active", async () => {
96
+ const wrapper = await mountSuspended(ResponsiveHeader, {
97
+ props: { responsiveNavLinks: navLinks },
98
+ route: "/about",
99
+ });
100
+ const items = wrapper.findAll(".main-navigation-item");
101
+ const activeItem = items.find((item) => item.find('a[href="/about"]').exists());
102
+ expect(activeItem?.classes()).toContain("is-active");
103
+ });
104
+
105
+ it("marks a dropdown item active when one of its childLinks matches the current route", async () => {
106
+ const wrapper = await mountSuspended(ResponsiveHeader, {
107
+ props: { responsiveNavLinks: navLinks },
108
+ route: "/ui/tabs",
109
+ });
110
+ const items = wrapper.findAll(".main-navigation-item");
111
+ const activeItem = items.find((item) => item.find(".main-navigation-details-summary").exists());
112
+ expect(activeItem?.classes()).toContain("is-active");
113
+ });
114
+
115
+ it("uses default overflow summary icons when not provided", async () => {
116
+ const wrapper = await mountSuspended(ResponsiveHeader, {
117
+ props: { responsiveNavLinks: navLinks },
118
+ });
119
+ const icons = wrapper.findAll(".overflow-details-summary .icon");
120
+ expect(icons[0]?.attributes("name") ?? icons[0]?.html()).toBeTruthy();
121
+ expect(wrapper.html()).toContain("gravity-ui:ellipsis");
122
+ expect(wrapper.html()).toContain("gravity-ui:bars");
123
+ });
124
+
125
+ it("uses custom overflow summary icons when provided", async () => {
126
+ const wrapper = await mountSuspended(ResponsiveHeader, {
127
+ props: {
128
+ responsiveNavLinks: navLinks,
129
+ overflowDetailsSummaryIcons: { more: "mdi:dots-horizontal", burger: "mdi:menu" },
130
+ },
131
+ });
132
+ expect(wrapper.html()).toContain("mdi:dots-horizontal");
133
+ expect(wrapper.html()).toContain("mdi:menu");
134
+ });
135
+
136
+ it("does not render the secondaryNavigation slot when not provided", async () => {
137
+ const wrapper = await mountSuspended(ResponsiveHeader, {
138
+ props: { responsiveNavLinks: navLinks },
139
+ });
140
+ expect(wrapper.text()).not.toContain("Settings link");
141
+ });
142
+
143
+ it("renders the secondaryNavigation slot when provided", async () => {
144
+ const wrapper = await mountSuspended(ResponsiveHeader, {
145
+ props: { responsiveNavLinks: navLinks },
146
+ slots: { secondaryNavigation: "<span>Settings link</span>" },
147
+ });
148
+ expect(wrapper.text()).toContain("Settings link");
149
+ });
150
+
151
+ it("applies styleClassPassthrough to the root element", async () => {
152
+ const wrapper = await mountSuspended(ResponsiveHeader, {
153
+ props: { responsiveNavLinks: navLinks, styleClassPassthrough: ["site-header-nav"] },
154
+ });
155
+ expect(wrapper.find(".navigation.site-header-nav").exists()).toBe(true);
156
+ });
157
+
158
+ it("toggles a dropdown's open attribute on summary click", async () => {
159
+ const wrapper = await mountSuspended(ResponsiveHeader, {
160
+ props: { responsiveNavLinks: navLinks },
161
+ });
162
+ const details = wrapper.find(".main-navigation-details");
163
+ const summary = wrapper.find(".main-navigation-details-summary");
164
+ expect(details.attributes("open")).toBeUndefined();
165
+
166
+ await summary.trigger("click");
167
+ expect(details.attributes("open")).toBe("");
168
+
169
+ await summary.trigger("click");
170
+ expect(details.attributes("open")).toBeUndefined();
171
+ });
172
+
173
+ it("does not close an open dropdown instantly when a sibling item is only briefly hovered in transit", async () => {
174
+ const wrapper = await mountSuspended(ResponsiveHeader, {
175
+ props: { responsiveNavLinks: navLinks },
176
+ });
177
+ const details = wrapper.find(".main-navigation-details");
178
+ await wrapper.find(".main-navigation-details-summary").trigger("click");
179
+ expect(details.attributes("open")).toBe("");
180
+
181
+ const siblingItem = wrapper.find('[data-group-key="firstNav"][data-local-index="0"]');
182
+ await siblingItem.trigger("mouseenter");
183
+ // Close is scheduled, not immediate — still open right after the hover.
184
+ expect(details.attributes("open")).toBe("");
185
+ });
186
+
187
+ it("closes an open dropdown after the hover-intent delay once the cursor moved on", async () => {
188
+ const wrapper = await mountSuspended(ResponsiveHeader, {
189
+ props: { responsiveNavLinks: navLinks },
190
+ });
191
+ const details = wrapper.find(".main-navigation-details");
192
+ await wrapper.find(".main-navigation-details-summary").trigger("click");
193
+ expect(details.attributes("open")).toBe("");
194
+
195
+ const siblingItem = wrapper.find('[data-group-key="firstNav"][data-local-index="0"]');
196
+ await siblingItem.trigger("mouseenter");
197
+ vi.advanceTimersByTime(200);
198
+ await nextTick();
199
+ expect(details.attributes("open")).toBeUndefined();
200
+ });
201
+
202
+ it("cancels the scheduled close when the sub-nav panel itself is reached", async () => {
203
+ const wrapper = await mountSuspended(ResponsiveHeader, {
204
+ props: { responsiveNavLinks: navLinks },
205
+ });
206
+ const details = wrapper.find(".main-navigation-details");
207
+ await wrapper.find(".main-navigation-details-summary").trigger("click");
208
+ expect(details.attributes("open")).toBe("");
209
+
210
+ const siblingItem = wrapper.find('[data-group-key="firstNav"][data-local-index="0"]');
211
+ await siblingItem.trigger("mouseenter");
212
+ await wrapper.find(".main-navigation-sub-nav").trigger("mouseenter");
213
+
214
+ vi.advanceTimersByTime(200);
215
+ await nextTick();
216
+ expect(details.attributes("open")).toBe("");
217
+ });
218
+
219
+ // A real mouse click moves focus to the clicked element before the click event
220
+ // fires — so a click on a summary the mouse already hover-opened dispatches
221
+ // BOTH a `focusin` (handleSummaryHover) and a `click` (handleSummaryAction) in
222
+ // quick succession. vue-test-utils' `.trigger("click")` doesn't synthesize that
223
+ // implicit focus side effect on its own, so these tests fire both events
224
+ // explicitly to reproduce what a real click actually does.
225
+ it("closes cleanly (no reopen) when clicking a summary the mouse had just hover-opened", async () => {
226
+ const wrapper = await mountSuspended(ResponsiveHeader, {
227
+ props: { responsiveNavLinks: navLinks },
228
+ });
229
+ const summary = wrapper.find(".main-navigation-details-summary");
230
+ const details = wrapper.find(".main-navigation-details");
231
+
232
+ await summary.trigger("mouseenter");
233
+ expect(details.attributes("open")).toBe("");
234
+
235
+ // Simulate the real browser sequence a click produces: focus moves first,
236
+ // then the click event itself fires.
237
+ await summary.trigger("focusin");
238
+ await summary.trigger("click");
239
+
240
+ expect(details.attributes("open")).toBeUndefined();
241
+ });
242
+
243
+ it("closes cleanly (no reopen) when clicking to close a dropdown switched-to via hovering away from another", async () => {
244
+ const responsiveNavLinksTwoDropdowns: ResponsiveHeaderProp = {
245
+ firstNav: [
246
+ {
247
+ name: "Components",
248
+ childLinksTitle: "UI Components",
249
+ childLinks: [{ name: "Buttons", path: "/forms/examples/buttons" }],
250
+ },
251
+ {
252
+ name: "Layouts",
253
+ childLinksTitle: "UI Layouts",
254
+ childLinks: [{ name: "Page Row", path: "/ui/page-row" }],
255
+ },
256
+ ],
257
+ };
258
+ const wrapper = await mountSuspended(ResponsiveHeader, {
259
+ props: { responsiveNavLinks: responsiveNavLinksTwoDropdowns },
260
+ });
261
+ const summaries = wrapper.findAll(".main-navigation-details-summary");
262
+ const detailsElements = wrapper.findAll(".main-navigation-details");
263
+
264
+ await summaries[0]!.trigger("mouseenter");
265
+ expect(detailsElements[0]!.attributes("open")).toBe("");
266
+
267
+ // Hover away to the second dropdown — closes the first, opens the second.
268
+ await summaries[1]!.trigger("mouseenter");
269
+ expect(detailsElements[0]!.attributes("open")).toBeUndefined();
270
+ expect(detailsElements[1]!.attributes("open")).toBe("");
271
+
272
+ await summaries[1]!.trigger("focusin");
273
+ await summaries[1]!.trigger("click");
274
+
275
+ expect(detailsElements[1]!.attributes("open")).toBeUndefined();
276
+ });
277
+
278
+ it("does not auto-open a dropdown on hover when allowExpandOnGesture is false", async () => {
279
+ const wrapper = await mountSuspended(ResponsiveHeader, {
280
+ props: { responsiveNavLinks: navLinks, allowExpandOnGesture: false },
281
+ });
282
+ const details = wrapper.find(".main-navigation-details");
283
+ const summary = wrapper.find(".main-navigation-details-summary");
284
+
285
+ await summary.trigger("mouseenter");
286
+ expect(details.attributes("open")).toBeUndefined();
287
+ });
288
+
289
+ it("passes mainNavigationState through to NavigationItems in the overflow panel", async () => {
290
+ const wrapper = await mountSuspended(ResponsiveHeader, {
291
+ props: { responsiveNavLinks: navLinks },
292
+ });
293
+ const overflowNav = wrapper.find(".overflow-details-nav");
294
+ expect(overflowNav.exists()).toBe(true);
295
+ expect(overflowNav.text()).toContain("Home");
296
+ expect(overflowNav.text()).toContain("UI Components");
297
+ });
298
+
299
+ it("completes the geometry pass and marks all items visually-hidden under jsdom's zero-size layout", async () => {
300
+ const wrapper = await mountSuspended(ResponsiveHeader, {
301
+ props: { responsiveNavLinks: navLinks },
302
+ });
303
+ await triggerGeometryPass();
304
+
305
+ // jsdom reports every rect as 0×0, so `rect.right + margin + gap < wrapperRect.right`
306
+ // (0 + 0 + 12 < 0) is always false — every item correctly collapses into overflow.
307
+ expect(wrapper.find(".navigation").classes()).toContain("geometry-ready");
308
+ const items = wrapper.findAll(".main-navigation-item");
309
+ expect(items.length).toBeGreaterThan(0);
310
+ items.forEach((item) => expect(item.classes()).toContain("visually-hidden"));
311
+ });
312
+
313
+ it("renders correct HTML structure with default props", async () => {
314
+ const wrapper = await mountSuspended(ResponsiveHeader, {
315
+ props: { responsiveNavLinks: navLinks },
316
+ });
317
+ expect(wrapper.html()).toMatchSnapshot();
318
+ });
319
+ });
@@ -0,0 +1,34 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`NavigationItems > renders correct HTML structure with default props 1`] = `
4
+ "<div class="overflow-navigation-wrapper" role="menu" aria-label="Overflow navigation menu">
5
+ <ul class="overflow-navigation-list visible" style="--_overflow-navigation-list-min-width: 0px;" role="none">
6
+ <li class="overflow-navigation-item is-active" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="0" role="none"><a href="/" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">Home</span></a></li>
7
+ <li class="overflow-navigation-item visible" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="1" role="none"><a href="/about" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">About</span></a></li>
8
+ <li class="overflow-navigation-item visible" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="2" role="none">
9
+ <div class="expanding-panel overflow-navigation-details">
10
+ <details class="expanding-panel-details" name="overflow-navigation-group">
11
+ <summary id="id-overflow-navigation-group-trigger" class="expanding-panel-summary" aria-controls="id-overflow-navigation-group-content" aria-expanded="false">
12
+ <div class="label-wrapper"><span class="overflow-navigation-text" aria-label="UI Components submenu" role="menuitem" aria-haspopup="true">UI Components</span></div>
13
+ <div class="icon-wrapper"><span class="iconify i-mdi:chevron-down icon" aria-hidden="true"></span></div>
14
+ </summary>
15
+ <div id="id-overflow-navigation-group-content" class="expanding-panel-content" aria-labelledby="id-overflow-navigation-group-trigger" role="region">
16
+ <div class="overflow-navigation-sub-nav-inner">
17
+ <ul class="overflow-navigation-sub-nav-list">
18
+ <li class="overflow-navigation-sub-nav-item"><a href="/forms/examples/buttons" class="overflow-navigation-sub-nav-link" role="menuitem"><span class="overflow-navigation-sub-nav-text">Buttons</span></a></li>
19
+ <li class="overflow-navigation-sub-nav-item"><a href="/ui/tabs" class="overflow-navigation-sub-nav-link" role="menuitem"><span class="overflow-navigation-sub-nav-text">Tabs</span></a></li>
20
+ </ul>
21
+ <div aria-hidden="true" class="overflow-sub-nav-indicator-hovered"></div>
22
+ </div>
23
+ </div>
24
+ </details>
25
+ </div>
26
+ </li>
27
+ </ul>
28
+ <ul class="overflow-navigation-list" style="--_overflow-navigation-list-min-width: 0px;" role="none">
29
+ <li class="overflow-navigation-item" style="--_main-navigation-item-width: 0px;" data-group-key="secondNav" data-local-index="0" role="none"><a href="/contact" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">Contact</span></a></li>
30
+ </ul>
31
+ <div aria-hidden="true" class="overflow-nav-indicator-hovered"></div>
32
+ <div aria-hidden="true" class="overflow-nav-indicator-active"></div>
33
+ </div>"
34
+ `;
@@ -0,0 +1,72 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`ResponsiveHeader > renders correct HTML structure with default props 1`] = `
4
+ "<div class="navigation loaded" role="banner">
5
+ <nav class="main-navigation is-animated" aria-label="Main navigation">
6
+ <ul class="main-navigation-list">
7
+ <li class="main-navigation-item visually-hidden is-active" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="0"><a href="/" class="main-navigation-link">
8
+ <!--v-if--> Home
9
+ </a></li>
10
+ <li class="main-navigation-item visually-hidden" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="1"><a href="/about" class="main-navigation-link has-icon"><span class="iconify i-mdi:home decorator-icon" aria-hidden="true"></span> About</a></li>
11
+ <li class="main-navigation-item visually-hidden" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="2">
12
+ <details class="main-navigation-details" name="navigation-group">
13
+ <summary class="main-navigation-details-summary has-toggle-icon" aria-label="UI Components submenu"><span class="iconify i-mdi:chevron-down icon" aria-hidden="true"></span>
14
+ <!--v-if--> UI Components
15
+ </summary>
16
+ <div class="main-navigation-sub-nav" role="menu">
17
+ <ul class="main-navigation-sub-nav-list">
18
+ <li class="main-navigation-sub-nav-item"><a href="/forms/examples/buttons" class="main-navigation-sub-nav-link" role="menuitem">Buttons</a></li>
19
+ <li class="main-navigation-sub-nav-item"><a href="/ui/tabs" class="main-navigation-sub-nav-link" role="menuitem">Tabs</a></li>
20
+ </ul>
21
+ </div>
22
+ </details>
23
+ </li>
24
+ </ul>
25
+ <ul class="main-navigation-list">
26
+ <li class="main-navigation-item visually-hidden" style="--_main-navigation-item-width: 0px;" data-group-key="secondNav" data-local-index="0"><a href="/contact" class="main-navigation-link">
27
+ <!--v-if--> Contact
28
+ </a></li>
29
+ </ul>
30
+ <div aria-hidden="true" class="nav-indicator-hovered"></div>
31
+ <div aria-hidden="true" class="nav-indicator-active"></div>
32
+ </nav>
33
+ <nav class="secondary-navigation" aria-label="Secondary navigation">
34
+ <details class="overflow-details visually-hidden" name="overflow-group">
35
+ <summary class="overflow-details-summary has-toggle-icon"><span class="iconify i-gravity-ui:ellipsis icon show" aria-hidden="true"></span><span class="iconify i-gravity-ui:bars icon" aria-hidden="true"></span></summary>
36
+ <div class="overflow-details-nav" role="menu">
37
+ <div class="overflow-navigation-wrapper" role="menu" aria-label="Overflow navigation menu">
38
+ <ul class="overflow-navigation-list visible" style="--_overflow-navigation-list-min-width: 0px;" role="none">
39
+ <li class="overflow-navigation-item visible is-active" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="0" role="none"><a href="/" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">Home</span></a></li>
40
+ <li class="overflow-navigation-item visible" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="1" role="none"><a href="/about" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">About</span></a></li>
41
+ <li class="overflow-navigation-item visible" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="2" role="none">
42
+ <div class="expanding-panel overflow-navigation-details">
43
+ <details class="expanding-panel-details" name="overflow-navigation-group">
44
+ <summary id="id-overflow-navigation-group-trigger" class="expanding-panel-summary" aria-controls="id-overflow-navigation-group-content" aria-expanded="false">
45
+ <div class="label-wrapper"><span class="overflow-navigation-text" aria-label="UI Components submenu" role="menuitem" aria-haspopup="true">UI Components</span></div>
46
+ <div class="icon-wrapper"><span class="iconify i-mdi:chevron-down icon" aria-hidden="true"></span></div>
47
+ </summary>
48
+ <div id="id-overflow-navigation-group-content" class="expanding-panel-content" aria-labelledby="id-overflow-navigation-group-trigger" role="region">
49
+ <div class="overflow-navigation-sub-nav-inner">
50
+ <ul class="overflow-navigation-sub-nav-list">
51
+ <li class="overflow-navigation-sub-nav-item"><a href="/forms/examples/buttons" class="overflow-navigation-sub-nav-link" role="menuitem"><span class="overflow-navigation-sub-nav-text">Buttons</span></a></li>
52
+ <li class="overflow-navigation-sub-nav-item"><a href="/ui/tabs" class="overflow-navigation-sub-nav-link" role="menuitem"><span class="overflow-navigation-sub-nav-text">Tabs</span></a></li>
53
+ </ul>
54
+ <div aria-hidden="true" class="overflow-sub-nav-indicator-hovered"></div>
55
+ </div>
56
+ </div>
57
+ </details>
58
+ </div>
59
+ </li>
60
+ </ul>
61
+ <ul class="overflow-navigation-list visible" style="--_overflow-navigation-list-min-width: 0px;" role="none">
62
+ <li class="overflow-navigation-item visible" style="--_main-navigation-item-width: 0px;" data-group-key="secondNav" data-local-index="0" role="none"><a href="/contact" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">Contact</span></a></li>
63
+ </ul>
64
+ <div aria-hidden="true" class="overflow-nav-indicator-hovered"></div>
65
+ <div aria-hidden="true" class="overflow-nav-indicator-active"></div>
66
+ </div>
67
+ </div>
68
+ </details>
69
+ <!--v-if-->
70
+ </nav>
71
+ </div>"
72
+ `;
@@ -0,0 +1,60 @@
1
+ # SiteHeader — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ All `--site-header-*` tokens style the outer `.site-header` grid row (branding column + nav
6
+ column). They're the stable override surface — set them at any scope without touching the
7
+ component itself.
8
+
9
+ | Token | Default | Controls |
10
+ |---|---|---|
11
+ | `--site-header-gap` | `2.4rem` | Gap between the branding column and the nav column |
12
+ | `--site-header-padding-inline` | `2.4rem` | Horizontal padding on `.site-header` |
13
+ | `--site-header-padding-block` | `0px` | Vertical padding on `.site-header` |
14
+ | `--site-header-bg` | `transparent` | Background colour of `.site-header` |
15
+ | `--site-header-position` | `static` | Set to `sticky` to pin the header, paired with `--site-header-sticky-offset` |
16
+ | `--site-header-sticky-offset` | `0px` | `top` offset when `--site-header-position: sticky` |
17
+ | `--site-header-z-index` | `9` | Stacking order of `.site-header` |
18
+
19
+ `SiteHeader` composes `PageRow` + `SkipLinks` + `ResponsiveHeader` internally. Two separate
20
+ `styleClassPassthrough` props reach the two composed components independently:
21
+
22
+ - `styleClassPassthrough` → forwarded to the root `PageRow` (targets `--site-header-*` tokens above)
23
+ - `navStyleClassPassthrough` → forwarded to the nested `ResponsiveHeader` (targets its own
24
+ `--responsive-header-*` and `--overflow-nav-*` tokens — see
25
+ [../../responsive-header/CONSUMER-STYLING.md](../../responsive-header/CONSUMER-STYLING.md) if present,
26
+ or the `ResponsiveHeader` skill doc)
27
+
28
+ ---
29
+
30
+ ## Global theming — recommended approach
31
+
32
+ Create `assets/styles/setup/07.components/site-header.css` in the consuming app and set tokens
33
+ on a scope class matching the one passed via `styleClassPassthrough`:
34
+
35
+ ```css
36
+ /* assets/styles/setup/07.components/site-header.css */
37
+ .header {
38
+ --site-header-gap: 1.6rem;
39
+ --site-header-padding-inline: 1.2rem;
40
+ --site-header-position: sticky;
41
+ --site-header-bg: #00000095;
42
+ }
43
+ ```
44
+
45
+ ```vue
46
+ <SiteHeader :style-class-passthrough="['header']">
47
+ <template #branding>
48
+ <NuxtLink to="/" class="brand-link">Brand</NuxtLink>
49
+ </template>
50
+ </SiteHeader>
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Notes
56
+
57
+ - The branding markup (logo image, wordmark, etc.) is entirely consumer-authored via the
58
+ `#branding` slot — `SiteHeader` has no opinion on brand styling.
59
+ - `SkipLinks`' hardcoded anchors (`#main-content`, `#footer-content`) expect the consuming
60
+ layout's main/footer regions to carry those `id`s — unchanged from using `SkipLinks` directly.