Skip to content

Commit 9f645ff

Browse files
committed
BannerVideo - Skills and tests update
1 parent 0fe4319 commit 9f645ff

3 files changed

Lines changed: 66 additions & 44 deletions

File tree

.claude/skills/components/banner-video.md

Lines changed: 31 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,38 @@
11
---
22
name: BannerVideo
3-
description: BannerVideo full-width hero video banner — props, objectFit/objectPosition, responsive max-height, reduced-motion fallback, CSS tokens, consumer styling
3+
description: BannerVideo full-width hero video banner — props, verticalPosition/horizontalPosition, responsive max-height, reduced-motion fallback, CSS tokens, consumer styling
44
type: reference
55
---
66

77
# BannerVideo
88

99
## Overview
1010

11-
`BannerVideo` renders a full-width banner section that plays a muted, looping mp4 video. A poster image is shown as fallback when:
11+
`BannerVideo` renders a full-width banner section that plays a muted, looping mp4 video. A poster image is shown as fallback when the user has `prefers-reduced-motion: reduce` set — handled entirely in CSS, no JS.
1212

13-
- The video fails to load or play
14-
- The user has `prefers-reduced-motion: reduce` set (handled entirely in CSS — no JS)
13+
The banner is sized via `aspect-ratio` so it scales naturally, with `max-height` props capping height at each breakpoint.
1514

16-
The banner is sized via `aspect-ratio` so it scales naturally, with `max-height` props capping height at each breakpoint. Both `objectFit` and `objectPosition` apply directly to the `<video>` element, which is a CSS replaced element and behaves exactly like `<img>`.
15+
### Autoplay mechanism
16+
17+
The video uses `autoplay muted loop playsinline preload="auto"` attributes on the `<video>` element with the src in a `<source>` child. A `:key="src"` on the video element forces Vue to remount it on src changes. `@loadeddata` and `@canplay` events call `tryPlay()`, and a `watch(() => props.src, ..., { immediate: true, flush: 'post' })` calls `kickOffLoad()` (which calls `v.load()` then `tryPlay()`) on mount and src change. `onActivated` handles keep-alive re-activation.
1718

1819
## Props
1920

2021
| Prop | Type | Default | Description |
2122
|------|------|---------|-------------|
2223
| `src` | `string` || Path to the mp4 video source. **Required.** |
23-
| `poster` | `string` || Path to the fallback/poster image. **Required.** Used as the video poster attribute and as the visible fallback. |
24+
| `poster` | `string` || Path to the fallback/poster image. **Required.** Used as the video `poster` attribute and as the `prefers-reduced-motion` fallback. |
2425
| `alt` | `string` | `""` | Alt text for the fallback `NuxtImg`. |
2526
| `imgWidth` | `number` | `1920` | Intrinsic width of the poster image — required for NuxtImg/IPX optimisation. |
2627
| `imgHeight` | `number` | `1080` | Intrinsic height of the poster image — required for NuxtImg/IPX optimisation. |
2728
| `tag` | `"section" \| "div" \| "header" \| "main" \| "article"` | `"section"` | HTML element rendered as the root. |
2829
| `maxHeight` | `string` | `"56rem"` | Maximum height at desktop (≥64em / 1024px). |
29-
| `maxHeightTablet` | `string` | `undefined` | Maximum height at tablet (48em–64em / 768px–1024px). Falls back to `maxHeight`. |
30-
| `maxHeightMobile` | `string` | `undefined` | Maximum height on mobile (<48em / 768px). Falls back through tablet → desktop. |
30+
| `maxHeightTablet` | `string` | `undefined` | Maximum height at tablet (48em–64em). Falls back to `maxHeight`. |
31+
| `maxHeightMobile` | `string` | `undefined` | Maximum height on mobile (<48em). Falls back through tablet → desktop. |
3132
| `aspectRatio` | `string` | `"21/9"` | CSS `aspect-ratio` of the container (e.g. `"16/9"`, `"21/9"`, `"4/3"`). |
3233
| `objectFit` | `"cover" \| "contain" \| "fill" \| "none" \| "scale-down"` | `"cover"` | How the video and fallback image fill the banner frame. |
33-
| `objectPosition` | `string` | `"50% 50%"` | Focal point within the video and fallback image — any valid CSS `object-position` value. |
34+
| `verticalPosition` | `"start" \| "center" \| "end"` | `"center"` | Vertical crop position. Maps to `align-self` on the video element and `object-position` Y on the fallback image. |
35+
| `horizontalPosition` | `"start" \| "center" \| "end"` | `"center"` | Horizontal crop position. Maps to `object-position` X on the fallback image. |
3436
| `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes applied to the root element. |
3537

3638
## Basic usage
@@ -70,25 +72,15 @@ The banner is sized via `aspect-ratio` so it scales naturally, with `max-height`
7072
/>
7173
```
7274

73-
### Standard 16/9 crop
74-
75-
```vue
76-
<BannerVideo
77-
src="/videos/hero.mp4"
78-
poster="/images/hero-poster.jpg"
79-
alt="Studio interior"
80-
aspect-ratio="16/9"
81-
/>
82-
```
83-
8475
### Custom focal point
8576

8677
```vue
8778
<BannerVideo
8879
src="/videos/hero.mp4"
8980
poster="/images/hero-poster.jpg"
9081
alt="Studio interior"
91-
object-position="50% 75%"
82+
vertical-position="end"
83+
horizontal-position="center"
9284
/>
9385
```
9486

@@ -103,19 +95,23 @@ The banner is sized via `aspect-ratio` so it scales naturally, with `max-height`
10395
/>
10496
```
10597

106-
## objectPosition explained
98+
## Positioning explained
10799

108-
`objectPosition` only produces a visible result when the video is **cropped** — i.e. when the container's aspect ratio differs from the video's native ratio, causing `object-fit: cover` to overflow.
100+
The video element uses `height: auto; min-height: 100%` so it overflows the container naturally when aspect ratios differ. `overflow: hidden` on the root clips it. `verticalPosition` maps to `align-self` on the video (shifting which portion of the overflow is visible) and to `object-position` Y on the fallback image.
109101

110-
- Default `aspectRatio: "21/9"` with a 16:9 video → always crops vertically → `objectPosition` Y value controls which vertical slice is shown.
111-
- `aspectRatio: "16/9"` with a 16:9 video → no overflow → `objectPosition` has no visible effect.
102+
`horizontalPosition` only affects the fallback image via `object-position` X — the video fills full width so horizontal alignment is a no-op on the video element itself.
112103

113-
| objectPosition | Effect with 21/9 container + 16:9 video |
104+
| verticalPosition | align-self | object-position Y |
105+
|---|---|---|
106+
| `"start"` | `start` | `top` |
107+
| `"center"` (default) | `center` | `center` |
108+
| `"end"` | `end` | `bottom` |
109+
110+
| horizontalPosition | object-position X |
114111
|---|---|
115-
| `"50% 0%"` | Top of the frame locked in view |
116-
| `"50% 50%"` (default) | Centre of the frame |
117-
| `"50% 100%"` | Bottom of the frame locked in view |
118-
| `"0% 50%"` | Left-biased horizontal crop |
112+
| `"start"` | `left` |
113+
| `"center"` (default) | `center` |
114+
| `"end"` | `right` |
119115

120116
## imgWidth / imgHeight
121117

@@ -129,14 +125,16 @@ Always match the intrinsic dimensions of the poster file. NuxtImg uses them to a
129125

130126
## CSS custom properties
131127

132-
All set from props via inline `:style` on the root element. Override in a scoped style block for responsive or contextual control.
128+
All set from props via inline `:style` on the root element.
133129

134130
| Property | Default | Set by prop |
135131
|---|---|---|
136132
| `--_max-height` | `56rem` | `maxHeight` |
137133
| `--_max-height-tablet` | *(unset)* | `maxHeightTablet` |
138134
| `--_max-height-mobile` | *(unset)* | `maxHeightMobile` |
139135
| `--_aspect-ratio` | `21/9` | `aspectRatio` |
136+
| `--_align-self` | `center` | `verticalPosition` |
137+
| `--_justify-self` | `center` | `horizontalPosition` |
140138

141139
**Responsive override example:**
142140

@@ -162,10 +160,7 @@ All set from props via inline `:style` on the root element. Override in a scoped
162160
|---|---|
163161
| `.banner-video` | Always — the root element |
164162
| `.video` | The `<video>` element |
165-
| `.fallback` | The `<NuxtImg>` fallback element |
166-
| `.video-failed` | Added to root when the video or source fires an error event |
167-
168-
When `.video-failed` is present, `.video` is hidden and `.fallback` is shown via CSS. The same swap happens at `@media (prefers-reduced-motion: reduce)` — no JS involved.
163+
| `.fallback` | The `<NuxtImg>` fallback — hidden by default, shown via `prefers-reduced-motion` CSS |
169164

170165
## Consumer styling
171166

@@ -175,7 +170,6 @@ Use an unscoped style block scoped by a page or section wrapper class. No `:deep
175170
<style>
176171
.my-page {
177172
.banner-video {
178-
/* Override max-height at a custom breakpoint */
179173
@media (width < 900px) {
180174
--_max-height: 36rem;
181175
}
@@ -187,7 +181,6 @@ Use an unscoped style block scoped by a page or section wrapper class. No `:deep
187181
## Notes
188182

189183
- `loading="eager"` and `decoding="async"` are hardcoded on the fallback `NuxtImg` — it is above the fold by definition.
190-
- The video has `autoplay muted loop playsinline` attributes. These are intentional and not configurable — this component is for ambient background video only, not user-controlled media.
184+
- The video has `autoplay muted loop playsinline preload="auto"` — intentional and not configurable. This component is for ambient background video only, not user-controlled media.
191185
- `prefers-reduced-motion` is handled in CSS (`.video { display: none }` + `.fallback { display: block }`), not via JS.
192-
- Error detection covers both `@error` on `<video>` and `@error` on `<source>` for cross-browser reliability.
193186
- Storybook: the `"none"` image provider is active, so `poster` paths pass through unchanged. Always provide explicit `img-width` and `img-height` to avoid the `w=1536` fallback in deployed Storybook.

app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,35 @@ describe("BannerVideo", () => {
9696
expect(wrapper.find("video").attributes("poster")).toBe(defaultProps.poster);
9797
});
9898

99-
it("video element has the correct src", async () => {
99+
it("video element has preload=auto", async () => {
100100
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
101-
expect(wrapper.find("video").attributes("src")).toBe(defaultProps.src);
101+
expect(wrapper.find("video").attributes("preload")).toBe("auto");
102102
});
103103

104-
it("video element has preload=auto", async () => {
104+
it("source element has the correct src", async () => {
105105
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
106-
expect(wrapper.find("video").attributes("preload")).toBe("auto");
106+
expect(wrapper.find("source").attributes("src")).toBe(defaultProps.src);
107+
});
108+
109+
it("source element has type video/mp4", async () => {
110+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
111+
expect(wrapper.find("source").attributes("type")).toBe("video/mp4");
112+
});
113+
114+
it("calls play() when loadeddata fires", async () => {
115+
const play = vi.fn().mockResolvedValue(undefined);
116+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
117+
(wrapper.find("video").element as HTMLVideoElement).play = play;
118+
await wrapper.find("video").trigger("loadeddata");
119+
expect(play).toHaveBeenCalled();
120+
});
121+
122+
it("calls play() when canplay fires", async () => {
123+
const play = vi.fn().mockResolvedValue(undefined);
124+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
125+
(wrapper.find("video").element as HTMLVideoElement).play = play;
126+
await wrapper.find("video").trigger("canplay");
127+
expect(play).toHaveBeenCalled();
107128
});
108129

109130
// ─── Fallback image ──────────────────────────────────────────────────────
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3-
exports[`BannerVideo > renders correct HTML structure (all props set) 1`] = `"<header class="banner-video full-bleed" style="--_max-height: 56rem; --_aspect-ratio: 21/9; --_align-self: center; --_justify-self: center;" height="40rem"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" src="/images/banners/video/lake-banner.mp4" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;"></video><img width="1280" height="720" data-nuxt-img="" srcset="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg 1x, /_ipx/s_2560x1440/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="Lake banner" loading="eager" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg"></header>"`;
3+
exports[`BannerVideo > renders correct HTML structure (all props set) 1`] = `
4+
"<header class="banner-video full-bleed" style="--_max-height: 56rem; --_aspect-ratio: 21/9; --_align-self: center; --_justify-self: center;" height="40rem"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;">
5+
<source src="/images/banners/video/lake-banner.mp4" type="video/mp4">
6+
</video><img width="1280" height="720" data-nuxt-img="" srcset="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg 1x, /_ipx/s_2560x1440/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="Lake banner" loading="eager" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg"></header>"
7+
`;
48
5-
exports[`BannerVideo > renders correct HTML structure (default props) 1`] = `"<section class="banner-video" style="--_max-height: 56rem; --_aspect-ratio: 21/9; --_align-self: center; --_justify-self: center;"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" src="/images/banners/video/lake-banner.mp4" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;"></video><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg 1x, /_ipx/s_3840x2160/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="" loading="eager" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg"></section>"`;
9+
exports[`BannerVideo > renders correct HTML structure (default props) 1`] = `
10+
"<section class="banner-video" style="--_max-height: 56rem; --_aspect-ratio: 21/9; --_align-self: center; --_justify-self: center;"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;">
11+
<source src="/images/banners/video/lake-banner.mp4" type="video/mp4">
12+
</video><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg 1x, /_ipx/s_3840x2160/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="" loading="eager" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg"></section>"
13+
`;

0 commit comments

Comments
 (0)