srcdev-nuxt-components 9.4.6 → 9.4.8

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 (109) hide show
  1. package/.claude/commands/migrate-component.md +34 -3
  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/component-dynamic-slots.md +1 -1
  7. package/.claude/skills/components/column-flow-grid.md +93 -0
  8. package/.claude/skills/components/entry-animation.md +88 -0
  9. package/.claude/skills/components/header-block.md +92 -0
  10. package/.claude/skills/components/input-copy.md +2 -0
  11. package/.claude/skills/components/input-text-core.md +186 -0
  12. package/.claude/skills/components/masonry-grid.md +153 -0
  13. package/.claude/skills/components/pop-over.md +100 -0
  14. package/.claude/skills/components/rotating-carousel-image.md +101 -0
  15. package/.claude/skills/components/skip-links.md +82 -0
  16. package/.claude/skills/components/tabs-core.md +187 -0
  17. package/.claude/skills/index.md +10 -1
  18. package/.vscode/srcdev-component-column-flow-grid.code-snippets +39 -0
  19. package/.vscode/srcdev-component-entry-animation.code-snippets +29 -0
  20. package/.vscode/srcdev-component-header-block.code-snippets +41 -0
  21. package/.vscode/srcdev-component-input-text.code-snippets +107 -0
  22. package/.vscode/srcdev-component-masonry-grid.code-snippets +51 -0
  23. package/.vscode/srcdev-component-pop-over.code-snippets +44 -0
  24. package/.vscode/srcdev-component-rotating-carousel-image.code-snippets +74 -0
  25. package/.vscode/srcdev-component-skip-links.code-snippets +55 -0
  26. package/.vscode/srcdev-component-tabs-core.code-snippets +78 -0
  27. package/app/assets/styles/setup/05.typography/02.utility-classes/_font-classes-page-heading.css +2 -1
  28. package/app/components/01.atoms/animations/entry/EntryAnimation.vue +7 -1
  29. package/app/components/01.atoms/animations/entry/stories/EntryAnimation.stories.ts +47 -0
  30. package/app/components/01.atoms/animations/entry/tests/EntryAnimation.spec.ts +57 -0
  31. package/app/components/01.atoms/animations/rotating-carousel-image/CONSUMER-STYLING.md +54 -0
  32. package/app/components/01.atoms/animations/rotating-carousel-image/RotatingCarouselImage.vue +315 -0
  33. package/app/components/01.atoms/animations/rotating-carousel-image/stories/RotatingCarouselImage.stories.ts +156 -0
  34. package/app/components/01.atoms/animations/rotating-carousel-image/tests/RotatingCarouselImage.spec.ts +230 -0
  35. package/app/components/01.atoms/animations/rotating-carousel-image/tests/__snapshots__/RotatingCarouselImage.spec.ts.snap +19 -0
  36. package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +9 -10
  37. package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +12 -13
  38. package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +33 -0
  39. package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +55 -0
  40. package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +178 -0
  41. package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +75 -0
  42. package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +35 -0
  43. package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +178 -0
  44. package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +199 -0
  45. package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +158 -0
  46. package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +31 -0
  47. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +92 -0
  48. package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +113 -0
  49. package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +71 -0
  50. package/app/components/01.atoms/navigation/skip-links/tests/__snapshots__/SkipLinks.spec.ts.snap +15 -0
  51. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +99 -0
  52. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +272 -0
  53. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +199 -0
  54. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +274 -0
  55. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +54 -0
  56. package/app/components/01.atoms/pop-over/PopOver.vue +201 -0
  57. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +141 -0
  58. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +195 -0
  59. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +11 -0
  60. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +43 -0
  61. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +50 -0
  62. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +111 -0
  63. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +119 -0
  64. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +5 -0
  65. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +8 -2
  66. package/app/components/02.molecules/input-copy/InputCopy.vue +14 -0
  67. package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +15 -0
  68. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +41 -0
  69. package/app/components/03.organisms/site-header/SiteHeader.vue +1 -1
  70. package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +1 -1
  71. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +3 -3
  72. package/app/components/05.forms/input-select/InputSelectCore.vue +1 -1
  73. package/app/components/05.forms/input-text/InputTextCore.vue +6 -0
  74. package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +49 -20
  75. package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +41 -22
  76. package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +48 -22
  77. package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +43 -17
  78. package/app/components/05.forms/input-text/tests/InputPasswordWithLabel.spec.ts +56 -0
  79. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +61 -0
  80. package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +60 -0
  81. package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +46 -0
  82. package/app/components/05.forms/input-text/variants/InputPasswordWithLabel.vue +7 -1
  83. package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +8 -2
  84. package/app/components/05.forms/input-text/variants/InputTextWithLabel.vue +6 -0
  85. package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +20 -20
  86. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +32 -26
  87. package/app/composables/useTabs.ts +225 -207
  88. package/app/types/components/index.ts +2 -0
  89. package/app/types/components/rotating-carousel-image.d.ts +4 -0
  90. package/app/types/components/skip-links.d.ts +4 -0
  91. package/app/types/forms/types.forms.d.ts +1 -1
  92. package/package.json +1 -1
  93. package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/DataGrid.spec.ts.snap +0 -11
  94. package/app/components/masonry-grid/MasonryGrid.vue +0 -68
  95. package/app/components/masonry-grid-ordered/MasonryGridOrdered.vue +0 -163
  96. package/app/components/masonry-grid-ordered/MasonryGridOrderedGridExperiment.vue +0 -259
  97. package/app/components/masonry-grid-ordered/stories/MasonryGridOrdered.stories.ts +0 -354
  98. package/app/components/masonry-grid-sorted/MasonryGridSorted.vue +0 -120
  99. package/app/components/pop-over/PopOver.vue +0 -90
  100. package/app/components/rotating-carousel/RotatingCarouselImage.vue +0 -216
  101. package/app/components/skip-links/SkipLinks.vue +0 -60
  102. package/app/components/tabs/TabsCore.vue +0 -306
  103. package/app/components/typography/HeaderBlock.vue +0 -35
  104. package/app/layouts/default.vue +0 -308
  105. package/app/layouts/site-navigation-demo.vue +0 -188
  106. /package/app/components/01.atoms/grids/{data-grid → auto-grid}/AutoGrid.vue +0 -0
  107. /package/app/components/01.atoms/grids/{data-grid → auto-grid}/stories/AutoGrid.stories.ts +0 -0
  108. /package/app/components/01.atoms/grids/{data-grid → auto-grid}/tests/AutoGrid.spec.ts +0 -0
  109. /package/app/components/01.atoms/grids/{data-grid → auto-grid}/tests/__snapshots__/AutoGrid.spec.ts.snap +0 -0
@@ -0,0 +1,274 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import TabsCore from "../TabsCore.vue";
4
+
5
+ class MockResizeObserver {
6
+ observe = vi.fn();
7
+ unobserve = vi.fn();
8
+ disconnect = vi.fn();
9
+ }
10
+
11
+ const slots = {
12
+ "tab-0-trigger": "Tab One",
13
+ "tab-0-content": "<p class='panel-0'>Content one</p>",
14
+ "tab-1-trigger": "Tab Two",
15
+ "tab-1-content": "<p class='panel-1'>Content two</p>",
16
+ "tab-2-trigger": "Tab Three",
17
+ "tab-2-content": "<p class='panel-2'>Content three</p>",
18
+ };
19
+
20
+ const fiveSlots = {
21
+ ...slots,
22
+ "tab-3-trigger": "Tab Four",
23
+ "tab-3-content": "<p class='panel-3'>Content four</p>",
24
+ "tab-4-trigger": "Tab Five",
25
+ "tab-4-content": "<p class='panel-4'>Content five</p>",
26
+ };
27
+
28
+ describe("TabsCore", () => {
29
+ beforeEach(() => {
30
+ vi.stubGlobal("ResizeObserver", MockResizeObserver);
31
+ });
32
+
33
+ // ─── Mount ───────────────────────────────────────────────────────────────
34
+
35
+ it("mounts without error", async () => {
36
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
37
+ expect(wrapper.vm).toBeTruthy();
38
+ });
39
+
40
+ it("renders one trigger and one panel per itemCount", async () => {
41
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
42
+ expect(wrapper.findAll(".tabs-list-item")).toHaveLength(3);
43
+ expect(wrapper.findAll(".tab-content")).toHaveLength(3);
44
+ });
45
+
46
+ // ─── Accessibility structure ─────────────────────────────────────────────
47
+
48
+ it("renders a tablist with the default aria-label", async () => {
49
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
50
+ const tablist = wrapper.find("[role='tablist']");
51
+ expect(tablist.exists()).toBe(true);
52
+ expect(tablist.attributes("aria-label")).toBe("Tabs");
53
+ });
54
+
55
+ it("uses a custom ariaLabel when provided", async () => {
56
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, ariaLabel: "Settings" }, slots });
57
+ expect(wrapper.find("[role='tablist']").attributes("aria-label")).toBe("Settings");
58
+ });
59
+
60
+ it("renders each trigger as a type=button with role=tab", async () => {
61
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
62
+ const triggers = wrapper.findAll(".tabs-list-item");
63
+ triggers.forEach((trigger) => {
64
+ expect(trigger.attributes("type")).toBe("button");
65
+ expect(trigger.attributes("role")).toBe("tab");
66
+ });
67
+ });
68
+
69
+ it("renders each panel as role=tabpanel labelled by its trigger", async () => {
70
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
71
+ const panels = wrapper.findAll(".tab-content");
72
+ panels.forEach((panel, index) => {
73
+ expect(panel.attributes("role")).toBe("tabpanel");
74
+ expect(panel.attributes("aria-labelledby")).toBe(`tab-${index}-trigger`);
75
+ });
76
+ });
77
+
78
+ // ─── Active tab state ────────────────────────────────────────────────────
79
+
80
+ it("marks the first tab active and its panel visible/unhidden on mount", async () => {
81
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
82
+ const triggers = wrapper.findAll(".tabs-list-item");
83
+ const panels = wrapper.findAll(".tab-content");
84
+
85
+ expect(triggers[0]!.attributes("aria-selected")).toBe("true");
86
+ expect(triggers[0]!.attributes("tabindex")).toBe("0");
87
+ expect(triggers[1]!.attributes("aria-selected")).toBe("false");
88
+ expect(triggers[1]!.attributes("tabindex")).toBe("-1");
89
+
90
+ expect(panels[0]!.attributes("aria-hidden")).toBe("false");
91
+ expect((panels[0]!.element as HTMLElement).style.display).toBe("block");
92
+ expect(panels[1]!.attributes("aria-hidden")).toBe("true");
93
+ expect((panels[1]!.element as HTMLElement).style.display).toBe("none");
94
+ });
95
+
96
+ it("activates a tab on click and updates aria-selected, tabindex, and panel visibility", async () => {
97
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
98
+ const triggers = wrapper.findAll(".tabs-list-item");
99
+ const panels = wrapper.findAll(".tab-content");
100
+
101
+ await triggers[1]!.trigger("click");
102
+
103
+ expect(triggers[0]!.attributes("aria-selected")).toBe("false");
104
+ expect(triggers[0]!.attributes("tabindex")).toBe("-1");
105
+ expect(triggers[1]!.attributes("aria-selected")).toBe("true");
106
+ expect(triggers[1]!.attributes("tabindex")).toBe("0");
107
+
108
+ expect(panels[0]!.attributes("aria-hidden")).toBe("true");
109
+ expect(panels[1]!.attributes("aria-hidden")).toBe("false");
110
+ expect((panels[1]!.element as HTMLElement).style.display).toBe("block");
111
+ });
112
+
113
+ // ─── Keyboard navigation ─────────────────────────────────────────────────
114
+
115
+ it("moves focus and activates the next tab on ArrowRight (axis x)", async () => {
116
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
117
+ const triggers = wrapper.findAll(".tabs-list-item");
118
+
119
+ await triggers[0]!.trigger("keydown", { key: "ArrowRight" });
120
+
121
+ expect(triggers[1]!.attributes("aria-selected")).toBe("true");
122
+ expect(triggers[1]!.attributes("tabindex")).toBe("0");
123
+ });
124
+
125
+ it("wraps to the first tab on ArrowRight from the last tab", async () => {
126
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
127
+ const triggers = wrapper.findAll(".tabs-list-item");
128
+
129
+ await triggers[2]!.trigger("click");
130
+ await triggers[2]!.trigger("keydown", { key: "ArrowRight" });
131
+
132
+ expect(triggers[0]!.attributes("aria-selected")).toBe("true");
133
+ });
134
+
135
+ it("moves focus to the previous tab on ArrowLeft (axis x)", async () => {
136
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
137
+ const triggers = wrapper.findAll(".tabs-list-item");
138
+
139
+ await triggers[1]!.trigger("click");
140
+ await triggers[1]!.trigger("keydown", { key: "ArrowLeft" });
141
+
142
+ expect(triggers[0]!.attributes("aria-selected")).toBe("true");
143
+ });
144
+
145
+ it("uses ArrowUp/ArrowDown instead of ArrowLeft/ArrowRight when axis is y", async () => {
146
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, axis: "y" }, slots });
147
+ const triggers = wrapper.findAll(".tabs-list-item");
148
+
149
+ await triggers[0]!.trigger("keydown", { key: "ArrowRight" });
150
+ expect(triggers[0]!.attributes("aria-selected")).toBe("true");
151
+
152
+ await triggers[0]!.trigger("keydown", { key: "ArrowDown" });
153
+ expect(triggers[1]!.attributes("aria-selected")).toBe("true");
154
+ });
155
+
156
+ it("jumps to the last tab on End and the first tab on Home", async () => {
157
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
158
+ const triggers = wrapper.findAll(".tabs-list-item");
159
+
160
+ await triggers[0]!.trigger("keydown", { key: "End" });
161
+ expect(triggers[2]!.attributes("aria-selected")).toBe("true");
162
+
163
+ await triggers[2]!.trigger("keydown", { key: "Home" });
164
+ expect(triggers[0]!.attributes("aria-selected")).toBe("true");
165
+ });
166
+
167
+ // ─── trackHover / trackActive / trackIndicator ──────────────────────────
168
+
169
+ it("renders all three indicator decorators by default", async () => {
170
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
171
+ expect(wrapper.find(".nav__hovered").exists()).toBe(true);
172
+ expect(wrapper.find(".nav__active").exists()).toBe(true);
173
+ expect(wrapper.find(".nav__active-indicator").exists()).toBe(true);
174
+ });
175
+
176
+ it("omits the hover decorator when trackHover is false", async () => {
177
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, trackHover: false }, slots });
178
+ expect(wrapper.find(".nav__hovered").exists()).toBe(false);
179
+ });
180
+
181
+ it("omits the active decorator when trackActive is false", async () => {
182
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, trackActive: false }, slots });
183
+ expect(wrapper.find(".nav__active").exists()).toBe(false);
184
+ });
185
+
186
+ it("omits the underline indicator when trackIndicator is false", async () => {
187
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, trackIndicator: false }, slots });
188
+ expect(wrapper.find(".nav__active-indicator").exists()).toBe(false);
189
+ });
190
+
191
+ // ─── Hover indicator movement ────────────────────────────────────────────
192
+
193
+ it("moves the hover indicator position on mouseenter", async () => {
194
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
195
+ const tabsList = wrapper.find(".tabs-list").element as HTMLElement;
196
+ const triggers = wrapper.findAll(".tabs-list-item");
197
+
198
+ expect(tabsList.style.getPropertyValue("--_width-hovered")).toBe("");
199
+
200
+ await triggers[1]!.trigger("mouseenter");
201
+
202
+ expect(tabsList.style.getPropertyValue("--_width-hovered")).not.toBe("");
203
+ });
204
+
205
+ it("resets the hover indicator to the active tab on mouseleave", async () => {
206
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
207
+ const tabsList = wrapper.find(".tabs-list");
208
+ const triggers = wrapper.findAll(".tabs-list-item");
209
+
210
+ await triggers[1]!.trigger("mouseenter");
211
+ await tabsList.trigger("mouseleave");
212
+
213
+ // Should not throw, and the hover indicator settles back onto the active (first) tab.
214
+ expect((tabsList.element as HTMLElement).style.getPropertyValue("--_x-hovered")).toBe("0px");
215
+ });
216
+
217
+ it("clears a pending settle timeout when hovering a second tab before the first settles", async () => {
218
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
219
+ const triggers = wrapper.findAll(".tabs-list-item");
220
+ const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
221
+
222
+ await triggers[1]!.trigger("mouseenter");
223
+ await triggers[2]!.trigger("mouseenter");
224
+
225
+ expect(clearTimeoutSpy).toHaveBeenCalled();
226
+ });
227
+
228
+ // ─── Regression: more than 3 tabs ────────────────────────────────────────
229
+ // The original hand-rolled version of this component had an indicator-positioning issue
230
+ // that only showed up with itemCount > 3 — these lock in correct behaviour at 5.
231
+
232
+ it("activates a distant tab (index 0 -> index 4) directly via click", async () => {
233
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 5 }, slots: fiveSlots });
234
+ const triggers = wrapper.findAll(".tabs-list-item");
235
+
236
+ await triggers[4]!.trigger("click");
237
+
238
+ expect(triggers[0]!.attributes("aria-selected")).toBe("false");
239
+ expect(triggers[4]!.attributes("aria-selected")).toBe("true");
240
+ expect(triggers[4]!.attributes("tabindex")).toBe("0");
241
+ expect(wrapper.findAll(".tab-content")[4]!.attributes("aria-hidden")).toBe("false");
242
+ });
243
+
244
+ it("does not recolour tabs spanned by a distant activation jump (regression: intermediate labels going invisible)", async () => {
245
+ // The old implementation force-applied a "transitioning" class to every tab spanned by a
246
+ // jump, forcing it to the active-indicator's text colour for the whole transition — even
247
+ // though the sliding indicator only visually reaches each spanned tab partway through, and
248
+ // for a forward jump doesn't reach the middle tabs until late in the transition. That made
249
+ // intermediate labels flash a colour matching neither their own background nor (yet) the
250
+ // indicator's, going invisible. Spanned tabs should only ever carry aria-selected/hover
251
+ // state of their own — never a class forcing them to look active.
252
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 5 }, slots: fiveSlots });
253
+ const triggers = wrapper.findAll(".tabs-list-item");
254
+
255
+ // Active tab starts at index 0, so activating index 4 spans every tab in between.
256
+ await triggers[4]!.trigger("click");
257
+
258
+ [1, 2, 3].forEach((index) => {
259
+ expect(triggers[index]!.attributes("aria-selected")).toBe("false");
260
+ expect(triggers[index]!.classes()).not.toContain("transitioning");
261
+ });
262
+ });
263
+
264
+ it("wraps correctly with End then ArrowRight across 5 tabs", async () => {
265
+ const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 5 }, slots: fiveSlots });
266
+ const triggers = wrapper.findAll(".tabs-list-item");
267
+
268
+ await triggers[0]!.trigger("keydown", { key: "End" });
269
+ expect(triggers[4]!.attributes("aria-selected")).toBe("true");
270
+
271
+ await triggers[4]!.trigger("keydown", { key: "ArrowRight" });
272
+ expect(triggers[0]!.attributes("aria-selected")).toBe("true");
273
+ });
274
+ });
@@ -0,0 +1,54 @@
1
+ # PopOver — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ | Token | Default | Controls |
6
+ |---|---|---|
7
+ | `--pop-over-gap` | `1rem` | Space between the trigger and the popover panel |
8
+ | `--pop-over-trigger-outline-width` | `0.1rem` | Trigger focus outline width |
9
+ | `--pop-over-trigger-outline-colour-hover` | `var(--theme-ring)` | Trigger outline colour on focus-visible |
10
+ | `--pop-over-width` | `30rem` | Popover panel width |
11
+ | `--pop-over-border-width` | `0.1rem` | Popover panel border width |
12
+ | `--pop-over-border-colour` | `light-dark(var(--slate-02), var(--slate-06))` | Popover panel border colour |
13
+ | `--pop-over-border-radius` | `0.8rem` | Popover panel corner radius |
14
+ | `--pop-over-background-colour` | `light-dark(var(--slate-00), var(--slate-07))` | Popover panel background |
15
+ | `--pop-over-text-colour` | `light-dark(var(--slate-09), var(--slate-01))` | Popover panel text colour |
16
+ | `--pop-over-shadow` | `light-dark(0 0.4rem 1.6rem rgba(0,0,0,.12), 0 0.4rem 1.6rem rgba(0,0,0,.5))` | Popover panel elevation shadow |
17
+ | `--pop-over-transition-duration` | `200ms` | Open/close fade duration |
18
+ | `--pop-over-content-padding` | `1.6rem` | Padding around the `content` slot |
19
+ | `--pop-over-content-padding-block-start` | `3.2rem` | Extra top padding so content clears the close button |
20
+ | `--pop-over-close-button-offset` | `0.8rem` | Distance of the close button from the panel's top/right edges |
21
+ | `--pop-over-close-button-size` | `2.4rem` | Close button box width/height |
22
+ | `--pop-over-close-button-border-radius` | `50%` | Close button corner radius |
23
+ | `--pop-over-close-button-colour` | `light-dark(var(--slate-06), var(--slate-03))` | Close button icon colour |
24
+ | `--pop-over-close-button-icon-size` | `1.4rem` | Close button icon glyph size |
25
+ | `--pop-over-close-button-outline-width` | `0.1rem` | Close button focus/hover outline width |
26
+ | `--pop-over-close-button-surface-hover` | `light-dark(var(--slate-01), var(--slate-06))` | Close button background on hover/focus-visible |
27
+ | `--pop-over-close-button-outline-colour-hover` | `var(--theme-ring)` | Close button outline colour on hover/focus-visible |
28
+
29
+ ```css
30
+ .my-page {
31
+ --pop-over-background-colour: #1a1a1a;
32
+ --pop-over-text-colour: white;
33
+ --pop-over-width: 24rem;
34
+ }
35
+ ```
36
+
37
+ Or scope to a single instance via `styleClassPassthrough`:
38
+
39
+ ```vue
40
+ <PopOver style-class-passthrough="promo-pop-over">...</PopOver>
41
+ ```
42
+
43
+ ## Notes
44
+
45
+ - Built on the native Popover API (`popover`/`popovertarget`) and CSS anchor-positioning
46
+ (`anchor-name`/`position-anchor`/`anchor()`) — check current browser support before relying on
47
+ it where broad support is a hard requirement.
48
+ - `placement` (`"top" | "right" | "bottom" | "left"`, default `"right"`) picks which side of the
49
+ trigger the panel opens on; each side gets a `position-try-fallbacks` so the browser flips it
50
+ automatically if it would overflow the viewport.
51
+ - Unlike `DisplayTooltip` (a fixed icon trigger for inline help text) or `ActionMenu` (menu
52
+ semantics with arrow-key navigation), `PopOver` is a generic disclosure: both `trigger` and
53
+ `content` slots are entirely consumer-supplied, and it always renders its own visible close
54
+ button rather than relying on click-outside/Escape alone.
@@ -0,0 +1,201 @@
1
+ <template>
2
+ <ClientOnly>
3
+ <div class="pop-over" :class="[elementClasses]" :style="{ '--_anchor-name': anchorName }" :data-placement="placement">
4
+ <button
5
+ ref="triggerRef"
6
+ :popovertarget="popoverId"
7
+ popovertargetaction="toggle"
8
+ type="button"
9
+ class="pop-over-trigger"
10
+ :aria-label="triggerAriaLabel || undefined"
11
+ >
12
+ <slot name="trigger"></slot>
13
+ </button>
14
+
15
+ <div :id="popoverId" ref="popoverRef" popover class="pop-over-popover" :aria-label="popoverAriaLabel || undefined" @toggle="handleToggle">
16
+ <button
17
+ ref="closeButtonRef"
18
+ :popovertarget="popoverId"
19
+ popovertargetaction="hide"
20
+ type="button"
21
+ class="pop-over-close-button"
22
+ :aria-label="closeButtonAriaLabel"
23
+ >
24
+ <Icon name="lucide:x" class="pop-over-close-button-icon" aria-hidden="true" />
25
+ </button>
26
+ <div class="pop-over-content">
27
+ <slot name="content"></slot>
28
+ </div>
29
+ </div>
30
+ </div>
31
+ </ClientOnly>
32
+ </template>
33
+
34
+ <script setup lang="ts">
35
+ interface Props {
36
+ placement?: "top" | "right" | "bottom" | "left";
37
+ /** aria-label on the trigger button — set this when the trigger slot is icon-only. */
38
+ triggerAriaLabel?: string;
39
+ /** aria-label on the popover content region — set this when the content slot has no visible heading. */
40
+ popoverAriaLabel?: string;
41
+ /** aria-label on the close button — override for localisation. */
42
+ closeButtonAriaLabel?: string;
43
+ styleClassPassthrough?: string | string[];
44
+ }
45
+
46
+ const props = withDefaults(defineProps<Props>(), {
47
+ placement: "right",
48
+ triggerAriaLabel: "",
49
+ popoverAriaLabel: "",
50
+ closeButtonAriaLabel: "Close",
51
+ styleClassPassthrough: () => [],
52
+ });
53
+
54
+ const id = useId();
55
+ const popoverId = `pop-over-${id}`;
56
+ const anchorName = `--pop-over-anchor-${id}`;
57
+
58
+ const triggerRef = ref<HTMLButtonElement | null>(null);
59
+ const popoverRef = ref<HTMLDivElement | null>(null);
60
+ const closeButtonRef = ref<HTMLButtonElement | null>(null);
61
+
62
+ /** Move focus into the popover on open; the Popover API restores focus to the trigger on close. */
63
+ const handleToggle = (event: Event) => {
64
+ const toggleEvent = event as ToggleEvent;
65
+ if (toggleEvent.newState === "open") {
66
+ closeButtonRef.value?.focus();
67
+ }
68
+ };
69
+
70
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
71
+
72
+ watch(
73
+ () => props.styleClassPassthrough,
74
+ () => {
75
+ resetElementClasses(props.styleClassPassthrough);
76
+ }
77
+ );
78
+ </script>
79
+
80
+ <style lang="css">
81
+ @layer components {
82
+ .pop-over {
83
+ display: inline-block;
84
+ position: relative;
85
+
86
+ .pop-over-trigger {
87
+ all: unset;
88
+ cursor: pointer;
89
+ anchor-name: var(--_anchor-name);
90
+ outline: var(--pop-over-trigger-outline-width, 0.1rem) solid transparent;
91
+
92
+ &:focus-visible {
93
+ outline-color: var(--pop-over-trigger-outline-colour-hover, var(--theme-ring));
94
+ outline-offset: 0.2rem;
95
+ }
96
+ }
97
+
98
+ .pop-over-popover {
99
+ display: none;
100
+ position: absolute;
101
+ position-anchor: var(--_anchor-name);
102
+ margin: 0;
103
+ inset: auto;
104
+ border: var(--pop-over-border-width, 0.1rem) solid
105
+ var(--pop-over-border-colour, light-dark(var(--slate-02), var(--slate-06)));
106
+ border-radius: var(--pop-over-border-radius, 0.8rem);
107
+ background-color: var(--pop-over-background-colour, light-dark(var(--slate-00), var(--slate-07)));
108
+ color: var(--pop-over-text-colour, light-dark(var(--slate-09), var(--slate-01)));
109
+ box-shadow: var(
110
+ --pop-over-shadow,
111
+ light-dark(0 0.4rem 1.6rem rgba(0, 0, 0, 0.12), 0 0.4rem 1.6rem rgba(0, 0, 0, 0.5))
112
+ );
113
+ width: var(--pop-over-width, 30rem);
114
+
115
+ opacity: 0;
116
+ transition:
117
+ opacity var(--pop-over-transition-duration, 200ms),
118
+ display var(--pop-over-transition-duration, 200ms),
119
+ overlay var(--pop-over-transition-duration, 200ms);
120
+ transition-behavior: allow-discrete;
121
+
122
+ &:popover-open {
123
+ display: block;
124
+ opacity: 1;
125
+
126
+ @starting-style {
127
+ opacity: 0;
128
+ }
129
+ }
130
+
131
+ .pop-over-close-button {
132
+ all: unset;
133
+ cursor: pointer;
134
+ position: absolute;
135
+ top: var(--pop-over-close-button-offset, 0.8rem);
136
+ right: var(--pop-over-close-button-offset, 0.8rem);
137
+ display: grid;
138
+ place-items: center;
139
+ width: var(--pop-over-close-button-size, 2.4rem);
140
+ height: var(--pop-over-close-button-size, 2.4rem);
141
+ border-radius: var(--pop-over-close-button-border-radius, 50%);
142
+ color: var(--pop-over-close-button-colour, light-dark(var(--slate-06), var(--slate-03)));
143
+ outline: var(--pop-over-close-button-outline-width, 0.1rem) solid transparent;
144
+
145
+ &:hover,
146
+ &:focus-visible {
147
+ background-color: var(--pop-over-close-button-surface-hover, light-dark(var(--slate-01), var(--slate-06)));
148
+ outline-color: var(--pop-over-close-button-outline-colour-hover, var(--theme-ring));
149
+ outline-offset: 0.1rem;
150
+ }
151
+
152
+ .pop-over-close-button-icon {
153
+ width: var(--pop-over-close-button-icon-size, 1.4rem);
154
+ height: var(--pop-over-close-button-icon-size, 1.4rem);
155
+ }
156
+ }
157
+
158
+ .pop-over-content {
159
+ padding: var(--pop-over-content-padding, 1.6rem);
160
+ padding-top: var(--pop-over-content-padding-block-start, 3.2rem);
161
+
162
+ /* Trim the slotted content's own default UA margins so this padding is the only
163
+ space above/below it — interior spacing between multiple children is untouched. */
164
+ > :first-child {
165
+ margin-block-start: 0;
166
+ }
167
+
168
+ > :last-child {
169
+ margin-block-end: 0;
170
+ }
171
+ }
172
+ }
173
+
174
+ &[data-placement="right"] .pop-over-popover {
175
+ top: anchor(top);
176
+ left: calc(anchor(right) + var(--pop-over-gap, 1rem));
177
+ position-try-fallbacks: flip-inline;
178
+ }
179
+
180
+ &[data-placement="left"] .pop-over-popover {
181
+ top: anchor(top);
182
+ right: calc(anchor(left) + var(--pop-over-gap, 1rem));
183
+ left: auto;
184
+ position-try-fallbacks: flip-inline;
185
+ }
186
+
187
+ &[data-placement="bottom"] .pop-over-popover {
188
+ top: calc(anchor(bottom) + var(--pop-over-gap, 1rem));
189
+ left: anchor(left);
190
+ position-try-fallbacks: flip-block;
191
+ }
192
+
193
+ &[data-placement="top"] .pop-over-popover {
194
+ bottom: calc(anchor(top) + var(--pop-over-gap, 1rem));
195
+ top: auto;
196
+ left: anchor(left);
197
+ position-try-fallbacks: flip-block;
198
+ }
199
+ }
200
+ }
201
+ </style>
@@ -0,0 +1,141 @@
1
+ import PopOver from "../PopOver.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+
4
+ const meta: Meta<typeof PopOver> = {
5
+ title: "Atoms/PopOver",
6
+ component: PopOver,
7
+ argTypes: {
8
+ placement: {
9
+ control: "select",
10
+ options: ["top", "right", "bottom", "left"],
11
+ description: "Which side of the trigger the popover panel opens on.",
12
+ table: { category: "Basic" },
13
+ },
14
+ triggerAriaLabel: {
15
+ control: "text",
16
+ description: "aria-label on the trigger button — set this when the trigger slot is icon-only.",
17
+ table: { category: "Accessibility" },
18
+ },
19
+ popoverAriaLabel: {
20
+ control: "text",
21
+ description: "aria-label on the popover content region — set this when the content slot has no visible heading.",
22
+ table: { category: "Accessibility" },
23
+ },
24
+ closeButtonAriaLabel: {
25
+ control: "text",
26
+ description: "aria-label on the close button — override for localisation.",
27
+ table: { category: "Accessibility" },
28
+ },
29
+ styleClassPassthrough: { table: { disable: true } },
30
+ },
31
+ args: {
32
+ placement: "right",
33
+ closeButtonAriaLabel: "Close",
34
+ },
35
+ parameters: {
36
+ docs: {
37
+ description: {
38
+ component:
39
+ "A generic anchor-positioned disclosure panel: fully consumer-supplied trigger and content slots, built on the native Popover API and CSS anchor-positioning. See DisplayTooltip for a fixed icon-trigger variant, or ActionMenu for one with menu semantics.",
40
+ },
41
+ },
42
+ },
43
+ };
44
+
45
+ export default meta;
46
+ type Story = StoryObj<typeof PopOver>;
47
+
48
+ export const Default: Story = {
49
+ render: (args) => ({
50
+ components: { PopOver },
51
+ setup() {
52
+ return { args };
53
+ },
54
+ template: `
55
+ <div style="padding: 8rem;">
56
+ <PopOver v-bind="args">
57
+ <template #trigger>
58
+ <span>Open popover</span>
59
+ </template>
60
+ <template #content>
61
+ <p>This is some popover content.</p>
62
+ </template>
63
+ </PopOver>
64
+ </div>
65
+ `,
66
+ }),
67
+ };
68
+
69
+ export const Placements: Story = {
70
+ render: (args) => ({
71
+ components: { PopOver },
72
+ setup() {
73
+ return { args };
74
+ },
75
+ template: `
76
+ <div style="display: flex; gap: 4rem; padding: 10rem;">
77
+ <PopOver v-bind="args" placement="top">
78
+ <template #trigger><span>Top</span></template>
79
+ <template #content><p>Opens above the trigger.</p></template>
80
+ </PopOver>
81
+ <PopOver v-bind="args" placement="right">
82
+ <template #trigger><span>Right</span></template>
83
+ <template #content><p>Opens to the right of the trigger.</p></template>
84
+ </PopOver>
85
+ <PopOver v-bind="args" placement="bottom">
86
+ <template #trigger><span>Bottom</span></template>
87
+ <template #content><p>Opens below the trigger.</p></template>
88
+ </PopOver>
89
+ <PopOver v-bind="args" placement="left">
90
+ <template #trigger><span>Left</span></template>
91
+ <template #content><p>Opens to the left of the trigger.</p></template>
92
+ </PopOver>
93
+ </div>
94
+ `,
95
+ }),
96
+ };
97
+
98
+ export const IconTrigger: Story = {
99
+ render: (args) => ({
100
+ components: { PopOver },
101
+ setup() {
102
+ return { args };
103
+ },
104
+ template: `
105
+ <div style="padding: 8rem;">
106
+ <PopOver v-bind="args" trigger-aria-label="Show filters" popover-aria-label="Filter options">
107
+ <template #trigger>
108
+ <Icon name="lucide:filter" aria-hidden="true" />
109
+ </template>
110
+ <template #content>
111
+ <p>Filter controls would go here.</p>
112
+ </template>
113
+ </PopOver>
114
+ </div>
115
+ `,
116
+ }),
117
+ };
118
+
119
+ export const CustomColours: Story = {
120
+ render: (args) => ({
121
+ components: { PopOver },
122
+ setup() {
123
+ return { args };
124
+ },
125
+ template: `
126
+ <div style="padding: 8rem;">
127
+ <PopOver
128
+ v-bind="args"
129
+ style="--pop-over-background-colour: #1a1a1a; --pop-over-text-colour: white; --pop-over-border-colour: transparent;"
130
+ >
131
+ <template #trigger>
132
+ <span>Open popover</span>
133
+ </template>
134
+ <template #content>
135
+ <p>Overriding the public tokens via inline custom properties.</p>
136
+ </template>
137
+ </PopOver>
138
+ </div>
139
+ `,
140
+ }),
141
+ };