srcdev-nuxt-components 9.1.27 → 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/nuxt.config.ts +2 -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,131 @@
|
|
|
1
|
+
# Partial Token Override in a Consuming App
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Use this skill when you need to override a specific category of tokens (e.g. form inputs, buttons,
|
|
6
|
+
colours) without replacing the entire default theme. The full token reference is in
|
|
7
|
+
`CONSUMER-STYLING.md` at the root of the layer package.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
All library tokens are declared inside `@layer theming`. Any CSS written outside a layer wins
|
|
12
|
+
automatically — no `!important` or specificity tricks required. Override files just need to be
|
|
13
|
+
imported **after** the layer styles.
|
|
14
|
+
|
|
15
|
+
## Steps
|
|
16
|
+
|
|
17
|
+
### 1. Create your override file
|
|
18
|
+
|
|
19
|
+
Create a CSS file for the category you want to override. Place it anywhere in your app's assets —
|
|
20
|
+
`app/assets/styles/` is conventional.
|
|
21
|
+
|
|
22
|
+
**Example: form input overrides**
|
|
23
|
+
|
|
24
|
+
```css
|
|
25
|
+
/* app/assets/styles/form-overrides.css */
|
|
26
|
+
:root {
|
|
27
|
+
--theme-input-surface: oklch(0.98 0.005 250);
|
|
28
|
+
--theme-input-border: oklch(0.45 0.08 270);
|
|
29
|
+
--theme-input-border-focus: oklch(0.55 0.18 280);
|
|
30
|
+
--theme-input-outline-focus: oklch(0.55 0.18 280);
|
|
31
|
+
--theme-focus-visible-shadow: 0 0 0 2px oklch(0.80 0.12 280);
|
|
32
|
+
--theme-input-placeholder: oklch(0.60 0.02 255);
|
|
33
|
+
--theme-input-text-color-normal: oklch(0.20 0.02 255);
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Example: add a red colour scale and remap accent tokens**
|
|
38
|
+
|
|
39
|
+
```css
|
|
40
|
+
/* app/assets/styles/red-palette.css */
|
|
41
|
+
:root {
|
|
42
|
+
/* Red scale — 00 (lightest) to 10 (darkest) */
|
|
43
|
+
--red-00: oklch(0.99 0.005 20);
|
|
44
|
+
--red-01: oklch(0.96 0.020 20);
|
|
45
|
+
--red-02: oklch(0.90 0.055 20);
|
|
46
|
+
--red-03: oklch(0.82 0.105 20);
|
|
47
|
+
--red-04: oklch(0.72 0.155 20);
|
|
48
|
+
--red-05: oklch(0.62 0.185 20);
|
|
49
|
+
--red-06: oklch(0.53 0.185 20);
|
|
50
|
+
--red-07: oklch(0.44 0.165 20);
|
|
51
|
+
--red-08: oklch(0.36 0.140 20);
|
|
52
|
+
--red-09: oklch(0.28 0.110 20);
|
|
53
|
+
--red-10: oklch(0.20 0.080 20);
|
|
54
|
+
|
|
55
|
+
/* Remap semantic accent tokens to red */
|
|
56
|
+
--colour-text-accent: var(--red-08);
|
|
57
|
+
--colour-text-eyebrow: var(--red-08);
|
|
58
|
+
--colour-link-default: var(--red-09);
|
|
59
|
+
--colour-link-hover: var(--red-08);
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Example: button overrides only**
|
|
64
|
+
|
|
65
|
+
```css
|
|
66
|
+
/* app/assets/styles/button-overrides.css */
|
|
67
|
+
:root {
|
|
68
|
+
--theme-button-primary-surface: oklch(0.55 0.20 280);
|
|
69
|
+
--theme-button-primary-surface-hover: oklch(0.48 0.22 280);
|
|
70
|
+
--theme-button-primary-surface-active: oklch(0.42 0.22 280);
|
|
71
|
+
--theme-button-primary-text: oklch(1 0 0);
|
|
72
|
+
--theme-button-primary-text-hover: oklch(1 0 0);
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 2. Register it in nuxt.config.ts
|
|
77
|
+
|
|
78
|
+
Import your override file **after** the layer styles:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
export default defineNuxtConfig({
|
|
82
|
+
extends: "srcdev-nuxt-components",
|
|
83
|
+
css: [
|
|
84
|
+
"~/assets/styles/form-overrides.css", // or whichever file(s) you created
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The layer's own CSS loads via the `extends` mechanism before app CSS, so your unlayered overrides
|
|
90
|
+
always win.
|
|
91
|
+
|
|
92
|
+
### 3. Dark mode (optional)
|
|
93
|
+
|
|
94
|
+
If you need dark mode variants of your overrides, add them in the same file using the `html.dark`
|
|
95
|
+
class selector:
|
|
96
|
+
|
|
97
|
+
```css
|
|
98
|
+
:root {
|
|
99
|
+
--theme-input-surface: oklch(0.98 0.005 250);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
:where(html.dark) {
|
|
103
|
+
--theme-input-surface: oklch(0.15 0.02 255);
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Scoped overrides (section or component level)
|
|
108
|
+
|
|
109
|
+
To restrict an override to a specific section of the page, scope to a wrapper class instead of
|
|
110
|
+
`:root`:
|
|
111
|
+
|
|
112
|
+
```css
|
|
113
|
+
.pricing-section {
|
|
114
|
+
--theme-button-primary-surface: oklch(0.55 0.20 140); /* green for pricing CTA */
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Token reference
|
|
119
|
+
|
|
120
|
+
See `CONSUMER-STYLING.md` in the layer package for the full list of available tokens, grouped
|
|
121
|
+
by category (typography, colours, form inputs, buttons, checkboxes, toggle, glass panel,
|
|
122
|
+
StepperList).
|
|
123
|
+
|
|
124
|
+
## Notes
|
|
125
|
+
|
|
126
|
+
- Only override the tokens you actually need — unset tokens fall back to layer defaults.
|
|
127
|
+
- Use oklch for all colour values. Use an oklch colour picker (e.g. oklch.com) to build scales.
|
|
128
|
+
- For a full palette replacement (replacing the entire default theme), use
|
|
129
|
+
`theming-override-default.md` instead.
|
|
130
|
+
- `--slate-*` neutral tokens come from the layer and do not need to be redefined unless you
|
|
131
|
+
genuinely want different neutrals.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Vue Video Autoplay on Client-Side Navigation
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
In Vue/Nuxt SPAs, binding `:src` directly on a `<video>` element silently skips the browser fetch when Vue patches the element during client-side navigation. The video appears but never loads — no network request is made. Use a `<source>` child element instead, combined with `:key` and an explicit `load()` call.
|
|
6
|
+
|
|
7
|
+
## The problem
|
|
8
|
+
|
|
9
|
+
When Vue patches an existing `<video>` DOM element (e.g. on route change), setting `src` via `:src` doesn't always trigger the browser to re-fetch the media resource. This fails silently — no console error, no network request, poster image just sits there.
|
|
10
|
+
|
|
11
|
+
## The fix
|
|
12
|
+
|
|
13
|
+
```vue
|
|
14
|
+
<!-- ✅ Correct — src in <source> child -->
|
|
15
|
+
<video
|
|
16
|
+
:key="src"
|
|
17
|
+
autoplay
|
|
18
|
+
muted
|
|
19
|
+
loop
|
|
20
|
+
playsinline
|
|
21
|
+
preload="auto"
|
|
22
|
+
:poster="poster"
|
|
23
|
+
>
|
|
24
|
+
<source :src="src" type="video/mp4" />
|
|
25
|
+
</video>
|
|
26
|
+
|
|
27
|
+
<!-- ❌ Wrong — :src on <video> silently skips fetch on Vue patches -->
|
|
28
|
+
<video autoplay muted loop playsinline preload="auto" :src="src"></video>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Three things working together
|
|
32
|
+
|
|
33
|
+
1. **`<source>` child** — the browser re-reads source children when `load()` is called, reliably initiating the fetch.
|
|
34
|
+
2. **`:key="src"`** — forces Vue to unmount/remount the `<video>` element when the source changes (covers same-component src swaps).
|
|
35
|
+
3. **Explicit `v.load()` call** — programmatically re-initiates fetching after mount and on src change. Do not rely on `autoplay` alone for client-side navigation.
|
|
36
|
+
|
|
37
|
+
## Autoplay pattern for Nuxt components
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
const videoEl = shallowRef<HTMLVideoElement | null>(null);
|
|
41
|
+
|
|
42
|
+
const tryPlay = async () => {
|
|
43
|
+
const v = videoEl.value;
|
|
44
|
+
if (!v) return;
|
|
45
|
+
v.muted = true; // required for programmatic autoplay in all browsers
|
|
46
|
+
try {
|
|
47
|
+
await v.play();
|
|
48
|
+
} catch {
|
|
49
|
+
// autoplay blocked — poster remains visible
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const kickOffLoad = async () => {
|
|
54
|
+
await nextTick();
|
|
55
|
+
const v = videoEl.value;
|
|
56
|
+
if (!v) return;
|
|
57
|
+
v.load();
|
|
58
|
+
void tryPlay();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Covers initial mount and src prop changes
|
|
62
|
+
watch(() => props.src, () => void kickOffLoad(), { immediate: true, flush: "post" });
|
|
63
|
+
|
|
64
|
+
// Covers keep-alive re-activation
|
|
65
|
+
onActivated(() => void kickOffLoad());
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Attach `@loadeddata="() => void tryPlay()"` and `@canplay="() => void tryPlay()"` to the `<video>` element as additional retry points once data arrives.
|
|
69
|
+
|
|
70
|
+
## Notes
|
|
71
|
+
|
|
72
|
+
- `preload="auto"` tells the browser to buffer eagerly — essential for autoplay reliability.
|
|
73
|
+
- `v.muted = true` set programmatically (in addition to the `muted` attribute) works around a Safari bug where the attribute alone is insufficient for programmatic play.
|
|
74
|
+
- This pattern applies to any Vue/Nuxt video component, not just background/ambient video.
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
Althought this repo is public and feel free to do what you wish with it, this has been developed for use with websites we develop.
|
|
10
10
|
|
|
11
|
-
## Install Nuxt
|
|
11
|
+
## Install Nuxt Components layer
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
npm install --save srcdev-nuxt-components
|
|
@@ -17,10 +17,7 @@ npm install --save srcdev-nuxt-components
|
|
|
17
17
|
```ts
|
|
18
18
|
defineNuxtConfig({
|
|
19
19
|
extends: "srcdev-nuxt-components",
|
|
20
|
-
css: [
|
|
21
|
-
"srcdev-nuxt-components/app/assets/styles/main.css",
|
|
22
|
-
"./app/assets/styles/main.css",
|
|
23
|
-
],
|
|
20
|
+
css: ["srcdev-nuxt-components/app/assets/styles/main.css", "./app/assets/styles/main.css"],
|
|
24
21
|
});
|
|
25
22
|
```
|
|
26
23
|
|
|
@@ -44,6 +41,19 @@ npm run setup:claude
|
|
|
44
41
|
|
|
45
42
|
Skills are copied into `.claude/skills/srcdev-nuxt-components/` so they never conflict with or overwrite skills your own project defines. Re-running the script after a package update is safe.
|
|
46
43
|
|
|
44
|
+
### Automate with postinstall (recommended)
|
|
45
|
+
|
|
46
|
+
To ensure skills are always up to date and `nuxt prepare` is never forgotten, combine both into a `postinstall` script. npm runs this automatically after every `npm install`:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
"scripts": {
|
|
50
|
+
"setup:claude": "cp -r node_modules/srcdev-nuxt-components/.claude/skills .claude/skills/srcdev-nuxt-components",
|
|
51
|
+
"postinstall": "nuxt prepare && npm run setup:claude"
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
> If your app uses a standalone env flag for `nuxt prepare` (e.g. `NUXT_STANDALONE=true`), include it in the `postinstall` command. This is project-specific — check your own `nuxt.config.ts` to confirm whether it is needed.
|
|
56
|
+
|
|
47
57
|
---
|
|
48
58
|
|
|
49
59
|
## Consumer App Configuration
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component
|
|
3
|
+
:is="tag"
|
|
4
|
+
class="banner-video"
|
|
5
|
+
:class="elementClasses"
|
|
6
|
+
:style="{
|
|
7
|
+
'--_max-height': maxHeight,
|
|
8
|
+
'--_max-height-tablet': maxHeightTablet,
|
|
9
|
+
'--_max-height-mobile': maxHeightMobile,
|
|
10
|
+
'--_aspect-ratio': aspectRatio,
|
|
11
|
+
'--_align-self': verticalPosition,
|
|
12
|
+
'--_justify-self': horizontalPosition,
|
|
13
|
+
}"
|
|
14
|
+
>
|
|
15
|
+
<video
|
|
16
|
+
:key="src"
|
|
17
|
+
ref="videoEl"
|
|
18
|
+
class="video"
|
|
19
|
+
autoplay
|
|
20
|
+
muted
|
|
21
|
+
loop
|
|
22
|
+
playsinline
|
|
23
|
+
preload="auto"
|
|
24
|
+
:poster="poster"
|
|
25
|
+
:style="{ objectFit: props.objectFit }"
|
|
26
|
+
@loadeddata="handleLoadedData"
|
|
27
|
+
@canplay="handleCanPlay"
|
|
28
|
+
>
|
|
29
|
+
<source :src="src" type="video/mp4" />
|
|
30
|
+
</video>
|
|
31
|
+
<NuxtImg
|
|
32
|
+
class="fallback"
|
|
33
|
+
:src="poster"
|
|
34
|
+
:alt="alt"
|
|
35
|
+
:width="imgWidth"
|
|
36
|
+
:height="imgHeight"
|
|
37
|
+
loading="eager"
|
|
38
|
+
decoding="async"
|
|
39
|
+
:style="{ objectFit: props.objectFit, objectPosition: imgObjectPosition }"
|
|
40
|
+
/>
|
|
41
|
+
</component>
|
|
42
|
+
</template>
|
|
43
|
+
|
|
44
|
+
<script setup lang="ts">
|
|
45
|
+
interface Props {
|
|
46
|
+
/** HTML element to render as the root. Defaults to `section` for landmark semantics. */
|
|
47
|
+
tag?: "section" | "div" | "header" | "main" | "article";
|
|
48
|
+
/** Path to the video source file (mp4). */
|
|
49
|
+
src: string;
|
|
50
|
+
/** Path to the fallback/poster image. Used as video poster and as the visible fallback
|
|
51
|
+
* when the video cannot play or when the user prefers reduced motion. */
|
|
52
|
+
poster: string;
|
|
53
|
+
alt?: string;
|
|
54
|
+
/** Intrinsic width of the poster image — required for NuxtImg optimisation. */
|
|
55
|
+
imgWidth?: number;
|
|
56
|
+
/** Intrinsic height of the poster image — required for NuxtImg optimisation. */
|
|
57
|
+
imgHeight?: number;
|
|
58
|
+
/** Maximum height at desktop (≥64em). Defaults to `"56rem"`. */
|
|
59
|
+
maxHeight?: string;
|
|
60
|
+
/** Maximum height at tablet (48em–64em). Falls back to `maxHeight` when unset. */
|
|
61
|
+
maxHeightTablet?: string;
|
|
62
|
+
/** Maximum height on mobile (<48em). Falls back to `maxHeightTablet` then `maxHeight` when unset. */
|
|
63
|
+
maxHeightMobile?: string;
|
|
64
|
+
/**
|
|
65
|
+
* CSS aspect-ratio of the banner container (e.g. `"16/9"`, `"21/9"`, `"4/3"`).
|
|
66
|
+
* Provides the intrinsic height that `max-height` caps at larger viewport widths.
|
|
67
|
+
*/
|
|
68
|
+
aspectRatio?: string;
|
|
69
|
+
/** How the video and fallback image fill the banner frame. Defaults to `"cover"`. */
|
|
70
|
+
objectFit?: "cover" | "contain" | "fill" | "none" | "scale-down";
|
|
71
|
+
/** Vertical crop position within the banner. Maps to `align-self` on the video and `object-position` on the fallback image. Defaults to `"center"`. */
|
|
72
|
+
verticalPosition?: "start" | "center" | "end";
|
|
73
|
+
/** Horizontal crop position within the banner. Maps to `object-position` on the fallback image. Defaults to `"center"`. */
|
|
74
|
+
horizontalPosition?: "start" | "center" | "end";
|
|
75
|
+
styleClassPassthrough?: string | string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
79
|
+
tag: "section",
|
|
80
|
+
alt: "",
|
|
81
|
+
imgWidth: 1920,
|
|
82
|
+
imgHeight: 1080,
|
|
83
|
+
maxHeight: "56rem",
|
|
84
|
+
maxHeightTablet: undefined,
|
|
85
|
+
maxHeightMobile: undefined,
|
|
86
|
+
aspectRatio: "21/9",
|
|
87
|
+
objectFit: "cover",
|
|
88
|
+
verticalPosition: "center",
|
|
89
|
+
horizontalPosition: "center",
|
|
90
|
+
styleClassPassthrough: () => [],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const positionKeywordMap = {
|
|
94
|
+
vertical: { start: "top", center: "center", end: "bottom" },
|
|
95
|
+
horizontal: { start: "left", center: "center", end: "right" },
|
|
96
|
+
} as const;
|
|
97
|
+
|
|
98
|
+
const imgObjectPosition = computed(
|
|
99
|
+
() =>
|
|
100
|
+
`${positionKeywordMap.horizontal[props.horizontalPosition]} ${positionKeywordMap.vertical[props.verticalPosition]}`
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
104
|
+
|
|
105
|
+
watch(
|
|
106
|
+
() => props.styleClassPassthrough,
|
|
107
|
+
() => resetElementClasses(props.styleClassPassthrough)
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const videoEl = shallowRef<HTMLVideoElement | null>(null);
|
|
111
|
+
|
|
112
|
+
const tryPlay = async () => {
|
|
113
|
+
const v = videoEl.value;
|
|
114
|
+
if (!v) return;
|
|
115
|
+
try {
|
|
116
|
+
// Ensure muted stays true — required for programmatic autoplay in all browsers
|
|
117
|
+
v.muted = true;
|
|
118
|
+
await v.play();
|
|
119
|
+
} catch {
|
|
120
|
+
// Autoplay blocked or interrupted — fallback image will remain visible
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const kickOffLoad = async () => {
|
|
125
|
+
await nextTick();
|
|
126
|
+
const v = videoEl.value;
|
|
127
|
+
if (!v) return;
|
|
128
|
+
// Force the media element to (re)read its source child and begin fetching
|
|
129
|
+
v.load();
|
|
130
|
+
// Attempt immediate play; loadeddata/canplay handlers will retry once data arrives
|
|
131
|
+
void tryPlay();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const handleLoadedData = () => {
|
|
135
|
+
void tryPlay();
|
|
136
|
+
};
|
|
137
|
+
const handleCanPlay = () => {
|
|
138
|
+
void tryPlay();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Runs on mount AND whenever src changes (covers route-change re-use edge cases)
|
|
142
|
+
watch(
|
|
143
|
+
() => props.src,
|
|
144
|
+
() => {
|
|
145
|
+
void kickOffLoad();
|
|
146
|
+
},
|
|
147
|
+
{ immediate: true, flush: "post" }
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Extra safety: when the component becomes active again (e.g. returning via keep-alive)
|
|
151
|
+
onActivated(() => {
|
|
152
|
+
void kickOffLoad();
|
|
153
|
+
});
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
<style lang="css">
|
|
157
|
+
@layer components {
|
|
158
|
+
.banner-video {
|
|
159
|
+
display: grid;
|
|
160
|
+
grid-template-areas: "media";
|
|
161
|
+
aspect-ratio: var(--_aspect-ratio, 16/9);
|
|
162
|
+
max-height: var(--_max-height-mobile, var(--_max-height, 56rem));
|
|
163
|
+
width: 100%;
|
|
164
|
+
overflow: hidden;
|
|
165
|
+
|
|
166
|
+
@media (min-width: 48em) {
|
|
167
|
+
max-height: var(--_max-height-tablet, var(--_max-height, 56rem));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@media (min-width: 64em) {
|
|
171
|
+
max-height: var(--_max-height, 56rem);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.video {
|
|
175
|
+
grid-area: media;
|
|
176
|
+
display: block;
|
|
177
|
+
width: 100%;
|
|
178
|
+
height: auto;
|
|
179
|
+
min-height: 100%;
|
|
180
|
+
align-self: var(--_align-self, center);
|
|
181
|
+
justify-self: var(--_justify-self, center);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.fallback {
|
|
185
|
+
grid-area: media;
|
|
186
|
+
display: none;
|
|
187
|
+
width: 100%;
|
|
188
|
+
height: 100%;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@media (prefers-reduced-motion: reduce) {
|
|
192
|
+
.video {
|
|
193
|
+
display: none;
|
|
194
|
+
}
|
|
195
|
+
.fallback {
|
|
196
|
+
display: block;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
</style>
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import BannerVideo from "../BannerVideo.vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof BannerVideo> = {
|
|
5
|
+
title: "Atoms/Media/BannerVideo",
|
|
6
|
+
component: BannerVideo,
|
|
7
|
+
argTypes: {
|
|
8
|
+
src: {
|
|
9
|
+
control: "text",
|
|
10
|
+
description: "Path to the mp4 video source file",
|
|
11
|
+
table: { category: "Media" },
|
|
12
|
+
},
|
|
13
|
+
poster: {
|
|
14
|
+
control: "text",
|
|
15
|
+
description: "Path to the fallback/poster image — shown before video loads, on error, and when prefers-reduced-motion is active",
|
|
16
|
+
table: { category: "Media" },
|
|
17
|
+
},
|
|
18
|
+
alt: {
|
|
19
|
+
control: "text",
|
|
20
|
+
description: "Alt text for the fallback image",
|
|
21
|
+
table: { category: "Media" },
|
|
22
|
+
},
|
|
23
|
+
imgWidth: {
|
|
24
|
+
control: { type: "number" },
|
|
25
|
+
description: "Intrinsic width of the poster image — required for NuxtImg optimisation",
|
|
26
|
+
table: { category: "Media" },
|
|
27
|
+
},
|
|
28
|
+
imgHeight: {
|
|
29
|
+
control: { type: "number" },
|
|
30
|
+
description: "Intrinsic height of the poster image — required for NuxtImg optimisation",
|
|
31
|
+
table: { category: "Media" },
|
|
32
|
+
},
|
|
33
|
+
tag: {
|
|
34
|
+
control: "select",
|
|
35
|
+
options: ["section", "div", "header", "main", "article"],
|
|
36
|
+
description: "HTML element rendered as the root",
|
|
37
|
+
table: { category: "Markup" },
|
|
38
|
+
},
|
|
39
|
+
maxHeight: {
|
|
40
|
+
control: "text",
|
|
41
|
+
description: "Maximum height at desktop (≥64em). Defaults to 56rem",
|
|
42
|
+
table: { category: "Layout" },
|
|
43
|
+
},
|
|
44
|
+
maxHeightTablet: {
|
|
45
|
+
control: "text",
|
|
46
|
+
description: "Maximum height at tablet (48em–64em). Falls back to maxHeight when unset",
|
|
47
|
+
table: { category: "Layout" },
|
|
48
|
+
},
|
|
49
|
+
maxHeightMobile: {
|
|
50
|
+
control: "text",
|
|
51
|
+
description: "Maximum height on mobile (<48em). Falls back through tablet → desktop when unset",
|
|
52
|
+
table: { category: "Layout" },
|
|
53
|
+
},
|
|
54
|
+
aspectRatio: {
|
|
55
|
+
control: "text",
|
|
56
|
+
description: "CSS aspect-ratio of the banner container (e.g. '16/9', '21/9', '4/3')",
|
|
57
|
+
table: { category: "Layout" },
|
|
58
|
+
},
|
|
59
|
+
objectFit: {
|
|
60
|
+
control: "select",
|
|
61
|
+
options: ["cover", "contain", "fill", "none", "scale-down"],
|
|
62
|
+
description: "How the video and fallback image fill the banner frame",
|
|
63
|
+
table: { category: "Appearance" },
|
|
64
|
+
},
|
|
65
|
+
verticalPosition: {
|
|
66
|
+
control: "select",
|
|
67
|
+
options: ["start", "center", "end"],
|
|
68
|
+
description: "Vertical crop position — start (top), center, or end (bottom). Maps to align-self on the video and object-position on the fallback image",
|
|
69
|
+
table: { category: "Appearance" },
|
|
70
|
+
},
|
|
71
|
+
horizontalPosition: {
|
|
72
|
+
control: "select",
|
|
73
|
+
options: ["start", "center", "end"],
|
|
74
|
+
description: "Horizontal crop position — start (left), center, or end (right). Maps to object-position on the fallback image",
|
|
75
|
+
table: { category: "Appearance" },
|
|
76
|
+
},
|
|
77
|
+
styleClassPassthrough: {
|
|
78
|
+
table: { disable: true },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
parameters: {
|
|
82
|
+
docs: {
|
|
83
|
+
description: {
|
|
84
|
+
component:
|
|
85
|
+
"A full-width banner that plays a muted, looping mp4 video. The poster image is shown before the video loads, when the video fails to play, and whenever the user has `prefers-reduced-motion: reduce` set. The banner is sized via `aspect-ratio` with per-breakpoint `max-height` caps — no fixed pixel heights. `objectFit` controls how media fills the frame. `verticalPosition` and `horizontalPosition` control the crop focal point — the video uses CSS grid alignment (`align-self`/`justify-self`) while the fallback image uses `object-position`.",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export default meta;
|
|
92
|
+
type Story = StoryObj<typeof BannerVideo>;
|
|
93
|
+
|
|
94
|
+
export const Default: Story = {
|
|
95
|
+
args: {
|
|
96
|
+
src: "/images/banners/video/lake-banner.mp4",
|
|
97
|
+
poster: "/images/banners/video/lake-banner.jpg",
|
|
98
|
+
alt: "A serene lake landscape",
|
|
99
|
+
imgWidth: 1920,
|
|
100
|
+
imgHeight: 1080,
|
|
101
|
+
tag: "section",
|
|
102
|
+
maxHeight: "56rem",
|
|
103
|
+
aspectRatio: "21/9",
|
|
104
|
+
objectFit: "cover",
|
|
105
|
+
verticalPosition: "center",
|
|
106
|
+
horizontalPosition: "center",
|
|
107
|
+
},
|
|
108
|
+
parameters: {
|
|
109
|
+
docs: {
|
|
110
|
+
description: {
|
|
111
|
+
story: "Default props. The lake video plays muted and looped at a 21/9 aspect ratio, capped at 56rem on desktop.",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const Widescreen: Story = {
|
|
118
|
+
args: {
|
|
119
|
+
...Default.args,
|
|
120
|
+
aspectRatio: "21/9",
|
|
121
|
+
maxHeight: "80rem",
|
|
122
|
+
},
|
|
123
|
+
parameters: {
|
|
124
|
+
docs: {
|
|
125
|
+
description: {
|
|
126
|
+
story: "A wider cinematic crop with a raised maxHeight, giving the banner more vertical presence on large screens.",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const Standard169: Story = {
|
|
133
|
+
args: {
|
|
134
|
+
...Default.args,
|
|
135
|
+
aspectRatio: "16/9",
|
|
136
|
+
maxHeight: "56rem",
|
|
137
|
+
},
|
|
138
|
+
parameters: {
|
|
139
|
+
docs: {
|
|
140
|
+
description: {
|
|
141
|
+
story: "Standard 16/9 aspect ratio — matches a typical video's native ratio, so objectPosition has minimal visible effect unless the frame is very narrow.",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const ShortBanner: Story = {
|
|
148
|
+
args: {
|
|
149
|
+
...Default.args,
|
|
150
|
+
maxHeight: "32rem",
|
|
151
|
+
verticalPosition: "end",
|
|
152
|
+
},
|
|
153
|
+
parameters: {
|
|
154
|
+
docs: {
|
|
155
|
+
description: {
|
|
156
|
+
story: "A shallower banner capped at 32rem. verticalPosition: 'end' shifts the crop to show the bottom of the frame.",
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const FullViewportHeight: Story = {
|
|
163
|
+
args: {
|
|
164
|
+
...Default.args,
|
|
165
|
+
maxHeight: "100vh",
|
|
166
|
+
aspectRatio: "16/9",
|
|
167
|
+
},
|
|
168
|
+
parameters: {
|
|
169
|
+
docs: {
|
|
170
|
+
description: {
|
|
171
|
+
story: "maxHeight set to 100vh — the banner fills the full viewport height on large screens and aspect-ratio controls height on smaller ones.",
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export const ResponsiveBreakpoints: Story = {
|
|
178
|
+
args: {
|
|
179
|
+
...Default.args,
|
|
180
|
+
maxHeight: "56rem",
|
|
181
|
+
maxHeightTablet: "40rem",
|
|
182
|
+
maxHeightMobile: "24rem",
|
|
183
|
+
},
|
|
184
|
+
parameters: {
|
|
185
|
+
docs: {
|
|
186
|
+
description: {
|
|
187
|
+
story: "Per-breakpoint max-height: 56rem desktop, 40rem tablet (≥48em), 24rem mobile (<48em). Resize the viewport to see each cap take effect.",
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
};
|