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
@@ -21,10 +21,12 @@
21
21
  <template v-if="row.status === 'open'">
22
22
  <span v-for="(session, index) in row.sessions" :key="index" class="opening-hours__session">
23
23
  <span v-if="session.label" class="opening-hours__session-label">{{ session.label }}</span>
24
- <time :datetime="session.opens">{{ formatTime(session.opens) }}</time>
25
- <span aria-hidden="true"> – </span>
26
- <span class="sr-only"> {{ toLabel }} </span>
27
- <time :datetime="session.closes">{{ formatTime(session.closes) }}</time>
24
+ <span class="opening-hours__session-times">
25
+ <time :datetime="session.opens">{{ formatTime(session.opens) }}</time>
26
+ <span aria-hidden="true"> – </span>
27
+ <span class="sr-only"> {{ toLabel }} </span>
28
+ <time :datetime="session.closes">{{ formatTime(session.closes) }}</time>
29
+ </span>
28
30
  </span>
29
31
  </template>
30
32
  <span v-else class="opening-hours__status">{{ statusLabel(row.status) }}</span>
@@ -61,10 +63,12 @@
61
63
  <template v-if="row.status === 'open'">
62
64
  <span v-for="(session, index) in row.sessions" :key="index" class="opening-hours__session">
63
65
  <span v-if="session.label" class="opening-hours__session-label">{{ session.label }}</span>
64
- <time :datetime="session.opens">{{ formatTime(session.opens) }}</time>
65
- <span aria-hidden="true"> – </span>
66
- <span class="sr-only"> {{ toLabel }} </span>
67
- <time :datetime="session.closes">{{ formatTime(session.closes) }}</time>
66
+ <span class="opening-hours__session-times">
67
+ <time :datetime="session.opens">{{ formatTime(session.opens) }}</time>
68
+ <span aria-hidden="true"> – </span>
69
+ <span class="sr-only"> {{ toLabel }} </span>
70
+ <time :datetime="session.closes">{{ formatTime(session.closes) }}</time>
71
+ </span>
68
72
  </span>
69
73
  </template>
70
74
  <span v-else class="opening-hours__status">{{ statusLabel(row.status) }}</span>
@@ -138,29 +142,35 @@ watch(
138
142
  const todayIso = ref<string | null>(null);
139
143
 
140
144
  onMounted(() => {
141
- todayIso.value = new Intl.DateTimeFormat("en-CA", {
142
- timeZone: props.timeZone,
143
- year: "numeric",
144
- month: "2-digit",
145
- day: "2-digit",
146
- }).format(new Date());
145
+ const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "2-digit", day: "2-digit" };
146
+ let formatter: Intl.DateTimeFormat;
147
+ try {
148
+ formatter = new Intl.DateTimeFormat("en-CA", { ...options, timeZone: props.timeZone });
149
+ } catch {
150
+ formatter = new Intl.DateTimeFormat("en-CA", options);
151
+ }
152
+ todayIso.value = formatter.format(new Date());
147
153
  });
148
154
 
149
- const parseIsoDate = (iso: string) => {
150
- const [y = 0, m = 1, d = 1] = iso.split("-").map(Number);
151
- return new Date(Date.UTC(y, m - 1, d));
152
- };
155
+ const todayWeekday = computed<OpeningWeekday | null>(() => {
156
+ const today = todayIso.value ? parseOpeningDate(todayIso.value) : null;
157
+ return today ? (((today.getUTCDay() + 6) % 7) as OpeningWeekday) : null;
158
+ });
153
159
 
154
- const todayWeekday = computed<OpeningWeekday | null>(() =>
155
- todayIso.value ? (((parseIsoDate(todayIso.value).getUTCDay() + 6) % 7) as OpeningWeekday) : null
156
- );
160
+ const safeLocale = computed(() => {
161
+ try {
162
+ return Intl.getCanonicalLocales(props.locale)[0] ?? "en-GB";
163
+ } catch {
164
+ return "en-GB";
165
+ }
166
+ });
157
167
 
158
168
  const dayFormatter = computed(
159
- () => new Intl.DateTimeFormat(props.locale, { weekday: props.dayFormat, timeZone: "UTC" })
169
+ () => new Intl.DateTimeFormat(safeLocale.value, { weekday: props.dayFormat, timeZone: "UTC" })
160
170
  );
161
171
  const timeFormatter = computed(
162
172
  () =>
163
- new Intl.DateTimeFormat(props.locale, {
173
+ new Intl.DateTimeFormat(safeLocale.value, {
164
174
  hour: props.hour12 ? "numeric" : "2-digit",
165
175
  minute: "2-digit",
166
176
  hour12: props.hour12,
@@ -168,15 +178,20 @@ const timeFormatter = computed(
168
178
  })
169
179
  );
170
180
  const dateFormatter = computed(
171
- () => new Intl.DateTimeFormat(props.locale, { day: "numeric", month: "long", timeZone: "UTC" })
181
+ () => new Intl.DateTimeFormat(safeLocale.value, { day: "numeric", month: "long", timeZone: "UTC" })
172
182
  );
173
183
 
174
184
  // 1 Jan 2024 was a Monday
175
185
  const dayName = (day: OpeningWeekday) => dayFormatter.value.format(new Date(Date.UTC(2024, 0, 1 + day)));
176
186
 
177
187
  const formatTime = (time: string) => {
178
- const [h = 0, m = 0] = time.split(":").map(Number);
179
- return timeFormatter.value.format(new Date(Date.UTC(2024, 0, 1, h, m)));
188
+ const parsed = parseOpeningTime(time);
189
+ return parsed ? timeFormatter.value.format(new Date(Date.UTC(2024, 0, 1, parsed.hours, parsed.minutes))) : time;
190
+ };
191
+
192
+ const formatDate = (iso: string) => {
193
+ const date = parseOpeningDate(iso);
194
+ return date ? dateFormatter.value.format(date) : iso;
180
195
  };
181
196
 
182
197
  const statusLabel = (status: OpeningStatus) => {
@@ -225,21 +240,22 @@ const weeklyRows = computed(() => {
225
240
 
226
241
  const exceptionRows = computed(() =>
227
242
  props.exceptions
228
- .filter((exception) => !(props.hidePastExceptions && todayIso.value && (exception.to ?? exception.from) < todayIso.value))
229
- .map((exception) => {
230
- const to = exception.to && exception.to !== exception.from ? exception.to : null;
243
+ .filter((exception) => {
244
+ const last = exception.to && exception.to > exception.from ? exception.to : exception.from;
245
+ return !(props.hidePastExceptions && todayIso.value && last < todayIso.value);
246
+ })
247
+ .map((exception, index) => {
248
+ const [from, rawTo] =
249
+ exception.to && exception.to < exception.from ? [exception.to, exception.from] : [exception.from, exception.to];
250
+ const to = rawTo && rawTo !== from ? rawTo : null;
231
251
  return {
232
- key: `${exception.from}-${to ?? ""}`,
252
+ key: `${index}-${from}-${to ?? ""}`,
233
253
  label: exception.label,
234
- from: exception.from,
254
+ from,
235
255
  to,
236
- start: dateFormatter.value.format(parseIsoDate(exception.from)),
237
- end: to ? dateFormatter.value.format(parseIsoDate(to)) : null,
238
- isToday:
239
- props.highlightToday &&
240
- todayIso.value !== null &&
241
- exception.from <= todayIso.value &&
242
- todayIso.value <= (to ?? exception.from),
256
+ start: formatDate(from),
257
+ end: to ? formatDate(to) : null,
258
+ isToday: props.highlightToday && todayIso.value !== null && from <= todayIso.value && todayIso.value <= (to ?? from),
243
259
  ...resolve(exception),
244
260
  };
245
261
  })
@@ -269,6 +285,7 @@ if (props.structuredData) {
269
285
  gap: var(--opening-hours-section-gap, 3.2rem);
270
286
 
271
287
  .opening-hours__exceptions-heading {
288
+ overflow-wrap: anywhere;
272
289
  font-size: var(--opening-hours-exceptions-heading-font-size, 1.6rem);
273
290
  font-weight: var(--opening-hours-exceptions-heading-font-weight, 600);
274
291
  margin: 0 0 var(--opening-hours-exceptions-heading-margin-block-end, 1.2rem);
@@ -279,10 +296,10 @@ if (props.structuredData) {
279
296
  padding: 0;
280
297
 
281
298
  .opening-hours__row {
282
- display: flex;
283
- justify-content: space-between;
299
+ display: grid;
300
+ grid-template-columns: minmax(0, 1fr) fit-content(var(--opening-hours-hours-max-inline-size, 60%));
284
301
  align-items: baseline;
285
- gap: var(--opening-hours-row-gap, 1.2rem);
302
+ column-gap: var(--opening-hours-row-gap, 1.2rem);
286
303
  padding-block: var(--opening-hours-row-padding-block, 1.2rem);
287
304
  padding-inline: var(--opening-hours-row-padding-inline, 0);
288
305
  border-block-end: var(--opening-hours-divider-width, 1px) solid
@@ -308,12 +325,17 @@ if (props.structuredData) {
308
325
  }
309
326
 
310
327
  .opening-hours__days {
328
+ overflow-wrap: anywhere;
311
329
  font-size: var(--opening-hours-day-font-size, 1.4rem);
312
330
  color: var(--opening-hours-day-colour, inherit);
313
331
  }
314
332
 
315
333
  .opening-hours__exception-label {
316
- display: block;
334
+ display: -webkit-box;
335
+ -webkit-box-orient: vertical;
336
+ overflow: hidden;
337
+ -webkit-line-clamp: var(--opening-hours-exception-label-line-clamp, none);
338
+ line-clamp: var(--opening-hours-exception-label-line-clamp, none);
317
339
  }
318
340
 
319
341
  .opening-hours__date {
@@ -329,11 +351,12 @@ if (props.structuredData) {
329
351
  gap: var(--opening-hours-session-gap, 0.4rem);
330
352
  margin: 0;
331
353
  text-align: end;
354
+ overflow-wrap: anywhere;
332
355
  font-size: var(--opening-hours-hours-font-size, 1.4rem);
333
356
  font-variant-numeric: tabular-nums;
334
357
  }
335
358
 
336
- .opening-hours__session {
359
+ .opening-hours__session-times {
337
360
  white-space: nowrap;
338
361
  }
339
362
 
@@ -343,6 +366,11 @@ if (props.structuredData) {
343
366
  }
344
367
 
345
368
  .opening-hours__note {
369
+ display: -webkit-box;
370
+ -webkit-box-orient: vertical;
371
+ overflow: hidden;
372
+ -webkit-line-clamp: var(--opening-hours-note-line-clamp, none);
373
+ line-clamp: var(--opening-hours-note-line-clamp, none);
346
374
  font-size: var(--opening-hours-note-font-size, 1.2rem);
347
375
  color: var(--opening-hours-note-colour, inherit);
348
376
  }
@@ -1,7 +1,19 @@
1
+ import { computed, ref } from "vue";
1
2
  import OpeningHours from "../OpeningHours.vue";
3
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
2
4
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
5
+ import type { MediaCanvas } from "~/types/components";
3
6
  import type { OpeningDay, OpeningException } from "~/types/components/opening-hours";
4
7
 
8
+ type LineClamp = "none" | "1" | "2" | "3" | "4";
9
+ const lineClampOptions: LineClamp[] = ["none", "1", "2", "3", "4"];
10
+
11
+ // Story-only args: they drive CSS tokens, not OpeningHours props. See the argTypes entries.
12
+ type StoryArgs = InstanceType<typeof OpeningHours>["$props"] & {
13
+ noteLineClamp: LineClamp;
14
+ exceptionLabelLineClamp: LineClamp;
15
+ };
16
+
5
17
  const shopDays: OpeningDay[] = [
6
18
  { day: 0, sessions: [{ opens: "09:00", closes: "17:30" }] },
7
19
  { day: 1, sessions: [{ opens: "09:00", closes: "17:30" }] },
@@ -38,10 +50,27 @@ const exceptions: OpeningException[] = [
38
50
  { from: isoInDays(30), label: "Bank holiday", sessions: [{ opens: "10:00", closes: "16:00" }] },
39
51
  ];
40
52
 
41
- const meta: Meta<typeof OpeningHours> = {
53
+ const meta: Meta<StoryArgs> = {
42
54
  title: "Molecules/OpeningHours",
43
55
  component: OpeningHours,
44
56
  argTypes: {
57
+ noteLineClamp: {
58
+ control: "select",
59
+ options: lineClampOptions,
60
+ description:
61
+ "**Story control, not a prop.** Sets the `--opening-hours-note-line-clamp` CSS token on a wrapper so you can try it here. " +
62
+ "To use it in an app, set the token in your own CSS, e.g. `.shop-hours { --opening-hours-note-line-clamp: 2; }`. " +
63
+ "`1` is single-line ellipsis, `none` (the default) shows everything.",
64
+ table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
65
+ },
66
+ exceptionLabelLineClamp: {
67
+ control: "select",
68
+ options: lineClampOptions,
69
+ description:
70
+ "**Story control, not a prop.** Sets the `--opening-hours-exception-label-line-clamp` CSS token on a wrapper. " +
71
+ "To use it in an app, set the token in your own CSS, e.g. `.shop-hours { --opening-hours-exception-label-line-clamp: 1; }`.",
72
+ table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
73
+ },
45
74
  days: { control: "object", description: "One entry per day (0 = Monday); missing days are closed", table: { category: "Content" } },
46
75
  exceptions: { control: "object", description: "Special dates that override the weekly pattern", table: { category: "Content" } },
47
76
  locale: { control: "text", description: "BCP 47 locale for day, date and time formatting", table: { category: "Formatting", defaultValue: { summary: "en-GB" } } },
@@ -61,18 +90,53 @@ const meta: Meta<typeof OpeningHours> = {
61
90
  structuredData: { control: false, table: { category: "SEO" } },
62
91
  styleClassPassthrough: { control: "object", table: { category: "Styling" } },
63
92
  },
64
- decorators: [() => ({ template: `<div style="max-width: 48rem;"><story /></div>` })],
93
+ args: {
94
+ noteLineClamp: "none",
95
+ exceptionLabelLineClamp: "none",
96
+ },
97
+ decorators: [
98
+ (story, context) => ({
99
+ components: { story, CanvasSwitcher },
100
+ setup() {
101
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
102
+ return { canvasName };
103
+ },
104
+ template: `
105
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
106
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
107
+ </div>
108
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
109
+ <div style="max-width: 48rem;"><story /></div>
110
+ </div>
111
+ `,
112
+ }),
113
+ ],
114
+ parameters: {
115
+ layout: "fullscreen",
116
+ },
65
117
  };
66
118
 
67
119
  export default meta;
68
- type Story = StoryObj<typeof OpeningHours>;
120
+ type Story = StoryObj<StoryArgs>;
69
121
 
70
- const render: Story["render"] = (args) => ({
122
+ function useStorySetup(args: StoryArgs) {
123
+ const componentArgs = computed(() => {
124
+ const { noteLineClamp: _noteLineClamp, exceptionLabelLineClamp: _exceptionLabelLineClamp, ...rest } = args;
125
+ return rest;
126
+ });
127
+ const tokenStyles = computed(() => ({
128
+ "--opening-hours-note-line-clamp": args.noteLineClamp,
129
+ "--opening-hours-exception-label-line-clamp": args.exceptionLabelLineClamp,
130
+ }));
131
+ return { componentArgs, tokenStyles };
132
+ }
133
+
134
+ const render = (args: StoryArgs) => ({
71
135
  components: { OpeningHours },
72
136
  setup() {
73
- return { args };
137
+ return useStorySetup(args);
74
138
  },
75
- template: `<OpeningHours v-bind="args" />`,
139
+ template: `<div :style="tokenStyles"><OpeningHours v-bind="componentArgs" /></div>`,
76
140
  });
77
141
 
78
142
  export const Default: Story = {
@@ -125,3 +189,71 @@ export const Localised: Story = {
125
189
  },
126
190
  render,
127
191
  };
192
+
193
+ // ─── Stress test ──────────────────────────────────────────────────────────────
194
+
195
+ const stressDays: OpeningDay[] = [
196
+ {
197
+ day: 0,
198
+ sessions: [
199
+ { opens: "09:00", closes: "12:00", label: "Vormittagssprechstunde nur für Bestandskundinnen und Bestandskunden" },
200
+ { opens: "13:00", closes: "17:30", label: "Nachmittagssprechstundeohneleerzeichenunddasziemlichlang" },
201
+ ],
202
+ note: "Bitte beachten Sie, dass wir an diesem Tag zusätzlich telefonisch erreichbar sind, Rückrufe aber erst ab dem nächsten Werktag erfolgen.",
203
+ },
204
+ { day: 1, sessions: [{ opens: "22:00", closes: "02:00", label: "Overnight" }], note: "Closes after midnight" },
205
+ { day: 2, sessions: [{ opens: "abc", closes: "25:99" }], note: "Malformed times are shown as written" },
206
+ {
207
+ day: 3,
208
+ sessions: Array.from({ length: 6 }, (_, i) => ({ opens: `${String(8 + i * 2).padStart(2, "0")}:00`, closes: `${String(9 + i * 2).padStart(2, "0")}:00` })),
209
+ },
210
+ { day: 4, status: "open", sessions: [], note: "Status open with no sessions, shown as closed" },
211
+ { day: 5, status: "by-appointment", note: "💇‍♀️ <b>not bold</b> https://example.com/a/very/long/booking/link/that/never/breaks/segmentsegment" },
212
+ { day: 6, status: "24-hours", note: "مفتوح طوال اليوم" },
213
+ ];
214
+
215
+ const stressExceptions: OpeningException[] = [
216
+ {
217
+ from: isoInDays(1),
218
+ label: "Betriebsversammlungundinventurwochenendeohneleerzeichen und danach noch eine sehr lange Beschreibung",
219
+ sessions: [{ opens: "10:00", closes: "12:00" }],
220
+ },
221
+ { from: isoInDays(20), to: isoInDays(18), label: "Reversed range (to before from)" },
222
+ { from: isoInDays(5), label: "Duplicate date" },
223
+ { from: isoInDays(5), label: "Duplicate date" },
224
+ { from: "not-a-date", label: "Invalid date" },
225
+ { from: isoInDays(40), label: "" },
226
+ ];
227
+
228
+ export const StressTest: Story = {
229
+ name: "Stress Test (Worst-Case Data)",
230
+ args: {
231
+ days: stressDays,
232
+ exceptions: stressExceptions,
233
+ hidePastExceptions: false,
234
+ groupDays: true,
235
+ locale: "not a locale!!",
236
+ timeZone: "Mars/Base",
237
+ exceptionsHeading: "Sonderöffnungszeiten an Feiertagen, Brückentagen und während betrieblicher Veranstaltungen",
238
+ closedLabel: "Geschlossen",
239
+ open24HoursLabel: "Rund um die Uhr geöffnet, auch an Sonn- und Feiertagen",
240
+ byAppointmentLabel: "Ausschließlich nach vorheriger telefonischer Terminvereinbarung",
241
+ toLabel: "bis",
242
+ },
243
+ render,
244
+ parameters: {
245
+ initialCanvas: "mobileCanvas",
246
+ docs: {
247
+ description: {
248
+ story:
249
+ "Deliberately hostile data to find breakage: an invalid locale (falls back to en-GB) and time zone (falls back to the browser's), " +
250
+ "malformed times like abc and 25:99 (shown as written, not reformatted), a session crossing midnight, six sessions in a day, " +
251
+ "status open with no sessions (shown as closed), long German session labels, notes, status labels and exceptions heading, an unbroken exception label, " +
252
+ "emoji, HTML-like text (must render as text), a long URL, right-to-left Arabic, a reversed date range (shown the right way round), " +
253
+ "duplicate exception dates, an invalid date (shown as written) and an empty label. " +
254
+ "Check at every canvas width: nothing overflows, each session's times stay on one line while its label wraps, and the hours " +
255
+ "column never takes more than 60% of the row. Try the line-clamp controls too: they're CSS tokens set by the story, not props.",
256
+ },
257
+ },
258
+ },
259
+ };
@@ -200,6 +200,59 @@ describe("OpeningHours", () => {
200
200
  wrapper = await createWrapper();
201
201
  expect(wrapper.find(".opening-hours__exceptions").exists()).toBe(false);
202
202
  });
203
+
204
+ it("swaps a range whose to is before from", async () => {
205
+ vi.setSystemTime(new Date("2026-12-25T12:00:00Z"));
206
+ wrapper = await createWrapper({ exceptions: [{ from: "2026-12-26", to: "2026-12-24", label: "Reversed" }] });
207
+ await nextTick();
208
+ expect(rowTexts(wrapper, ".opening-hours__exceptions")).toEqual(["Reversed 24 December – to 26 December Closed"]);
209
+ expect(wrapper.find(".opening-hours__exceptions .opening-hours__row").attributes("data-today")).toBe("true");
210
+ });
211
+
212
+ it("renders duplicate exceptions without key clashes", async () => {
213
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
214
+ vi.setSystemTime(new Date("2026-09-27T12:00:00Z"));
215
+ wrapper = await createWrapper({ exceptions: [{ from: "2026-12-25" }, { from: "2026-12-25" }] });
216
+ await nextTick();
217
+ expect(wrapper.find(".opening-hours__exceptions").findAll(".opening-hours__row").length).toBe(2);
218
+ expect(warn.mock.calls.some(([msg]) => String(msg).includes("Duplicate keys"))).toBe(false);
219
+ warn.mockRestore();
220
+ });
221
+ });
222
+
223
+ describe("Bad data", () => {
224
+ it("shows an unparseable or out-of-range time as written instead of throwing", async () => {
225
+ wrapper = await createWrapper({ days: [{ day: 0, sessions: [{ opens: "abc", closes: "25:99" }] }], groupDays: false });
226
+ expect(rowTexts(wrapper)[0]).toBe("Monday abc – to 25:99");
227
+ });
228
+
229
+ it("shows an invalid exception date as written instead of throwing", async () => {
230
+ wrapper = await createWrapper({
231
+ exceptions: [{ from: "not-a-date", label: "Bad" }],
232
+ hidePastExceptions: false,
233
+ });
234
+ await nextTick();
235
+ expect(rowTexts(wrapper, ".opening-hours__exceptions")).toEqual(["Bad not-a-date Closed"]);
236
+ });
237
+
238
+ it("shows the closed label for status open with no sessions", async () => {
239
+ wrapper = await createWrapper({ days: [{ day: 0, status: "open", sessions: [] }], groupDays: false });
240
+ const row = wrapper.find(".opening-hours__row");
241
+ expect(row.attributes("data-status")).toBe("closed");
242
+ expect(row.find(".opening-hours__status").text()).toBe("Closed");
243
+ });
244
+
245
+ it("falls back to en-GB for an invalid locale", async () => {
246
+ wrapper = await createWrapper({ locale: "not a locale!!" });
247
+ expect(rowTexts(wrapper)[0]).toBe("Monday – to Saturday 09:00 – to 17:30");
248
+ });
249
+
250
+ it("still mounts with an invalid timeZone", async () => {
251
+ vi.setSystemTime(new Date("2026-09-28T12:00:00Z"));
252
+ wrapper = await createWrapper({ timeZone: "Mars/Base" });
253
+ await nextTick();
254
+ expect(wrapper.find("[data-today]").exists()).toBe(true);
255
+ });
203
256
  });
204
257
 
205
258
  describe("Accessibility", () => {
@@ -5,7 +5,7 @@ exports[`OpeningHours > Snapshots > default 1`] = `
5
5
  <dl class="opening-hours__list">
6
6
  <div class="opening-hours__row" data-status="open" data-today="true" aria-current="date">
7
7
  <dt class="opening-hours__days"><span>Monday</span><span aria-hidden="true"> – </span><span class="sr-only">to</span><span>Saturday</span></dt>
8
- <dd class="opening-hours__hours"><span class="opening-hours__session"><!--v-if--><time datetime="09:00">09:00</time><span aria-hidden="true"> – </span><span class="sr-only">to</span><time datetime="17:30">17:30</time></span>
8
+ <dd class="opening-hours__hours"><span class="opening-hours__session"><!--v-if--><span class="opening-hours__session-times"><time datetime="09:00">09:00</time><span aria-hidden="true"> – </span><span class="sr-only">to</span><time datetime="17:30">17:30</time></span></span>
9
9
  <!--v-if-->
10
10
  </dd>
11
11
  </div>
@@ -1,5 +1,36 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { buildOpeningHoursSpecification, resolveOpeningStatus } from "~/utils/opening-hours";
2
+ import {
3
+ buildOpeningHoursSpecification,
4
+ parseOpeningDate,
5
+ parseOpeningTime,
6
+ resolveOpeningStatus,
7
+ } from "~/utils/opening-hours";
8
+
9
+ describe("parseOpeningTime", () => {
10
+ it("parses HH:MM and H:MM", () => {
11
+ expect(parseOpeningTime("09:30")).toEqual({ hours: 9, minutes: 30 });
12
+ expect(parseOpeningTime("9:05")).toEqual({ hours: 9, minutes: 5 });
13
+ expect(parseOpeningTime("23:59")).toEqual({ hours: 23, minutes: 59 });
14
+ });
15
+
16
+ it("rejects malformed and out-of-range times", () => {
17
+ for (const bad of ["", "abc", "9", "24:00", "25:99", "12:60", "12:3", "-1:00"]) {
18
+ expect(parseOpeningTime(bad)).toBeNull();
19
+ }
20
+ });
21
+ });
22
+
23
+ describe("parseOpeningDate", () => {
24
+ it("parses a real YYYY-MM-DD date as UTC midnight", () => {
25
+ expect(parseOpeningDate("2026-02-28")?.toISOString()).toBe("2026-02-28T00:00:00.000Z");
26
+ });
27
+
28
+ it("rejects malformed and impossible dates", () => {
29
+ for (const bad of ["", "not-a-date", "2026-13-01", "2026-02-30", "26-01-01", "2026-1-1"]) {
30
+ expect(parseOpeningDate(bad)).toBeNull();
31
+ }
32
+ });
33
+ });
3
34
 
4
35
  describe("resolveOpeningStatus", () => {
5
36
  it("defaults to open with sessions and closed without", () => {
@@ -7,6 +38,11 @@ describe("resolveOpeningStatus", () => {
7
38
  expect(resolveOpeningStatus({})).toBe("closed");
8
39
  expect(resolveOpeningStatus({ status: "by-appointment" })).toBe("by-appointment");
9
40
  });
41
+
42
+ it("treats open with no sessions as closed", () => {
43
+ expect(resolveOpeningStatus({ status: "open" })).toBe("closed");
44
+ expect(resolveOpeningStatus({ status: "open", sessions: [] })).toBe("closed");
45
+ });
10
46
  });
11
47
 
12
48
  describe("buildOpeningHoursSpecification", () => {
@@ -2,7 +2,8 @@
2
2
 
3
3
  `PriceList` renders one or more columns of priced items (a salon/service menu). Each column is a
4
4
  `HeroText` heading followed by a `<dl>` of description/price rows separated by dividers. Columns
5
- stack on narrow viewports and sit two-up from `48em`.
5
+ stack in a narrow container and sit side by side (two at most by default) once the container is
6
+ wide enough for each to reach `--price-list-column-min-inline-size`.
6
7
 
7
8
  ## Public token API
8
9
 
@@ -10,7 +11,17 @@ stack on narrow viewports and sit two-up from `48em`.
10
11
 
11
12
  | Token | Default | Controls |
12
13
  |---|---|---|
13
- | `--price-list-column-gap` | `2.4rem` | Gap between columns (and between stacked columns on mobile) |
14
+ | `--price-list-column-gap` | `2.4rem` | Gap between columns (and between stacked columns) |
15
+ | `--price-list-column-min-inline-size` | `32rem` | Narrowest a column gets before the columns stack. Measured against the component's own width, not the viewport |
16
+ | `--price-list-max-columns` | `2` | Most columns side by side. A single column still takes only its share of the width (half, by default) |
17
+
18
+ Private `--_column-gap` and `--_max-columns` hold the two tokens above so the column formula can
19
+ read each twice; they are not public API.
20
+
21
+ > **Changed 2026-10-05**: the two-column switch used to be a `48em` viewport media query, so a
22
+ > list in a narrow container (a sidebar, a card) stayed two-up and cramped on a wide screen. It now
23
+ > follows the component's own width, set by `--price-list-column-min-inline-size` and
24
+ > `--price-list-max-columns`.
14
25
 
15
26
  ### Column heading
16
27
 
@@ -35,6 +46,7 @@ The heading is a `HeroText`, so `--hero-text-font-family` also applies to it.
35
46
  | `--price-list-divider-opacity` | `0.15` | Divider opacity (0 to 1), mixed into the colour |
36
47
  | `--price-list-description-font-size` | `1.4rem` | Item description size |
37
48
  | `--price-list-description-colour` | `inherit` | Item description colour |
49
+ | `--price-list-description-line-clamp` | `none` | Cap the description at this many lines, with an ellipsis on the last. `1` is single-line ellipsis, `none` shows everything |
38
50
 
39
51
  ### Price
40
52
 
@@ -46,8 +58,9 @@ The heading is a `HeroText`, so `--hero-text-font-family` also applies to it.
46
58
  | `--price-list-from-font-size` | `1.4rem` | "from" label size |
47
59
  | `--price-list-from-colour` | `inherit` | "from" label colour |
48
60
  | `--price-list-from-gap` | `0.5ch` | Space between "from" and the amount |
61
+ | `--price-list-price-max-inline-size` | `50%` | Most of the row the price can take. A price longer than this wraps inside it, right-aligned; normal short prices stay on one line |
49
62
 
50
- There are no private `--_` tokens.
63
+ Long unbroken descriptions, headings and prices wrap anywhere rather than overflow the column.
51
64
 
52
65
  > **Changed 2026-09-27**: `--price-list-divider-color` is now `--price-list-divider-colour`
53
66
  > (repo spelling convention). `--price-list-price-font-size` used to be dead for the amount itself
@@ -74,8 +87,7 @@ No `data-*` attributes. Inner element classes, safe to target:
74
87
  | `.price-list__from` | "from" label (only when `item.from` is true) |
75
88
  | `.price-list__amount` | Price amount |
76
89
 
77
- The two-column breakpoint (`48em`) is a media query and can't be tokenised; override
78
- `grid-template-columns` on `.price-list` if you need a different split.
90
+ `.price-list__heading` is not rendered for a column whose `headingtext` is empty.
79
91
 
80
92
  ---
81
93
 
@@ -2,6 +2,7 @@
2
2
  <div class="price-list" :class="elementClasses">
3
3
  <div v-for="(column, colIndex) in priceListData" :key="colIndex" class="price-list__column">
4
4
  <HeroText
5
+ v-if="column.headingtext"
5
6
  :tag="headingTag"
6
7
  font-size="subheading"
7
8
  :text-content="[{ text: column.headingtext }]"
@@ -49,15 +50,28 @@ watch(
49
50
  <style lang="css">
50
51
  @layer components {
51
52
  .price-list {
52
- display: grid;
53
- grid-template-columns: 1fr;
54
- gap: var(--price-list-column-gap, 2.4rem);
53
+ --_column-gap: var(--price-list-column-gap, 2.4rem);
54
+ --_max-columns: var(--price-list-max-columns, 2);
55
55
 
56
- @media (min-width: 48em) {
57
- grid-template-columns: 1fr 1fr;
56
+ display: grid;
57
+ grid-template-columns: repeat(
58
+ auto-fill,
59
+ minmax(
60
+ max(
61
+ min(100%, var(--price-list-column-min-inline-size, 32rem)),
62
+ calc((100% - (var(--_max-columns) - 1) * var(--_column-gap)) / var(--_max-columns))
63
+ ),
64
+ 1fr
65
+ )
66
+ );
67
+ gap: var(--_column-gap);
68
+
69
+ .price-list__column {
70
+ min-inline-size: 0;
58
71
  }
59
72
 
60
73
  .price-list__heading.hero-text {
74
+ overflow-wrap: anywhere;
61
75
  font-size: var(--price-list-heading-font-size, var(--hero-text-subheading));
62
76
  font-weight: var(--price-list-heading-font-weight, 600);
63
77
  color: var(--price-list-heading-colour, inherit);
@@ -75,10 +89,10 @@ watch(
75
89
  flex-direction: column;
76
90
 
77
91
  .price-list__row {
78
- display: flex;
79
- justify-content: space-between;
92
+ display: grid;
93
+ grid-template-columns: minmax(0, 1fr) fit-content(var(--price-list-price-max-inline-size, 50%));
80
94
  align-items: center;
81
- gap: var(--price-list-row-gap, 1.2rem);
95
+ column-gap: var(--price-list-row-gap, 1.2rem);
82
96
  padding-block: var(--price-list-row-padding-block, 1.4rem);
83
97
  border-block-end: var(--price-list-divider-width, 1px) solid
84
98
  color-mix(
@@ -93,17 +107,26 @@ watch(
93
107
  }
94
108
 
95
109
  .price-list__description {
110
+ display: -webkit-box;
111
+ -webkit-box-orient: vertical;
112
+ overflow: hidden;
113
+ overflow-wrap: anywhere;
114
+ -webkit-line-clamp: var(--price-list-description-line-clamp, none);
115
+ line-clamp: var(--price-list-description-line-clamp, none);
96
116
  font-size: var(--price-list-description-font-size, 1.4rem);
97
117
  color: var(--price-list-description-colour, inherit);
98
118
  }
99
119
 
100
120
  .price-list__price {
101
121
  display: flex;
122
+ flex-wrap: wrap;
123
+ justify-content: flex-end;
102
124
  align-items: baseline;
103
125
  gap: var(--price-list-from-gap, 0.5ch);
104
126
  margin: 0;
127
+ text-align: end;
128
+ overflow-wrap: anywhere;
105
129
  font-variant-numeric: tabular-nums;
106
- white-space: nowrap;
107
130
  }
108
131
 
109
132
  .price-list__from {