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.
Files changed (82) 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 +178 -0
  4. package/.claude/skills/components/grid-stack.md +133 -0
  5. package/.claude/skills/components/scroll-reveal-frame.md +136 -0
  6. package/.claude/skills/components/scroll-reveal-image.md +161 -0
  7. package/.claude/skills/index.md +9 -1
  8. package/.claude/skills/new-app-scaffold.md +301 -0
  9. package/.claude/skills/setup-postinstall.md +45 -0
  10. package/.claude/skills/theming-override-default.md +6 -0
  11. package/.claude/skills/theming-partial-override.md +131 -0
  12. package/.claude/skills/vue-video-autoplay.md +74 -0
  13. package/README.md +30 -5
  14. package/app/components/01.atoms/banner-video/BannerVideo.vue +207 -0
  15. package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +180 -0
  16. package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +350 -0
  17. package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +13 -0
  18. package/app/components/01.atoms/grid-stack/GridStack.vue +34 -0
  19. package/app/components/01.atoms/grid-stack/stories/GridStack.stories.ts +162 -0
  20. package/app/components/01.atoms/grid-stack/tests/GridStack.spec.ts +66 -0
  21. package/app/components/01.atoms/scroll-reveal-frame/ScrollRevealFrame.vue +125 -0
  22. package/app/components/01.atoms/scroll-reveal-frame/stories/ScrollRevealFrame.stories.ts +166 -0
  23. package/app/components/01.atoms/scroll-reveal-frame/tests/ScrollRevealFrame.spec.ts +148 -0
  24. package/app/components/01.atoms/scroll-reveal-frame/tests/__snapshots__/ScrollRevealFrame.spec.ts.snap +13 -0
  25. package/app/components/01.atoms/scroll-reveal-image/ScrollRevealImage.vue +96 -0
  26. package/app/components/01.atoms/scroll-reveal-image/stories/ScrollRevealImage.stories.ts +181 -0
  27. package/app/components/01.atoms/scroll-reveal-image/tests/ScrollRevealImage.spec.ts +235 -0
  28. package/app/components/01.atoms/scroll-reveal-image/tests/__snapshots__/ScrollRevealImage.spec.ts.snap +13 -0
  29. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +1 -0
  30. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +1 -0
  31. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +1 -0
  32. package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +1 -0
  33. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -0
  34. package/app/components/03.organisms/services/services-grids/tests/ServicesSectionGrid.spec.ts +1 -0
  35. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +3 -3
  36. package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts +1 -0
  37. package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +1 -0
  38. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +1 -1
  39. package/app/layouts/default.vue +2 -0
  40. package/app/pages/banner-video.vue +238 -0
  41. package/app/pages/grid-stack.vue +252 -0
  42. package/app/pages/index.vue +5 -0
  43. package/app/pages/ui/scroll-reveal-image.vue +483 -0
  44. package/app/types/types.services.ts +1 -0
  45. package/package.json +1 -1
  46. package/app/assets/styles/extends-layer/srcdev-components/components/_display-prompt-core.css +0 -94
  47. package/app/assets/styles/extends-layer/srcdev-components/components/_display-toast.css +0 -5
  48. package/app/assets/styles/extends-layer/srcdev-components/components/_expanding-panel.css +0 -37
  49. package/app/assets/styles/extends-layer/srcdev-components/components/index.css +0 -3
  50. package/app/assets/styles/extends-layer/srcdev-components/index.css +0 -1
  51. package/app/assets/styles/extends-layer/srcdev-forms/components/_form-fieldset.css +0 -38
  52. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-button.css +0 -67
  53. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-checkbox-radio-core.css +0 -87
  54. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-checkbox-radio-options-button.css +0 -74
  55. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-checkbox-radio-with-label.css +0 -14
  56. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-description.css +0 -13
  57. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-error.css +0 -54
  58. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-label.css +0 -21
  59. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-select.css +0 -143
  60. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-text.css +0 -190
  61. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-textarea.css +0 -96
  62. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-toggle-switch.css +0 -68
  63. package/app/assets/styles/extends-layer/srcdev-forms/components/index.css +0 -12
  64. package/app/assets/styles/extends-layer/srcdev-forms/index.css +0 -2
  65. package/app/assets/styles/extends-layer/srcdev-forms/setup/_generic.css +0 -21
  66. package/app/assets/styles/extends-layer/srcdev-forms/setup/index.css +0 -3
  67. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_default.css +0 -13
  68. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_large.css +0 -12
  69. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_medium.css +0 -12
  70. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_small.css +0 -12
  71. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_x-small.css +0 -11
  72. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/index.css +0 -23
  73. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_default.css +0 -62
  74. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_error.css +0 -63
  75. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_ghost.css +0 -63
  76. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_info.css +0 -63
  77. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_primary.css +0 -66
  78. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_secondary.css +0 -64
  79. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_success.css +0 -63
  80. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_tertiary.css +0 -63
  81. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_warning.css +0 -63
  82. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/index.css +0 -7
@@ -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
+ };
@@ -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>