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/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
- /** Maximum height at desktop (≥64em). Defaults to `"56rem"`. */
59
- maxHeight?: string;
60
- /** Maximum height at tablet (48em–64em). Falls back to `maxHeight` when unset. */
61
- maxHeightTablet?: string;
62
- /** Maximum height on mobile (<48em). Falls back to `maxHeightTablet` then `maxHeight` when unset. */
63
- maxHeightMobile?: string;
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: "section",
77
+ tag: "div",
80
78
  alt: "",
81
79
  imgWidth: 1920,
82
80
  imgHeight: 1080,
83
- maxHeight: "56rem",
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-mobile, var(--_max-height, 56rem));
160
+ max-height: var(--_max-height);
163
161
  width: 100%;
164
162
  overflow: hidden;
165
163
 
166
- @media (min-width: 48em) {
167
- max-height: var(--_max-height-tablet, var(--_max-height, 56rem));
164
+ &[data-depth="xs"] {
165
+ --_max-height: var(--theme-banner-video-max-height-xs, clamp(12rem, 15vw, 24rem));
168
166
  }
169
-
170
- @media (min-width: 64em) {
171
- max-height: var(--_max-height, 56rem);
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
- maxHeight: {
40
- control: "text",
41
- description: "Maximum height at desktop (≥64em). Defaults to 56rem",
42
- table: { category: "Layout" },
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 per-breakpoint `max-height` caps no fixed pixel heights. `objectFit` controls how media fills the frame. `verticalPosition` and `horizontalPosition` control the crop focal point — the video uses CSS grid alignment (`align-self`/`justify-self`) while the fallback image uses `object-position`.",
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
- maxHeight: "56rem",
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, capped at 56rem on desktop.",
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 Widescreen: Story = {
108
+ export const Compact: Story = {
118
109
  args: {
119
110
  ...Default.args,
120
- aspectRatio: "21/9",
121
- maxHeight: "80rem",
111
+ depth: "xs",
112
+ verticalPosition: "end",
122
113
  },
123
114
  parameters: {
124
115
  docs: {
125
116
  description: {
126
- story: "A wider cinematic crop with a raised maxHeight, giving the banner more vertical presence on large screens.",
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 Standard169: Story = {
123
+ export const Shallow: Story = {
133
124
  args: {
134
125
  ...Default.args,
135
- aspectRatio: "16/9",
136
- maxHeight: "56rem",
126
+ depth: "sm",
137
127
  },
138
128
  parameters: {
139
129
  docs: {
140
130
  description: {
141
- story: "Standard 16/9 aspect ratio matches a typical video's native ratio, so objectPosition has minimal visible effect unless the frame is very narrow.",
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 ShortBanner: Story = {
137
+ export const Deep: Story = {
148
138
  args: {
149
139
  ...Default.args,
150
- maxHeight: "32rem",
151
- verticalPosition: "end",
140
+ depth: "lg",
141
+ aspectRatio: "21/9",
152
142
  },
153
143
  parameters: {
154
144
  docs: {
155
145
  description: {
156
- story: "A shallower banner capped at 32rem. verticalPosition: 'end' shifts the crop to show the bottom of the frame.",
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 FullViewportHeight: Story = {
152
+ export const Hero: Story = {
163
153
  args: {
164
154
  ...Default.args,
165
- maxHeight: "100vh",
155
+ depth: "xl",
166
156
  aspectRatio: "16/9",
167
157
  },
168
158
  parameters: {
169
159
  docs: {
170
160
  description: {
171
- story: "maxHeight set to 100vh the banner fills the full viewport height on large screens and aspect-ratio controls height on smaller ones.",
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 ResponsiveBreakpoints: Story = {
167
+ export const Standard169: Story = {
178
168
  args: {
179
169
  ...Default.args,
180
- maxHeight: "56rem",
181
- maxHeightTablet: "40rem",
182
- maxHeightMobile: "24rem",
170
+ aspectRatio: "16/9",
171
+ depth: "md",
183
172
  },
184
173
  parameters: {
185
174
  docs: {
186
175
  description: {
187
- story: "Per-breakpoint max-height: 56rem desktop, 40rem tablet (≥48em), 24rem mobile (<48em). Resize the viewport to see each cap take effect.",
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
- height: "40rem",
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
- // ─── CSS custom properties ───────────────────────────────────────────────
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("does not set --_max-height-tablet when maxHeightTablet is not provided", async () => {
192
+ it("sets data-depth to md by default", async () => {
206
193
  const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
207
- const style = (wrapper.element as HTMLElement).style;
208
- expect(style.getPropertyValue("--_max-height-tablet")).toBe("");
194
+ expect(wrapper.attributes("data-depth")).toBe("md");
209
195
  });
210
196
 
211
- it("reflects maxHeightTablet prop in --_max-height-tablet", async () => {
212
- const wrapper = await mountSuspended(BannerVideo, {
213
- props: { ...defaultProps, maxHeightTablet: "48rem" },
214
- });
215
- const style = (wrapper.element as HTMLElement).style;
216
- expect(style.getPropertyValue("--_max-height-tablet")).toBe("48rem");
217
- });
218
-
219
- it("does not set --_max-height-mobile when maxHeightMobile is not provided", async () => {
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
- it("reflects maxHeightMobile prop in --_max-height-mobile", async () => {
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="--_max-height: 56rem; --_aspect-ratio: 21/9; --_align-self: center; --_justify-self: center;" height="40rem"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;">
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="--_max-height: 56rem; --_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;">
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
+ };
@@ -0,0 +1,3 @@
1
+ export function useCapitalize(str: string) {
2
+ return str.charAt(0).toUpperCase() + str.slice(1);
3
+ }