srcdev-nuxt-components 9.5.7 → 9.6.1

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 (159) hide show
  1. package/.claude/skills/component-compliance-checklist.md +8 -2
  2. package/.claude/skills/component-dynamic-slots.md +3 -3
  3. package/.claude/skills/component-export-types.md +10 -16
  4. package/.claude/skills/component-naming.md +1 -1
  5. package/.claude/skills/components/alert-content.md +9 -1
  6. package/.claude/skills/components/breadcrumb.md +5 -0
  7. package/.claude/skills/components/container-glow.md +21 -8
  8. package/.claude/skills/components/display-avatar.md +13 -7
  9. package/.claude/skills/components/display-chip.md +37 -24
  10. package/.claude/skills/components/display-pill.md +58 -8
  11. package/.claude/skills/components/display-prompt.md +8 -5
  12. package/.claude/skills/components/display-toast.md +21 -4
  13. package/.claude/skills/components/glowing-border.md +20 -2
  14. package/.claude/skills/components/input-button.md +3 -0
  15. package/.claude/skills/components/responsive-header.md +59 -0
  16. package/.claude/skills/components/select-menu.md +54 -5
  17. package/.claude/skills/components/service-detail.md +32 -0
  18. package/.claude/skills/components/service-summary.md +8 -0
  19. package/.claude/skills/components/services-card-grid.md +5 -3
  20. package/.claude/skills/components/services-card.md +22 -12
  21. package/.claude/skills/components/site-header.md +2 -2
  22. package/.claude/skills/components/slider-gallery.md +10 -4
  23. package/.claude/skills/components/tabbed-content.md +300 -0
  24. package/.claude/skills/composable-anchor-scroll.md +16 -15
  25. package/.claude/skills/index.md +4 -5
  26. package/.claude/skills/storybook-add-story.md +53 -0
  27. package/.claude/skills/theming-status-tokens.md +60 -0
  28. package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
  29. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  30. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  31. package/.vscode/srcdev-component-responsive-header.code-snippets +16 -0
  32. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  33. package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
  34. package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
  35. package/.vscode/srcdev-component-services-card.code-snippets +38 -36
  36. package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
  37. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  38. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  39. package/app/assets/styles/setup/03.theming/index.css +1 -0
  40. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  41. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  42. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  43. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  44. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  45. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  46. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  47. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  48. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  49. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  50. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +78 -9
  51. package/app/components/01.atoms/display-pill/DisplayPill.vue +59 -23
  52. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  53. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  54. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  55. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
  56. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
  57. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
  58. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
  59. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  60. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  61. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  62. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  63. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  64. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  65. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  66. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  67. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  68. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
  69. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
  70. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
  71. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
  72. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
  73. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
  74. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  75. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  76. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  77. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  78. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  79. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  80. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  81. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  82. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  83. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  84. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  85. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  86. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  87. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  88. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  89. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  90. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  91. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  92. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  93. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  94. package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
  95. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
  96. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
  97. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
  98. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
  99. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +42 -11
  100. package/app/components/03.organisms/responsive-header/NavigationItems.vue +60 -16
  101. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +86 -24
  102. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +52 -0
  103. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +20 -0
  104. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +40 -0
  105. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
  106. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
  107. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
  108. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
  109. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +5 -4
  110. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
  111. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
  112. package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
  113. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
  114. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +2 -2
  115. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
  116. package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
  117. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
  118. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
  119. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
  120. package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
  121. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
  122. package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
  123. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
  124. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
  125. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +6 -6
  126. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
  127. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
  128. package/app/components/05.forms/input-button/InputButton.vue +3 -1
  129. package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
  130. package/app/composables/useAnchorScroll.ts +1 -1
  131. package/app/composables/useTabs.ts +148 -206
  132. package/app/types/components/display-chip.d.ts +8 -4
  133. package/app/types/components/display-toast.d.ts +2 -0
  134. package/app/types/components/index.ts +0 -1
  135. package/app/types/components/select-menu.d.ts +2 -0
  136. package/app/utils/pausableTimeout.ts +41 -0
  137. package/nuxt.config.ts +1 -1
  138. package/package.json +1 -1
  139. package/.claude/skills/components/site-navigation.md +0 -145
  140. package/.claude/skills/components/tab-navigation.md +0 -272
  141. package/.claude/skills/components/tabs-core.md +0 -187
  142. package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
  143. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  144. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  145. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  146. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  147. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  148. package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
  149. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
  150. package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
  151. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
  152. package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
  153. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -150
  154. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -518
  155. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -340
  156. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -339
  157. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  158. package/app/composables/useNavCollapse.ts +0 -86
  159. package/app/types/components/nav-item.d.ts +0 -11
@@ -25,7 +25,14 @@
25
25
  :data-text-brightness="item.textBrightness"
26
26
  :data-has-text="hasSlideText(item) ? '' : undefined"
27
27
  >
28
- <NuxtImg :src="item.src" :alt="item.alt" @load="handleImageLoad(index)" @error="handleImageError(index)" />
28
+ <NuxtImg
29
+ :src="item.src"
30
+ :alt="item.alt"
31
+ width="1920"
32
+ height="1080"
33
+ @load="handleImageLoad(index)"
34
+ @error="handleImageError(index)"
35
+ />
29
36
  <div class="slider-gallery-item-content" :class="item.textBrightness">
30
37
  <div v-if="item.stylist" class="slider-gallery-author">{{ item.stylist }}</div>
31
38
  <div v-if="item.title" class="slider-gallery-title">{{ item.title }}</div>
@@ -41,7 +48,7 @@
41
48
  <div ref="sliderGalleryThumbnailsList" class="slider-gallery-thumbnails" aria-hidden="true">
42
49
  <div v-for="(item, index) in galleryData" :key="index" class="slider-gallery-item">
43
50
  <div class="slider-gallery-thumbnail-overlay">
44
- <NuxtImg :src="item.src" alt="" loading="lazy" />
51
+ <NuxtImg :src="item.src" alt="" width="640" height="940" loading="lazy" />
45
52
  <div class="slider-gallery-item-content" :class="item.textBrightness">
46
53
  <div v-if="item.thumbnail?.title" class="slider-gallery-title">{{ item.thumbnail.title }}</div>
47
54
  <div v-if="item.thumbnail?.description" class="slider-gallery-description">
@@ -138,6 +145,12 @@ const prefersReducedMotion = () =>
138
145
  typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
139
146
  const shouldAutoRun = () => props.autoRun && !prefersReducedMotion();
140
147
 
148
+ const finiteOr = (value: number, fallback: number) => (Number.isFinite(value) ? value : fallback);
149
+ const safeAnimationDuration = computed(() => Math.max(0, finiteOr(props.animationDuration, 3000)));
150
+ const safeAutoRunInterval = computed(() =>
151
+ Math.max(1000, safeAnimationDuration.value, finiteOr(props.autoRunInterval, 7000))
152
+ );
153
+
141
154
  onMounted(async () => {
142
155
  await nextTick();
143
156
 
@@ -203,12 +216,12 @@ function scheduleAutoRun() {
203
216
  if (runNextAuto) clearTimeout(runNextAuto);
204
217
  runNextAuto = setTimeout(() => {
205
218
  if (!shouldAutoRun() || isLoading.value) return;
206
- if (isPaused.value) {
219
+ if (isPaused.value || transitionRunning.value) {
207
220
  scheduleAutoRun();
208
221
  return;
209
222
  }
210
223
  doNext();
211
- }, props.autoRunInterval);
224
+ }, safeAutoRunInterval.value);
212
225
  }
213
226
 
214
227
  function showSlider(type: "next" | "prev") {
@@ -249,7 +262,7 @@ function showSlider(type: "next" | "prev") {
249
262
  sliderGalleryWrapper.value.querySelectorAll(".is-prepended").forEach((el) => el.classList.remove("is-prepended"));
250
263
  }
251
264
  transitionRunning.value = false;
252
- }, props.animationDuration);
265
+ }, safeAnimationDuration.value);
253
266
 
254
267
  scheduleAutoRun();
255
268
  }
@@ -286,13 +299,13 @@ onBeforeUnmount(() => {
286
299
  <style lang="css">
287
300
  @layer components {
288
301
  .slider-gallery {
289
- --_animation-duration: v-bind(animationDuration + "ms");
302
+ --_animation-duration: v-bind(safeAnimationDuration + "ms");
290
303
  --_accent: var(--slider-gallery-accent, #f1683a);
291
304
  --_thumbnail-width: var(--slider-gallery-thumbnail-width, 100px);
292
305
  --_thumbnail-height: var(--slider-gallery-thumbnail-height, 165px);
293
306
 
294
307
  height: var(--slider-gallery-height, 100svh);
295
- width: 100vw;
308
+ width: var(--slider-gallery-width, 100vw);
296
309
  overflow: hidden;
297
310
  position: absolute;
298
311
  inset: 0;
@@ -333,6 +346,10 @@ onBeforeUnmount(() => {
333
346
  p {
334
347
  font-size: 1.2em;
335
348
  font-weight: 500;
349
+ max-inline-size: 100%;
350
+ padding-inline: 1.6rem;
351
+ text-align: center;
352
+ overflow-wrap: anywhere;
336
353
  }
337
354
  }
338
355
 
@@ -435,14 +452,41 @@ onBeforeUnmount(() => {
435
452
  padding-right: 30%;
436
453
  box-sizing: border-box;
437
454
  text-shadow: 0 5px 10px #0004;
455
+ overflow-wrap: anywhere;
438
456
 
439
457
  @container (width < 678px) {
440
458
  padding-right: 0;
441
459
  }
442
460
 
461
+ .slider-gallery-author,
462
+ .slider-gallery-title,
463
+ .slider-gallery-topic,
464
+ .slider-gallery-description {
465
+ display: -webkit-box;
466
+ -webkit-box-orient: vertical;
467
+ overflow: hidden;
468
+ }
469
+
443
470
  .slider-gallery-author {
444
471
  font-weight: bold;
445
472
  letter-spacing: 10px;
473
+ -webkit-line-clamp: var(--slider-gallery-author-line-clamp, none);
474
+ line-clamp: var(--slider-gallery-author-line-clamp, none);
475
+ }
476
+
477
+ .slider-gallery-title {
478
+ -webkit-line-clamp: var(--slider-gallery-title-line-clamp, none);
479
+ line-clamp: var(--slider-gallery-title-line-clamp, none);
480
+ }
481
+
482
+ .slider-gallery-topic {
483
+ -webkit-line-clamp: var(--slider-gallery-topic-line-clamp, none);
484
+ line-clamp: var(--slider-gallery-topic-line-clamp, none);
485
+ }
486
+
487
+ .slider-gallery-description {
488
+ -webkit-line-clamp: var(--slider-gallery-description-line-clamp, none);
489
+ line-clamp: var(--slider-gallery-description-line-clamp, none);
446
490
  }
447
491
 
448
492
  .slider-gallery-title,
@@ -466,7 +510,9 @@ onBeforeUnmount(() => {
466
510
  place-items: center;
467
511
  min-width: 130px;
468
512
  min-height: 40px;
513
+ max-inline-size: 100%;
469
514
  padding-inline: 1.2rem;
515
+ box-sizing: border-box;
470
516
  background-color: var(--slider-gallery-cta-background, #99999975);
471
517
  border: 1px solid var(--slider-gallery-cta-border-colour, #fff);
472
518
  color: var(--slider-gallery-cta-colour, #fff);
@@ -528,13 +574,25 @@ onBeforeUnmount(() => {
528
574
  bottom: 10px;
529
575
  left: 10px;
530
576
  right: 10px;
577
+ overflow-wrap: anywhere;
578
+
579
+ .slider-gallery-title,
580
+ .slider-gallery-description {
581
+ display: -webkit-box;
582
+ -webkit-box-orient: vertical;
583
+ overflow: hidden;
584
+ }
531
585
 
532
586
  .slider-gallery-title {
533
587
  font-weight: 500;
588
+ -webkit-line-clamp: var(--slider-gallery-thumbnail-title-line-clamp, 2);
589
+ line-clamp: var(--slider-gallery-thumbnail-title-line-clamp, 2);
534
590
  }
535
591
 
536
592
  .slider-gallery-description {
537
593
  font-weight: 300;
594
+ -webkit-line-clamp: var(--slider-gallery-thumbnail-description-line-clamp, 2);
595
+ line-clamp: var(--slider-gallery-thumbnail-description-line-clamp, 2);
538
596
  }
539
597
  }
540
598
  }
@@ -1,11 +1,54 @@
1
+ import { computed, ref } from "vue";
1
2
  import SliderGallery from "../SliderGallery.vue";
3
+ import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
2
4
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
- import type { IGalleryData } from "~/types/components";
5
+ import type { IGalleryData, MediaCanvas } from "~/types/components";
4
6
 
5
- const meta: Meta<typeof SliderGallery> = {
7
+ type LineClamp = "none" | "1" | "2" | "3" | "4";
8
+
9
+ // Story-only args: each drives a CSS token, not a SliderGallery prop. See the argTypes entries.
10
+ const lineClampTokens = {
11
+ authorLineClamp: { token: "--slider-gallery-author-line-clamp", label: "Author (stylist)", fallback: "none" },
12
+ titleLineClamp: { token: "--slider-gallery-title-line-clamp", label: "Title", fallback: "none" },
13
+ topicLineClamp: { token: "--slider-gallery-topic-line-clamp", label: "Topic (category)", fallback: "none" },
14
+ descriptionLineClamp: { token: "--slider-gallery-description-line-clamp", label: "Description", fallback: "none" },
15
+ thumbnailTitleLineClamp: { token: "--slider-gallery-thumbnail-title-line-clamp", label: "Thumbnail title", fallback: "2" },
16
+ thumbnailDescriptionLineClamp: {
17
+ token: "--slider-gallery-thumbnail-description-line-clamp",
18
+ label: "Thumbnail description",
19
+ fallback: "2",
20
+ },
21
+ } as const;
22
+
23
+ type LineClampArg = keyof typeof lineClampTokens;
24
+
25
+ type StoryArgs = InstanceType<typeof SliderGallery>["$props"] & Record<LineClampArg, LineClamp>;
26
+
27
+ const lineClampArgTypes = Object.fromEntries(
28
+ Object.entries(lineClampTokens).map(([arg, { token, label, fallback }]) => [
29
+ arg,
30
+ {
31
+ name: `${label} line clamp`,
32
+ control: "select",
33
+ options: ["none", "1", "2", "3", "4"] satisfies LineClamp[],
34
+ description:
35
+ `**Story control, not a prop.** Sets the \`${token}\` CSS token on a wrapper so you can try it here. ` +
36
+ `To use it in an app, set the token in your own CSS, e.g. \`.hero { ${token}: 2; }\`. ` +
37
+ `\`1\` is single-line ellipsis, \`none\` shows everything.`,
38
+ table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: fallback } },
39
+ },
40
+ ])
41
+ );
42
+
43
+ const lineClampArgs = Object.fromEntries(
44
+ Object.entries(lineClampTokens).map(([arg, { fallback }]) => [arg, fallback])
45
+ ) as Record<LineClampArg, LineClamp>;
46
+
47
+ const meta: Meta<StoryArgs> = {
6
48
  title: "Organisms/Image Galleries/Slider Gallery",
7
49
  component: SliderGallery,
8
50
  argTypes: {
51
+ ...lineClampArgTypes,
9
52
  autoRun: {
10
53
  control: { type: "boolean" },
11
54
  description: "Automatically advance to the next slide",
@@ -40,6 +83,7 @@ const meta: Meta<typeof SliderGallery> = {
40
83
  },
41
84
  },
42
85
  args: {
86
+ ...lineClampArgs,
43
87
  autoRun: true,
44
88
  autoRunInterval: 7000,
45
89
  animationDuration: 3000,
@@ -64,7 +108,7 @@ const meta: Meta<typeof SliderGallery> = {
64
108
  };
65
109
 
66
110
  export default meta;
67
- type Story = StoryObj<typeof SliderGallery>;
111
+ type Story = StoryObj<StoryArgs>;
68
112
 
69
113
  // ─── Fixtures ─────────────────────────────────────────────────────────────────
70
114
 
@@ -123,17 +167,105 @@ const sampleSlides: IGalleryData[] = [
123
167
  },
124
168
  ];
125
169
 
170
+ const longGerman =
171
+ "Donaudampfschifffahrtsgesellschaftskapitänsmützenbandherstellungsbetriebsgenossenschaftsvorsitzender";
172
+ const longUrl =
173
+ "https://example.com/a/very/long/path/that/never/breaks/because/it/has/no/spaces/at/all?utm_source=newsletter&utm_medium=email&utm_campaign=autumn-collection-2026-final-final-v3";
174
+ const longParagraph =
175
+ "Eine ausgesprochen lange Beschreibung, wie sie ein CMS liefert, wenn niemand die Zeichenanzahl prüft. ".repeat(8);
176
+
177
+ const stressSlides: IGalleryData[] = [
178
+ {
179
+ src: "https://picsum.photos/seed/stress1/1920/1080",
180
+ alt: "Long German copy in every field",
181
+ stylist: `STYLISTIN ${longGerman.toUpperCase()}`,
182
+ title: `${longGerman} und Freunde`,
183
+ category: "Haarverlängerungen, Strähnchen und vollständige Farbkorrekturen",
184
+ description: longParagraph,
185
+ thumbnail: { title: longGerman, description: longParagraph },
186
+ textBrightness: "light",
187
+ href: longUrl,
188
+ },
189
+ {
190
+ src: "https://picsum.photos/seed/stress2/1920/1080",
191
+ alt: "Emoji and HTML-like text",
192
+ stylist: "💇‍♀️✨ SALON 🎉",
193
+ title: "<script>alert('xss')</script>",
194
+ category: "<b>not bold</b> &amp; &lt;tags&gt;",
195
+ description: "🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈",
196
+ thumbnail: { title: "😀 Emoji first", description: "<img src=x onerror=alert(1)>" },
197
+ textBrightness: "dark",
198
+ href: "#emoji",
199
+ },
200
+ {
201
+ src: "https://picsum.photos/seed/stress3/1920/1080",
202
+ alt: "صورة لصالون تصفيف الشعر",
203
+ stylist: "مصففة الشعر",
204
+ title: "تسريحات شعر جديدة لموسم الخريف",
205
+ category: "تلوين الشعر",
206
+ description: "وصف طويل باللغة العربية يمتد عبر عدة أسطر للتحقق من أن النص من اليمين إلى اليسار يعرض بشكل صحيح.",
207
+ thumbnail: { title: "تسريحات", description: "تلوين الشعر" },
208
+ textBrightness: "light",
209
+ },
210
+ {
211
+ src: "https://example.invalid/this-image-does-not-exist.jpg",
212
+ alt: "Broken image URL: the alt text should show instead",
213
+ stylist: "A",
214
+ title: "B",
215
+ category: "C",
216
+ description: "",
217
+ thumbnail: { title: "", description: "" },
218
+ textBrightness: "dark",
219
+ href: "",
220
+ },
221
+ {
222
+ src: "https://picsum.photos/seed/stress5/1920/1080",
223
+ alt: "",
224
+ textBrightness: "light",
225
+ },
226
+ {
227
+ src: "https://picsum.photos/seed/stress6/1920/1080",
228
+ alt: "Unbroken URL as description",
229
+ title: "Link soup",
230
+ description: longUrl,
231
+ thumbnail: { title: longUrl, description: longUrl },
232
+ textBrightness: "light",
233
+ href: "#link-soup",
234
+ },
235
+ ...Array.from({ length: 24 }, (_, index): IGalleryData => ({
236
+ src: `https://picsum.photos/seed/stress-many-${index}/1920/1080`,
237
+ alt: `Filler slide ${index + 7}`,
238
+ title: `Slide ${index + 7}`,
239
+ thumbnail: { title: `Slide ${index + 7}`, description: "Filler" },
240
+ textBrightness: "light",
241
+ })),
242
+ ];
243
+
126
244
  // ─── Stories ──────────────────────────────────────────────────────────────────
127
245
 
246
+ function useStorySetup(args: StoryArgs) {
247
+ const componentArgs = computed(() =>
248
+ Object.fromEntries(Object.entries(args).filter(([arg]) => !(arg in lineClampTokens)))
249
+ );
250
+ const tokenStyles = computed(() =>
251
+ Object.fromEntries(
252
+ Object.entries(lineClampTokens).map(([arg, { token }]) => [token, args[arg as LineClampArg]])
253
+ )
254
+ );
255
+ return { componentArgs, tokenStyles };
256
+ }
257
+
258
+ const renderWith = (slides: IGalleryData[]) => (args: StoryArgs) => ({
259
+ components: { SliderGallery },
260
+ setup() {
261
+ const galleryData = ref<IGalleryData[]>(slides);
262
+ return { ...useStorySetup(args), galleryData };
263
+ },
264
+ template: `<div :style="tokenStyles"><SliderGallery v-bind="componentArgs" v-model:gallery-data="galleryData" /></div>`,
265
+ });
266
+
128
267
  export const Default: Story = {
129
- render: (args) => ({
130
- components: { SliderGallery },
131
- setup() {
132
- const galleryData = ref<IGalleryData[]>(sampleSlides);
133
- return { args, galleryData };
134
- },
135
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
136
- }),
268
+ render: renderWith(sampleSlides),
137
269
  };
138
270
 
139
271
  export const AutoRunDisabled: Story = {
@@ -141,14 +273,7 @@ export const AutoRunDisabled: Story = {
141
273
  args: {
142
274
  autoRun: false,
143
275
  },
144
- render: (args) => ({
145
- components: { SliderGallery },
146
- setup() {
147
- const galleryData = ref<IGalleryData[]>(sampleSlides);
148
- return { args, galleryData };
149
- },
150
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
151
- }),
276
+ render: renderWith(sampleSlides),
152
277
  parameters: {
153
278
  docs: {
154
279
  description: {
@@ -164,14 +289,7 @@ export const FastTransition: Story = {
164
289
  animationDuration: 500,
165
290
  autoRunInterval: 3000,
166
291
  },
167
- render: (args) => ({
168
- components: { SliderGallery },
169
- setup() {
170
- const galleryData = ref<IGalleryData[]>(sampleSlides);
171
- return { args, galleryData };
172
- },
173
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
174
- }),
292
+ render: renderWith(sampleSlides),
175
293
  };
176
294
 
177
295
  export const SlowTransition: Story = {
@@ -180,14 +298,7 @@ export const SlowTransition: Story = {
180
298
  animationDuration: 5000,
181
299
  autoRunInterval: 10000,
182
300
  },
183
- render: (args) => ({
184
- components: { SliderGallery },
185
- setup() {
186
- const galleryData = ref<IGalleryData[]>(sampleSlides);
187
- return { args, galleryData };
188
- },
189
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
190
- }),
301
+ render: renderWith(sampleSlides),
191
302
  };
192
303
 
193
304
  export const SingleSlide: Story = {
@@ -195,14 +306,7 @@ export const SingleSlide: Story = {
195
306
  args: {
196
307
  autoRun: false,
197
308
  },
198
- render: (args) => ({
199
- components: { SliderGallery },
200
- setup() {
201
- const galleryData = ref<IGalleryData[]>([sampleSlides[0]!]);
202
- return { args, galleryData };
203
- },
204
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
205
- }),
309
+ render: renderWith([sampleSlides[0]!]),
206
310
  parameters: {
207
311
  docs: {
208
312
  description: {
@@ -217,18 +321,11 @@ export const MinimalSlideData: Story = {
217
321
  args: {
218
322
  autoRun: false,
219
323
  },
220
- render: (args) => ({
221
- components: { SliderGallery },
222
- setup() {
223
- const galleryData = ref<IGalleryData[]>([
224
- { src: "https://picsum.photos/seed/min1/1920/1080", alt: "Image one", textBrightness: "light" },
225
- { src: "https://picsum.photos/seed/min2/1920/1080", alt: "Image two", textBrightness: "dark" },
226
- { src: "https://picsum.photos/seed/min3/1920/1080", alt: "Image three", textBrightness: "light" },
227
- ]);
228
- return { args, galleryData };
229
- },
230
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
231
- }),
324
+ render: renderWith([
325
+ { src: "https://picsum.photos/seed/min1/1920/1080", alt: "Image one", textBrightness: "light" },
326
+ { src: "https://picsum.photos/seed/min2/1920/1080", alt: "Image two", textBrightness: "dark" },
327
+ { src: "https://picsum.photos/seed/min3/1920/1080", alt: "Image three", textBrightness: "light" },
328
+ ]),
232
329
  parameters: {
233
330
  docs: {
234
331
  description: {
@@ -240,14 +337,7 @@ export const MinimalSlideData: Story = {
240
337
 
241
338
  export const EmptyGallery: Story = {
242
339
  name: "Empty Gallery",
243
- render: (args) => ({
244
- components: { SliderGallery },
245
- setup() {
246
- const galleryData = ref<IGalleryData[]>([]);
247
- return { args, galleryData };
248
- },
249
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
250
- }),
340
+ render: renderWith([]),
251
341
  parameters: {
252
342
  docs: {
253
343
  description: {
@@ -256,3 +346,51 @@ export const EmptyGallery: Story = {
256
346
  },
257
347
  },
258
348
  };
349
+
350
+ export const StressTest: Story = {
351
+ name: "Stress Test (Worst-Case Data)",
352
+ args: {
353
+ autoRunInterval: 0,
354
+ loadingText: "Die Galerie wird geladen, bitte haben Sie einen Augenblick Geduld...",
355
+ seeMoreText: "WEITERE INFORMATIONEN ZU DIESEM BEITRAG ANZEIGEN",
356
+ ariaLabel: "",
357
+ },
358
+ render: renderWith(stressSlides),
359
+ decorators: [
360
+ (story, context) => ({
361
+ components: { story, CanvasSwitcher },
362
+ setup() {
363
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
364
+ return { canvasName };
365
+ },
366
+ template: `
367
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
368
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
369
+ </div>
370
+ <div
371
+ :class="canvasName"
372
+ style="position: relative; block-size: 80svh; margin-inline: auto; outline: 1px dashed currentColor; --slider-gallery-width: 100%; --slider-gallery-height: 100%;"
373
+ >
374
+ <story />
375
+ </div>
376
+ `,
377
+ }),
378
+ ],
379
+ parameters: {
380
+ initialCanvas: "mobileCanvas",
381
+ docs: {
382
+ description: {
383
+ story:
384
+ "Deliberately hostile data to find breakage, sized to the canvas with `--slider-gallery-width: 100%` and " +
385
+ "`--slider-gallery-height: 100%`: long German copy and unbroken strings (a 100-character compound word, a long URL) " +
386
+ "in every field including the see-more and loading copy, emoji (including first), HTML-like text (must render as text), " +
387
+ "right-to-left Arabic, a broken image URL (its alt text shows), single characters, empty strings (no empty elements or " +
388
+ "empty link are rendered), a slide with an empty alt and no text, an empty carousel label, and 30 slides. " +
389
+ "`autoRunInterval` is 0: auto-advance is floored to the transition length (minimum 1s), so it keeps cycling rather than " +
390
+ "stalling; try a negative `animationDuration` too. Hover the gallery to pause it while you look. " +
391
+ "Check at every canvas width: no text runs off the slide, thumbnail text stays inside its card (2 lines each by default), " +
392
+ "and the arrows stay usable. Try the line clamp controls too: they set CSS tokens, they are not props.",
393
+ },
394
+ },
395
+ },
396
+ };
@@ -43,9 +43,13 @@ type MockImageInstance = {
43
43
  // ─── Helpers ──────────────────────────────────────────────────────────────────
44
44
 
45
45
  /** Mount the gallery and simulate the first image loading successfully. */
46
- async function mountAndLoad(galleryData: IGalleryData[], mockImage: MockImageInstance) {
46
+ async function mountAndLoad(
47
+ galleryData: IGalleryData[],
48
+ mockImage: MockImageInstance,
49
+ props: Record<string, unknown> = {}
50
+ ) {
47
51
  const wrapper = await mountSuspended(SliderGallery, {
48
- props: { galleryData },
52
+ props: { galleryData, ...props },
49
53
  });
50
54
  mockImage.onload?.();
51
55
  await nextTick(); // let onMounted resume after Promise.race resolves
@@ -310,4 +314,39 @@ describe("SliderGallery", () => {
310
314
  await nextTick();
311
315
  expect(wrapper.classes()).not.toContain("is-next");
312
316
  });
317
+
318
+ // ─── Worst-case numbers ─────────────────────────────────────────────────
319
+
320
+ const activeAlt = (wrapper: Awaited<ReturnType<typeof mountAndLoad>>) =>
321
+ wrapper.find(".slider-gallery-list .slider-gallery-item img").attributes("alt");
322
+
323
+ // Mount with motion allowed, then let the load chain settle so the first auto-run timer is queued.
324
+ async function mountForAutoRun(props: Record<string, unknown>) {
325
+ vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: false })));
326
+ const wrapper = await mountAndLoad(mockGalleryData, mockImage, props);
327
+ vi.advanceTimersByTime(1000);
328
+ await nextTick();
329
+ return wrapper;
330
+ }
331
+
332
+ it("keeps auto-advancing when autoRunInterval is shorter than the transition", async () => {
333
+ const wrapper = await mountForAutoRun({ autoRunInterval: 0, animationDuration: 3000 });
334
+ expect(activeAlt(wrapper)).toBe("Slide 1");
335
+ vi.advanceTimersByTime(3000);
336
+ await nextTick();
337
+ expect(activeAlt(wrapper)).toBe("Slide 2");
338
+ vi.advanceTimersByTime(3000);
339
+ await nextTick();
340
+ expect(activeAlt(wrapper)).toBe("Slide 3");
341
+ });
342
+
343
+ it("floors auto-advance at 1s when both timings are zero or negative", async () => {
344
+ const wrapper = await mountForAutoRun({ autoRunInterval: 0, animationDuration: -500 });
345
+ vi.advanceTimersByTime(999);
346
+ await nextTick();
347
+ expect(activeAlt(wrapper)).toBe("Slide 1");
348
+ vi.advanceTimersByTime(1);
349
+ await nextTick();
350
+ expect(activeAlt(wrapper)).toBe("Slide 2");
351
+ });
313
352
  });
@@ -8,7 +8,7 @@ exports[`SliderGallery > renders correct HTML structure after load 1`] = `
8
8
  </div>
9
9
  <div class="slider-gallery-content">
10
10
  <div class="slider-gallery-list">
11
- <div class="slider-gallery-item" data-text-brightness="light" data-has-text=""><img data-nuxt-img="" srcset="/_ipx/_/images/slide-1.jpg 1x, /_ipx/_/images/slide-1.jpg 2x" alt="Slide 1" src="/_ipx/_/images/slide-1.jpg">
11
+ <div class="slider-gallery-item" data-text-brightness="light" data-has-text=""><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/slide-1.jpg 1x, /_ipx/s_3840x2160/images/slide-1.jpg 2x" alt="Slide 1" src="/_ipx/s_1920x1080/images/slide-1.jpg">
12
12
  <div class="slider-gallery-item-content light">
13
13
  <div class="slider-gallery-author">Stylist One</div>
14
14
  <div class="slider-gallery-title">First Slide</div>
@@ -17,7 +17,7 @@ exports[`SliderGallery > renders correct HTML structure after load 1`] = `
17
17
  <!--v-if-->
18
18
  </div>
19
19
  </div>
20
- <div class="slider-gallery-item" data-text-brightness="dark" data-has-text=""><img data-nuxt-img="" srcset="/_ipx/_/images/slide-2.jpg 1x, /_ipx/_/images/slide-2.jpg 2x" alt="Slide 2" src="/_ipx/_/images/slide-2.jpg">
20
+ <div class="slider-gallery-item" data-text-brightness="dark" data-has-text=""><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/slide-2.jpg 1x, /_ipx/s_3840x2160/images/slide-2.jpg 2x" alt="Slide 2" src="/_ipx/s_1920x1080/images/slide-2.jpg">
21
21
  <div class="slider-gallery-item-content dark">
22
22
  <!--v-if-->
23
23
  <div class="slider-gallery-title">Second Slide</div>
@@ -26,7 +26,7 @@ exports[`SliderGallery > renders correct HTML structure after load 1`] = `
26
26
  <!--v-if-->
27
27
  </div>
28
28
  </div>
29
- <div class="slider-gallery-item" data-text-brightness="light"><img data-nuxt-img="" srcset="/_ipx/_/images/slide-3.jpg 1x, /_ipx/_/images/slide-3.jpg 2x" alt="Slide 3" src="/_ipx/_/images/slide-3.jpg">
29
+ <div class="slider-gallery-item" data-text-brightness="light"><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/slide-3.jpg 1x, /_ipx/s_3840x2160/images/slide-3.jpg 2x" alt="Slide 3" src="/_ipx/s_1920x1080/images/slide-3.jpg">
30
30
  <div class="slider-gallery-item-content light">
31
31
  <!--v-if-->
32
32
  <!--v-if-->
@@ -38,7 +38,7 @@ exports[`SliderGallery > renders correct HTML structure after load 1`] = `
38
38
  </div>
39
39
  <div class="slider-gallery-thumbnails" aria-hidden="true">
40
40
  <div class="slider-gallery-item">
41
- <div class="slider-gallery-thumbnail-overlay"><img data-nuxt-img="" srcset="/_ipx/_/images/slide-1.jpg 1x, /_ipx/_/images/slide-1.jpg 2x" alt="" loading="lazy" src="/_ipx/_/images/slide-1.jpg">
41
+ <div class="slider-gallery-thumbnail-overlay"><img width="640" height="940" data-nuxt-img="" srcset="/_ipx/s_640x940/images/slide-1.jpg 1x, /_ipx/s_1280x1880/images/slide-1.jpg 2x" alt="" loading="lazy" src="/_ipx/s_640x940/images/slide-1.jpg">
42
42
  <div class="slider-gallery-item-content light">
43
43
  <div class="slider-gallery-title">Thumb 1</div>
44
44
  <div class="slider-gallery-description">Thumb desc 1</div>
@@ -46,7 +46,7 @@ exports[`SliderGallery > renders correct HTML structure after load 1`] = `
46
46
  </div>
47
47
  </div>
48
48
  <div class="slider-gallery-item">
49
- <div class="slider-gallery-thumbnail-overlay"><img data-nuxt-img="" srcset="/_ipx/_/images/slide-2.jpg 1x, /_ipx/_/images/slide-2.jpg 2x" alt="" loading="lazy" src="/_ipx/_/images/slide-2.jpg">
49
+ <div class="slider-gallery-thumbnail-overlay"><img width="640" height="940" data-nuxt-img="" srcset="/_ipx/s_640x940/images/slide-2.jpg 1x, /_ipx/s_1280x1880/images/slide-2.jpg 2x" alt="" loading="lazy" src="/_ipx/s_640x940/images/slide-2.jpg">
50
50
  <div class="slider-gallery-item-content dark">
51
51
  <div class="slider-gallery-title">Thumb 2</div>
52
52
  <div class="slider-gallery-description">Thumb desc 2</div>
@@ -54,7 +54,7 @@ exports[`SliderGallery > renders correct HTML structure after load 1`] = `
54
54
  </div>
55
55
  </div>
56
56
  <div class="slider-gallery-item">
57
- <div class="slider-gallery-thumbnail-overlay"><img data-nuxt-img="" srcset="/_ipx/_/images/slide-3.jpg 1x, /_ipx/_/images/slide-3.jpg 2x" alt="" loading="lazy" src="/_ipx/_/images/slide-3.jpg">
57
+ <div class="slider-gallery-thumbnail-overlay"><img width="640" height="940" data-nuxt-img="" srcset="/_ipx/s_640x940/images/slide-3.jpg 1x, /_ipx/s_1280x1880/images/slide-3.jpg 2x" alt="" loading="lazy" src="/_ipx/s_640x940/images/slide-3.jpg">
58
58
  <div class="slider-gallery-item-content light">
59
59
  <!--v-if-->
60
60
  <!--v-if-->