srcdev-nuxt-components 9.1.28 → 9.1.29
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 +186 -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 +7 -1
- 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 +15 -5
- package/app/components/01.atoms/banner-video/BannerVideo.vue +201 -0
- package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +191 -0
- package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +374 -0
- package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +13 -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 +296 -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,374 @@
|
|
|
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
|
+
height: "40rem",
|
|
35
|
+
styleClassPassthrough: ["full-bleed"],
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ─── Root element ────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
it("renders a <section> as the root element by default", async () => {
|
|
44
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
45
|
+
expect(wrapper.element.tagName).toBe("SECTION");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders the specified tag as root", async () => {
|
|
49
|
+
for (const tag of ["div", "header", "main", "article"] as const) {
|
|
50
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
51
|
+
props: { ...defaultProps, tag },
|
|
52
|
+
});
|
|
53
|
+
expect(wrapper.element.tagName).toBe(tag.toUpperCase());
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("always has the banner-video class", async () => {
|
|
58
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
59
|
+
expect(wrapper.classes()).toContain("banner-video");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ─── Video element ───────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
it("renders a video element", async () => {
|
|
65
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
66
|
+
expect(wrapper.find("video").exists()).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("video has the video class", async () => {
|
|
70
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
71
|
+
expect(wrapper.find("video").classes()).toContain("video");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("video has autoplay attribute", async () => {
|
|
75
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
76
|
+
expect(wrapper.find("video").attributes()).toHaveProperty("autoplay");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("video has muted attribute", async () => {
|
|
80
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
81
|
+
expect(wrapper.find("video").attributes()).toHaveProperty("muted");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("video has loop attribute", async () => {
|
|
85
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
86
|
+
expect(wrapper.find("video").attributes()).toHaveProperty("loop");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("video has playsinline attribute", async () => {
|
|
90
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
91
|
+
expect(wrapper.find("video").attributes()).toHaveProperty("playsinline");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("video has the poster attribute set to the poster prop", async () => {
|
|
95
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
96
|
+
expect(wrapper.find("video").attributes("poster")).toBe(defaultProps.poster);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("video element has preload=auto", async () => {
|
|
100
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
101
|
+
expect(wrapper.find("video").attributes("preload")).toBe("auto");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("source element has the correct src", async () => {
|
|
105
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
106
|
+
expect(wrapper.find("source").attributes("src")).toBe(defaultProps.src);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("source element has type video/mp4", async () => {
|
|
110
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
111
|
+
expect(wrapper.find("source").attributes("type")).toBe("video/mp4");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("calls play() when loadeddata fires", async () => {
|
|
115
|
+
const play = vi.fn().mockResolvedValue(undefined);
|
|
116
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
117
|
+
(wrapper.find("video").element as HTMLVideoElement).play = play;
|
|
118
|
+
await wrapper.find("video").trigger("loadeddata");
|
|
119
|
+
expect(play).toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("calls play() when canplay fires", async () => {
|
|
123
|
+
const play = vi.fn().mockResolvedValue(undefined);
|
|
124
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
125
|
+
(wrapper.find("video").element as HTMLVideoElement).play = play;
|
|
126
|
+
await wrapper.find("video").trigger("canplay");
|
|
127
|
+
expect(play).toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ─── Fallback image ──────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
it("renders a NuxtImg fallback element", async () => {
|
|
133
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
134
|
+
expect(wrapper.find("img[data-nuxt-img]").exists()).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("fallback image has the fallback class", async () => {
|
|
138
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
139
|
+
expect(wrapper.find("img[data-nuxt-img]").classes()).toContain("fallback");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("fallback image src contains the poster filename", async () => {
|
|
143
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
144
|
+
const src = wrapper.find("img[data-nuxt-img]").attributes("src") ?? "";
|
|
145
|
+
expect(src).toContain("lake-banner.jpg");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("fallback image alt defaults to empty string", async () => {
|
|
149
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
150
|
+
expect(wrapper.find("img[data-nuxt-img]").attributes("alt")).toBe("");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("fallback image alt reflects the alt prop", async () => {
|
|
154
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
155
|
+
props: { ...defaultProps, alt: "A serene lake" },
|
|
156
|
+
});
|
|
157
|
+
expect(wrapper.find("img[data-nuxt-img]").attributes("alt")).toBe("A serene lake");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("fallback image width defaults to 1920", async () => {
|
|
161
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
162
|
+
expect(wrapper.find("img[data-nuxt-img]").attributes("width")).toBe("1920");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("fallback image height defaults to 1080", async () => {
|
|
166
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
167
|
+
expect(wrapper.find("img[data-nuxt-img]").attributes("height")).toBe("1080");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("fallback image uses custom imgWidth and imgHeight when provided", async () => {
|
|
171
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
172
|
+
props: { ...defaultProps, imgWidth: 1280, imgHeight: 720 },
|
|
173
|
+
});
|
|
174
|
+
const img = wrapper.find("img[data-nuxt-img]");
|
|
175
|
+
expect(img.attributes("width")).toBe("1280");
|
|
176
|
+
expect(img.attributes("height")).toBe("720");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("fallback image has loading=eager", async () => {
|
|
180
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
181
|
+
expect(wrapper.find("img[data-nuxt-img]").attributes("loading")).toBe("eager");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("fallback image has decoding=async", async () => {
|
|
185
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
186
|
+
expect(wrapper.find("img[data-nuxt-img]").attributes("decoding")).toBe("async");
|
|
187
|
+
});
|
|
188
|
+
|
|
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
|
+
});
|
|
204
|
+
|
|
205
|
+
it("does not set --_max-height-tablet when maxHeightTablet is not provided", async () => {
|
|
206
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
207
|
+
const style = (wrapper.element as HTMLElement).style;
|
|
208
|
+
expect(style.getPropertyValue("--_max-height-tablet")).toBe("");
|
|
209
|
+
});
|
|
210
|
+
|
|
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
|
+
});
|
|
224
|
+
|
|
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
|
+
});
|
|
232
|
+
|
|
233
|
+
it("sets --_aspect-ratio to 21/9 by default", async () => {
|
|
234
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
235
|
+
const style = (wrapper.element as HTMLElement).style;
|
|
236
|
+
expect(style.getPropertyValue("--_aspect-ratio")).toBe("21/9");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("reflects aspectRatio prop in --_aspect-ratio", async () => {
|
|
240
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
241
|
+
props: { ...defaultProps, aspectRatio: "16/9" },
|
|
242
|
+
});
|
|
243
|
+
const style = (wrapper.element as HTMLElement).style;
|
|
244
|
+
expect(style.getPropertyValue("--_aspect-ratio")).toBe("16/9");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("sets object-fit to cover on the video element by default", async () => {
|
|
248
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
249
|
+
const style = (wrapper.find("video").element as HTMLElement).style;
|
|
250
|
+
expect(style.objectFit).toBe("cover");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("reflects objectFit prop on the video element", async () => {
|
|
254
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
255
|
+
props: { ...defaultProps, objectFit: "contain" },
|
|
256
|
+
});
|
|
257
|
+
const style = (wrapper.find("video").element as HTMLElement).style;
|
|
258
|
+
expect(style.objectFit).toBe("contain");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("sets object-fit to cover on the fallback image by default", async () => {
|
|
262
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
263
|
+
const style = (wrapper.find("img[data-nuxt-img]").element as HTMLElement).style;
|
|
264
|
+
expect(style.objectFit).toBe("cover");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("reflects objectFit prop on the fallback image", async () => {
|
|
268
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
269
|
+
props: { ...defaultProps, objectFit: "contain" },
|
|
270
|
+
});
|
|
271
|
+
const style = (wrapper.find("img[data-nuxt-img]").element as HTMLElement).style;
|
|
272
|
+
expect(style.objectFit).toBe("contain");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("video element does not have object-position style", async () => {
|
|
276
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
277
|
+
const style = (wrapper.find("video").element as HTMLElement).style;
|
|
278
|
+
expect(style.objectPosition).toBe("");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("sets --_align-self to center by default", async () => {
|
|
282
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
283
|
+
const style = (wrapper.element as HTMLElement).style;
|
|
284
|
+
expect(style.getPropertyValue("--_align-self")).toBe("center");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("reflects verticalPosition prop in --_align-self", async () => {
|
|
288
|
+
for (const [prop, expected] of [["start", "start"], ["end", "end"]] as const) {
|
|
289
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
290
|
+
props: { ...defaultProps, verticalPosition: prop },
|
|
291
|
+
});
|
|
292
|
+
expect((wrapper.element as HTMLElement).style.getPropertyValue("--_align-self")).toBe(expected);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("sets --_justify-self to center by default", async () => {
|
|
297
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
298
|
+
const style = (wrapper.element as HTMLElement).style;
|
|
299
|
+
expect(style.getPropertyValue("--_justify-self")).toBe("center");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("reflects horizontalPosition prop in --_justify-self", async () => {
|
|
303
|
+
for (const [prop, expected] of [["start", "start"], ["end", "end"]] as const) {
|
|
304
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
305
|
+
props: { ...defaultProps, horizontalPosition: prop },
|
|
306
|
+
});
|
|
307
|
+
expect((wrapper.element as HTMLElement).style.getPropertyValue("--_justify-self")).toBe(expected);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("fallback image object-position defaults to center center", async () => {
|
|
312
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
313
|
+
const style = (wrapper.find("img[data-nuxt-img]").element as HTMLElement).style;
|
|
314
|
+
expect(style.objectPosition).toBe("center center");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("fallback image object-position reflects verticalPosition", async () => {
|
|
318
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
319
|
+
props: { ...defaultProps, verticalPosition: "start" },
|
|
320
|
+
});
|
|
321
|
+
const style = (wrapper.find("img[data-nuxt-img]").element as HTMLElement).style;
|
|
322
|
+
expect(style.objectPosition).toBe("center top");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("fallback image object-position reflects horizontalPosition", async () => {
|
|
326
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
327
|
+
props: { ...defaultProps, horizontalPosition: "end" },
|
|
328
|
+
});
|
|
329
|
+
const style = (wrapper.find("img[data-nuxt-img]").element as HTMLElement).style;
|
|
330
|
+
expect(style.objectPosition).toBe("right center");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("fallback image object-position reflects both verticalPosition and horizontalPosition", async () => {
|
|
334
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
335
|
+
props: { ...defaultProps, verticalPosition: "end", horizontalPosition: "start" },
|
|
336
|
+
});
|
|
337
|
+
const style = (wrapper.find("img[data-nuxt-img]").element as HTMLElement).style;
|
|
338
|
+
expect(style.objectPosition).toBe("left bottom");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ─── styleClassPassthrough ───────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
it("applies a single styleClassPassthrough string to the root", async () => {
|
|
344
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
345
|
+
props: { ...defaultProps, styleClassPassthrough: "full-bleed" },
|
|
346
|
+
});
|
|
347
|
+
expect(wrapper.classes()).toContain("full-bleed");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("applies multiple styleClassPassthrough classes from an array", async () => {
|
|
351
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
352
|
+
props: { ...defaultProps, styleClassPassthrough: ["full-bleed", "mbe-0"] },
|
|
353
|
+
});
|
|
354
|
+
expect(wrapper.classes()).toContain("full-bleed");
|
|
355
|
+
expect(wrapper.classes()).toContain("mbe-0");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("does not apply styleClassPassthrough to the video element", async () => {
|
|
359
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
360
|
+
props: { ...defaultProps, styleClassPassthrough: "full-bleed" },
|
|
361
|
+
});
|
|
362
|
+
expect(wrapper.find("video").classes()).not.toContain("full-bleed");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("updates classes when styleClassPassthrough prop changes", async () => {
|
|
366
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
367
|
+
props: { ...defaultProps, styleClassPassthrough: ["original"] },
|
|
368
|
+
});
|
|
369
|
+
expect(wrapper.classes()).toContain("original");
|
|
370
|
+
await wrapper.setProps({ styleClassPassthrough: ["updated"] });
|
|
371
|
+
expect(wrapper.classes()).not.toContain("original");
|
|
372
|
+
expect(wrapper.classes()).toContain("updated");
|
|
373
|
+
});
|
|
374
|
+
});
|
|
@@ -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" 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;">
|
|
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" 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;">
|
|
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,125 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<figure
|
|
3
|
+
class="reveal-frame"
|
|
4
|
+
:class="[elementClasses]"
|
|
5
|
+
:style="{
|
|
6
|
+
'--_frame-height': frameHeight,
|
|
7
|
+
'--_parallax-offset': parallaxOffset,
|
|
8
|
+
'--_radius': radius,
|
|
9
|
+
}"
|
|
10
|
+
>
|
|
11
|
+
<div class="reveal-content">
|
|
12
|
+
<slot></slot>
|
|
13
|
+
</div>
|
|
14
|
+
</figure>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<script setup lang="ts">
|
|
18
|
+
/**
|
|
19
|
+
* ScrollRevealFrame
|
|
20
|
+
*
|
|
21
|
+
* Generic clipping frame that pans its slot content vertically as it scrolls
|
|
22
|
+
* through the viewport — driven entirely by CSS Scroll-driven Animations.
|
|
23
|
+
* No scroll listeners, no JS animation.
|
|
24
|
+
*
|
|
25
|
+
* How it works:
|
|
26
|
+
* - The <figure> is a fixed-height clipping window (overflow: hidden).
|
|
27
|
+
* - It registers a named view-timeline scoped to itself.
|
|
28
|
+
* - The inner .reveal-content wrapper is taller than the frame by
|
|
29
|
+
* `parallaxOffset`, and animates translateY as the frame scrolls.
|
|
30
|
+
* - Any slot content (single image, grid, video, etc.) fills that wrapper.
|
|
31
|
+
*
|
|
32
|
+
* Browser support (as of 2026):
|
|
33
|
+
* Chrome 115+, Edge 115+, Firefox 114+, Safari 17.2+
|
|
34
|
+
* Falls back gracefully to a static cropped view in older browsers.
|
|
35
|
+
*
|
|
36
|
+
* For single-image use with NuxtImg optimisation and focal-point control,
|
|
37
|
+
* use ScrollRevealImage instead — it wraps this component.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
interface Props {
|
|
41
|
+
/** Height of the visible clipping frame. */
|
|
42
|
+
frameHeight?: string;
|
|
43
|
+
/**
|
|
44
|
+
* How far the content travels vertically as the frame scrolls through the
|
|
45
|
+
* viewport. Larger = more content revealed = stronger parallax feel.
|
|
46
|
+
*/
|
|
47
|
+
parallaxOffset?: string;
|
|
48
|
+
/** Optional rounded corners on the frame. */
|
|
49
|
+
radius?: string;
|
|
50
|
+
styleClassPassthrough?: string | string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
54
|
+
frameHeight: "540px",
|
|
55
|
+
parallaxOffset: "36rem",
|
|
56
|
+
radius: "0px",
|
|
57
|
+
styleClassPassthrough: () => [],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
61
|
+
|
|
62
|
+
watch(
|
|
63
|
+
() => props.styleClassPassthrough,
|
|
64
|
+
() => resetElementClasses(props.styleClassPassthrough)
|
|
65
|
+
);
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<style lang="css">
|
|
69
|
+
@layer components {
|
|
70
|
+
.reveal-frame {
|
|
71
|
+
/* Public tokens (overridable by consumer) */
|
|
72
|
+
--_frame-height: 540px;
|
|
73
|
+
--_parallax-offset: 36rem;
|
|
74
|
+
--_radius: 0px;
|
|
75
|
+
|
|
76
|
+
position: relative;
|
|
77
|
+
height: var(--_frame-height);
|
|
78
|
+
width: 100%;
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
border-radius: var(--_radius);
|
|
81
|
+
|
|
82
|
+
/*
|
|
83
|
+
* Named view-timeline lets the child content reference this element's
|
|
84
|
+
* scroll progress rather than its own (which would be distorted by the
|
|
85
|
+
* artificially inflated height).
|
|
86
|
+
*/
|
|
87
|
+
view-timeline: --reveal-frame-timeline block;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.reveal-content {
|
|
91
|
+
display: block;
|
|
92
|
+
width: 100%;
|
|
93
|
+
/*
|
|
94
|
+
* Content is taller than the frame by parallaxOffset so there is always
|
|
95
|
+
* content to travel into as the animation progresses.
|
|
96
|
+
*/
|
|
97
|
+
height: calc(100% + var(--_parallax-offset));
|
|
98
|
+
|
|
99
|
+
animation: reveal-pan linear both;
|
|
100
|
+
animation-timeline: --reveal-frame-timeline;
|
|
101
|
+
animation-range: entry 0% exit 100%;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@keyframes reveal-pan {
|
|
105
|
+
from { transform: translateY(0); }
|
|
106
|
+
to { transform: translateY(calc(-1 * var(--_parallax-offset))); }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ── Fallback: browsers without Scroll-driven Animations ── */
|
|
110
|
+
@supports not (animation-timeline: scroll()) {
|
|
111
|
+
.reveal-content {
|
|
112
|
+
animation: none;
|
|
113
|
+
height: 100%;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ── Reduced-motion: static view, no travel ── */
|
|
118
|
+
@media (prefers-reduced-motion: reduce) {
|
|
119
|
+
.reveal-content {
|
|
120
|
+
animation: none;
|
|
121
|
+
height: 100%;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
</style>
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import ScrollRevealFrame from "../ScrollRevealFrame.vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ScrollRevealFrame> = {
|
|
5
|
+
title: "Atoms/Effects/ScrollRevealFrame",
|
|
6
|
+
component: ScrollRevealFrame,
|
|
7
|
+
argTypes: {
|
|
8
|
+
frameHeight: {
|
|
9
|
+
control: "text",
|
|
10
|
+
description: "Height of the visible clipping frame",
|
|
11
|
+
table: { category: "Layout" },
|
|
12
|
+
},
|
|
13
|
+
parallaxOffset: {
|
|
14
|
+
control: "text",
|
|
15
|
+
description: "Distance the content travels vertically as the frame scrolls through the viewport",
|
|
16
|
+
table: { category: "Layout" },
|
|
17
|
+
},
|
|
18
|
+
radius: {
|
|
19
|
+
control: "text",
|
|
20
|
+
description: "Border-radius applied to the clipping frame",
|
|
21
|
+
table: { category: "Appearance" },
|
|
22
|
+
},
|
|
23
|
+
styleClassPassthrough: {
|
|
24
|
+
table: { disable: true },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
parameters: {
|
|
28
|
+
docs: {
|
|
29
|
+
description: {
|
|
30
|
+
component:
|
|
31
|
+
"Generic clipping frame that pans any slot content vertically as it scrolls through the viewport — driven entirely by CSS Scroll-driven Animations. No scroll listeners, no JS. Use this when you need the parallax reveal with arbitrary content (grids, video, etc.). For a single NuxtImg with optimisation and focal-point control, use ScrollRevealImage instead.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default meta;
|
|
38
|
+
type Story = StoryObj<typeof ScrollRevealFrame>;
|
|
39
|
+
|
|
40
|
+
const scrollWrapper = (inner: string) => `
|
|
41
|
+
<div style="padding-block: 60vh; max-width: 860px; margin-inline: auto;">
|
|
42
|
+
<p style="text-align: center; font-size: 1.4rem; opacity: 0.5; margin-block-end: 4rem;">Scroll to see the parallax effect</p>
|
|
43
|
+
${inner}
|
|
44
|
+
</div>
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
export const Default: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
frameHeight: "540px",
|
|
50
|
+
parallaxOffset: "36rem",
|
|
51
|
+
radius: "0px",
|
|
52
|
+
},
|
|
53
|
+
render: (args) => ({
|
|
54
|
+
components: { ScrollRevealFrame },
|
|
55
|
+
setup() {
|
|
56
|
+
return { args };
|
|
57
|
+
},
|
|
58
|
+
template: scrollWrapper(`
|
|
59
|
+
<ScrollRevealFrame v-bind="args">
|
|
60
|
+
<img
|
|
61
|
+
src="/images/page/hero/hero-blonde.jpg"
|
|
62
|
+
alt="Blonde hair portrait"
|
|
63
|
+
style="display: block; width: 100%; height: 100%; object-fit: cover;"
|
|
64
|
+
/>
|
|
65
|
+
</ScrollRevealFrame>
|
|
66
|
+
`),
|
|
67
|
+
}),
|
|
68
|
+
parameters: {
|
|
69
|
+
docs: {
|
|
70
|
+
description: {
|
|
71
|
+
story: "Default props — a single image in the slot panning from top to bottom as the frame crosses the viewport.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const ImageGrid: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
frameHeight: "480px",
|
|
80
|
+
parallaxOffset: "36rem",
|
|
81
|
+
radius: "0px",
|
|
82
|
+
},
|
|
83
|
+
render: (args) => ({
|
|
84
|
+
components: { ScrollRevealFrame },
|
|
85
|
+
setup() {
|
|
86
|
+
return { args };
|
|
87
|
+
},
|
|
88
|
+
template: scrollWrapper(`
|
|
89
|
+
<ScrollRevealFrame v-bind="args">
|
|
90
|
+
<div style="display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(2, 1fr); gap: 4px; height: 100%;">
|
|
91
|
+
<div style="overflow: hidden; min-height: 0;">
|
|
92
|
+
<img src="/images/services/service-balayage.jpg" alt="Balayage" style="width: 100%; height: 100%; object-fit: cover; display: block;" />
|
|
93
|
+
</div>
|
|
94
|
+
<div style="overflow: hidden; min-height: 0;">
|
|
95
|
+
<img src="/images/services/service-colour.jpg" alt="Colour" style="width: 100%; height: 100%; object-fit: cover; display: block;" />
|
|
96
|
+
</div>
|
|
97
|
+
<div style="overflow: hidden; min-height: 0;">
|
|
98
|
+
<img src="/images/services/service-cut.jpg" alt="Cut" style="width: 100%; height: 100%; object-fit: cover; display: block;" />
|
|
99
|
+
</div>
|
|
100
|
+
<div style="overflow: hidden; min-height: 0;">
|
|
101
|
+
<img src="/images/services/service-highlights.jpg" alt="Highlights" style="width: 100%; height: 100%; object-fit: cover; display: block;" />
|
|
102
|
+
</div>
|
|
103
|
+
<div style="overflow: hidden; min-height: 0;">
|
|
104
|
+
<img src="/images/services/service-lowlights.jpg" alt="Lowlights" style="width: 100%; height: 100%; object-fit: cover; display: block;" />
|
|
105
|
+
</div>
|
|
106
|
+
<div style="overflow: hidden; min-height: 0;">
|
|
107
|
+
<img src="/images/services/service-toner.jpg" alt="Toner" style="width: 100%; height: 100%; object-fit: cover; display: block;" />
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</ScrollRevealFrame>
|
|
111
|
+
`),
|
|
112
|
+
}),
|
|
113
|
+
parameters: {
|
|
114
|
+
docs: {
|
|
115
|
+
description: {
|
|
116
|
+
story: "A six-image CSS grid in the slot — all six images pan together as a single unit. This is the primary use case for ScrollRevealFrame over ScrollRevealImage.",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const RoundedCorners: Story = {
|
|
123
|
+
args: {
|
|
124
|
+
frameHeight: "480px",
|
|
125
|
+
parallaxOffset: "36rem",
|
|
126
|
+
radius: "2.4rem",
|
|
127
|
+
},
|
|
128
|
+
render: Default.render,
|
|
129
|
+
parameters: {
|
|
130
|
+
docs: {
|
|
131
|
+
description: {
|
|
132
|
+
story: "The radius prop applies border-radius to the clipping frame. The reveal stays contained within the rounded shape.",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const ShortFrame: Story = {
|
|
139
|
+
args: {
|
|
140
|
+
frameHeight: "320px",
|
|
141
|
+
parallaxOffset: "24rem",
|
|
142
|
+
radius: "0px",
|
|
143
|
+
},
|
|
144
|
+
render: (args) => ({
|
|
145
|
+
components: { ScrollRevealFrame },
|
|
146
|
+
setup() {
|
|
147
|
+
return { args };
|
|
148
|
+
},
|
|
149
|
+
template: scrollWrapper(`
|
|
150
|
+
<ScrollRevealFrame v-bind="args">
|
|
151
|
+
<img
|
|
152
|
+
src="/images/banners/banner-mid-brown.webp"
|
|
153
|
+
alt="Mid brown hair banner"
|
|
154
|
+
style="display: block; width: 100%; height: 100%; object-fit: cover;"
|
|
155
|
+
/>
|
|
156
|
+
</ScrollRevealFrame>
|
|
157
|
+
`),
|
|
158
|
+
}),
|
|
159
|
+
parameters: {
|
|
160
|
+
docs: {
|
|
161
|
+
description: {
|
|
162
|
+
story: "A shallow banner frame. parallaxOffset is reduced proportionally to match the shorter frame height.",
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
};
|