srcdev-nuxt-components 9.1.28 → 9.1.30
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/settings.json +7 -2
- package/.claude/settings.local.json +2 -1
- package/.claude/skills/components/banner-video.md +178 -0
- package/.claude/skills/components/grid-stack.md +133 -0
- package/.claude/skills/components/scroll-reveal-frame.md +136 -0
- package/.claude/skills/components/scroll-reveal-image.md +161 -0
- package/.claude/skills/index.md +9 -1
- package/.claude/skills/new-app-scaffold.md +301 -0
- package/.claude/skills/setup-postinstall.md +45 -0
- package/.claude/skills/theming-override-default.md +6 -0
- package/.claude/skills/theming-partial-override.md +131 -0
- package/.claude/skills/vue-video-autoplay.md +74 -0
- package/README.md +30 -5
- package/app/components/01.atoms/banner-video/BannerVideo.vue +207 -0
- package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +180 -0
- package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +350 -0
- package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +13 -0
- 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/components/01.atoms/scroll-reveal-frame/ScrollRevealFrame.vue +125 -0
- package/app/components/01.atoms/scroll-reveal-frame/stories/ScrollRevealFrame.stories.ts +166 -0
- package/app/components/01.atoms/scroll-reveal-frame/tests/ScrollRevealFrame.spec.ts +148 -0
- package/app/components/01.atoms/scroll-reveal-frame/tests/__snapshots__/ScrollRevealFrame.spec.ts.snap +13 -0
- package/app/components/01.atoms/scroll-reveal-image/ScrollRevealImage.vue +96 -0
- package/app/components/01.atoms/scroll-reveal-image/stories/ScrollRevealImage.stories.ts +181 -0
- package/app/components/01.atoms/scroll-reveal-image/tests/ScrollRevealImage.spec.ts +235 -0
- package/app/components/01.atoms/scroll-reveal-image/tests/__snapshots__/ScrollRevealImage.spec.ts.snap +13 -0
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +1 -0
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +1 -0
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +1 -0
- package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +1 -0
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -0
- package/app/components/03.organisms/services/services-grids/tests/ServicesSectionGrid.spec.ts +1 -0
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts +1 -0
- package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +1 -0
- package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +1 -1
- package/app/layouts/default.vue +2 -0
- package/app/pages/banner-video.vue +238 -0
- package/app/pages/grid-stack.vue +252 -0
- package/app/pages/index.vue +5 -0
- package/app/pages/ui/scroll-reveal-image.vue +483 -0
- package/app/types/types.services.ts +1 -0
- package/package.json +1 -1
- package/app/assets/styles/extends-layer/srcdev-components/components/_display-prompt-core.css +0 -94
- package/app/assets/styles/extends-layer/srcdev-components/components/_display-toast.css +0 -5
- package/app/assets/styles/extends-layer/srcdev-components/components/_expanding-panel.css +0 -37
- package/app/assets/styles/extends-layer/srcdev-components/components/index.css +0 -3
- package/app/assets/styles/extends-layer/srcdev-components/index.css +0 -1
- package/app/assets/styles/extends-layer/srcdev-forms/components/_form-fieldset.css +0 -38
- package/app/assets/styles/extends-layer/srcdev-forms/components/_input-button.css +0 -67
- package/app/assets/styles/extends-layer/srcdev-forms/components/_input-checkbox-radio-core.css +0 -87
- package/app/assets/styles/extends-layer/srcdev-forms/components/_input-checkbox-radio-options-button.css +0 -74
- package/app/assets/styles/extends-layer/srcdev-forms/components/_input-checkbox-radio-with-label.css +0 -14
- package/app/assets/styles/extends-layer/srcdev-forms/components/_input-description.css +0 -13
- package/app/assets/styles/extends-layer/srcdev-forms/components/_input-error.css +0 -54
- package/app/assets/styles/extends-layer/srcdev-forms/components/_input-label.css +0 -21
- package/app/assets/styles/extends-layer/srcdev-forms/components/_input-select.css +0 -143
- package/app/assets/styles/extends-layer/srcdev-forms/components/_input-text.css +0 -190
- package/app/assets/styles/extends-layer/srcdev-forms/components/_input-textarea.css +0 -96
- package/app/assets/styles/extends-layer/srcdev-forms/components/_input-toggle-switch.css +0 -68
- package/app/assets/styles/extends-layer/srcdev-forms/components/index.css +0 -12
- package/app/assets/styles/extends-layer/srcdev-forms/index.css +0 -2
- package/app/assets/styles/extends-layer/srcdev-forms/setup/_generic.css +0 -21
- package/app/assets/styles/extends-layer/srcdev-forms/setup/index.css +0 -3
- package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_default.css +0 -13
- package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_large.css +0 -12
- package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_medium.css +0 -12
- package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_small.css +0 -12
- package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_x-small.css +0 -11
- package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/index.css +0 -23
- package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_default.css +0 -62
- package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_error.css +0 -63
- package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_ghost.css +0 -63
- package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_info.css +0 -63
- package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_primary.css +0 -66
- package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_secondary.css +0 -64
- package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_success.css +0 -63
- package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_tertiary.css +0 -63
- package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_warning.css +0 -63
- package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/index.css +0 -7
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import { nextTick } from "vue";
|
|
4
|
+
import BannerVideo from "../BannerVideo.vue";
|
|
5
|
+
|
|
6
|
+
const defaultProps = {
|
|
7
|
+
src: "/images/banners/video/lake-banner.mp4",
|
|
8
|
+
poster: "/images/banners/video/lake-banner.jpg",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("BannerVideo", () => {
|
|
12
|
+
// ─── Mount ───────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
it("mounts without error", async () => {
|
|
15
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
16
|
+
expect(wrapper.vm).toBeTruthy();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// ─── Snapshots ───────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
it("renders correct HTML structure (default props)", async () => {
|
|
22
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
23
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("renders correct HTML structure (all props set)", async () => {
|
|
27
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
28
|
+
props: {
|
|
29
|
+
...defaultProps,
|
|
30
|
+
tag: "header",
|
|
31
|
+
alt: "Lake banner",
|
|
32
|
+
imgWidth: 1280,
|
|
33
|
+
imgHeight: 720,
|
|
34
|
+
depth: "xl",
|
|
35
|
+
aspectRatio: "16/9",
|
|
36
|
+
styleClassPassthrough: ["full-bleed"],
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ─── Root element ────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
it("renders a <section> as the root element by default", async () => {
|
|
45
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
46
|
+
expect(wrapper.element.tagName).toBe("SECTION");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders the specified tag as root", async () => {
|
|
50
|
+
for (const tag of ["div", "header", "main", "article"] as const) {
|
|
51
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
52
|
+
props: { ...defaultProps, tag },
|
|
53
|
+
});
|
|
54
|
+
expect(wrapper.element.tagName).toBe(tag.toUpperCase());
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("always has the banner-video class", async () => {
|
|
59
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
60
|
+
expect(wrapper.classes()).toContain("banner-video");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ─── Video element ───────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
it("renders a video element", async () => {
|
|
66
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
67
|
+
expect(wrapper.find("video").exists()).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("video has the video class", async () => {
|
|
71
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
72
|
+
expect(wrapper.find("video").classes()).toContain("video");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("video has autoplay attribute", async () => {
|
|
76
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
77
|
+
expect(wrapper.find("video").attributes()).toHaveProperty("autoplay");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("video has muted attribute", async () => {
|
|
81
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
82
|
+
expect(wrapper.find("video").attributes()).toHaveProperty("muted");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("video has loop attribute", async () => {
|
|
86
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
87
|
+
expect(wrapper.find("video").attributes()).toHaveProperty("loop");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("video has playsinline attribute", async () => {
|
|
91
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
92
|
+
expect(wrapper.find("video").attributes()).toHaveProperty("playsinline");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("video has the poster attribute set to the poster prop", async () => {
|
|
96
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
97
|
+
expect(wrapper.find("video").attributes("poster")).toBe(defaultProps.poster);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("video element has preload=auto", async () => {
|
|
101
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
102
|
+
expect(wrapper.find("video").attributes("preload")).toBe("auto");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("source element has the correct src", async () => {
|
|
106
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
107
|
+
expect(wrapper.find("source").attributes("src")).toBe(defaultProps.src);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("source element has type video/mp4", async () => {
|
|
111
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
112
|
+
expect(wrapper.find("source").attributes("type")).toBe("video/mp4");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("calls play() when loadeddata fires", async () => {
|
|
116
|
+
const play = vi.fn().mockResolvedValue(undefined);
|
|
117
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
118
|
+
(wrapper.find("video").element as HTMLVideoElement).play = play;
|
|
119
|
+
await wrapper.find("video").trigger("loadeddata");
|
|
120
|
+
expect(play).toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("calls play() when canplay fires", async () => {
|
|
124
|
+
const play = vi.fn().mockResolvedValue(undefined);
|
|
125
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
126
|
+
(wrapper.find("video").element as HTMLVideoElement).play = play;
|
|
127
|
+
await wrapper.find("video").trigger("canplay");
|
|
128
|
+
expect(play).toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ─── Fallback image ──────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
it("renders a NuxtImg fallback element", async () => {
|
|
134
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
135
|
+
expect(wrapper.find("img[data-nuxt-img]").exists()).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("fallback image has the fallback class", async () => {
|
|
139
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
140
|
+
expect(wrapper.find("img[data-nuxt-img]").classes()).toContain("fallback");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("fallback image src contains the poster filename", async () => {
|
|
144
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
145
|
+
const src = wrapper.find("img[data-nuxt-img]").attributes("src") ?? "";
|
|
146
|
+
expect(src).toContain("lake-banner.jpg");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("fallback image alt defaults to empty string", async () => {
|
|
150
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
151
|
+
expect(wrapper.find("img[data-nuxt-img]").attributes("alt")).toBe("");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("fallback image alt reflects the alt prop", async () => {
|
|
155
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
156
|
+
props: { ...defaultProps, alt: "A serene lake" },
|
|
157
|
+
});
|
|
158
|
+
expect(wrapper.find("img[data-nuxt-img]").attributes("alt")).toBe("A serene lake");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("fallback image width defaults to 1920", async () => {
|
|
162
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
163
|
+
expect(wrapper.find("img[data-nuxt-img]").attributes("width")).toBe("1920");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("fallback image height defaults to 1080", async () => {
|
|
167
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
168
|
+
expect(wrapper.find("img[data-nuxt-img]").attributes("height")).toBe("1080");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("fallback image uses custom imgWidth and imgHeight when provided", async () => {
|
|
172
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
173
|
+
props: { ...defaultProps, imgWidth: 1280, imgHeight: 720 },
|
|
174
|
+
});
|
|
175
|
+
const img = wrapper.find("img[data-nuxt-img]");
|
|
176
|
+
expect(img.attributes("width")).toBe("1280");
|
|
177
|
+
expect(img.attributes("height")).toBe("720");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("fallback image has loading=eager", async () => {
|
|
181
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
182
|
+
expect(wrapper.find("img[data-nuxt-img]").attributes("loading")).toBe("eager");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("fallback image has decoding=async", async () => {
|
|
186
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
187
|
+
expect(wrapper.find("img[data-nuxt-img]").attributes("decoding")).toBe("async");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ─── depth / data-depth ──────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
it("sets data-depth to md by default", async () => {
|
|
193
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
194
|
+
expect(wrapper.attributes("data-depth")).toBe("md");
|
|
195
|
+
});
|
|
196
|
+
|
|
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
|
+
);
|
|
206
|
+
|
|
207
|
+
// ─── CSS custom properties ───────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
it("sets --_aspect-ratio to 21/9 by default", async () => {
|
|
210
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
211
|
+
const style = (wrapper.element as HTMLElement).style;
|
|
212
|
+
expect(style.getPropertyValue("--_aspect-ratio")).toBe("21/9");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("reflects aspectRatio prop in --_aspect-ratio", async () => {
|
|
216
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
217
|
+
props: { ...defaultProps, aspectRatio: "16/9" },
|
|
218
|
+
});
|
|
219
|
+
const style = (wrapper.element as HTMLElement).style;
|
|
220
|
+
expect(style.getPropertyValue("--_aspect-ratio")).toBe("16/9");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("sets object-fit to cover on the video element by default", async () => {
|
|
224
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
225
|
+
const style = (wrapper.find("video").element as HTMLElement).style;
|
|
226
|
+
expect(style.objectFit).toBe("cover");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("reflects objectFit prop on the video element", async () => {
|
|
230
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
231
|
+
props: { ...defaultProps, objectFit: "contain" },
|
|
232
|
+
});
|
|
233
|
+
const style = (wrapper.find("video").element as HTMLElement).style;
|
|
234
|
+
expect(style.objectFit).toBe("contain");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("sets object-fit to cover on the fallback image by default", async () => {
|
|
238
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
239
|
+
const style = (wrapper.find("img[data-nuxt-img]").element as HTMLElement).style;
|
|
240
|
+
expect(style.objectFit).toBe("cover");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("reflects objectFit prop on the fallback image", async () => {
|
|
244
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
245
|
+
props: { ...defaultProps, objectFit: "contain" },
|
|
246
|
+
});
|
|
247
|
+
const style = (wrapper.find("img[data-nuxt-img]").element as HTMLElement).style;
|
|
248
|
+
expect(style.objectFit).toBe("contain");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("video element does not have object-position style", async () => {
|
|
252
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
253
|
+
const style = (wrapper.find("video").element as HTMLElement).style;
|
|
254
|
+
expect(style.objectPosition).toBe("");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("sets --_align-self to center by default", async () => {
|
|
258
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
259
|
+
const style = (wrapper.element as HTMLElement).style;
|
|
260
|
+
expect(style.getPropertyValue("--_align-self")).toBe("center");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("reflects verticalPosition prop in --_align-self", async () => {
|
|
264
|
+
for (const [prop, expected] of [["start", "start"], ["end", "end"]] as const) {
|
|
265
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
266
|
+
props: { ...defaultProps, verticalPosition: prop },
|
|
267
|
+
});
|
|
268
|
+
expect((wrapper.element as HTMLElement).style.getPropertyValue("--_align-self")).toBe(expected);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("sets --_justify-self to center by default", async () => {
|
|
273
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
274
|
+
const style = (wrapper.element as HTMLElement).style;
|
|
275
|
+
expect(style.getPropertyValue("--_justify-self")).toBe("center");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("reflects horizontalPosition prop in --_justify-self", async () => {
|
|
279
|
+
for (const [prop, expected] of [["start", "start"], ["end", "end"]] as const) {
|
|
280
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
281
|
+
props: { ...defaultProps, horizontalPosition: prop },
|
|
282
|
+
});
|
|
283
|
+
expect((wrapper.element as HTMLElement).style.getPropertyValue("--_justify-self")).toBe(expected);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("fallback image object-position defaults to center center", async () => {
|
|
288
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
289
|
+
const style = (wrapper.find("img[data-nuxt-img]").element as HTMLElement).style;
|
|
290
|
+
expect(style.objectPosition).toBe("center center");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("fallback image object-position reflects verticalPosition", async () => {
|
|
294
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
295
|
+
props: { ...defaultProps, verticalPosition: "start" },
|
|
296
|
+
});
|
|
297
|
+
const style = (wrapper.find("img[data-nuxt-img]").element as HTMLElement).style;
|
|
298
|
+
expect(style.objectPosition).toBe("center top");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("fallback image object-position reflects horizontalPosition", async () => {
|
|
302
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
303
|
+
props: { ...defaultProps, horizontalPosition: "end" },
|
|
304
|
+
});
|
|
305
|
+
const style = (wrapper.find("img[data-nuxt-img]").element as HTMLElement).style;
|
|
306
|
+
expect(style.objectPosition).toBe("right center");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("fallback image object-position reflects both verticalPosition and horizontalPosition", async () => {
|
|
310
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
311
|
+
props: { ...defaultProps, verticalPosition: "end", horizontalPosition: "start" },
|
|
312
|
+
});
|
|
313
|
+
const style = (wrapper.find("img[data-nuxt-img]").element as HTMLElement).style;
|
|
314
|
+
expect(style.objectPosition).toBe("left bottom");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ─── styleClassPassthrough ───────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
it("applies a single styleClassPassthrough string to the root", async () => {
|
|
320
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
321
|
+
props: { ...defaultProps, styleClassPassthrough: "full-bleed" },
|
|
322
|
+
});
|
|
323
|
+
expect(wrapper.classes()).toContain("full-bleed");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("applies multiple styleClassPassthrough classes from an array", async () => {
|
|
327
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
328
|
+
props: { ...defaultProps, styleClassPassthrough: ["full-bleed", "mbe-0"] },
|
|
329
|
+
});
|
|
330
|
+
expect(wrapper.classes()).toContain("full-bleed");
|
|
331
|
+
expect(wrapper.classes()).toContain("mbe-0");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("does not apply styleClassPassthrough to the video element", async () => {
|
|
335
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
336
|
+
props: { ...defaultProps, styleClassPassthrough: "full-bleed" },
|
|
337
|
+
});
|
|
338
|
+
expect(wrapper.find("video").classes()).not.toContain("full-bleed");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("updates classes when styleClassPassthrough prop changes", async () => {
|
|
342
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
343
|
+
props: { ...defaultProps, styleClassPassthrough: ["original"] },
|
|
344
|
+
});
|
|
345
|
+
expect(wrapper.classes()).toContain("original");
|
|
346
|
+
await wrapper.setProps({ styleClassPassthrough: ["updated"] });
|
|
347
|
+
expect(wrapper.classes()).not.toContain("original");
|
|
348
|
+
expect(wrapper.classes()).toContain("updated");
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`BannerVideo > renders correct HTML structure (all props set) 1`] = `
|
|
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
|
+
<source src="/images/banners/video/lake-banner.mp4" type="video/mp4">
|
|
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
|
+
`;
|
|
8
|
+
|
|
9
|
+
exports[`BannerVideo > renders correct HTML structure (default props) 1`] = `
|
|
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
|
+
<source src="/images/banners/video/lake-banner.mp4" type="video/mp4">
|
|
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
|
+
`;
|
|
@@ -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
|
+
});
|