srcdev-nuxt-components 9.1.29 → 9.1.31
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/skills/components/banner-video.md +37 -45
- package/.claude/skills/components/grid-stack.md +133 -0
- package/.claude/skills/composable-canonical-url.md +92 -0
- package/.claude/skills/index.md +4 -1
- package/.claude/skills/new-app-scaffold.md +305 -0
- package/README.md +15 -0
- package/app/components/01.atoms/banner-video/BannerVideo.vue +25 -19
- package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +25 -36
- package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +15 -39
- package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +2 -2
- package/app/components/01.atoms/grid-stack/GridStack.vue +34 -0
- package/app/components/01.atoms/grid-stack/stories/GridStack.stories.ts +162 -0
- package/app/components/01.atoms/grid-stack/tests/GridStack.spec.ts +66 -0
- package/app/composables/useCanonicalUrl.ts +23 -0
- package/app/composables/useCapitalize.ts +3 -0
- package/app/pages/banner-video.vue +13 -71
- package/app/pages/grid-stack.vue +252 -0
- package/app/pages/index.vue +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,6 +56,21 @@ To ensure skills are always up to date and `nuxt prepare` is never forgotten, co
|
|
|
56
56
|
|
|
57
57
|
---
|
|
58
58
|
|
|
59
|
+
## Scaffolding a New App
|
|
60
|
+
|
|
61
|
+
A Claude Code skill is included to scaffold a new Nuxt consumer app from scratch. It generates
|
|
62
|
+
`package.json`, `nuxt.config.ts`, ESLint/Prettier config, the full `app/` directory structure,
|
|
63
|
+
and a `CLAUDE.md` — all pre-wired to extend this layer correctly.
|
|
64
|
+
|
|
65
|
+
**Trigger it by saying to Claude Code:**
|
|
66
|
+
|
|
67
|
+
> "Scaffold a new layer consumer app. Repo: `/path/to/repo`, name: `my-app`, domain: `myapp.co.uk`, fonts: `Fraunces, Manrope`."
|
|
68
|
+
|
|
69
|
+
The skill is available at `.claude/skills/new-app-scaffold.md` once copied into your project
|
|
70
|
+
via `npm run setup:claude`.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
59
74
|
## Consumer App Configuration
|
|
60
75
|
|
|
61
76
|
Configuration options for apps extending this layer. All options go in the consumer's `nuxt.config.ts` under `runtimeConfig.public` and can also be set via environment variable.
|
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
:is="tag"
|
|
4
4
|
class="banner-video"
|
|
5
5
|
:class="elementClasses"
|
|
6
|
+
:data-depth="depth"
|
|
6
7
|
:style="{
|
|
7
|
-
'--_max-height': maxHeight,
|
|
8
|
-
'--_max-height-tablet': maxHeightTablet,
|
|
9
|
-
'--_max-height-mobile': maxHeightMobile,
|
|
10
8
|
'--_aspect-ratio': aspectRatio,
|
|
11
9
|
'--_align-self': verticalPosition,
|
|
12
10
|
'--_justify-self': horizontalPosition,
|
|
@@ -55,12 +53,12 @@ interface Props {
|
|
|
55
53
|
imgWidth?: number;
|
|
56
54
|
/** Intrinsic height of the poster image — required for NuxtImg optimisation. */
|
|
57
55
|
imgHeight?: number;
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Depth tier controlling the responsive max-height via a `clamp()` scale.
|
|
58
|
+
* Each tier maps to a `--theme-banner-video-max-height-{depth}` CSS token that
|
|
59
|
+
* consuming pages can override. Defaults to `"md"`.
|
|
60
|
+
*/
|
|
61
|
+
depth?: "xs" | "sm" | "md" | "lg" | "xl";
|
|
64
62
|
/**
|
|
65
63
|
* CSS aspect-ratio of the banner container (e.g. `"16/9"`, `"21/9"`, `"4/3"`).
|
|
66
64
|
* Provides the intrinsic height that `max-height` caps at larger viewport widths.
|
|
@@ -76,13 +74,11 @@ interface Props {
|
|
|
76
74
|
}
|
|
77
75
|
|
|
78
76
|
const props = withDefaults(defineProps<Props>(), {
|
|
79
|
-
tag: "
|
|
77
|
+
tag: "div",
|
|
80
78
|
alt: "",
|
|
81
79
|
imgWidth: 1920,
|
|
82
80
|
imgHeight: 1080,
|
|
83
|
-
|
|
84
|
-
maxHeightTablet: undefined,
|
|
85
|
-
maxHeightMobile: undefined,
|
|
81
|
+
depth: "md",
|
|
86
82
|
aspectRatio: "21/9",
|
|
87
83
|
objectFit: "cover",
|
|
88
84
|
verticalPosition: "center",
|
|
@@ -156,19 +152,29 @@ onActivated(() => {
|
|
|
156
152
|
<style lang="css">
|
|
157
153
|
@layer components {
|
|
158
154
|
.banner-video {
|
|
155
|
+
--_max-height: var(--theme-banner-video-max-height, clamp(28rem, 38vw, 56rem));
|
|
156
|
+
|
|
159
157
|
display: grid;
|
|
160
158
|
grid-template-areas: "media";
|
|
161
159
|
aspect-ratio: var(--_aspect-ratio, 16/9);
|
|
162
|
-
max-height: var(--_max-height
|
|
160
|
+
max-height: var(--_max-height);
|
|
163
161
|
width: 100%;
|
|
164
162
|
overflow: hidden;
|
|
165
163
|
|
|
166
|
-
|
|
167
|
-
|
|
164
|
+
&[data-depth="xs"] {
|
|
165
|
+
--_max-height: var(--theme-banner-video-max-height-xs, clamp(12rem, 15vw, 24rem));
|
|
168
166
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
167
|
+
&[data-depth="sm"] {
|
|
168
|
+
--_max-height: var(--theme-banner-video-max-height-sm, clamp(18rem, 22vw, 36rem));
|
|
169
|
+
}
|
|
170
|
+
&[data-depth="md"] {
|
|
171
|
+
--_max-height: var(--theme-banner-video-max-height-md, clamp(28rem, 38vw, 56rem));
|
|
172
|
+
}
|
|
173
|
+
&[data-depth="lg"] {
|
|
174
|
+
--_max-height: var(--theme-banner-video-max-height-lg, clamp(40rem, 52vw, 72rem));
|
|
175
|
+
}
|
|
176
|
+
&[data-depth="xl"] {
|
|
177
|
+
--_max-height: var(--theme-banner-video-max-height-xl, clamp(52rem, 65vw, 90rem));
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
.video {
|
|
@@ -36,19 +36,10 @@ const meta: Meta<typeof BannerVideo> = {
|
|
|
36
36
|
description: "HTML element rendered as the root",
|
|
37
37
|
table: { category: "Markup" },
|
|
38
38
|
},
|
|
39
|
-
|
|
40
|
-
control: "
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
},
|
|
44
|
-
maxHeightTablet: {
|
|
45
|
-
control: "text",
|
|
46
|
-
description: "Maximum height at tablet (48em–64em). Falls back to maxHeight when unset",
|
|
47
|
-
table: { category: "Layout" },
|
|
48
|
-
},
|
|
49
|
-
maxHeightMobile: {
|
|
50
|
-
control: "text",
|
|
51
|
-
description: "Maximum height on mobile (<48em). Falls back through tablet → desktop when unset",
|
|
39
|
+
depth: {
|
|
40
|
+
control: "select",
|
|
41
|
+
options: ["xs", "sm", "md", "lg", "xl"],
|
|
42
|
+
description: "Responsive max-height tier. Each maps to a clamp() scale; override via --theme-banner-video-max-height-{depth}",
|
|
52
43
|
table: { category: "Layout" },
|
|
53
44
|
},
|
|
54
45
|
aspectRatio: {
|
|
@@ -82,7 +73,7 @@ const meta: Meta<typeof BannerVideo> = {
|
|
|
82
73
|
docs: {
|
|
83
74
|
description: {
|
|
84
75
|
component:
|
|
85
|
-
"A full-width banner that plays a muted, looping mp4 video. The poster image is shown before the video loads, when the video fails to play, and whenever the user has `prefers-reduced-motion: reduce` set. The banner is sized via `aspect-ratio` with
|
|
76
|
+
"A full-width banner that plays a muted, looping mp4 video. The poster image is shown before the video loads, when the video fails to play, and whenever the user has `prefers-reduced-motion: reduce` set. The banner is sized via `aspect-ratio` with a responsive `max-height` driven by the `depth` tier (`xs` → `xl`). Each tier uses a `clamp()` scale and exposes a `--theme-banner-video-max-height-{depth}` token for consuming pages to override. `objectFit` controls how media fills the frame. `verticalPosition` and `horizontalPosition` control the crop focal point.",
|
|
86
77
|
},
|
|
87
78
|
},
|
|
88
79
|
},
|
|
@@ -99,7 +90,7 @@ export const Default: Story = {
|
|
|
99
90
|
imgWidth: 1920,
|
|
100
91
|
imgHeight: 1080,
|
|
101
92
|
tag: "section",
|
|
102
|
-
|
|
93
|
+
depth: "md",
|
|
103
94
|
aspectRatio: "21/9",
|
|
104
95
|
objectFit: "cover",
|
|
105
96
|
verticalPosition: "center",
|
|
@@ -108,83 +99,81 @@ export const Default: Story = {
|
|
|
108
99
|
parameters: {
|
|
109
100
|
docs: {
|
|
110
101
|
description: {
|
|
111
|
-
story: "Default props. The lake video plays muted and looped at a 21/9 aspect ratio
|
|
102
|
+
story: "Default props. The lake video plays muted and looped at a 21/9 aspect ratio with the md depth tier (clamp 28rem → 56rem).",
|
|
112
103
|
},
|
|
113
104
|
},
|
|
114
105
|
},
|
|
115
106
|
};
|
|
116
107
|
|
|
117
|
-
export const
|
|
108
|
+
export const Compact: Story = {
|
|
118
109
|
args: {
|
|
119
110
|
...Default.args,
|
|
120
|
-
|
|
121
|
-
|
|
111
|
+
depth: "xs",
|
|
112
|
+
verticalPosition: "end",
|
|
122
113
|
},
|
|
123
114
|
parameters: {
|
|
124
115
|
docs: {
|
|
125
116
|
description: {
|
|
126
|
-
story: "
|
|
117
|
+
story: "xs depth tier (clamp 12rem → 24rem) — a thin strip banner. verticalPosition: 'end' crops to the bottom of the frame.",
|
|
127
118
|
},
|
|
128
119
|
},
|
|
129
120
|
},
|
|
130
121
|
};
|
|
131
122
|
|
|
132
|
-
export const
|
|
123
|
+
export const Shallow: Story = {
|
|
133
124
|
args: {
|
|
134
125
|
...Default.args,
|
|
135
|
-
|
|
136
|
-
maxHeight: "56rem",
|
|
126
|
+
depth: "sm",
|
|
137
127
|
},
|
|
138
128
|
parameters: {
|
|
139
129
|
docs: {
|
|
140
130
|
description: {
|
|
141
|
-
story: "
|
|
131
|
+
story: "sm depth tier (clamp 18rem → 36rem) — a shorter banner suitable for secondary sections.",
|
|
142
132
|
},
|
|
143
133
|
},
|
|
144
134
|
},
|
|
145
135
|
};
|
|
146
136
|
|
|
147
|
-
export const
|
|
137
|
+
export const Deep: Story = {
|
|
148
138
|
args: {
|
|
149
139
|
...Default.args,
|
|
150
|
-
|
|
151
|
-
|
|
140
|
+
depth: "lg",
|
|
141
|
+
aspectRatio: "21/9",
|
|
152
142
|
},
|
|
153
143
|
parameters: {
|
|
154
144
|
docs: {
|
|
155
145
|
description: {
|
|
156
|
-
story: "
|
|
146
|
+
story: "lg depth tier (clamp 40rem → 72rem) — a tall cinematic banner with more vertical presence on large screens.",
|
|
157
147
|
},
|
|
158
148
|
},
|
|
159
149
|
},
|
|
160
150
|
};
|
|
161
151
|
|
|
162
|
-
export const
|
|
152
|
+
export const Hero: Story = {
|
|
163
153
|
args: {
|
|
164
154
|
...Default.args,
|
|
165
|
-
|
|
155
|
+
depth: "xl",
|
|
166
156
|
aspectRatio: "16/9",
|
|
167
157
|
},
|
|
168
158
|
parameters: {
|
|
169
159
|
docs: {
|
|
170
160
|
description: {
|
|
171
|
-
story: "
|
|
161
|
+
story: "xl depth tier (clamp 52rem → 90rem) — near full-screen hero. Pair with a 16/9 ratio to maximise coverage.",
|
|
172
162
|
},
|
|
173
163
|
},
|
|
174
164
|
},
|
|
175
165
|
};
|
|
176
166
|
|
|
177
|
-
export const
|
|
167
|
+
export const Standard169: Story = {
|
|
178
168
|
args: {
|
|
179
169
|
...Default.args,
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
maxHeightMobile: "24rem",
|
|
170
|
+
aspectRatio: "16/9",
|
|
171
|
+
depth: "md",
|
|
183
172
|
},
|
|
184
173
|
parameters: {
|
|
185
174
|
docs: {
|
|
186
175
|
description: {
|
|
187
|
-
story: "
|
|
176
|
+
story: "Standard 16/9 aspect ratio with md depth — matches a typical video's native ratio.",
|
|
188
177
|
},
|
|
189
178
|
},
|
|
190
179
|
},
|
|
@@ -31,7 +31,8 @@ describe("BannerVideo", () => {
|
|
|
31
31
|
alt: "Lake banner",
|
|
32
32
|
imgWidth: 1280,
|
|
33
33
|
imgHeight: 720,
|
|
34
|
-
|
|
34
|
+
depth: "xl",
|
|
35
|
+
aspectRatio: "16/9",
|
|
35
36
|
styleClassPassthrough: ["full-bleed"],
|
|
36
37
|
},
|
|
37
38
|
});
|
|
@@ -186,49 +187,24 @@ describe("BannerVideo", () => {
|
|
|
186
187
|
expect(wrapper.find("img[data-nuxt-img]").attributes("decoding")).toBe("async");
|
|
187
188
|
});
|
|
188
189
|
|
|
189
|
-
// ───
|
|
190
|
-
|
|
191
|
-
it("sets --_max-height to 56rem by default", async () => {
|
|
192
|
-
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
193
|
-
const style = (wrapper.element as HTMLElement).style;
|
|
194
|
-
expect(style.getPropertyValue("--_max-height")).toBe("56rem");
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it("reflects maxHeight prop in --_max-height", async () => {
|
|
198
|
-
const wrapper = await mountSuspended(BannerVideo, {
|
|
199
|
-
props: { ...defaultProps, maxHeight: "80rem" },
|
|
200
|
-
});
|
|
201
|
-
const style = (wrapper.element as HTMLElement).style;
|
|
202
|
-
expect(style.getPropertyValue("--_max-height")).toBe("80rem");
|
|
203
|
-
});
|
|
190
|
+
// ─── depth / data-depth ──────────────────────────────────────────────────
|
|
204
191
|
|
|
205
|
-
it("
|
|
192
|
+
it("sets data-depth to md by default", async () => {
|
|
206
193
|
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
207
|
-
|
|
208
|
-
expect(style.getPropertyValue("--_max-height-tablet")).toBe("");
|
|
194
|
+
expect(wrapper.attributes("data-depth")).toBe("md");
|
|
209
195
|
});
|
|
210
196
|
|
|
211
|
-
it("
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
221
|
-
const style = (wrapper.element as HTMLElement).style;
|
|
222
|
-
expect(style.getPropertyValue("--_max-height-mobile")).toBe("");
|
|
223
|
-
});
|
|
197
|
+
it.each(["xs", "sm", "md", "lg", "xl"] as const)(
|
|
198
|
+
"sets data-depth to %s when depth prop is %s",
|
|
199
|
+
async (depth) => {
|
|
200
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
201
|
+
props: { ...defaultProps, depth },
|
|
202
|
+
});
|
|
203
|
+
expect(wrapper.attributes("data-depth")).toBe(depth);
|
|
204
|
+
}
|
|
205
|
+
);
|
|
224
206
|
|
|
225
|
-
|
|
226
|
-
const wrapper = await mountSuspended(BannerVideo, {
|
|
227
|
-
props: { ...defaultProps, maxHeightMobile: "32rem" },
|
|
228
|
-
});
|
|
229
|
-
const style = (wrapper.element as HTMLElement).style;
|
|
230
|
-
expect(style.getPropertyValue("--_max-height-mobile")).toBe("32rem");
|
|
231
|
-
});
|
|
207
|
+
// ─── CSS custom properties ───────────────────────────────────────────────
|
|
232
208
|
|
|
233
209
|
it("sets --_aspect-ratio to 21/9 by default", async () => {
|
|
234
210
|
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
2
|
|
|
3
3
|
exports[`BannerVideo > renders correct HTML structure (all props set) 1`] = `
|
|
4
|
-
"<header class="banner-video full-bleed" style="--
|
|
4
|
+
"<header class="banner-video full-bleed" data-depth="xl" style="--_aspect-ratio: 16/9; --_align-self: center; --_justify-self: center;"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;">
|
|
5
5
|
<source src="/images/banners/video/lake-banner.mp4" type="video/mp4">
|
|
6
6
|
</video><img width="1280" height="720" data-nuxt-img="" srcset="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg 1x, /_ipx/s_2560x1440/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="Lake banner" loading="eager" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg"></header>"
|
|
7
7
|
`;
|
|
8
8
|
|
|
9
9
|
exports[`BannerVideo > renders correct HTML structure (default props) 1`] = `
|
|
10
|
-
"<section class="banner-video" style="--
|
|
10
|
+
"<section class="banner-video" data-depth="md" style="--_aspect-ratio: 21/9; --_align-self: center; --_justify-self: center;"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;">
|
|
11
11
|
<source src="/images/banners/video/lake-banner.mp4" type="video/mp4">
|
|
12
12
|
</video><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg 1x, /_ipx/s_3840x2160/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="" loading="eager" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg"></section>"
|
|
13
13
|
`;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component :is="tag" class="grid-stack" :class="[elementClasses]">
|
|
3
|
+
<div v-for="(_, name) in $slots" :key="name" class="grid-stack__layer">
|
|
4
|
+
<slot :name="name"></slot>
|
|
5
|
+
</div>
|
|
6
|
+
</component>
|
|
7
|
+
</template>
|
|
8
|
+
|
|
9
|
+
<script setup lang="ts">
|
|
10
|
+
interface Props {
|
|
11
|
+
tag?: "div" | "section" | "article" | "main";
|
|
12
|
+
styleClassPassthrough?: string | string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
16
|
+
tag: "div",
|
|
17
|
+
styleClassPassthrough: () => [],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<style lang="css">
|
|
24
|
+
@layer components {
|
|
25
|
+
.grid-stack {
|
|
26
|
+
display: grid;
|
|
27
|
+
grid-template-areas: "stack";
|
|
28
|
+
|
|
29
|
+
.grid-stack__layer {
|
|
30
|
+
grid-area: stack;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
</style>
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
+
import GridStackComponent from "../GridStack.vue";
|
|
3
|
+
import BannerVideoComponent from "../../banner-video/BannerVideo.vue";
|
|
4
|
+
|
|
5
|
+
interface GridStackArgs {
|
|
6
|
+
tag: "div" | "section" | "article" | "main";
|
|
7
|
+
styleClassPassthrough: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
title: "Atoms/Layout/GridStack",
|
|
12
|
+
component: GridStackComponent,
|
|
13
|
+
argTypes: {
|
|
14
|
+
tag: {
|
|
15
|
+
control: { type: "select" },
|
|
16
|
+
options: ["div", "section", "article", "main"],
|
|
17
|
+
description: "HTML element rendered as the root",
|
|
18
|
+
table: { category: "Markup" },
|
|
19
|
+
},
|
|
20
|
+
styleClassPassthrough: {
|
|
21
|
+
table: { disable: true },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
args: {
|
|
25
|
+
tag: "div",
|
|
26
|
+
styleClassPassthrough: [],
|
|
27
|
+
},
|
|
28
|
+
parameters: {
|
|
29
|
+
docs: {
|
|
30
|
+
description: {
|
|
31
|
+
component:
|
|
32
|
+
"Stacks slot content in the z-axis using `grid-template-areas` — no `position: absolute`. All named slots share a single grid area (`stack`), so they overlap in DOM order: the last slot renders on top. The container sizes to the tallest child (usually the base layer). Slot names are free-form; `layer-1`, `layer-2`, etc. is the conventional pattern.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
} as Meta<GridStackArgs>;
|
|
37
|
+
|
|
38
|
+
// ── Two layers ──────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const TwoLayersTemplate: StoryFn<GridStackArgs> = (args) => ({
|
|
41
|
+
components: { GridStackComponent },
|
|
42
|
+
setup() {
|
|
43
|
+
return { args };
|
|
44
|
+
},
|
|
45
|
+
template: `
|
|
46
|
+
<GridStackComponent :tag="args.tag" :style-class-passthrough="args.styleClassPassthrough">
|
|
47
|
+
<template #layer-1>
|
|
48
|
+
<div style="display: flex; align-items: center; justify-content: center; min-block-size: 24rem; background: #e2e8f0; border-radius: 0.8rem; font-size: 1.4rem; font-weight: 600; color: #475569;">
|
|
49
|
+
layer-1 (base)
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
<template #layer-2>
|
|
53
|
+
<div style="display: flex; align-items: flex-end; justify-content: flex-start; padding: 2rem; background: linear-gradient(to top, rgba(15,23,42,0.6) 0%, transparent 60%); border-radius: 0.8rem; pointer-events: none; color: white; font-size: 1.4rem; font-weight: 600;">
|
|
54
|
+
layer-2 (overlay)
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
57
|
+
</GridStackComponent>
|
|
58
|
+
`,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const TwoLayers = TwoLayersTemplate.bind({});
|
|
62
|
+
TwoLayers.args = {};
|
|
63
|
+
TwoLayers.parameters = {
|
|
64
|
+
docs: {
|
|
65
|
+
description: {
|
|
66
|
+
story:
|
|
67
|
+
"The most common use: a base layer that drives the container height, with a second layer overlaid on top. The overlay uses `pointer-events: none` so the base layer remains interactive.",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ── Three layers ─────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const ThreeLayersTemplate: StoryFn<GridStackArgs> = (args) => ({
|
|
75
|
+
components: { GridStackComponent },
|
|
76
|
+
setup() {
|
|
77
|
+
return { args };
|
|
78
|
+
},
|
|
79
|
+
template: `
|
|
80
|
+
<GridStackComponent :tag="args.tag" :style-class-passthrough="args.styleClassPassthrough">
|
|
81
|
+
<template #layer-1>
|
|
82
|
+
<div style="display: flex; align-items: center; justify-content: center; min-block-size: 28rem; background: #e2e8f0; border-radius: 0.8rem; font-size: 1.4rem; font-weight: 600; color: #475569;">
|
|
83
|
+
layer-1 (base)
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
86
|
+
<template #layer-2>
|
|
87
|
+
<div style="display: flex; align-items: center; justify-content: center; margin: 2rem; border-radius: 0.6rem; border: 2px dashed #f97316; background: rgba(249,115,22,0.1); color: #c2410c; font-size: 1.4rem; font-weight: 600;">
|
|
88
|
+
layer-2 (mid)
|
|
89
|
+
</div>
|
|
90
|
+
</template>
|
|
91
|
+
<template #layer-3>
|
|
92
|
+
<div style="display: flex; align-items: flex-end; justify-content: flex-start; padding: 2rem; background: linear-gradient(to top, rgba(15,23,42,0.6) 0%, transparent 50%); border-radius: 0.8rem; pointer-events: none; color: white; font-size: 1.4rem; font-weight: 600;">
|
|
93
|
+
layer-3 (top)
|
|
94
|
+
</div>
|
|
95
|
+
</template>
|
|
96
|
+
</GridStackComponent>
|
|
97
|
+
`,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const ThreeLayers = ThreeLayersTemplate.bind({});
|
|
101
|
+
ThreeLayers.args = {};
|
|
102
|
+
ThreeLayers.parameters = {
|
|
103
|
+
docs: {
|
|
104
|
+
description: {
|
|
105
|
+
story:
|
|
106
|
+
"Three layers stacked in the same grid area. DOM order is z-order — layer-3 is always on top regardless of z-index. The dashed mid-layer shows how intermediate layers behave.",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// ── BannerVideo with overlay ──────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
const VideoOverlayTemplate: StoryFn<GridStackArgs> = (args) => ({
|
|
114
|
+
components: { GridStackComponent, BannerVideoComponent },
|
|
115
|
+
setup() {
|
|
116
|
+
return { args };
|
|
117
|
+
},
|
|
118
|
+
template: `
|
|
119
|
+
<GridStackComponent :tag="args.tag" :style-class-passthrough="args.styleClassPassthrough">
|
|
120
|
+
<template #layer-1>
|
|
121
|
+
<BannerVideoComponent
|
|
122
|
+
src="/images/banners/video/lake-banner.mp4"
|
|
123
|
+
poster="/images/banners/video/lake-banner.jpg"
|
|
124
|
+
alt="A serene lake landscape"
|
|
125
|
+
:img-width="1920"
|
|
126
|
+
:img-height="1080"
|
|
127
|
+
max-height="56rem"
|
|
128
|
+
aspect-ratio="21/9"
|
|
129
|
+
object-fit="cover"
|
|
130
|
+
vertical-position="center"
|
|
131
|
+
horizontal-position="center"
|
|
132
|
+
/>
|
|
133
|
+
</template>
|
|
134
|
+
<template #layer-2>
|
|
135
|
+
<div style="display: grid; place-items: center; pointer-events: none;">
|
|
136
|
+
<div style="text-align: center; padding: 2.4rem; pointer-events: auto;">
|
|
137
|
+
<p style="font-size: 1.1rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: white; margin-block-end: 0.8rem; opacity: 0.8;">
|
|
138
|
+
GridStack layer-2
|
|
139
|
+
</p>
|
|
140
|
+
<h2 style="font-size: 4rem; font-weight: 700; color: white; margin-block-end: 1.2rem; text-shadow: 0 2px 12px rgba(0,0,0,0.5);">
|
|
141
|
+
Content over video
|
|
142
|
+
</h2>
|
|
143
|
+
<p style="font-size: 1.6rem; color: white; opacity: 0.9; text-shadow: 0 1px 6px rgba(0,0,0,0.4);">
|
|
144
|
+
No <code style="font-family: monospace; background: rgba(0,0,0,0.3); padding: 0.1em 0.4em; border-radius: 0.3rem;">position: absolute</code> required.
|
|
145
|
+
</p>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</template>
|
|
149
|
+
</GridStackComponent>
|
|
150
|
+
`,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export const WithVideoOverlay = VideoOverlayTemplate.bind({});
|
|
154
|
+
WithVideoOverlay.args = {};
|
|
155
|
+
WithVideoOverlay.parameters = {
|
|
156
|
+
docs: {
|
|
157
|
+
description: {
|
|
158
|
+
story:
|
|
159
|
+
"The primary intended use case: a BannerVideo as the base layer with a content overlay on top. The overlay container uses `pointer-events: none` so the video remains interactive; `pointer-events: auto` is restored on the text content itself.",
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import GridStack from "../GridStack.vue";
|
|
4
|
+
|
|
5
|
+
describe("GridStack", () => {
|
|
6
|
+
it("mounts without error", async () => {
|
|
7
|
+
const wrapper = await mountSuspended(GridStack);
|
|
8
|
+
expect(wrapper.vm).toBeTruthy();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("renders as div by default", async () => {
|
|
12
|
+
const wrapper = await mountSuspended(GridStack);
|
|
13
|
+
expect(wrapper.element.tagName).toBe("DIV");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("renders a different tag when provided", async () => {
|
|
17
|
+
const wrapper = await mountSuspended(GridStack, {
|
|
18
|
+
props: { tag: "section" },
|
|
19
|
+
});
|
|
20
|
+
expect(wrapper.element.tagName).toBe("SECTION");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders slot content", async () => {
|
|
24
|
+
const wrapper = await mountSuspended(GridStack, {
|
|
25
|
+
slots: {
|
|
26
|
+
"layer-1": "<div>Layer 1</div>",
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
expect(wrapper.text()).toContain("Layer 1");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders multiple slots as stacked layers", async () => {
|
|
33
|
+
const wrapper = await mountSuspended(GridStack, {
|
|
34
|
+
slots: {
|
|
35
|
+
"layer-1": "<div>Layer 1</div>",
|
|
36
|
+
"layer-2": "<div>Layer 2</div>",
|
|
37
|
+
"layer-3": "<div>Layer 3</div>",
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const layers = wrapper.findAll(".grid-stack__layer");
|
|
41
|
+
expect(layers).toHaveLength(3);
|
|
42
|
+
expect(layers[0]!.text()).toBe("Layer 1");
|
|
43
|
+
expect(layers[1]!.text()).toBe("Layer 2");
|
|
44
|
+
expect(layers[2]!.text()).toBe("Layer 3");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders no layers when no slots are provided", async () => {
|
|
48
|
+
const wrapper = await mountSuspended(GridStack);
|
|
49
|
+
expect(wrapper.findAll(".grid-stack__layer")).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("applies styleClassPassthrough", async () => {
|
|
53
|
+
const wrapper = await mountSuspended(GridStack, {
|
|
54
|
+
props: { styleClassPassthrough: "custom-class" },
|
|
55
|
+
});
|
|
56
|
+
expect(wrapper.classes()).toContain("custom-class");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("applies multiple styleClassPassthrough classes", async () => {
|
|
60
|
+
const wrapper = await mountSuspended(GridStack, {
|
|
61
|
+
props: { styleClassPassthrough: ["class-a", "class-b"] },
|
|
62
|
+
});
|
|
63
|
+
expect(wrapper.classes()).toContain("class-a");
|
|
64
|
+
expect(wrapper.classes()).toContain("class-b");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const useCanonicalUrl = () => {
|
|
2
|
+
const route = useRoute();
|
|
3
|
+
const config = useRuntimeConfig();
|
|
4
|
+
|
|
5
|
+
const canonicalHost = config.public.canonicalHost;
|
|
6
|
+
|
|
7
|
+
// Construct the canonical URL
|
|
8
|
+
const canonicalUrl = `https://${canonicalHost}${route.path}`;
|
|
9
|
+
|
|
10
|
+
// Set the canonical link in the head
|
|
11
|
+
useHead({
|
|
12
|
+
link: [
|
|
13
|
+
{
|
|
14
|
+
rel: 'canonical',
|
|
15
|
+
href: canonicalUrl,
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
canonicalUrl,
|
|
22
|
+
};
|
|
23
|
+
};
|