srcdev-nuxt-components 9.4.6 → 9.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) 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/components/column-flow-grid.md +93 -0
  7. package/.claude/skills/components/entry-animation.md +88 -0
  8. package/.claude/skills/components/input-copy.md +2 -0
  9. package/.claude/skills/components/input-text-core.md +186 -0
  10. package/.claude/skills/components/masonry-grid.md +153 -0
  11. package/.claude/skills/components/pop-over.md +100 -0
  12. package/.claude/skills/index.md +6 -1
  13. package/.vscode/srcdev-component-column-flow-grid.code-snippets +39 -0
  14. package/.vscode/srcdev-component-entry-animation.code-snippets +29 -0
  15. package/.vscode/srcdev-component-input-text.code-snippets +107 -0
  16. package/.vscode/srcdev-component-masonry-grid.code-snippets +51 -0
  17. package/.vscode/srcdev-component-pop-over.code-snippets +44 -0
  18. package/app/components/01.atoms/animations/entry/EntryAnimation.vue +7 -1
  19. package/app/components/01.atoms/animations/entry/stories/EntryAnimation.stories.ts +47 -0
  20. package/app/components/01.atoms/animations/entry/tests/EntryAnimation.spec.ts +57 -0
  21. package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +9 -10
  22. package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +12 -13
  23. package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +33 -0
  24. package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +55 -0
  25. package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +178 -0
  26. package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +75 -0
  27. package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +35 -0
  28. package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +178 -0
  29. package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +199 -0
  30. package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +158 -0
  31. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +54 -0
  32. package/app/components/01.atoms/pop-over/PopOver.vue +201 -0
  33. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +141 -0
  34. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +195 -0
  35. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +11 -0
  36. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +8 -2
  37. package/app/components/02.molecules/input-copy/InputCopy.vue +14 -0
  38. package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +15 -0
  39. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +41 -0
  40. package/app/components/05.forms/input-select/InputSelectCore.vue +1 -1
  41. package/app/components/05.forms/input-text/InputTextCore.vue +6 -0
  42. package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +49 -20
  43. package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +41 -22
  44. package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +48 -22
  45. package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +43 -17
  46. package/app/components/05.forms/input-text/tests/InputPasswordWithLabel.spec.ts +56 -0
  47. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +61 -0
  48. package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +60 -0
  49. package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +46 -0
  50. package/app/components/05.forms/input-text/variants/InputPasswordWithLabel.vue +7 -1
  51. package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +8 -2
  52. package/app/components/05.forms/input-text/variants/InputTextWithLabel.vue +6 -0
  53. package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +20 -20
  54. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +32 -26
  55. package/app/types/forms/types.forms.d.ts +1 -1
  56. package/package.json +1 -1
  57. package/app/components/masonry-grid/MasonryGrid.vue +0 -68
  58. package/app/components/masonry-grid-ordered/MasonryGridOrdered.vue +0 -163
  59. package/app/components/masonry-grid-ordered/MasonryGridOrderedGridExperiment.vue +0 -259
  60. package/app/components/masonry-grid-ordered/stories/MasonryGridOrdered.stories.ts +0 -354
  61. package/app/components/masonry-grid-sorted/MasonryGridSorted.vue +0 -120
  62. package/app/components/pop-over/PopOver.vue +0 -90
  63. package/app/layouts/default.vue +0 -308
  64. package/app/layouts/site-navigation-demo.vue +0 -188
@@ -0,0 +1,158 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { nextTick } from "vue";
4
+ import MasonryGrid from "../MasonryGrid.vue";
5
+
6
+ // useElementSize/useResizeObserver (from @vueuse/core) require ResizeObserver, which jsdom
7
+ // doesn't implement. Mock it and capture the callback so tests can simulate a real resize event —
8
+ // jsdom also never actually measures layout (offsetWidth/offsetHeight stay 0), so this is the only
9
+ // way to feed the component a non-zero measured width.
10
+ // Vitest 4: a mock's implementation must be a regular function (not an arrow function)
11
+ // to remain constructible via `new`.
12
+ let resizeCallback: ResizeObserverCallback | undefined;
13
+ const mockResizeObserver = vi.fn(function (callback: ResizeObserverCallback) {
14
+ resizeCallback = callback;
15
+ return {
16
+ observe: vi.fn(),
17
+ unobserve: vi.fn(),
18
+ disconnect: vi.fn(),
19
+ };
20
+ });
21
+
22
+ beforeEach(() => {
23
+ resizeCallback = undefined;
24
+ vi.stubGlobal("ResizeObserver", mockResizeObserver);
25
+ // ⚠️ Do NOT call vi.unstubAllGlobals() in afterEach — it removes global stubs from
26
+ // vitest.setup.ts ($fetch, etc.)
27
+ });
28
+
29
+ function simulateResizeTo(width: number) {
30
+ resizeCallback?.(
31
+ [{ contentBoxSize: [{ inlineSize: width, blockSize: 0 }], contentRect: { width } } as unknown as ResizeObserverEntry],
32
+ {} as ResizeObserver
33
+ );
34
+ }
35
+
36
+ function stubOffsetHeight(el: Element, height: number) {
37
+ Object.defineProperty(el, "offsetHeight", { value: height, configurable: true });
38
+ }
39
+
40
+ describe("MasonryGrid", () => {
41
+ it("mounts without error", async () => {
42
+ const wrapper = await mountSuspended(MasonryGrid);
43
+ expect(wrapper.vm).toBeTruthy();
44
+ });
45
+
46
+ it("renders as div by default", async () => {
47
+ const wrapper = await mountSuspended(MasonryGrid);
48
+ expect(wrapper.element.tagName).toBe("DIV");
49
+ });
50
+
51
+ it("renders a different tag when provided", async () => {
52
+ const wrapper = await mountSuspended(MasonryGrid, {
53
+ props: { tag: "section" },
54
+ });
55
+ expect(wrapper.element.tagName).toBe("SECTION");
56
+ });
57
+
58
+ it("renders slot content in natural authoring order", async () => {
59
+ const wrapper = await mountSuspended(MasonryGrid, {
60
+ slots: {
61
+ "item-1": "<div>Item 1</div>",
62
+ "item-2": "<div>Item 2</div>",
63
+ },
64
+ });
65
+ const items = wrapper.findAll(".masonry-grid-item");
66
+ expect(items.map((item) => item.text())).toEqual(["Item 1", "Item 2"]);
67
+ });
68
+
69
+ it("renders no items when no slots are provided", async () => {
70
+ const wrapper = await mountSuspended(MasonryGrid);
71
+ expect(wrapper.findAll(".masonry-grid-item")).toHaveLength(0);
72
+ });
73
+
74
+ it("does not absolutely position items when only one column fits (single-column fallback)", async () => {
75
+ const wrapper = await mountSuspended(MasonryGrid, {
76
+ slots: { "item-1": "<div>Item 1</div>", "item-2": "<div>Item 2</div>" },
77
+ });
78
+ await nextTick();
79
+ await nextTick();
80
+ expect(wrapper.find(".masonry-grid-wrapper").classes()).not.toContain("multiple-cols");
81
+ const item = wrapper.find(".masonry-grid-item").element as HTMLElement;
82
+ expect(item.style.getPropertyValue("--_position")).toBe("");
83
+ });
84
+
85
+ it("packs items into whichever column is currently shortest", async () => {
86
+ const wrapper = await mountSuspended(MasonryGrid, {
87
+ // itemMinWidth (300, default) + gap (12, default) = 312 -> floor(700/312) = 2 columns
88
+ slots: {
89
+ "item-1": "<div>Item 1</div>",
90
+ "item-2": "<div>Item 2</div>",
91
+ "item-3": "<div>Item 3</div>",
92
+ },
93
+ });
94
+ const items = wrapper.findAll(".masonry-grid-item");
95
+ stubOffsetHeight(items[0]!.element, 100);
96
+ stubOffsetHeight(items[1]!.element, 50);
97
+ stubOffsetHeight(items[2]!.element, 80);
98
+
99
+ simulateResizeTo(700);
100
+ await nextTick();
101
+ await nextTick(); // updateGrid is itself wrapped in an extra nextTick()
102
+
103
+ // col heights start [0, 0]. item-1 (h=100) -> shortest is col 0 -> col becomes 112.
104
+ // item-2 (h=50) -> shortest is now col 1 (0 < 112) -> col becomes 62.
105
+ // item-3 (h=80) -> shortest is still col 1 (62 < 112) -> placed below item-2.
106
+ // itemWidth (stretch) = floor((700 - 1*12) / 2) = 344; column steps are itemWidth+gap = 356px.
107
+ const el1 = items[0]!.element as HTMLElement;
108
+ const el2 = items[1]!.element as HTMLElement;
109
+ const el3 = items[2]!.element as HTMLElement;
110
+ expect(el1.style.getPropertyValue("--_position-top")).toBe("0px");
111
+ expect(el1.style.getPropertyValue("--_position-left")).toBe("0px");
112
+ expect(el2.style.getPropertyValue("--_position-top")).toBe("0px");
113
+ expect(el2.style.getPropertyValue("--_position-left")).toBe("356px");
114
+ expect(el3.style.getPropertyValue("--_position-top")).toBe("62px");
115
+ expect(el3.style.getPropertyValue("--_position-left")).toBe("356px");
116
+ });
117
+
118
+ it("uses a fixed item width when fixedWidth is set, ignoring stretch sizing", async () => {
119
+ const wrapper = await mountSuspended(MasonryGrid, {
120
+ props: { fixedWidth: true, itemMinWidth: 200 },
121
+ slots: { "item-1": "<div>Item 1</div>", "item-2": "<div>Item 2</div>" },
122
+ });
123
+ simulateResizeTo(700); // (200 + 12) -> floor(700/212) = 3 columns
124
+ await nextTick();
125
+ await nextTick();
126
+ const item = wrapper.find(".masonry-grid-item").element as HTMLElement;
127
+ expect(item.style.getPropertyValue("--_element-width")).toBe("200px");
128
+ });
129
+
130
+ it("spaces fixed-width columns by exactly itemWidth + gap, and centers the block via justify", async () => {
131
+ const wrapper = await mountSuspended(MasonryGrid, {
132
+ props: { fixedWidth: true, itemMinWidth: 200, gap: 20, justify: "center" },
133
+ slots: {
134
+ "item-1": "<div>Item 1</div>",
135
+ "item-2": "<div>Item 2</div>",
136
+ "item-3": "<div>Item 3</div>",
137
+ },
138
+ });
139
+ simulateResizeTo(700); // (200 + 20) -> floor(700/220) = 3 columns
140
+ await nextTick();
141
+ await nextTick();
142
+
143
+ // 3 columns * 200px + 2 gaps * 20px = 640px total content width, centered in 700px
144
+ // available -> 30px leftover on each side.
145
+ const items = wrapper.findAll(".masonry-grid-item");
146
+ const [el1, el2, el3] = items.map((item) => item.element as HTMLElement);
147
+ expect(el1!.style.getPropertyValue("--_position-left")).toBe("30px");
148
+ expect(el2!.style.getPropertyValue("--_position-left")).toBe("250px");
149
+ expect(el3!.style.getPropertyValue("--_position-left")).toBe("470px");
150
+ });
151
+
152
+ it("applies styleClassPassthrough", async () => {
153
+ const wrapper = await mountSuspended(MasonryGrid, {
154
+ props: { styleClassPassthrough: "custom-class" },
155
+ });
156
+ expect(wrapper.classes()).toContain("custom-class");
157
+ });
158
+ });
@@ -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
+ };