srcdev-nuxt-components 9.1.36 → 9.1.38

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 (32) hide show
  1. package/.claude/settings.json +2 -1
  2. package/.claude/settings.local.json +2 -2
  3. package/.claude/skills/components/capture-qr-code.md +84 -0
  4. package/.claude/skills/components/data-grid.md +167 -0
  5. package/.claude/skills/components/decode-qr-code.md +84 -0
  6. package/.claude/skills/components/display-qr-code.md +64 -0
  7. package/.claude/skills/index.md +5 -2
  8. package/app/components/01.atoms/grids/data-grid/DataGrid.vue +39 -0
  9. package/app/components/01.atoms/grids/data-grid/stories/DataGrid.stories.ts +234 -0
  10. package/app/components/01.atoms/grids/data-grid/tests/DataGrid.spec.ts +140 -0
  11. package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/DataGrid.spec.ts.snap +11 -0
  12. package/app/components/01.atoms/qr-code/DisplayQrCode.vue +50 -0
  13. package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +206 -0
  14. package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +139 -0
  15. package/app/components/02.molecules/qr-code/CaptureQrCode.vue +142 -0
  16. package/app/components/{qr-code → 02.molecules/qr-code}/DecodeQrCode.vue +11 -35
  17. package/app/components/02.molecules/qr-code/stories/QrCode.stories.ts +101 -0
  18. package/app/components/02.molecules/qr-code/tests/CaptureQrCode.spec.ts +212 -0
  19. package/app/components/02.molecules/qr-code/tests/DecodeQrCode.spec.ts +145 -0
  20. package/app/layouts/default.vue +0 -1
  21. package/app/pages/ui/qr-code/[componentName].vue +3 -3
  22. package/app/pages/ui/simple-grid.vue +2 -2
  23. package/package.json +1 -1
  24. package/.claude/skills/components/scroll-parallax-section.md +0 -148
  25. package/app/components/01.atoms/scroll-parallax-section/ScrollParallaxSection.vue +0 -108
  26. package/app/components/01.atoms/scroll-parallax-section/stories/ScrollParallaxSection.stories.ts +0 -151
  27. package/app/components/01.atoms/scroll-parallax-section/tests/ScrollParallaxSection.spec.ts +0 -91
  28. package/app/components/display-grid/DisplayGridCore.vue +0 -22
  29. package/app/components/qr-code/CaptureQrCode.vue +0 -183
  30. package/app/components/qr-code/DisplayQrCode.vue +0 -53
  31. package/app/components/qr-code/stories/QrCode.stories.ts +0 -933
  32. package/app/pages/ui/scroll-parallax-section.vue +0 -65
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { nextTick } from "vue";
4
+ import DecodeQrCode from "../DecodeQrCode.vue";
5
+
6
+ interface DecodeVM {
7
+ result: string[] | undefined;
8
+ isDropping: boolean;
9
+ onDetect: (codes: { rawValue: string }[]) => void;
10
+ onDropping: (dropping: boolean) => void;
11
+ }
12
+
13
+ describe("DecodeQrCode", () => {
14
+ // ─── Mount ───────────────────────────────────────────────────────────────
15
+
16
+ it("mounts without error", async () => {
17
+ const wrapper = await mountSuspended(DecodeQrCode);
18
+ expect(wrapper.vm).toBeTruthy();
19
+ });
20
+
21
+ // ─── Root element ─────────────────────────────────────────────────────────
22
+
23
+ it("renders as a div", async () => {
24
+ const wrapper = await mountSuspended(DecodeQrCode);
25
+ expect(wrapper.element.tagName).toBe("DIV");
26
+ });
27
+
28
+ it("always has the decode-qr-code class", async () => {
29
+ const wrapper = await mountSuspended(DecodeQrCode);
30
+ expect(wrapper.classes()).toContain("decode-qr-code");
31
+ });
32
+
33
+ // ─── Initial DOM structure ─────────────────────────────────────────────────
34
+
35
+ it("renders the file capture element", async () => {
36
+ const wrapper = await mountSuspended(DecodeQrCode);
37
+ expect(wrapper.find(".qr-code-capture").exists()).toBe(true);
38
+ });
39
+
40
+ it("renders the drop zone element", async () => {
41
+ const wrapper = await mountSuspended(DecodeQrCode);
42
+ expect(wrapper.find(".qr-code-dropzone").exists()).toBe(true);
43
+ });
44
+
45
+ it("does not show scanned results initially", async () => {
46
+ const wrapper = await mountSuspended(DecodeQrCode);
47
+ expect(wrapper.find(".scanned-results").exists()).toBe(false);
48
+ });
49
+
50
+ // ─── Detect ───────────────────────────────────────────────────────────────
51
+
52
+ it("shows scanned results after onDetect is called", async () => {
53
+ const wrapper = await mountSuspended(DecodeQrCode);
54
+ const vm = wrapper.vm as unknown as DecodeVM;
55
+ vm.onDetect([{ rawValue: "https://example.com" }]);
56
+ await nextTick();
57
+ expect(wrapper.find(".scanned-results").exists()).toBe(true);
58
+ });
59
+
60
+ it("shows the detected value in the results list", async () => {
61
+ const wrapper = await mountSuspended(DecodeQrCode);
62
+ const vm = wrapper.vm as unknown as DecodeVM;
63
+ vm.onDetect([{ rawValue: "https://example.com" }]);
64
+ await nextTick();
65
+ expect(wrapper.find(".scanned-results").text()).toContain("https://example.com");
66
+ });
67
+
68
+ it("shows all detected values when multiple codes are decoded", async () => {
69
+ const wrapper = await mountSuspended(DecodeQrCode);
70
+ const vm = wrapper.vm as unknown as DecodeVM;
71
+ vm.onDetect([{ rawValue: "https://one.com" }, { rawValue: "https://two.com" }]);
72
+ await nextTick();
73
+ const items = wrapper.findAll(".scanned-results li");
74
+ expect(items).toHaveLength(2);
75
+ expect(items[0]!.text()).toBe("https://one.com");
76
+ expect(items[1]!.text()).toBe("https://two.com");
77
+ });
78
+
79
+ it("replaces results on subsequent detections", async () => {
80
+ const wrapper = await mountSuspended(DecodeQrCode);
81
+ const vm = wrapper.vm as unknown as DecodeVM;
82
+ vm.onDetect([{ rawValue: "first" }]);
83
+ await nextTick();
84
+ vm.onDetect([{ rawValue: "second" }]);
85
+ await nextTick();
86
+ const items = wrapper.findAll(".scanned-results li");
87
+ expect(items).toHaveLength(1);
88
+ expect(items[0]!.text()).toBe("second");
89
+ });
90
+
91
+ it("hides results when detecting an empty array", async () => {
92
+ const wrapper = await mountSuspended(DecodeQrCode);
93
+ const vm = wrapper.vm as unknown as DecodeVM;
94
+ vm.onDetect([{ rawValue: "https://example.com" }]);
95
+ await nextTick();
96
+ vm.onDetect([]);
97
+ await nextTick();
98
+ expect(wrapper.find(".scanned-results").exists()).toBe(false);
99
+ });
100
+
101
+ // ─── Dropping state ───────────────────────────────────────────────────────
102
+
103
+ it("sets isDropping to true when onDropping is called with true", async () => {
104
+ const wrapper = await mountSuspended(DecodeQrCode);
105
+ const vm = wrapper.vm as unknown as DecodeVM;
106
+ vm.onDropping(true);
107
+ await nextTick();
108
+ expect(vm.isDropping).toBe(true);
109
+ });
110
+
111
+ it("sets isDropping to false when onDropping is called with false", async () => {
112
+ const wrapper = await mountSuspended(DecodeQrCode);
113
+ const vm = wrapper.vm as unknown as DecodeVM;
114
+ vm.onDropping(true);
115
+ await nextTick();
116
+ vm.onDropping(false);
117
+ await nextTick();
118
+ expect(vm.isDropping).toBe(false);
119
+ });
120
+
121
+ // ─── styleClassPassthrough ────────────────────────────────────────────────
122
+
123
+ it("applies a single styleClassPassthrough string", async () => {
124
+ const wrapper = await mountSuspended(DecodeQrCode, {
125
+ props: { styleClassPassthrough: "custom-class" },
126
+ });
127
+ expect(wrapper.classes()).toContain("custom-class");
128
+ });
129
+
130
+ it("applies multiple styleClassPassthrough classes from an array", async () => {
131
+ const wrapper = await mountSuspended(DecodeQrCode, {
132
+ props: { styleClassPassthrough: ["class-a", "class-b"] },
133
+ });
134
+ expect(wrapper.classes()).toContain("class-a");
135
+ expect(wrapper.classes()).toContain("class-b");
136
+ });
137
+
138
+ it("retains decode-qr-code class alongside styleClassPassthrough", async () => {
139
+ const wrapper = await mountSuspended(DecodeQrCode, {
140
+ props: { styleClassPassthrough: "extra" },
141
+ });
142
+ expect(wrapper.classes()).toContain("decode-qr-code");
143
+ expect(wrapper.classes()).toContain("extra");
144
+ });
145
+ });
@@ -68,7 +68,6 @@ const responsiveNavLinks = {
68
68
  { name: "Banner", path: "/ui/display-banner" },
69
69
  { name: "Banner Video", path: "/banner-video" },
70
70
  { name: "Section Parallax", path: "/ui/section-parallax" },
71
- { name: "Scroll Parallax Section", path: "/ui/scroll-parallax-section" },
72
71
  { name: "Animated SVG Text", path: "/ui/animated-svg-text" },
73
72
  { name: "Carousel (Basic)", path: "/ui/carousel-basic" },
74
73
  { name: "Carousel (Infinite)", path: "/ui/carousel-infinite" },
@@ -121,9 +121,9 @@ type QrComponentName = "decode" | "capture" | "display";
121
121
  const componentName = computed<QrComponentName>(() => route.params.componentName as QrComponentName);
122
122
 
123
123
  // Component set up
124
- const DecodeQrCode = defineAsyncComponent(() => import("~/components/qr-code/DecodeQrCode.vue"));
125
- const CaptureQrCode = defineAsyncComponent(() => import("~/components/qr-code/CaptureQrCode.vue"));
126
- const DisplayQrCode = defineAsyncComponent(() => import("~/components/qr-code/DisplayQrCode.vue"));
124
+ const DecodeQrCode = defineAsyncComponent(() => import("~/components/02.molecules/qr-code/DecodeQrCode.vue"));
125
+ const CaptureQrCode = defineAsyncComponent(() => import("~/components/02.molecules/qr-code/CaptureQrCode.vue"));
126
+ const DisplayQrCode = defineAsyncComponent(() => import("~/components/01.atoms/qr-code/DisplayQrCode.vue"));
127
127
 
128
128
  const components: Record<
129
129
  QrComponentName,
@@ -6,7 +6,7 @@
6
6
  <h1 class="page-heading-3">Simple Grid</h1>
7
7
  <p class="page-body-normal">Simple grid displaying dummy posts data</p>
8
8
 
9
- <DisplayGridCore
9
+ <GridCore
10
10
  v-if="status === 'success'"
11
11
  :grid-data="postsData?.posts.slice(0, displayCount) ?? ({} as Posts)"
12
12
  :style-class-passthrough="['display-posts']"
@@ -18,7 +18,7 @@
18
18
  <div>{{ item.body }}</div>
19
19
  </div>
20
20
  </template>
21
- </DisplayGridCore>
21
+ </GridCore>
22
22
 
23
23
  <p v-else class="page-body-normal">&hellip;Loading</p>
24
24
  </LayoutRow>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.1.36",
4
+ "version": "9.1.38",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",
@@ -1,148 +0,0 @@
1
- ---
2
- name: ScrollParallaxSection
3
- description: ScrollParallaxSection JS-driven parallax background section — props, CSS height token, slot usage, parallaxStrength guide, reduced-motion, vs ScrollRevealFrame
4
- type: reference
5
- ---
6
-
7
- # ScrollParallaxSection
8
-
9
- ## Overview
10
-
11
- `ScrollParallaxSection` is a full-width section with a parallax background image driven by `requestAnimationFrame` and `IntersectionObserver`. The background image bleeds beyond the container bounds and is translated vertically as the component scrolls through the viewport.
12
-
13
- Use this component for **full-width decorative background sections** — hero banners, dividers, and atmospheric breaks between content. For arbitrary slot content that itself needs to pan (grids of images, video), use `ScrollRevealFrame` instead.
14
-
15
- ### How it works
16
-
17
- - The root element is a fixed-height container with `overflow: hidden`.
18
- - `.scroll-parallax-section__bg` is positioned absolutely with a negative inset (derived from `parallaxStrength`) so the image bleeds beyond the frame top and bottom, ensuring full coverage at all scroll positions.
19
- - On scroll/resize, a `requestAnimationFrame` callback reads `getBoundingClientRect()` and sets `translateY` on the background element.
20
- - An `IntersectionObserver` pauses the RAF loop when the component is offscreen, reducing CPU usage.
21
- - The background layer uses `will-change: transform` for GPU compositing.
22
-
23
- ### Difference from ScrollRevealFrame / ScrollRevealImage
24
-
25
- | | `ScrollParallaxSection` | `ScrollRevealFrame` / `ScrollRevealImage` |
26
- |---|---|---|
27
- | Mechanism | JS (RAF + IntersectionObserver) | CSS Scroll-driven Animations |
28
- | Browser support | All modern + older browsers | Chrome 115+, Firefox 114+, Safari 17.2+ |
29
- | Content | Background image only (slot above) | Slot content pans as a unit |
30
- | Use case | Full-width decorative background sections | Clipping frames with panning image/content |
31
-
32
- ## Props
33
-
34
- | Prop | Type | Default | Description |
35
- |------|------|---------|-------------|
36
- | `tag` | `"div" \| "section" \| "article" \| "aside"` | `"div"` | HTML element rendered as the container. Use `"section"` for landmark regions. |
37
- | `backgroundImage` | `string` | — | **Required.** Path or URL of the background image. Passed via CSS `background-image`. |
38
- | `parallaxStrength` | `number` | `1` | Multiplier for the parallax movement and image bleed. `0` = no movement, `1` = standard, `2` = very dramatic. See the guide below. |
39
- | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes applied to the root element. |
40
-
41
- ## Slots
42
-
43
- | Slot | Description |
44
- |------|-------------|
45
- | `default` | Content placed above the parallax background at `z-index: 1`. |
46
-
47
- ## CSS custom properties
48
-
49
- Height is controlled entirely via CSS — there is no `height` prop.
50
-
51
- | Property | Default | Description |
52
- |----------|---------|-------------|
53
- | `--scroll-parallax-section-height` | `25svh` | Height of the visible section. Set on the component or a parent wrapper. |
54
-
55
- ## Basic usage
56
-
57
- ```vue
58
- <ScrollParallaxSection background-image="/images/banners/banner-mid-brown.webp" />
59
- ```
60
-
61
- ## With slot content
62
-
63
- Slot content is layered at `z-index: 1` above the parallax background. Use flexbox or grid on the root element (via `styleClassPassthrough` or an inline style) to position it.
64
-
65
- ```vue
66
- <ScrollParallaxSection
67
- background-image="/images/banners/banner-ginger.webp"
68
- :parallax-strength="0.8"
69
- tag="section"
70
- style="
71
- --scroll-parallax-section-height: 40svh;
72
- display: flex;
73
- align-items: center;
74
- justify-content: center;
75
- "
76
- >
77
- <h2 style="color: white; text-shadow: 0 2px 8px rgba(0,0,0,0.5);">Section heading</h2>
78
- </ScrollParallaxSection>
79
- ```
80
-
81
- ## Custom height via CSS override
82
-
83
- Override the height in a scoped stylesheet — useful for responsive breakpoints.
84
-
85
- ```css
86
- .my-page {
87
- .scroll-parallax-section {
88
- --scroll-parallax-section-height: 20svh;
89
-
90
- @media (width >= 768px) {
91
- --scroll-parallax-section-height: 35svh;
92
- }
93
-
94
- @media (width >= 1200px) {
95
- --scroll-parallax-section-height: 25svh;
96
- }
97
- }
98
- }
99
- ```
100
-
101
- ## Choosing parallaxStrength
102
-
103
- `parallaxStrength` controls two things simultaneously:
104
-
105
- 1. **Movement** — how far the background travels per pixel of scroll.
106
- 2. **Image bleed** — the negative inset applied to `.scroll-parallax-section__bg`. The inset is calculated as `ceil(parallaxStrength × 100)%`, ensuring the image always fills the frame even at extremes of the scroll position.
107
-
108
- | `parallaxStrength` | Character | Inset |
109
- |---|---|---|
110
- | `0` | No movement (static background) | `0%` |
111
- | `0.3–0.5` | Subtle — good for text-heavy sections | `30–50%` |
112
- | `1` (default) | Standard parallax feel | `100%` |
113
- | `1.5–2` | Dramatic — large image travel | `150–200%` |
114
-
115
- Values above `2` are rarely useful and increase layout memory cost.
116
-
117
- ## Reduced motion
118
-
119
- The component does not yet implement a `prefers-reduced-motion` media query. If your consuming app needs to respect user motion preferences, disable the parallax effect by setting `parallaxStrength` to `0` and watching the CSS media feature:
120
-
121
- ```vue
122
- <script setup lang="ts">
123
- const prefersReducedMotion = ref(false);
124
-
125
- onMounted(() => {
126
- const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
127
- prefersReducedMotion.value = mq.matches;
128
- mq.addEventListener("change", (e) => {
129
- prefersReducedMotion.value = e.matches;
130
- });
131
- });
132
- </script>
133
-
134
- <template>
135
- <ScrollParallaxSection
136
- background-image="/images/banner.webp"
137
- :parallax-strength="prefersReducedMotion ? 0 : 1"
138
- />
139
- </template>
140
- ```
141
-
142
- ## Notes
143
-
144
- - `backgroundImage` is passed through `v-bind()` as a CSS `background-image` value. The image is not processed by `@nuxt/image` — use a path under `public/` or a full URL.
145
- - `overflow: hidden` is on the root element. Content that needs to escape (dropdowns, tooltips) must be portalled outside.
146
- - The background layer uses `background-position: center` and `background-size: cover`. The focal point cannot be changed per-prop — override `background-position` in CSS if needed.
147
- - Multiple `ScrollParallaxSection` instances on the same page each run independent observers and RAF loops. They pause individually when offscreen.
148
- - Do not nest `ScrollParallaxSection` inside a container with CSS `transform` — this creates a new stacking context and breaks the `getBoundingClientRect` viewport calculation.
@@ -1,108 +0,0 @@
1
- <template>
2
- <component :is="tag" ref="containerRef" class="scroll-parallax-section" :class="[elementClasses]">
3
- <div ref="bgRef" class="scroll-parallax-section__bg"></div>
4
- <div class="scroll-parallax-section__content">
5
- <slot></slot>
6
- </div>
7
- </component>
8
- </template>
9
-
10
- <script setup lang="ts">
11
- interface Props {
12
- tag?: "div" | "section" | "article" | "aside";
13
- backgroundImage: string;
14
- parallaxStrength?: number;
15
- styleClassPassthrough?: string | string[];
16
- }
17
-
18
- const props = withDefaults(defineProps<Props>(), {
19
- tag: "div",
20
- parallaxStrength: 1,
21
- styleClassPassthrough: () => [],
22
- });
23
-
24
- const containerRef = useTemplateRef<HTMLElement>("containerRef");
25
- const bgRef = useTemplateRef<HTMLElement>("bgRef");
26
-
27
- const bgImage = computed(() => `url("${props.backgroundImage}")`);
28
- const bgInset = computed(() => `-${Math.ceil(props.parallaxStrength * 100)}% 0`);
29
-
30
- const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
31
-
32
- watch(
33
- () => props.styleClassPassthrough,
34
- () => resetElementClasses(props.styleClassPassthrough)
35
- );
36
-
37
- let rafId: number | null = null;
38
- let isVisible = false;
39
- let observer: IntersectionObserver | null = null;
40
-
41
- function updateParallax() {
42
- if (!bgRef.value || !containerRef.value) return;
43
- const rect = containerRef.value.getBoundingClientRect();
44
- const viewportCenter = window.innerHeight / 2;
45
- const elementCenter = rect.top + rect.height / 2;
46
- const offset = (viewportCenter - elementCenter) * props.parallaxStrength;
47
- bgRef.value.style.transform = `translate3d(0, ${offset}px, 0)`;
48
- }
49
-
50
- function onScroll() {
51
- if (!isVisible || rafId !== null) return;
52
- rafId = requestAnimationFrame(() => {
53
- updateParallax();
54
- rafId = null;
55
- });
56
- }
57
-
58
- onMounted(() => {
59
- updateParallax();
60
-
61
- observer = new IntersectionObserver(
62
- (entries) => {
63
- isVisible = entries[0]!.isIntersecting;
64
- if (isVisible) updateParallax();
65
- },
66
- { rootMargin: "50px" }
67
- );
68
-
69
- if (containerRef.value) observer.observe(containerRef.value);
70
-
71
- window.addEventListener("scroll", onScroll, { passive: true });
72
- window.addEventListener("resize", onScroll, { passive: true });
73
- });
74
-
75
- onUnmounted(() => {
76
- window.removeEventListener("scroll", onScroll);
77
- window.removeEventListener("resize", onScroll);
78
- if (rafId !== null) cancelAnimationFrame(rafId);
79
- observer?.disconnect();
80
- });
81
- </script>
82
-
83
- <style lang="css">
84
- @layer components {
85
- .scroll-parallax-section {
86
- --scroll-parallax-section-height: 25svh;
87
- height: var(--scroll-parallax-section-height);
88
- position: relative;
89
- overflow: hidden;
90
- width: 100%;
91
-
92
- .scroll-parallax-section__bg {
93
- position: absolute;
94
- inset: v-bind(bgInset);
95
- background-image: v-bind(bgImage);
96
- background-position: center;
97
- background-repeat: no-repeat;
98
- background-size: cover;
99
- will-change: transform;
100
- }
101
-
102
- .scroll-parallax-section__content {
103
- position: relative;
104
- z-index: 1;
105
- }
106
- }
107
- }
108
- </style>
@@ -1,151 +0,0 @@
1
- import ScrollParallaxSection from "../ScrollParallaxSection.vue";
2
- import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
-
4
- const meta: Meta<typeof ScrollParallaxSection> = {
5
- title: "Atoms/Effects/ScrollParallaxSection",
6
- component: ScrollParallaxSection,
7
- argTypes: {
8
- tag: {
9
- control: "select",
10
- options: ["div", "section", "article", "aside"],
11
- description: "HTML element rendered as the container",
12
- table: { category: "Layout" },
13
- },
14
- backgroundImage: {
15
- control: "text",
16
- description: "Path or URL of the background image",
17
- table: { category: "Image" },
18
- },
19
- parallaxStrength: {
20
- control: { type: "range", min: 0, max: 2, step: 0.1 },
21
- description:
22
- "Multiplier for the parallax offset. 0 = no movement, 1 = standard, 2 = very dramatic. Also controls the image bleed (inset) so the full image fills the frame at all scroll positions.",
23
- table: { category: "Layout" },
24
- },
25
- styleClassPassthrough: {
26
- table: { disable: true },
27
- },
28
- },
29
- parameters: {
30
- docs: {
31
- description: {
32
- component:
33
- "A section with a fixed-background parallax effect implemented via `requestAnimationFrame` and `IntersectionObserver`. The background image bleeds beyond the container bounds and is translated vertically as the component scrolls through the viewport. Height is controlled via the `--scroll-parallax-section-height` CSS custom property (default `25svh`). Slot content is layered above the background at `z-index: 1`.",
34
- },
35
- },
36
- },
37
- };
38
-
39
- export default meta;
40
- type Story = StoryObj<typeof ScrollParallaxSection>;
41
-
42
- const scrollWrapper = (inner: string) => `
43
- <div style="padding-block: 60vh; max-width: 960px; margin-inline: auto;">
44
- <p style="text-align: center; font-size: 1.4rem; opacity: 0.5; margin-block-end: 4rem;">Scroll to see the parallax effect</p>
45
- ${inner}
46
- </div>
47
- `;
48
-
49
- export const Default: Story = {
50
- args: {
51
- backgroundImage: "/images/banners/banner-mid-brown.webp",
52
- parallaxStrength: 1,
53
- tag: "div",
54
- },
55
- render: (args) => ({
56
- components: { ScrollParallaxSection },
57
- setup() {
58
- return { args };
59
- },
60
- template: scrollWrapper(`<ScrollParallaxSection v-bind="args" />`),
61
- }),
62
- parameters: {
63
- docs: {
64
- description: {
65
- story: "Default configuration — standard parallax strength with no slot content.",
66
- },
67
- },
68
- },
69
- };
70
-
71
- export const SubtleEffect: Story = {
72
- args: {
73
- backgroundImage: "/images/banners/banner-light-brunette.webp",
74
- parallaxStrength: 0.4,
75
- tag: "div",
76
- },
77
- render: Default.render,
78
- parameters: {
79
- docs: {
80
- description: {
81
- story: "A low parallaxStrength (0.4) gives a gentle, understated movement — good for hero banners where content legibility matters.",
82
- },
83
- },
84
- },
85
- };
86
-
87
- export const DramaticEffect: Story = {
88
- args: {
89
- backgroundImage: "/images/banners/banner-ginger.webp",
90
- parallaxStrength: 1.8,
91
- tag: "div",
92
- },
93
- render: Default.render,
94
- parameters: {
95
- docs: {
96
- description: {
97
- story: "A high parallaxStrength (1.8) creates a dramatic sweep — the image travels a much larger distance relative to the scroll position.",
98
- },
99
- },
100
- },
101
- };
102
-
103
- export const WithSlotContent: Story = {
104
- args: {
105
- backgroundImage: "/images/page/hero/hero-dark.jpg",
106
- parallaxStrength: 1,
107
- tag: "section",
108
- },
109
- render: (args) => ({
110
- components: { ScrollParallaxSection },
111
- setup() {
112
- return { args };
113
- },
114
- template: scrollWrapper(`
115
- <ScrollParallaxSection v-bind="args" style="--scroll-parallax-section-height: 40svh; display: flex; align-items: center; justify-content: center;">
116
- <p style="color: white; font-size: 2.4rem; font-weight: 600; text-align: center; text-shadow: 0 2px 8px rgba(0,0,0,0.6); padding: 2rem;">
117
- Slot content sits above the parallax background
118
- </p>
119
- </ScrollParallaxSection>
120
- `),
121
- }),
122
- parameters: {
123
- docs: {
124
- description: {
125
- story: "Slot content is placed at `z-index: 1` above the parallax layer. Height is increased via the `--scroll-parallax-section-height` CSS custom property to accommodate the text.",
126
- },
127
- },
128
- },
129
- };
130
-
131
- export const TallSection: Story = {
132
- args: {
133
- backgroundImage: "/images/page/hero/hero-blonde.jpg",
134
- parallaxStrength: 1,
135
- tag: "div",
136
- },
137
- render: (args) => ({
138
- components: { ScrollParallaxSection },
139
- setup() {
140
- return { args };
141
- },
142
- template: scrollWrapper(`<ScrollParallaxSection v-bind="args" style="--scroll-parallax-section-height: 60svh;" />`),
143
- }),
144
- parameters: {
145
- docs: {
146
- description: {
147
- story: "The `--scroll-parallax-section-height` custom property overrides the default `25svh` height. Here it is set to `60svh` for a tall banner.",
148
- },
149
- },
150
- },
151
- };