srcdev-nuxt-components 9.5.6 → 9.6.0

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 (113) hide show
  1. package/.claude/skills/component-compliance-checklist.md +6 -2
  2. package/.claude/skills/components/breadcrumb.md +5 -0
  3. package/.claude/skills/components/display-pill.md +2 -0
  4. package/.claude/skills/components/input-button.md +3 -0
  5. package/.claude/skills/components/navigation-items.md +1 -0
  6. package/.claude/skills/components/opening-hours.md +12 -0
  7. package/.claude/skills/components/price-list.md +10 -1
  8. package/.claude/skills/components/responsive-header.md +74 -0
  9. package/.claude/skills/components/service-detail.md +32 -0
  10. package/.claude/skills/components/service-summary.md +8 -0
  11. package/.claude/skills/components/services-card-grid.md +5 -3
  12. package/.claude/skills/components/services-card.md +22 -12
  13. package/.claude/skills/components/site-header.md +2 -0
  14. package/.claude/skills/components/slider-gallery.md +10 -4
  15. package/.claude/skills/components/tab-navigation.md +25 -9
  16. package/.claude/skills/index.md +1 -2
  17. package/.claude/skills/storybook-add-story.md +14 -0
  18. package/.vscode/srcdev-component-responsive-header.code-snippets +13 -1
  19. package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
  20. package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
  21. package/.vscode/srcdev-component-services-card.code-snippets +38 -36
  22. package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
  23. package/.vscode/srcdev-component-site-header.code-snippets +3 -1
  24. package/.vscode/srcdev-component-tab-navigation.code-snippets +1 -1
  25. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +3 -0
  26. package/app/components/01.atoms/display-pill/DisplayPill.vue +5 -2
  27. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
  28. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
  29. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
  30. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
  31. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +52 -11
  32. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +55 -23
  33. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +176 -257
  34. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +42 -2
  35. package/app/components/02.molecules/opening-hours/CONSUMER-STYLING.md +11 -1
  36. package/app/components/02.molecules/opening-hours/OpeningHours.vue +71 -43
  37. package/app/components/02.molecules/opening-hours/stories/OpeningHours.stories.ts +138 -6
  38. package/app/components/02.molecules/opening-hours/tests/OpeningHours.spec.ts +53 -0
  39. package/app/components/02.molecules/opening-hours/tests/__snapshots__/OpeningHours.spec.ts.snap +1 -1
  40. package/app/components/02.molecules/opening-hours/tests/buildOpeningHoursSpecification.spec.ts +37 -1
  41. package/app/components/02.molecules/price-list/CONSUMER-STYLING.md +17 -5
  42. package/app/components/02.molecules/price-list/PriceList.vue +32 -9
  43. package/app/components/02.molecules/price-list/stories/PriceList.stories.ts +125 -29
  44. package/app/components/02.molecules/price-list/tests/PriceList.spec.ts +20 -0
  45. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +9 -4
  46. package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
  47. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
  48. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
  49. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
  50. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
  51. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +18 -0
  52. package/app/components/03.organisms/responsive-header/NavigationItems.vue +56 -18
  53. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +106 -31
  54. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +201 -18
  55. package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +21 -0
  56. package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +66 -0
  57. package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +30 -0
  58. package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +1 -0
  59. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +35 -0
  60. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +23 -0
  61. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +1 -0
  62. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +71 -0
  63. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +386 -0
  64. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +11 -0
  65. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +79 -0
  66. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +17 -0
  67. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +64 -0
  68. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +29 -0
  69. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +32 -0
  70. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +10 -0
  71. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
  72. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +36 -0
  73. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +131 -6
  74. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +9 -9
  75. package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +1 -0
  76. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
  77. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
  78. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
  79. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
  80. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -3
  81. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
  82. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
  83. package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
  84. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
  85. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  86. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
  87. package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
  88. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
  89. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
  90. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
  91. package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
  92. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
  93. package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
  94. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
  95. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
  96. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  97. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
  98. package/app/components/03.organisms/site-header/SiteHeader.vue +8 -0
  99. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
  100. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
  101. package/app/components/05.forms/input-button/InputButton.vue +3 -1
  102. package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
  103. package/app/composables/useAnchorScroll.ts +1 -1
  104. package/app/utils/opening-hours.ts +23 -2
  105. package/nuxt.config.ts +1 -1
  106. package/package.json +1 -1
  107. package/.claude/skills/components/site-navigation.md +0 -145
  108. package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
  109. package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
  110. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
  111. package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
  112. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
  113. package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
@@ -1,5 +1,8 @@
1
+ import { computed, ref } from "vue";
1
2
  import PriceList from "../PriceList.vue";
3
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
2
4
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
5
+ import type { MediaCanvas, PriceListData } from "~/types/components";
3
6
 
4
7
  const priceListData = [
5
8
  {
@@ -26,10 +29,26 @@ const priceListData = [
26
29
  },
27
30
  ];
28
31
 
29
- const meta: Meta<typeof PriceList> = {
32
+ type LineClamp = "none" | "1" | "2" | "3" | "4";
33
+
34
+ // Story-only arg: drives a CSS token, not a PriceList prop. See the argTypes entry.
35
+ type StoryArgs = InstanceType<typeof PriceList>["$props"] & {
36
+ descriptionLineClamp: LineClamp;
37
+ };
38
+
39
+ const meta: Meta<StoryArgs> = {
30
40
  title: "Molecules/PriceList",
31
41
  component: PriceList,
32
42
  argTypes: {
43
+ descriptionLineClamp: {
44
+ control: "select",
45
+ options: ["none", "1", "2", "3", "4"] satisfies LineClamp[],
46
+ description:
47
+ "**Story control, not a prop.** Sets the `--price-list-description-line-clamp` CSS token on a wrapper so you can try it here. " +
48
+ "To use it in an app, set the token in your own CSS, e.g. `.salon-menu { --price-list-description-line-clamp: 2; }`. " +
49
+ "`1` is single-line ellipsis, `none` (the default) shows everything.",
50
+ table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
51
+ },
33
52
  priceListData: {
34
53
  control: "object",
35
54
  description: "Array of columns, each with a heading and list of items",
@@ -52,10 +71,52 @@ const meta: Meta<typeof PriceList> = {
52
71
  table: { category: "Styling" },
53
72
  },
54
73
  },
74
+ args: {
75
+ descriptionLineClamp: "none",
76
+ },
77
+ decorators: [
78
+ (story, context) => ({
79
+ components: { story, CanvasSwitcher },
80
+ setup() {
81
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
82
+ return { canvasName };
83
+ },
84
+ template: `
85
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
86
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
87
+ </div>
88
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
89
+ <story />
90
+ </div>
91
+ `,
92
+ }),
93
+ ],
94
+ parameters: {
95
+ layout: "fullscreen",
96
+ },
55
97
  };
56
98
 
57
99
  export default meta;
58
- type Story = StoryObj<typeof PriceList>;
100
+ type Story = StoryObj<StoryArgs>;
101
+
102
+ function useStorySetup(args: StoryArgs) {
103
+ const componentArgs = computed(() => {
104
+ const { descriptionLineClamp: _descriptionLineClamp, ...rest } = args;
105
+ return rest;
106
+ });
107
+ const tokenStyles = computed(() => ({
108
+ "--price-list-description-line-clamp": args.descriptionLineClamp,
109
+ }));
110
+ return { componentArgs, tokenStyles };
111
+ }
112
+
113
+ const render = (args: StoryArgs) => ({
114
+ components: { PriceList },
115
+ setup() {
116
+ return useStorySetup(args);
117
+ },
118
+ template: `<div :style="tokenStyles"><PriceList v-bind="componentArgs" /></div>`,
119
+ });
59
120
 
60
121
  export const Default: Story = {
61
122
  args: {
@@ -64,13 +125,7 @@ export const Default: Story = {
64
125
  fromLabel: "from",
65
126
  styleClassPassthrough: [],
66
127
  },
67
- render: (args) => ({
68
- components: { PriceList },
69
- setup() {
70
- return { args };
71
- },
72
- template: `<PriceList v-bind="args" />`,
73
- }),
128
+ render,
74
129
  };
75
130
 
76
131
  export const SingleColumn: Story = {
@@ -78,13 +133,7 @@ export const SingleColumn: Story = {
78
133
  args: {
79
134
  priceListData: [priceListData[0]!],
80
135
  },
81
- render: (args) => ({
82
- components: { PriceList },
83
- setup() {
84
- return { args };
85
- },
86
- template: `<PriceList v-bind="args" />`,
87
- }),
136
+ render,
88
137
  };
89
138
 
90
139
  export const EmptyState: Story = {
@@ -92,13 +141,7 @@ export const EmptyState: Story = {
92
141
  args: {
93
142
  priceListData: priceListData.map(({ headingtext, headingIcon }) => ({ headingtext, headingIcon, items: [] })),
94
143
  },
95
- render: (args) => ({
96
- components: { PriceList },
97
- setup() {
98
- return { args };
99
- },
100
- template: `<PriceList v-bind="args" />`,
101
- }),
144
+ render,
102
145
  };
103
146
 
104
147
  export const NoIcons: Story = {
@@ -106,11 +149,64 @@ export const NoIcons: Story = {
106
149
  args: {
107
150
  priceListData: priceListData.map(({ headingtext, items }) => ({ headingtext, items })),
108
151
  },
109
- render: (args) => ({
110
- components: { PriceList },
111
- setup() {
112
- return { args };
152
+ render,
153
+ };
154
+
155
+ // ─── Stress test ──────────────────────────────────────────────────────────────
156
+
157
+ const stressPriceListData: PriceListData[] = [
158
+ {
159
+ headingtext: "Haarschnitte, Pflegebehandlungen und Kopfhautmassagen für jeden Haartyp und jede Länge",
160
+ headingIcon: "lucide:sparkles",
161
+ items: [
162
+ { description: "Pneumonoultramicroscopicsilicovolcanoconiosisbehandlungohneleerzeichen", price: "£45" },
163
+ {
164
+ description: `${"Cut, wash, condition, blow dry and finish with a heat protectant serum. ".repeat(5)}`,
165
+ price: "£1,234.56 – £9,999.99 pro Sitzung inklusive Beratung",
166
+ from: true,
167
+ },
168
+ { description: "Price with no spaces", price: "£123456789012345678901234567890.00" },
169
+ { description: "From, but the price is empty", price: "", from: true },
170
+ { description: "", price: "£10" },
171
+ { description: "💇‍♀️💅🏽 Emoji first", price: "£0" },
172
+ { description: "<script>alert('xss')</script> rendered as text &amp; entities", price: "<b>£5</b>" },
173
+ { description: "قص الشعر وتصفيفه", price: "٤٥ £" },
174
+ { description: "X", price: "1" },
175
+ { description: "See https://example.com/a/very/long/path/that/never/breaks/segmentsegmentsegment", price: "-£20" },
176
+ ],
177
+ },
178
+ {
179
+ headingtext: "",
180
+ items: Array.from({ length: 30 }, (_, i) => ({ description: `Item ${i + 1} in a column with no heading`, price: `£${(i + 1) * 5}` })),
181
+ },
182
+ {
183
+ headingtext: "Broken icon, no items",
184
+ headingIcon: "lucide:this-icon-does-not-exist",
185
+ items: [],
186
+ },
187
+ ];
188
+
189
+ export const StressTest: Story = {
190
+ name: "Stress Test (Worst-Case Data)",
191
+ args: {
192
+ priceListData: stressPriceListData,
193
+ headingTag: "h2",
194
+ fromLabel: "ab einem Mindestpreis von",
195
+ },
196
+ render,
197
+ parameters: {
198
+ initialCanvas: "mobileCanvas",
199
+ docs: {
200
+ description: {
201
+ story:
202
+ "Deliberately hostile data to find breakage: a long German heading, unbroken description and price strings, " +
203
+ "a long price range with a long German 'from' label, a 'from' item with an empty price, an empty description, " +
204
+ "emoji, HTML-like text (must render as text), right-to-left Arabic, single characters, a negative price, " +
205
+ "a column with no heading (no empty heading is rendered) and 30 items, a broken icon name, and a column with no items. " +
206
+ "Check at every canvas width: nothing overflows its column, the price column never takes more than half the row " +
207
+ "and wraps inside it, columns go two-up only when the container is wide enough, and a third column wraps to a new row. " +
208
+ "Try the Description line clamp control too: it's a CSS token set by the story, not a prop.",
209
+ },
113
210
  },
114
- template: `<PriceList v-bind="args" />`,
115
- }),
211
+ },
116
212
  };
@@ -191,6 +191,26 @@ describe("PriceList", () => {
191
191
  expect(classes).not.toContain("first-class");
192
192
  });
193
193
 
194
+ it("does not render an empty heading when headingtext is empty", async () => {
195
+ wrapper = await createWrapper([
196
+ { headingtext: "", items: [{ description: "Cut", price: "£45" }] },
197
+ { headingtext: "Colour", items: [] },
198
+ ]);
199
+ const columns = wrapper.findAll(".price-list__column");
200
+ expect(columns[0]!.find(".price-list__heading").exists()).toBe(false);
201
+ expect(columns[0]!.findAll(".price-list__row").length).toBe(1);
202
+ expect(columns[1]!.find(".price-list__heading").text()).toBe("Colour");
203
+ });
204
+
205
+ it("renders HTML-like text as text", async () => {
206
+ wrapper = await createWrapper([
207
+ { headingtext: "Cuts", items: [{ description: "<script>alert(1)</script>", price: "<b>£5</b>" }] },
208
+ ]);
209
+ expect(wrapper.find("script").exists()).toBe(false);
210
+ expect(wrapper.find("dd b").exists()).toBe(false);
211
+ expect(wrapper.find(".price-list__description").text()).toBe("<script>alert(1)</script>");
212
+ });
213
+
194
214
  it("does not render a 'from' label when item.from is omitted", async () => {
195
215
  wrapper = await createWrapper();
196
216
  expect(wrapper.find(".price-list__from").exists()).toBe(false);
@@ -54,14 +54,19 @@ const meta: Meta<StoryArgs> = {
54
54
  authorLineClamp: {
55
55
  control: "select",
56
56
  options: lineClampOptions,
57
- description: "Story control for --google-reviews-author-line-clamp (1 = single-line ellipsis)",
58
- table: { category: "CSS tokens" },
57
+ description:
58
+ "**Story control, not a prop.** Sets the `--google-reviews-author-line-clamp` CSS token on a wrapper. " +
59
+ "To use it in an app, set the token in your own CSS, e.g. `.reviews { --google-reviews-author-line-clamp: 1; }`. " +
60
+ "`1` is single-line ellipsis.",
61
+ table: { category: "CSS tokens (story only, set in your CSS)" },
59
62
  },
60
63
  textLineClamp: {
61
64
  control: "select",
62
65
  options: lineClampOptions,
63
- description: "Story control for --google-reviews-text-line-clamp",
64
- table: { category: "CSS tokens" },
66
+ description:
67
+ "**Story control, not a prop.** Sets the `--google-reviews-text-line-clamp` CSS token on a wrapper. " +
68
+ "To use it in an app, set the token in your own CSS, e.g. `.reviews { --google-reviews-text-line-clamp: 3; }`.",
69
+ table: { category: "CSS tokens (story only, set in your CSS)" },
65
70
  },
66
71
  data: { control: "object", description: "GoogleReviewsData, usually from useGoogleReviews()" },
67
72
  minRating: { control: { type: "range", min: 0, max: 5, step: 1 }, description: "Hide reviews below this rating; shows the filter notice" },
@@ -6,7 +6,8 @@
6
6
 
7
7
  | Token | Default | Controls |
8
8
  |---|---|---|
9
- | `--slider-gallery-height` | `100svh` | Gallery height (width is always `100vw`) |
9
+ | `--slider-gallery-height` | `100svh` | Gallery height |
10
+ | `--slider-gallery-width` | `100vw` | Gallery width. Set `100%` to fill a positioned container instead of the viewport |
10
11
  | `--slider-gallery-z-index` | `9999` | Stacking order (full-screen takeover) |
11
12
  | `--slider-gallery-accent` | `#f1683a` | Loading spinner and progress bar colour |
12
13
  | `--slider-gallery-loading-background` | `var(--page-bg)` | Loading overlay background |
@@ -25,6 +26,15 @@
25
26
  | `--slider-gallery-cta-background` | `#99999975` | See-more link background |
26
27
  | `--slider-gallery-cta-border-colour` | `#fff` | See-more link border |
27
28
  | `--slider-gallery-cta-colour` | `#fff` | See-more link text |
29
+ | `--slider-gallery-author-line-clamp` | `none` | Max lines of the stylist line |
30
+ | `--slider-gallery-title-line-clamp` | `none` | Max lines of the title |
31
+ | `--slider-gallery-topic-line-clamp` | `none` | Max lines of the category line |
32
+ | `--slider-gallery-description-line-clamp` | `none` | Max lines of the description |
33
+
34
+ Line-clamp tokens: `1` is single-line ellipsis, any other number caps the lines with an ellipsis
35
+ on the last, `none` shows everything. All slide text wraps long unbroken strings (`overflow-wrap: anywhere`).
36
+ The slide text is absolutely positioned, so very long copy can run into the thumbnails and arrows:
37
+ clamp the description if your CMS copy is unbounded.
28
38
 
29
39
  ### Thumbnails
30
40
 
@@ -43,6 +53,11 @@
43
53
  | `--slider-gallery-thumbnail-outline-offset-hover` | the resting offset | Outline gap on hover |
44
54
  | `--slider-gallery-thumbnail-border-radius` | `20px` | Thumbnail rounding |
45
55
  | `--slider-gallery-thumbnail-overlay` | `#0004` | Tint over thumbnail images |
56
+ | `--slider-gallery-thumbnail-title-line-clamp` | `2` | Max lines of a thumbnail title |
57
+ | `--slider-gallery-thumbnail-description-line-clamp` | `2` | Max lines of a thumbnail description |
58
+
59
+ The thumbnail clamps default to `2` because the card is a fixed size with its text anchored to the
60
+ bottom: uncapped, long text grows upwards and its first lines are cut off.
46
61
 
47
62
  ### Arrows
48
63
 
@@ -83,6 +98,9 @@ Private (not public API): `--_animation-duration` (from the `animationDuration`
83
98
  `--_arrow-border-*`/`--_arrow-outline-*` (resting values the hover tokens fall back to) and
84
99
  `--_arrows-top`/`-right`/`-width`/`-max-width` (the resolved, breakpoint-switched row position).
85
100
 
101
+ > Changed 2026-10-06: added `--slider-gallery-width` (was a fixed `100vw`) and the line-clamp tokens;
102
+ > slide, thumbnail, see-more and loading text now wrap unbroken strings.
103
+
86
104
  > Changed 2026-09-28: the thumbnail sizing/border/radius overrides used to be private names
87
105
  > (`--_thumbnailMobileWidth`, `--_thumbnailBorderRadius`, etc.) with no public token; colours were
88
106
  > hardcoded. The arrow icon is now sized by `--slider-gallery-arrow-icon-size` (width/height), separately from the button, and the `--_translate-x` icon nudge was removed. The wide thumbnail size now also applies to the image
@@ -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
  }