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.
- package/.claude/commands/migrate-component.md +34 -3
- package/.claude/component-ledger/audit.json +1 -1
- package/.claude/component-ledger/build.mjs +27 -0
- package/.claude/component-ledger/output.html +8 -3
- package/.claude/component-ledger/template.html +7 -2
- package/.claude/skills/components/column-flow-grid.md +93 -0
- package/.claude/skills/components/entry-animation.md +88 -0
- package/.claude/skills/components/input-copy.md +2 -0
- package/.claude/skills/components/input-text-core.md +186 -0
- package/.claude/skills/components/masonry-grid.md +153 -0
- package/.claude/skills/components/pop-over.md +100 -0
- package/.claude/skills/index.md +6 -1
- package/.vscode/srcdev-component-column-flow-grid.code-snippets +39 -0
- package/.vscode/srcdev-component-entry-animation.code-snippets +29 -0
- package/.vscode/srcdev-component-input-text.code-snippets +107 -0
- package/.vscode/srcdev-component-masonry-grid.code-snippets +51 -0
- package/.vscode/srcdev-component-pop-over.code-snippets +44 -0
- package/app/components/01.atoms/animations/entry/EntryAnimation.vue +7 -1
- package/app/components/01.atoms/animations/entry/stories/EntryAnimation.stories.ts +47 -0
- package/app/components/01.atoms/animations/entry/tests/EntryAnimation.spec.ts +57 -0
- package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +9 -10
- package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +12 -13
- package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +33 -0
- package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +55 -0
- package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +178 -0
- package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +75 -0
- package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +35 -0
- package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +178 -0
- package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +199 -0
- package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +158 -0
- package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +54 -0
- package/app/components/01.atoms/pop-over/PopOver.vue +201 -0
- package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +141 -0
- package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +195 -0
- package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +11 -0
- package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +8 -2
- package/app/components/02.molecules/input-copy/InputCopy.vue +14 -0
- package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +15 -0
- package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +41 -0
- package/app/components/05.forms/input-select/InputSelectCore.vue +1 -1
- package/app/components/05.forms/input-text/InputTextCore.vue +6 -0
- package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +49 -20
- package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +41 -22
- package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +48 -22
- package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +43 -17
- package/app/components/05.forms/input-text/tests/InputPasswordWithLabel.spec.ts +56 -0
- package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +61 -0
- package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +60 -0
- package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +46 -0
- package/app/components/05.forms/input-text/variants/InputPasswordWithLabel.vue +7 -1
- package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +8 -2
- package/app/components/05.forms/input-text/variants/InputTextWithLabel.vue +6 -0
- package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +20 -20
- package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +32 -26
- package/app/types/forms/types.forms.d.ts +1 -1
- package/package.json +1 -1
- package/app/components/masonry-grid/MasonryGrid.vue +0 -68
- package/app/components/masonry-grid-ordered/MasonryGridOrdered.vue +0 -163
- package/app/components/masonry-grid-ordered/MasonryGridOrderedGridExperiment.vue +0 -259
- package/app/components/masonry-grid-ordered/stories/MasonryGridOrdered.stories.ts +0 -354
- package/app/components/masonry-grid-sorted/MasonryGridSorted.vue +0 -120
- package/app/components/pop-over/PopOver.vue +0 -90
- package/app/layouts/default.vue +0 -308
- 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
|
+
};
|