srcdev-nuxt-components 9.6.11 → 9.6.13

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 (77) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +2 -1
  2. package/.claude/rules/consumer/vfor-keys-from-data.md +14 -0
  3. package/.claude/skills/component-compliance-checklist.md +24 -1
  4. package/.claude/skills/component-naming.md +1 -0
  5. package/.claude/skills/components/banner-video.md +24 -30
  6. package/.claude/skills/components/content-docs.md +18 -5
  7. package/.claude/skills/components/display-dialog.md +16 -6
  8. package/.claude/skills/components/{display-tooltip-defined.md → display-tooltip-rich.md} +40 -42
  9. package/.claude/skills/components/display-tooltip.md +17 -6
  10. package/.claude/skills/components/form-field.md +15 -0
  11. package/.claude/skills/components/form-fieldset.md +18 -2
  12. package/.claude/skills/components/marquee-scroller.md +51 -14
  13. package/.claude/skills/components/skip-links.md +16 -0
  14. package/.claude/skills/composable-tooltips-guide.md +4 -4
  15. package/.claude/skills/index.md +6 -6
  16. package/.claude/skills/testing-add-unit-test.md +8 -0
  17. package/.vscode/srcdev-component-banner-video.code-snippets +38 -25
  18. package/.vscode/srcdev-component-content-docs.code-snippets +69 -60
  19. package/.vscode/srcdev-component-display-dialog.code-snippets +7 -5
  20. package/.vscode/srcdev-component-display-tooltip-rich.code-snippets +50 -0
  21. package/.vscode/srcdev-component-display-tooltip.code-snippets +31 -0
  22. package/.vscode/srcdev-component-form-field.code-snippets +7 -6
  23. package/.vscode/srcdev-component-form-fieldset.code-snippets +12 -12
  24. package/.vscode/srcdev-component-marquee-scroller.code-snippets +31 -28
  25. package/.vscode/srcdev-component-skip-links.code-snippets +1 -1
  26. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +77 -54
  27. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +103 -91
  28. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +132 -3
  29. package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +190 -102
  30. package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +17 -13
  31. package/app/components/01.atoms/banner-video/BannerVideo.vue +51 -19
  32. package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +59 -40
  33. package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +88 -1
  34. package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +127 -78
  35. package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +12 -4
  36. package/app/components/01.atoms/content-wrappers/docs-pages/CONSUMER-STYLING.md +42 -14
  37. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +86 -112
  38. package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +134 -8
  39. package/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts +131 -65
  40. package/app/components/01.atoms/display-dialog/CONSUMER-STYLING.md +28 -3
  41. package/app/components/01.atoms/display-dialog/DisplayDialog.vue +39 -31
  42. package/app/components/01.atoms/display-dialog/stories/DisplayDialog.stories.ts +92 -1
  43. package/app/components/01.atoms/display-dialog/tests/DisplayDialog.spec.ts +87 -41
  44. package/app/components/01.atoms/display-dialog/tests/__snapshots__/DisplayDialog.spec.ts.snap +5 -1
  45. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +70 -24
  46. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +24 -12
  47. package/app/components/01.atoms/display-tooltip/stories/DisplayTooltip.stories.ts +89 -2
  48. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +65 -18
  49. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +3 -3
  50. package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +65 -19
  51. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +20 -14
  52. package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +89 -20
  53. package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +70 -10
  54. package/app/components/02.molecules/display-tooltip-rich/CONSUMER-STYLING.md +110 -0
  55. package/app/components/02.molecules/display-tooltip-rich/DisplayTooltipRich.vue +148 -0
  56. package/app/components/02.molecules/display-tooltip-rich/stories/DisplayTooltipRich.stories.ts +254 -0
  57. package/app/components/02.molecules/display-tooltip-rich/tests/DisplayTooltipRich.spec.ts +156 -0
  58. package/app/components/02.molecules/display-tooltip-rich/tests/__snapshots__/DisplayTooltipRich.spec.ts.snap +22 -0
  59. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -0
  60. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +1 -1
  61. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +1 -0
  62. package/app/components/05.forms/form-field/CONSUMER-STYLING.md +7 -0
  63. package/app/components/05.forms/form-field/stories/FormField.stories.ts +116 -2
  64. package/app/components/05.forms/form-fieldset/CONSUMER-STYLING.md +11 -1
  65. package/app/components/05.forms/form-fieldset/FormFieldset.vue +4 -2
  66. package/app/components/05.forms/form-fieldset/stories/FormFieldset.stories.ts +68 -4
  67. package/app/components/05.forms/form-fieldset/tests/FormFieldset.spec.ts +10 -0
  68. package/app/composables/tests/useTooltips.spec.ts +3 -3
  69. package/app/types/components/index.ts +1 -1
  70. package/package.json +1 -1
  71. package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +0 -29
  72. package/app/components/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +0 -53
  73. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +0 -117
  74. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +0 -129
  75. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +0 -85
  76. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +0 -17
  77. /package/app/types/components/{display-tooltip-defined.d.ts → display-tooltip-rich.d.ts} +0 -0
@@ -5,7 +5,7 @@
5
5
  :class="elementClasses"
6
6
  :data-depth="depth"
7
7
  :style="{
8
- '--_aspect-ratio': aspectRatio,
8
+ '--_aspect-ratio': safeAspectRatio,
9
9
  '--_align-self': verticalPosition,
10
10
  '--_justify-self': horizontalPosition,
11
11
  }"
@@ -13,7 +13,7 @@
13
13
  <video
14
14
  :key="src"
15
15
  ref="videoEl"
16
- class="video"
16
+ class="banner-video-media"
17
17
  autoplay
18
18
  muted
19
19
  loop
@@ -30,7 +30,7 @@
30
30
  <source :src="src" type="video/mp4" />
31
31
  </video>
32
32
  <NuxtImg
33
- class="fallback"
33
+ class="banner-video-fallback"
34
34
  :src="poster"
35
35
  :alt="alt"
36
36
  :width="imgWidth"
@@ -40,16 +40,19 @@
40
40
  decoding="async"
41
41
  :style="{ objectFit: props.objectFit, objectPosition: imgObjectPosition }"
42
42
  />
43
- <button
43
+ <InputButton
44
44
  type="button"
45
- class="banner-video__toggle"
46
- :aria-label="isPlaying ? 'Pause background video' : 'Play background video'"
45
+ variant="inline"
46
+ :button-text="isPlaying ? pauseLabel : playLabel"
47
+ style-class-passthrough="banner-video-toggle"
47
48
  @click="togglePlayback"
48
49
  >
49
- <slot name="toggle-icon" :is-playing="isPlaying">
50
- <Icon :name="isPlaying ? pauseIcon : playIcon" aria-hidden="true" />
51
- </slot>
52
- </button>
50
+ <template #iconOnly>
51
+ <slot name="toggle-icon" :is-playing="isPlaying">
52
+ <Icon :name="isPlaying ? pauseIcon : playIcon" aria-hidden="true" />
53
+ </slot>
54
+ </template>
55
+ </InputButton>
53
56
  </component>
54
57
  </template>
55
58
 
@@ -68,6 +71,10 @@ interface Props {
68
71
  horizontalPosition?: "start" | "center" | "end";
69
72
  playIcon?: string;
70
73
  pauseIcon?: string;
74
+ /** Toggle button label while paused — override for localisation. */
75
+ playLabel?: string;
76
+ /** Toggle button label while playing — override for localisation. */
77
+ pauseLabel?: string;
71
78
  styleClassPassthrough?: string | string[];
72
79
  }
73
80
 
@@ -83,9 +90,22 @@ const props = withDefaults(defineProps<Props>(), {
83
90
  horizontalPosition: "center",
84
91
  playIcon: "mdi:play",
85
92
  pauseIcon: "mdi:pause",
93
+ playLabel: "Play background video",
94
+ pauseLabel: "Pause background video",
86
95
  styleClassPassthrough: () => [],
87
96
  });
88
97
 
98
+ const DEFAULT_ASPECT_RATIO = "21/9";
99
+ const ratioPattern = /^\s*(\d*\.?\d+)\s*(?:\/\s*(\d*\.?\d+)\s*)?$/;
100
+
101
+ const safeAspectRatio = computed(() => {
102
+ const match = ratioPattern.exec(props.aspectRatio ?? "");
103
+ if (!match) return DEFAULT_ASPECT_RATIO;
104
+ const width = Number(match[1]);
105
+ const height = match[2] === undefined ? 1 : Number(match[2]);
106
+ return width > 0 && height > 0 ? props.aspectRatio.trim() : DEFAULT_ASPECT_RATIO;
107
+ });
108
+
89
109
  const positionKeywordMap = {
90
110
  vertical: { start: "top", center: "center", end: "bottom" },
91
111
  horizontal: { start: "left", center: "center", end: "right" },
@@ -123,7 +143,7 @@ const tryPlay = async () => {
123
143
  v.muted = true;
124
144
  await v.play();
125
145
  } catch {
126
- // Autoplay blocked or interrupted — fallback image will remain visible
146
+ // Autoplay blocked or interrupted: the video's poster stays visible
127
147
  }
128
148
  };
129
149
 
@@ -162,7 +182,7 @@ onActivated(() => {
162
182
 
163
183
  display: grid;
164
184
  grid-template-areas: "media";
165
- aspect-ratio: var(--_aspect-ratio, 16/9);
185
+ aspect-ratio: var(--_aspect-ratio, 21/9);
166
186
  max-height: var(--_max-height);
167
187
  width: 100%;
168
188
  overflow: hidden;
@@ -183,7 +203,7 @@ onActivated(() => {
183
203
  --_max-height: var(--banner-video-max-height-xl, clamp(52rem, 65vw, 90rem));
184
204
  }
185
205
 
186
- .video {
206
+ .banner-video-media {
187
207
  grid-area: media;
188
208
  display: block;
189
209
  width: 100%;
@@ -193,24 +213,26 @@ onActivated(() => {
193
213
  justify-self: var(--_justify-self, center);
194
214
  }
195
215
 
196
- .fallback {
216
+ .banner-video-fallback {
197
217
  grid-area: media;
198
218
  display: none;
199
219
  width: 100%;
200
220
  height: 100%;
201
221
  }
202
222
 
203
- .banner-video__toggle {
223
+ .banner-video-toggle.input-button {
204
224
  grid-area: media;
205
225
  align-self: end;
206
226
  justify-self: end;
207
227
  margin: var(--banner-video-toggle-offset, 1.2rem);
208
228
  z-index: 1;
209
229
 
230
+ box-sizing: border-box;
210
231
  display: grid;
211
232
  place-items: center;
212
233
  width: var(--banner-video-toggle-size, 3.2rem);
213
234
  height: var(--banner-video-toggle-size, 3.2rem);
235
+ min-height: auto;
214
236
  padding: 0;
215
237
 
216
238
  background-color: var(--banner-video-toggle-background, oklch(0% 0 0 / 0.4));
@@ -220,20 +242,30 @@ onActivated(() => {
220
242
  cursor: pointer;
221
243
  transition: background-color 0.2s ease;
222
244
 
223
- &:hover,
245
+ & .input-button-icon {
246
+ margin: 0;
247
+ }
248
+
249
+ &:hover {
250
+ background-color: var(--banner-video-toggle-background-hover, oklch(0% 0 0 / 0.6));
251
+ }
252
+
224
253
  &:focus-visible {
225
254
  background-color: var(--banner-video-toggle-background-hover, oklch(0% 0 0 / 0.6));
255
+ outline: var(--banner-video-toggle-focus-outline-width, 2px) solid
256
+ var(--banner-video-toggle-focus-outline-color, currentColor);
257
+ outline-offset: var(--banner-video-toggle-focus-outline-offset, 2px);
226
258
  }
227
259
  }
228
260
 
229
261
  @media (prefers-reduced-motion: reduce) {
230
- .video {
262
+ .banner-video-media {
231
263
  display: none;
232
264
  }
233
- .fallback {
265
+ .banner-video-fallback {
234
266
  display: block;
235
267
  }
236
- .banner-video__toggle {
268
+ .banner-video-toggle.input-button {
237
269
  display: none;
238
270
  }
239
271
  }
@@ -14,7 +14,10 @@ file, scoped to a page wrapper, or per-instance via `styleClassPassthrough`.
14
14
  | `--banner-video-max-height-md` | `clamp(28rem, 38vw, 56rem)` | Max height at `depth="md"` (default) |
15
15
  | `--banner-video-max-height-lg` | `clamp(40rem, 52vw, 72rem)` | Max height at `depth="lg"` |
16
16
  | `--banner-video-max-height-xl` | `clamp(52rem, 65vw, 90rem)` | Max height at `depth="xl"` |
17
- | `--banner-video-max-height` | `clamp(28rem, 38vw, 56rem)` | Fallback used if the active depth tier's own token isn't set |
17
+ | `--banner-video-max-height` | `clamp(28rem, 38vw, 56rem)` | Only reached if the root has no valid `data-depth`; every typed `depth` value has its own token above |
18
+
19
+ Only the active tier's token matters: `depth="lg"` reads `--banner-video-max-height-lg` and nothing
20
+ else. The `vw` values in the defaults track the viewport, not the banner's container.
18
21
 
19
22
  ### Pause/play toggle button
20
23
 
@@ -23,14 +26,41 @@ file, scoped to a page wrapper, or per-instance via `styleClassPassthrough`.
23
26
  | `--banner-video-toggle-size` | `3.2rem` | Width and height of the toggle button |
24
27
  | `--banner-video-toggle-offset` | `1.2rem` | Margin from the bottom-right corner of the banner |
25
28
  | `--banner-video-toggle-background` | `oklch(0% 0 0 / 0.4)` | Button background |
26
- | `--banner-video-toggle-background-hover` | `oklch(0% 0 0 / 0.6)` | Button background on hover/focus |
27
- | `--banner-video-toggle-icon-color` | `white` | Icon colour (and any text colour, via `color`) |
29
+ | `--banner-video-toggle-background-hover` | `oklch(0% 0 0 / 0.6)` | Button background on hover and keyboard focus |
30
+ | `--banner-video-toggle-icon-color` | `white` | Icon colour (and slot text colour, via `color`) |
31
+ | `--banner-video-toggle-focus-outline-width` | `2px` | Focus ring width |
32
+ | `--banner-video-toggle-focus-outline-color` | `currentColor` (the icon colour) | Focus ring colour |
33
+ | `--banner-video-toggle-focus-outline-offset` | `2px` | Focus ring offset |
34
+
35
+ The toggle defaults are a dark scrim with a white icon rather than `--theme-*` colours: they sit
36
+ over video, whose colours the theme doesn't know.
37
+
38
+ Private tokens (not public API): `--_max-height` (the active depth tier's value), and
39
+ `--_aspect-ratio`, `--_align-self`, `--_justify-self`, set inline from the `aspectRatio`,
40
+ `verticalPosition` and `horizontalPosition` props.
41
+
42
+ ## State hooks
43
+
44
+ | Hook | Meaning |
45
+ |---|---|
46
+ | `[data-depth="xs" \| "sm" \| "md" \| "lg" \| "xl"]` | The active depth tier, on the root |
47
+
48
+ | Class | Element |
49
+ |---|---|
50
+ | `.banner-video` | Root (tag from the `tag` prop) |
51
+ | `.banner-video-media` | The `<video>` |
52
+ | `.banner-video-fallback` | The poster `NuxtImg`, shown only under `prefers-reduced-motion: reduce` |
53
+ | `.banner-video-toggle` | The pause/play `InputButton` (also carries `.input-button`); hidden under reduced motion |
28
54
 
29
- ---
55
+ > **Changed 2026-10-18**: `.video` → `.banner-video-media`, `.fallback` → `.banner-video-fallback`
56
+ > and `.banner-video__toggle` → `.banner-video-toggle`, so they can't collide with consumer CSS.
57
+ > The toggle is now an icon-only `InputButton`: its label is visually hidden text
58
+ > (`.input-button-text.sr-only`, from `playLabel`/`pauseLabel`) instead of an `aria-label`. Token
59
+ > names are unchanged.
30
60
 
31
- ## Icon and content overrides — props and slots, not CSS
61
+ ## Icon and label overrides: props and slots, not CSS
32
62
 
33
- The toggle's icon is prop-driven, not hardcoded:
63
+ The toggle's icon is prop-driven:
34
64
 
35
65
  ```vue
36
66
  <BannerVideo
@@ -41,9 +71,9 @@ The toggle's icon is prop-driven, not hardcoded:
41
71
  />
42
72
  ```
43
73
 
44
- For anything beyond swapping the icon name (e.g. a custom SVG, or an icon set the `Icon`
45
- component doesn't cover), replace it with the `toggle-icon` scoped slot, which receives
46
- `isPlaying`:
74
+ For anything beyond swapping the icon name (a custom SVG, or an icon set the `Icon` component
75
+ doesn't cover), use the `toggle-icon` scoped slot, which receives `isPlaying`. Slot content is
76
+ clipped to the round button, so size it to `--banner-video-toggle-size`:
47
77
 
48
78
  ```vue
49
79
  <BannerVideo src="/videos/hero.mp4" poster="/images/hero-poster.jpg">
@@ -53,16 +83,13 @@ component doesn't cover), replace it with the `toggle-icon` scoped slot, which r
53
83
  </BannerVideo>
54
84
  ```
55
85
 
56
- ---
86
+ The button's accessible name comes from `play-label`/`pause-label` (defaults "Play background
87
+ video" / "Pause background video"); pass translated strings from your own i18n.
57
88
 
58
89
  ## Global theming
59
90
 
60
- Create `assets/styles/setup/07.components/banner-video.css` in the consuming app and set
61
- tokens on `:root`. This applies to every `BannerVideo` across the site.
62
-
63
91
  ```css
64
- /* assets/styles/setup/07.components/banner-video.css */
65
- :root {
92
+ :where(html) {
66
93
  --banner-video-max-height-md: clamp(32rem, 45vw, 64rem);
67
94
  --banner-video-toggle-background: var(--brand-overlay);
68
95
  --banner-video-toggle-background-hover: var(--brand-overlay-strong);
@@ -70,8 +97,6 @@ tokens on `:root`. This applies to every `BannerVideo` across the site.
70
97
  }
71
98
  ```
72
99
 
73
- ---
74
-
75
100
  ## Local overrides
76
101
 
77
102
  Set the tokens above on an element you own (a page or section class, or a class added with
@@ -82,46 +107,40 @@ Set the tokens above on an element you own (a page or section class, or a class
82
107
 
83
108
  ### Page or section
84
109
 
85
- Override tokens for a specific banner by scoping them under the page or layout wrapper.
86
- No `:deep()` is required (component styles are unscoped).
87
-
88
110
  ```css
89
- /* In the consuming page's unscoped <style> block */
90
111
  .homepage-hero {
91
- .banner-video {
92
- --banner-video-max-height-xl: 80rem;
93
- --banner-video-toggle-size: 4rem;
94
- --banner-video-toggle-offset: 2rem;
95
- }
112
+ --banner-video-max-height-xl: 80rem;
113
+ --banner-video-toggle-size: 4rem;
114
+ --banner-video-toggle-offset: 2rem;
96
115
  }
97
116
  ```
98
117
 
99
118
  ### One instance
100
119
 
101
- Use sparingly — prefer global or page-scoped CSS. When a single instance needs a distinct
102
- visual style, pass a modifier class:
103
-
104
120
  ```vue
105
121
  <BannerVideo :style-class-passthrough="['quiet-toggle']" src="…" poster="…" />
106
122
  ```
107
123
 
108
124
  ```css
109
- .banner-video.quiet-toggle {
125
+ .quiet-toggle {
110
126
  --banner-video-toggle-background: transparent;
111
127
  --banner-video-toggle-background-hover: oklch(0% 0 0 / 0.3);
112
128
  }
113
129
  ```
114
130
 
115
- ---
131
+ ## Class passthrough
116
132
 
117
- ## Notes
133
+ `:style-class-passthrough` (string or string array) adds classes to the root `.banner-video`
134
+ element only, never to the video, image or toggle. Use it for per-instance token overrides or
135
+ layout utilities (`full-bleed`, `mbe-0`).
118
136
 
119
- - `--banner-video-max-height` (no suffix) only applies if the active `data-depth` tier's own
120
- token isn't set — in practice this only matters if you delete a depth tier's token without
121
- replacing it. Prefer setting the specific `-{depth}` token you need.
122
- - The toggle button's `border-radius` (`100vw`, i.e. a full circle) and `transition-duration`
123
- (`0.2s`) are not tokenized — they're low-variance visual details rather than something
124
- consuming apps are expected to reskin per-brand.
125
- - `aspectRatio`, `objectFit`, `verticalPosition`, and `horizontalPosition` are props, not
126
- tokens — set them directly on the component.
137
+ ## Notes
127
138
 
139
+ - The toggle button's `border-radius` (`100vw`, a full circle) and transition (`0.2s`) are not
140
+ tokenized: low-variance details rather than something consuming apps reskin per brand.
141
+ - `aspectRatio`, `objectFit`, `verticalPosition` and `horizontalPosition` are props, not tokens.
142
+ An invalid `aspectRatio` (empty, zero, negative, not a number or `w/h` ratio) falls back to
143
+ `21/9`.
144
+ - Once the viewport is wide enough, the depth tier's `max-height` wins over `aspect-ratio`, so
145
+ changing `aspectRatio` can look like a no-op on desktop. Raise the active tier's token to let the
146
+ ratio dominate.
@@ -1,5 +1,8 @@
1
+ import { ref } from "vue";
1
2
  import BannerVideo from "../BannerVideo.vue";
3
+ import CanvasSwitcher from "../../canvas-switcher/CanvasSwitcher.vue";
2
4
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
5
+ import type { MediaCanvas } from "~/types/components";
3
6
 
4
7
  const meta: Meta<typeof BannerVideo> = {
5
8
  title: "Atoms/Media/BannerVideo",
@@ -75,15 +78,43 @@ const meta: Meta<typeof BannerVideo> = {
75
78
  description: "Icon name for the toggle button once playback starts (any Iconify icon)",
76
79
  table: { category: "Appearance" },
77
80
  },
81
+ playLabel: {
82
+ control: "text",
83
+ description: "Toggle button label while paused (visually hidden, its accessible name). Override for localisation",
84
+ table: { category: "Content" },
85
+ },
86
+ pauseLabel: {
87
+ control: "text",
88
+ description: "Toggle button label while playing (visually hidden, its accessible name). Override for localisation",
89
+ table: { category: "Content" },
90
+ },
78
91
  styleClassPassthrough: {
79
92
  table: { disable: true },
80
93
  },
81
94
  },
95
+ decorators: [
96
+ // The banner is 100% of its container wide and takes its height from aspect-ratio, so a constrained wrapper stands in for a narrower viewport.
97
+ (story, context) => ({
98
+ components: { story, CanvasSwitcher },
99
+ setup() {
100
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
101
+ return { canvasName };
102
+ },
103
+ template: `
104
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
105
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
106
+ </div>
107
+ <div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
108
+ <story />
109
+ </div>
110
+ `,
111
+ }),
112
+ ],
82
113
  parameters: {
83
114
  docs: {
84
115
  description: {
85
116
  component:
86
- "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 a responsive `max-height` driven by the `depth` tier (`xs` → `xl`). Each tier uses a `clamp()` scale and exposes a `--banner-video-max-height-{depth}` token for consuming pages to override. `objectFit` controls how media fills the frame. `verticalPosition` and `horizontalPosition` control the crop focal point. The pause/play toggle's icon is configurable via `playIcon`/`pauseIcon` props, or fully replaceable via the `toggle-icon` scoped slot.",
117
+ "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 a responsive `max-height` driven by the `depth` tier (`xs` → `xl`). Each tier uses a `clamp()` scale and exposes a `--banner-video-max-height-{depth}` token for consuming pages to override. `objectFit` controls how media fills the frame. `verticalPosition` and `horizontalPosition` control the crop focal point. The pause/play toggle (WCAG 2.2.2) is an icon-only `InputButton`: its icon is configurable via `playIcon`/`pauseIcon`, or fully replaceable via the `toggle-icon` scoped slot, and its visually hidden label comes from `playLabel`/`pauseLabel`. An invalid `aspectRatio` (empty, zero, negative, not a number or `w/h` ratio) falls back to `21/9`.",
87
118
  },
88
119
  },
89
120
  },
@@ -229,3 +260,59 @@ export const CustomToggleIconSlot: Story = {
229
260
  },
230
261
  },
231
262
  };
263
+
264
+ export const StressTest: Story = {
265
+ name: "Stress Test (Worst-Case Data)",
266
+ args: {
267
+ ...Default.args,
268
+ src: "https://example.invalid/missing-video.mp4",
269
+ alt: "Bevorzugtekontaktmethodefürrückfragenundterminvereinbarungen 😀 <b>not bold</b> طريقة الاتصال المفضلة",
270
+ playLabel: "Hintergrundvideoabspielenundweiterlaufenlassen 😀 <b>not bold</b>",
271
+ pauseLabel: "Hintergrundvideoanhaltenundstehenlassen",
272
+ },
273
+ render: (args) => ({
274
+ components: { BannerVideo },
275
+ setup() {
276
+ const lake = Default.args!;
277
+ return { args, lake };
278
+ },
279
+ template: `
280
+ <div style="display: grid; gap: 2.4rem; font-size: 1.4rem;">
281
+ <p style="margin: 0;">Broken video URL, long unbroken localised labels with emoji and HTML-like text, and an alt with right-to-left text. The video never loads, so its poster shows instead.</p>
282
+ <BannerVideo v-bind="args"></BannerVideo>
283
+
284
+ <p style="margin: 0;">Broken video and broken poster URLs: an empty frame at the banner's size, the toggle still in its corner.</p>
285
+ <BannerVideo
286
+ src="https://example.invalid/missing-video.mp4"
287
+ poster="https://example.invalid/missing-poster.jpg"
288
+ :alt="args.alt"
289
+ depth="sm"
290
+ ></BannerVideo>
291
+
292
+ <p style="margin: 0;">Invalid <code>aspectRatio</code> values (<code>"abc"</code>, <code>"0"</code>, <code>"-16/9"</code>, <code>""</code>): each falls back to <code>21/9</code> instead of collapsing to the video's intrinsic size.</p>
293
+ <BannerVideo v-for="ratio in ['abc', '0', '-16/9', '']" :key="ratio" v-bind="lake" :aspect-ratio="ratio" depth="xs"></BannerVideo>
294
+
295
+ <p style="margin: 0;">Extreme ratios: a <code>10/1</code> sliver and a <code>1/3</code> portrait (capped by the depth tier's max-height).</p>
296
+ <BannerVideo v-bind="lake" aspect-ratio="10/1"></BannerVideo>
297
+ <BannerVideo v-bind="lake" aspect-ratio="1/3" depth="xs"></BannerVideo>
298
+
299
+ <p style="margin: 0;">Oversized <code>toggle-icon</code> slot content: clipped to the round button, never spilling over the video.</p>
300
+ <BannerVideo v-bind="lake" depth="xs">
301
+ <template #toggle-icon="{ isPlaying }">
302
+ <span style="font-size: 1.4rem;">{{ isPlaying ? 'Anhaltenundstehenlassen' : 'Abspielenundweiterlaufen' }}</span>
303
+ </template>
304
+ </BannerVideo>
305
+ </div>
306
+ `,
307
+ }),
308
+ parameters: {
309
+ initialCanvas: "mobileCanvas",
310
+ docs: {
311
+ story: { inline: false, height: "1600px" },
312
+ description: {
313
+ story:
314
+ "Worst-case data across nine banners. Check at every canvas width: a broken video falls back to its poster, a broken poster leaves an empty frame of the right size (not a 300x150 default box), invalid aspect ratios fall back to 21/9, the toggle stays in the bottom-right corner at every size, oversized slot content is clipped to the button, and the long labels never show on screen (they're the button's visually hidden accessible name: inspect `.input-button-text`). Under `prefers-reduced-motion: reduce` every banner shows its poster image instead, and a broken poster then shows its alt text.",
315
+ },
316
+ },
317
+ },
318
+ };