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.
Files changed (75) hide show
  1. package/.claude/settings.json +7 -2
  2. package/.claude/settings.local.json +2 -1
  3. package/.claude/skills/components/banner-video.md +186 -0
  4. package/.claude/skills/components/scroll-reveal-frame.md +136 -0
  5. package/.claude/skills/components/scroll-reveal-image.md +161 -0
  6. package/.claude/skills/index.md +7 -1
  7. package/.claude/skills/setup-postinstall.md +45 -0
  8. package/.claude/skills/theming-override-default.md +6 -0
  9. package/.claude/skills/theming-partial-override.md +131 -0
  10. package/.claude/skills/vue-video-autoplay.md +74 -0
  11. package/README.md +15 -5
  12. package/app/components/01.atoms/banner-video/BannerVideo.vue +201 -0
  13. package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +191 -0
  14. package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +374 -0
  15. package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +13 -0
  16. package/app/components/01.atoms/scroll-reveal-frame/ScrollRevealFrame.vue +125 -0
  17. package/app/components/01.atoms/scroll-reveal-frame/stories/ScrollRevealFrame.stories.ts +166 -0
  18. package/app/components/01.atoms/scroll-reveal-frame/tests/ScrollRevealFrame.spec.ts +148 -0
  19. package/app/components/01.atoms/scroll-reveal-frame/tests/__snapshots__/ScrollRevealFrame.spec.ts.snap +13 -0
  20. package/app/components/01.atoms/scroll-reveal-image/ScrollRevealImage.vue +96 -0
  21. package/app/components/01.atoms/scroll-reveal-image/stories/ScrollRevealImage.stories.ts +181 -0
  22. package/app/components/01.atoms/scroll-reveal-image/tests/ScrollRevealImage.spec.ts +235 -0
  23. package/app/components/01.atoms/scroll-reveal-image/tests/__snapshots__/ScrollRevealImage.spec.ts.snap +13 -0
  24. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +1 -0
  25. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +1 -0
  26. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +1 -0
  27. package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +1 -0
  28. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -0
  29. package/app/components/03.organisms/services/services-grids/tests/ServicesSectionGrid.spec.ts +1 -0
  30. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +3 -3
  31. package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts +1 -0
  32. package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +1 -0
  33. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +1 -1
  34. package/app/layouts/default.vue +2 -0
  35. package/app/pages/banner-video.vue +296 -0
  36. package/app/pages/ui/scroll-reveal-image.vue +483 -0
  37. package/app/types/types.services.ts +1 -0
  38. package/package.json +1 -1
  39. package/app/assets/styles/extends-layer/srcdev-components/components/_display-prompt-core.css +0 -94
  40. package/app/assets/styles/extends-layer/srcdev-components/components/_display-toast.css +0 -5
  41. package/app/assets/styles/extends-layer/srcdev-components/components/_expanding-panel.css +0 -37
  42. package/app/assets/styles/extends-layer/srcdev-components/components/index.css +0 -3
  43. package/app/assets/styles/extends-layer/srcdev-components/index.css +0 -1
  44. package/app/assets/styles/extends-layer/srcdev-forms/components/_form-fieldset.css +0 -38
  45. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-button.css +0 -67
  46. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-checkbox-radio-core.css +0 -87
  47. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-checkbox-radio-options-button.css +0 -74
  48. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-checkbox-radio-with-label.css +0 -14
  49. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-description.css +0 -13
  50. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-error.css +0 -54
  51. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-label.css +0 -21
  52. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-select.css +0 -143
  53. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-text.css +0 -190
  54. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-textarea.css +0 -96
  55. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-toggle-switch.css +0 -68
  56. package/app/assets/styles/extends-layer/srcdev-forms/components/index.css +0 -12
  57. package/app/assets/styles/extends-layer/srcdev-forms/index.css +0 -2
  58. package/app/assets/styles/extends-layer/srcdev-forms/setup/_generic.css +0 -21
  59. package/app/assets/styles/extends-layer/srcdev-forms/setup/index.css +0 -3
  60. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_default.css +0 -13
  61. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_large.css +0 -12
  62. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_medium.css +0 -12
  63. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_small.css +0 -12
  64. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_x-small.css +0 -11
  65. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/index.css +0 -23
  66. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_default.css +0 -62
  67. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_error.css +0 -63
  68. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_ghost.css +0 -63
  69. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_info.css +0 -63
  70. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_primary.css +0 -66
  71. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_secondary.css +0 -64
  72. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_success.css +0 -63
  73. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_tertiary.css +0 -63
  74. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_warning.css +0 -63
  75. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/index.css +0 -7
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import ScrollRevealFrame from "../ScrollRevealFrame.vue";
4
+
5
+ describe("ScrollRevealFrame", () => {
6
+ // ─── Mount ───────────────────────────────────────────────────────────────
7
+
8
+ it("mounts without error", async () => {
9
+ const wrapper = await mountSuspended(ScrollRevealFrame);
10
+ expect(wrapper.vm).toBeTruthy();
11
+ });
12
+
13
+ // ─── Snapshots ───────────────────────────────────────────────────────────
14
+
15
+ it("renders correct HTML structure (default props)", async () => {
16
+ const wrapper = await mountSuspended(ScrollRevealFrame);
17
+ expect(wrapper.html()).toMatchSnapshot();
18
+ });
19
+
20
+ it("renders correct HTML structure (all props set)", async () => {
21
+ const wrapper = await mountSuspended(ScrollRevealFrame, {
22
+ props: {
23
+ frameHeight: "400px",
24
+ parallaxOffset: "20rem",
25
+ radius: "1.6rem",
26
+ styleClassPassthrough: ["custom-class"],
27
+ },
28
+ });
29
+ expect(wrapper.html()).toMatchSnapshot();
30
+ });
31
+
32
+ // ─── Root element ────────────────────────────────────────────────────────
33
+
34
+ it("renders a <figure> as the root element", async () => {
35
+ const wrapper = await mountSuspended(ScrollRevealFrame);
36
+ expect(wrapper.element.tagName).toBe("FIGURE");
37
+ });
38
+
39
+ it("always has the reveal-frame class", async () => {
40
+ const wrapper = await mountSuspended(ScrollRevealFrame);
41
+ expect(wrapper.classes()).toContain("reveal-frame");
42
+ });
43
+
44
+ // ─── Inner content wrapper ───────────────────────────────────────────────
45
+
46
+ it("renders a .reveal-content child element", async () => {
47
+ const wrapper = await mountSuspended(ScrollRevealFrame);
48
+ expect(wrapper.find(".reveal-content").exists()).toBe(true);
49
+ });
50
+
51
+ // ─── Slot ────────────────────────────────────────────────────────────────
52
+
53
+ it("renders slot content inside .reveal-content", async () => {
54
+ const wrapper = await mountSuspended(ScrollRevealFrame, {
55
+ slots: { default: "<p class='slot-child'>Hello</p>" },
56
+ });
57
+ const content = wrapper.find(".reveal-content");
58
+ expect(content.find(".slot-child").exists()).toBe(true);
59
+ expect(content.find(".slot-child").text()).toBe("Hello");
60
+ });
61
+
62
+ it("renders multiple slot children inside .reveal-content", async () => {
63
+ const wrapper = await mountSuspended(ScrollRevealFrame, {
64
+ slots: { default: "<div class='child-a'></div><div class='child-b'></div>" },
65
+ });
66
+ const content = wrapper.find(".reveal-content");
67
+ expect(content.find(".child-a").exists()).toBe(true);
68
+ expect(content.find(".child-b").exists()).toBe(true);
69
+ });
70
+
71
+ // ─── CSS custom properties ───────────────────────────────────────────────
72
+
73
+ it("sets --_frame-height to 540px by default", async () => {
74
+ const wrapper = await mountSuspended(ScrollRevealFrame);
75
+ const style = (wrapper.element as HTMLElement).style;
76
+ expect(style.getPropertyValue("--_frame-height")).toBe("540px");
77
+ });
78
+
79
+ it("sets --_parallax-offset to 36rem by default", async () => {
80
+ const wrapper = await mountSuspended(ScrollRevealFrame);
81
+ const style = (wrapper.element as HTMLElement).style;
82
+ expect(style.getPropertyValue("--_parallax-offset")).toBe("36rem");
83
+ });
84
+
85
+ it("sets --_radius to 0px by default", async () => {
86
+ const wrapper = await mountSuspended(ScrollRevealFrame);
87
+ const style = (wrapper.element as HTMLElement).style;
88
+ expect(style.getPropertyValue("--_radius")).toBe("0px");
89
+ });
90
+
91
+ it("reflects frameHeight prop in --_frame-height", async () => {
92
+ const wrapper = await mountSuspended(ScrollRevealFrame, {
93
+ props: { frameHeight: "80vh" },
94
+ });
95
+ const style = (wrapper.element as HTMLElement).style;
96
+ expect(style.getPropertyValue("--_frame-height")).toBe("80vh");
97
+ });
98
+
99
+ it("reflects parallaxOffset prop in --_parallax-offset", async () => {
100
+ const wrapper = await mountSuspended(ScrollRevealFrame, {
101
+ props: { parallaxOffset: "48rem" },
102
+ });
103
+ const style = (wrapper.element as HTMLElement).style;
104
+ expect(style.getPropertyValue("--_parallax-offset")).toBe("48rem");
105
+ });
106
+
107
+ it("reflects radius prop in --_radius", async () => {
108
+ const wrapper = await mountSuspended(ScrollRevealFrame, {
109
+ props: { radius: "2.4rem" },
110
+ });
111
+ const style = (wrapper.element as HTMLElement).style;
112
+ expect(style.getPropertyValue("--_radius")).toBe("2.4rem");
113
+ });
114
+
115
+ // ─── styleClassPassthrough ───────────────────────────────────────────────
116
+
117
+ it("applies a single styleClassPassthrough string to the root", async () => {
118
+ const wrapper = await mountSuspended(ScrollRevealFrame, {
119
+ props: { styleClassPassthrough: "hero-frame" },
120
+ });
121
+ expect(wrapper.classes()).toContain("hero-frame");
122
+ });
123
+
124
+ it("applies multiple styleClassPassthrough classes from an array", async () => {
125
+ const wrapper = await mountSuspended(ScrollRevealFrame, {
126
+ props: { styleClassPassthrough: ["hero-frame", "mbe-32"] },
127
+ });
128
+ expect(wrapper.classes()).toContain("hero-frame");
129
+ expect(wrapper.classes()).toContain("mbe-32");
130
+ });
131
+
132
+ it("does not apply styleClassPassthrough to .reveal-content", async () => {
133
+ const wrapper = await mountSuspended(ScrollRevealFrame, {
134
+ props: { styleClassPassthrough: "hero-frame" },
135
+ });
136
+ expect(wrapper.find(".reveal-content").classes()).not.toContain("hero-frame");
137
+ });
138
+
139
+ it("updates classes when styleClassPassthrough prop changes", async () => {
140
+ const wrapper = await mountSuspended(ScrollRevealFrame, {
141
+ props: { styleClassPassthrough: ["original"] },
142
+ });
143
+ expect(wrapper.classes()).toContain("original");
144
+ await wrapper.setProps({ styleClassPassthrough: ["updated"] });
145
+ expect(wrapper.classes()).not.toContain("original");
146
+ expect(wrapper.classes()).toContain("updated");
147
+ });
148
+ });
@@ -0,0 +1,13 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`ScrollRevealFrame > renders correct HTML structure (all props set) 1`] = `
4
+ "<figure class="reveal-frame custom-class" style="--_frame-height: 400px; --_parallax-offset: 20rem; --_radius: 1.6rem;">
5
+ <div class="reveal-content"></div>
6
+ </figure>"
7
+ `;
8
+
9
+ exports[`ScrollRevealFrame > renders correct HTML structure (default props) 1`] = `
10
+ "<figure class="reveal-frame" style="--_frame-height: 540px; --_parallax-offset: 36rem; --_radius: 0px;">
11
+ <div class="reveal-content"></div>
12
+ </figure>"
13
+ `;
@@ -0,0 +1,96 @@
1
+ <template>
2
+ <ScrollRevealFrame
3
+ :frame-height="frameHeight"
4
+ :parallax-offset="parallaxOffset"
5
+ :radius="radius"
6
+ :style-class-passthrough="styleClassPassthrough"
7
+ :style="{ '--_focal-x': focalX }"
8
+ >
9
+ <NuxtImg
10
+ class="reveal-image"
11
+ :src="src"
12
+ :alt="alt"
13
+ :width="imgWidth"
14
+ :height="imgHeight"
15
+ loading="lazy"
16
+ decoding="async"
17
+ />
18
+ </ScrollRevealFrame>
19
+ </template>
20
+
21
+ <script setup lang="ts">
22
+ /**
23
+ * ScrollRevealImage
24
+ *
25
+ * A single-image convenience wrapper around ScrollRevealFrame.
26
+ * Adds NuxtImg optimisation (src, alt, intrinsic dimensions) and horizontal
27
+ * focal-point control (focalX → CSS object-position x-axis).
28
+ *
29
+ * For a grid of images or arbitrary slot content use ScrollRevealFrame directly.
30
+ */
31
+
32
+ interface Props {
33
+ src: string;
34
+ alt?: string;
35
+ /** Intrinsic width of the source image — required for NuxtImg optimisation. */
36
+ imgWidth?: number;
37
+ /** Intrinsic height of the source image — required for NuxtImg optimisation. */
38
+ imgHeight?: number;
39
+ /** Height of the visible clipping frame. */
40
+ frameHeight?: string;
41
+ /**
42
+ * How far the image travels vertically as the frame scrolls through the
43
+ * viewport. Larger = more image revealed = stronger parallax feel.
44
+ */
45
+ parallaxOffset?: string;
46
+ /**
47
+ * Horizontal focal point — CSS `object-position` x-axis value.
48
+ * Controls which horizontal slice of the image is kept in view.
49
+ * Examples: "50%", "left", "30%".
50
+ */
51
+ focalX?: string;
52
+ /** Optional rounded corners on the frame. */
53
+ radius?: string;
54
+ styleClassPassthrough?: string | string[];
55
+ }
56
+
57
+ withDefaults(defineProps<Props>(), {
58
+ alt: "",
59
+ imgWidth: 1920,
60
+ imgHeight: 1080,
61
+ frameHeight: "540px",
62
+ parallaxOffset: "36rem",
63
+ focalX: "50%",
64
+ radius: "0px",
65
+ styleClassPassthrough: () => [],
66
+ });
67
+ </script>
68
+
69
+ <style lang="css">
70
+ @layer components {
71
+ .reveal-image {
72
+ display: block;
73
+ width: 100%;
74
+ height: 100%;
75
+ object-fit: cover;
76
+ /*
77
+ * Y is always 0% — ScrollRevealFrame's translateY handles vertical travel.
78
+ * focalX (--_focal-x) lets the consumer pin the horizontal crop point.
79
+ */
80
+ object-position: var(--_focal-x, 50%) 0%;
81
+ }
82
+
83
+ /* ── Fallback: centre the crop vertically when there is no animation ── */
84
+ @supports not (animation-timeline: scroll()) {
85
+ .reveal-image {
86
+ object-position: var(--_focal-x, 50%) 50%;
87
+ }
88
+ }
89
+
90
+ @media (prefers-reduced-motion: reduce) {
91
+ .reveal-image {
92
+ object-position: var(--_focal-x, 50%) 50%;
93
+ }
94
+ }
95
+ }
96
+ </style>
@@ -0,0 +1,181 @@
1
+ import ScrollRevealImage from "../ScrollRevealImage.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+
4
+ const meta: Meta<typeof ScrollRevealImage> = {
5
+ title: "Atoms/Effects/ScrollRevealImage",
6
+ component: ScrollRevealImage,
7
+ argTypes: {
8
+ src: {
9
+ control: "text",
10
+ description: "Image source path",
11
+ table: { category: "Image" },
12
+ },
13
+ alt: {
14
+ control: "text",
15
+ description: "Alt text for the image",
16
+ table: { category: "Image" },
17
+ },
18
+ imgWidth: {
19
+ control: { type: "number" },
20
+ description: "Intrinsic width of the source image — required for NuxtImg optimisation",
21
+ table: { category: "Image" },
22
+ },
23
+ imgHeight: {
24
+ control: { type: "number" },
25
+ description: "Intrinsic height of the source image — required for NuxtImg optimisation",
26
+ table: { category: "Image" },
27
+ },
28
+ frameHeight: {
29
+ control: "text",
30
+ description: "Height of the visible clipping frame",
31
+ table: { category: "Layout" },
32
+ },
33
+ parallaxOffset: {
34
+ control: "text",
35
+ description: "Distance the image travels vertically as the frame scrolls through the viewport",
36
+ table: { category: "Layout" },
37
+ },
38
+ focalX: {
39
+ control: "text",
40
+ description: "Horizontal focal point — CSS object-position x-axis value (e.g. '50%', 'left', '30%')",
41
+ table: { category: "Layout" },
42
+ },
43
+ radius: {
44
+ control: "text",
45
+ description: "Border-radius applied to the clipping frame",
46
+ table: { category: "Appearance" },
47
+ },
48
+ styleClassPassthrough: {
49
+ table: { disable: true },
50
+ },
51
+ },
52
+ parameters: {
53
+ docs: {
54
+ description: {
55
+ component:
56
+ "Single-image parallax reveal built on ScrollRevealFrame. Adds NuxtImg optimisation (src, alt, intrinsic dimensions) and a horizontal focal-point prop (focalX). For arbitrary slot content — grids, video, etc. — use ScrollRevealFrame directly.",
57
+ },
58
+ },
59
+ },
60
+ };
61
+
62
+ export default meta;
63
+ type Story = StoryObj<typeof ScrollRevealImage>;
64
+
65
+ const scrollWrapper = (inner: string) => `
66
+ <div style="padding-block: 60vh; max-width: 860px; margin-inline: auto;">
67
+ <p style="text-align: center; font-size: 1.4rem; opacity: 0.5; margin-block-end: 4rem;">Scroll to see the parallax effect</p>
68
+ ${inner}
69
+ </div>
70
+ `;
71
+
72
+ export const Default: Story = {
73
+ args: {
74
+ src: "/images/page/hero/hero-blonde.jpg",
75
+ alt: "Blonde hair portrait",
76
+ imgWidth: 1280,
77
+ imgHeight: 1920,
78
+ frameHeight: "540px",
79
+ parallaxOffset: "36rem",
80
+ focalX: "50%",
81
+ radius: "0px",
82
+ },
83
+ render: (args) => ({
84
+ components: { ScrollRevealImage },
85
+ setup() {
86
+ return { args };
87
+ },
88
+ template: scrollWrapper(`<ScrollRevealImage v-bind="args" />`),
89
+ }),
90
+ parameters: {
91
+ docs: {
92
+ description: {
93
+ story: "All props at their default values. A portrait image panning from top to bottom as the frame crosses the viewport.",
94
+ },
95
+ },
96
+ },
97
+ };
98
+
99
+ export const ShortFrame: Story = {
100
+ args: {
101
+ src: "/images/banners/banner-mid-brown.webp",
102
+ alt: "Mid brown hair banner",
103
+ imgWidth: 1920,
104
+ imgHeight: 800,
105
+ frameHeight: "320px",
106
+ parallaxOffset: "24rem",
107
+ focalX: "50%",
108
+ radius: "0px",
109
+ },
110
+ render: Default.render,
111
+ parameters: {
112
+ docs: {
113
+ description: {
114
+ story: "A shallow banner frame with a wide landscape image. parallaxOffset is reduced proportionally to match the shorter frame height.",
115
+ },
116
+ },
117
+ },
118
+ };
119
+
120
+ export const RoundedCorners: Story = {
121
+ args: {
122
+ src: "/images/banners/banner-ginger.webp",
123
+ alt: "Ginger hair banner",
124
+ imgWidth: 1920,
125
+ imgHeight: 800,
126
+ frameHeight: "480px",
127
+ parallaxOffset: "36rem",
128
+ focalX: "50%",
129
+ radius: "2.4rem",
130
+ },
131
+ render: Default.render,
132
+ parameters: {
133
+ docs: {
134
+ description: {
135
+ story: "The radius prop applies border-radius to the clipping frame. The image reveal stays contained within the rounded shape.",
136
+ },
137
+ },
138
+ },
139
+ };
140
+
141
+ export const FocalPoint: Story = {
142
+ args: {
143
+ src: "/images/page/hero/hero-red.jpg",
144
+ alt: "Red hair portrait",
145
+ imgWidth: 1280,
146
+ imgHeight: 1920,
147
+ frameHeight: "540px",
148
+ parallaxOffset: "36rem",
149
+ focalX: "75%",
150
+ radius: "0px",
151
+ },
152
+ render: Default.render,
153
+ parameters: {
154
+ docs: {
155
+ description: {
156
+ story: "focalX pins the horizontal crop axis. Here it is pushed right to keep the subject centred in a narrower frame.",
157
+ },
158
+ },
159
+ },
160
+ };
161
+
162
+ export const TallFrame: Story = {
163
+ args: {
164
+ src: "/images/page/hero/hero-hair.jpg",
165
+ alt: "Hair portrait",
166
+ imgWidth: 1280,
167
+ imgHeight: 1920,
168
+ frameHeight: "70vh",
169
+ parallaxOffset: "60rem",
170
+ focalX: "50%",
171
+ radius: "0px",
172
+ },
173
+ render: Default.render,
174
+ parameters: {
175
+ docs: {
176
+ description: {
177
+ story: "A tall frame combined with a large parallaxOffset creates the most dramatic reveal — nearly the full image height is traversed as you scroll.",
178
+ },
179
+ },
180
+ },
181
+ };
@@ -0,0 +1,235 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import ScrollRevealImage from "../ScrollRevealImage.vue";
4
+
5
+ describe("ScrollRevealImage", () => {
6
+ // ─── Mount ───────────────────────────────────────────────────────────────
7
+
8
+ it("mounts without error", async () => {
9
+ const wrapper = await mountSuspended(ScrollRevealImage, {
10
+ props: { src: "/images/test.jpg" },
11
+ });
12
+ expect(wrapper.vm).toBeTruthy();
13
+ });
14
+
15
+ // ─── Snapshots ───────────────────────────────────────────────────────────
16
+
17
+ it("renders correct HTML structure (default props)", async () => {
18
+ const wrapper = await mountSuspended(ScrollRevealImage, {
19
+ props: { src: "/images/test.jpg", alt: "Test image" },
20
+ });
21
+ expect(wrapper.html()).toMatchSnapshot();
22
+ });
23
+
24
+ it("renders correct HTML structure (all props set)", async () => {
25
+ const wrapper = await mountSuspended(ScrollRevealImage, {
26
+ props: {
27
+ src: "/images/test.jpg",
28
+ alt: "All props",
29
+ imgWidth: 800,
30
+ imgHeight: 600,
31
+ frameHeight: "400px",
32
+ parallaxOffset: "20rem",
33
+ focalX: "30%",
34
+ radius: "1.6rem",
35
+ styleClassPassthrough: ["custom-class"],
36
+ },
37
+ });
38
+ expect(wrapper.html()).toMatchSnapshot();
39
+ });
40
+
41
+ // ─── Root element ────────────────────────────────────────────────────────
42
+
43
+ it("renders a <figure> as the root element", async () => {
44
+ const wrapper = await mountSuspended(ScrollRevealImage, {
45
+ props: { src: "/images/test.jpg" },
46
+ });
47
+ expect(wrapper.element.tagName).toBe("FIGURE");
48
+ });
49
+
50
+ it("always has the reveal-frame class", async () => {
51
+ const wrapper = await mountSuspended(ScrollRevealImage, {
52
+ props: { src: "/images/test.jpg" },
53
+ });
54
+ expect(wrapper.classes()).toContain("reveal-frame");
55
+ });
56
+
57
+ // ─── CSS custom properties ───────────────────────────────────────────────
58
+
59
+ it("sets --_frame-height to 540px by default", async () => {
60
+ const wrapper = await mountSuspended(ScrollRevealImage, {
61
+ props: { src: "/images/test.jpg" },
62
+ });
63
+ const style = (wrapper.element as HTMLElement).style;
64
+ expect(style.getPropertyValue("--_frame-height")).toBe("540px");
65
+ });
66
+
67
+ it("sets --_parallax-offset to 36rem by default", async () => {
68
+ const wrapper = await mountSuspended(ScrollRevealImage, {
69
+ props: { src: "/images/test.jpg" },
70
+ });
71
+ const style = (wrapper.element as HTMLElement).style;
72
+ expect(style.getPropertyValue("--_parallax-offset")).toBe("36rem");
73
+ });
74
+
75
+ it("sets --_radius to 0px by default", async () => {
76
+ const wrapper = await mountSuspended(ScrollRevealImage, {
77
+ props: { src: "/images/test.jpg" },
78
+ });
79
+ const style = (wrapper.element as HTMLElement).style;
80
+ expect(style.getPropertyValue("--_radius")).toBe("0px");
81
+ });
82
+
83
+ it("sets --_focal-x to 50% by default", async () => {
84
+ const wrapper = await mountSuspended(ScrollRevealImage, {
85
+ props: { src: "/images/test.jpg" },
86
+ });
87
+ const style = (wrapper.element as HTMLElement).style;
88
+ expect(style.getPropertyValue("--_focal-x")).toBe("50%");
89
+ });
90
+
91
+ it("reflects frameHeight prop in --_frame-height", async () => {
92
+ const wrapper = await mountSuspended(ScrollRevealImage, {
93
+ props: { src: "/images/test.jpg", frameHeight: "80vh" },
94
+ });
95
+ const style = (wrapper.element as HTMLElement).style;
96
+ expect(style.getPropertyValue("--_frame-height")).toBe("80vh");
97
+ });
98
+
99
+ it("reflects parallaxOffset prop in --_parallax-offset", async () => {
100
+ const wrapper = await mountSuspended(ScrollRevealImage, {
101
+ props: { src: "/images/test.jpg", parallaxOffset: "48rem" },
102
+ });
103
+ const style = (wrapper.element as HTMLElement).style;
104
+ expect(style.getPropertyValue("--_parallax-offset")).toBe("48rem");
105
+ });
106
+
107
+ it("reflects radius prop in --_radius", async () => {
108
+ const wrapper = await mountSuspended(ScrollRevealImage, {
109
+ props: { src: "/images/test.jpg", radius: "2.4rem" },
110
+ });
111
+ const style = (wrapper.element as HTMLElement).style;
112
+ expect(style.getPropertyValue("--_radius")).toBe("2.4rem");
113
+ });
114
+
115
+ it("reflects focalX prop in --_focal-x", async () => {
116
+ const wrapper = await mountSuspended(ScrollRevealImage, {
117
+ props: { src: "/images/test.jpg", focalX: "75%" },
118
+ });
119
+ const style = (wrapper.element as HTMLElement).style;
120
+ expect(style.getPropertyValue("--_focal-x")).toBe("75%");
121
+ });
122
+
123
+ // ─── Image element ───────────────────────────────────────────────────────
124
+
125
+ it("renders an img child element with the reveal-image class", async () => {
126
+ const wrapper = await mountSuspended(ScrollRevealImage, {
127
+ props: { src: "/images/test.jpg" },
128
+ });
129
+ // NuxtImg resolves to a real <img> element in the Nuxt test environment,
130
+ // identified by the data-nuxt-img attribute it injects.
131
+ const img = wrapper.find("img[data-nuxt-img]");
132
+ expect(img.exists()).toBe(true);
133
+ expect(img.classes()).toContain("reveal-image");
134
+ });
135
+
136
+ it("passes src through to the image element", async () => {
137
+ const wrapper = await mountSuspended(ScrollRevealImage, {
138
+ props: { src: "/images/hero.jpg" },
139
+ });
140
+ // NuxtImg transforms the URL via /_ipx/, so check the original path is present.
141
+ const src = wrapper.find("img[data-nuxt-img]").attributes("src") ?? "";
142
+ expect(src).toContain("hero.jpg");
143
+ });
144
+
145
+ it("passes alt to the image element (default empty string)", async () => {
146
+ const wrapper = await mountSuspended(ScrollRevealImage, {
147
+ props: { src: "/images/test.jpg" },
148
+ });
149
+ expect(wrapper.find("img[data-nuxt-img]").attributes("alt")).toBe("");
150
+ });
151
+
152
+ it("passes alt to the image element when provided", async () => {
153
+ const wrapper = await mountSuspended(ScrollRevealImage, {
154
+ props: { src: "/images/test.jpg", alt: "A hair portrait" },
155
+ });
156
+ expect(wrapper.find("img[data-nuxt-img]").attributes("alt")).toBe("A hair portrait");
157
+ });
158
+
159
+ it("passes imgWidth to the image element (default 1920)", async () => {
160
+ const wrapper = await mountSuspended(ScrollRevealImage, {
161
+ props: { src: "/images/test.jpg" },
162
+ });
163
+ expect(wrapper.find("img[data-nuxt-img]").attributes("width")).toBe("1920");
164
+ });
165
+
166
+ it("passes imgHeight to the image element (default 1080)", async () => {
167
+ const wrapper = await mountSuspended(ScrollRevealImage, {
168
+ props: { src: "/images/test.jpg" },
169
+ });
170
+ expect(wrapper.find("img[data-nuxt-img]").attributes("height")).toBe("1080");
171
+ });
172
+
173
+ it("passes custom imgWidth and imgHeight to the image element", async () => {
174
+ const wrapper = await mountSuspended(ScrollRevealImage, {
175
+ props: { src: "/images/test.jpg", imgWidth: 1280, imgHeight: 1920 },
176
+ });
177
+ const img = wrapper.find("img[data-nuxt-img]");
178
+ expect(img.attributes("width")).toBe("1280");
179
+ expect(img.attributes("height")).toBe("1920");
180
+ });
181
+
182
+ it("sets loading=lazy on the image element", async () => {
183
+ const wrapper = await mountSuspended(ScrollRevealImage, {
184
+ props: { src: "/images/test.jpg" },
185
+ });
186
+ expect(wrapper.find("img[data-nuxt-img]").attributes("loading")).toBe("lazy");
187
+ });
188
+
189
+ it("sets decoding=async on the image element", async () => {
190
+ const wrapper = await mountSuspended(ScrollRevealImage, {
191
+ props: { src: "/images/test.jpg" },
192
+ });
193
+ expect(wrapper.find("img[data-nuxt-img]").attributes("decoding")).toBe("async");
194
+ });
195
+
196
+ // ─── styleClassPassthrough ───────────────────────────────────────────────
197
+
198
+ it("applies a single styleClassPassthrough string to the root", async () => {
199
+ const wrapper = await mountSuspended(ScrollRevealImage, {
200
+ props: { src: "/images/test.jpg", styleClassPassthrough: "hero-image" },
201
+ });
202
+ expect(wrapper.classes()).toContain("hero-image");
203
+ });
204
+
205
+ it("applies multiple styleClassPassthrough classes from an array", async () => {
206
+ const wrapper = await mountSuspended(ScrollRevealImage, {
207
+ props: {
208
+ src: "/images/test.jpg",
209
+ styleClassPassthrough: ["hero-image", "mbe-32"],
210
+ },
211
+ });
212
+ expect(wrapper.classes()).toContain("hero-image");
213
+ expect(wrapper.classes()).toContain("mbe-32");
214
+ });
215
+
216
+ it("does not apply styleClassPassthrough to the image element", async () => {
217
+ const wrapper = await mountSuspended(ScrollRevealImage, {
218
+ props: {
219
+ src: "/images/test.jpg",
220
+ styleClassPassthrough: "hero-image",
221
+ },
222
+ });
223
+ expect(wrapper.find("img[data-nuxt-img]").classes()).not.toContain("hero-image");
224
+ });
225
+
226
+ it("updates classes when styleClassPassthrough prop changes", async () => {
227
+ const wrapper = await mountSuspended(ScrollRevealImage, {
228
+ props: { src: "/images/test.jpg", styleClassPassthrough: ["original"] },
229
+ });
230
+ expect(wrapper.classes()).toContain("original");
231
+ await wrapper.setProps({ styleClassPassthrough: ["updated"] });
232
+ expect(wrapper.classes()).not.toContain("original");
233
+ expect(wrapper.classes()).toContain("updated");
234
+ });
235
+ });
@@ -0,0 +1,13 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`ScrollRevealImage > renders correct HTML structure (all props set) 1`] = `
4
+ "<figure class="reveal-frame custom-class" style="--_frame-height: 400px; --_parallax-offset: 20rem; --_radius: 1.6rem; --_focal-x: 30%;">
5
+ <div class="reveal-content"><img width="800" height="600" data-nuxt-img="" srcset="/_ipx/s_800x600/images/test.jpg 1x, /_ipx/s_1600x1200/images/test.jpg 2x" class="reveal-image" alt="All props" loading="lazy" decoding="async" src="/_ipx/s_800x600/images/test.jpg"></div>
6
+ </figure>"
7
+ `;
8
+
9
+ exports[`ScrollRevealImage > renders correct HTML structure (default props) 1`] = `
10
+ "<figure class="reveal-frame" style="--_frame-height: 540px; --_parallax-offset: 36rem; --_radius: 0px; --_focal-x: 50%;">
11
+ <div class="reveal-content"><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/test.jpg 1x, /_ipx/s_3840x2160/images/test.jpg 2x" class="reveal-image" alt="Test image" loading="lazy" decoding="async" src="/_ipx/s_1920x1080/images/test.jpg"></div>
12
+ </figure>"
13
+ `;