srcdev-nuxt-components 9.5.5 → 9.5.7

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 (98) hide show
  1. package/.claude/skills/component-compliance-checklist.md +6 -2
  2. package/.claude/skills/components/action-menu.md +21 -15
  3. package/.claude/skills/components/deep-expanding-menu.md +18 -6
  4. package/.claude/skills/components/display-tooltip-defined.md +2 -1
  5. package/.claude/skills/components/display-tooltip.md +10 -4
  6. package/.claude/skills/components/navigation-items.md +1 -0
  7. package/.claude/skills/components/opening-hours.md +12 -0
  8. package/.claude/skills/components/pop-over.md +11 -8
  9. package/.claude/skills/components/price-list.md +10 -1
  10. package/.claude/skills/components/responsive-header.md +25 -0
  11. package/.claude/skills/components/select-menu.md +18 -8
  12. package/.claude/skills/components/site-header.md +2 -0
  13. package/.claude/skills/components/ui-block-decorated.md +7 -0
  14. package/.claude/skills/composable-anchored-popover.md +108 -0
  15. package/.claude/skills/composable-tooltips-guide.md +3 -1
  16. package/.claude/skills/index.md +1 -0
  17. package/.claude/skills/storybook-add-story.md +14 -0
  18. package/.vscode/srcdev-component-responsive-header.code-snippets +3 -1
  19. package/.vscode/srcdev-component-site-header.code-snippets +3 -1
  20. package/app/assets/styles/setup/06.utility-classes/animations/_animation-scroller-x.css +5 -3
  21. package/app/assets/styles/setup/06.utility-classes/animations/_auto-rotate.css +5 -3
  22. package/app/assets/styles/setup/06.utility-classes/animations/_entry-exit-blur.css +5 -3
  23. package/app/assets/styles/setup/06.utility-classes/animations/_entry-slide-in.css +5 -3
  24. package/app/assets/styles/setup/06.utility-classes/animations/_entry-zoom-reveal.css +5 -3
  25. package/app/components/01.atoms/animations/entry/CONSUMER-STYLING.md +6 -2
  26. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +15 -3
  27. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +62 -3
  28. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +36 -1
  29. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +1 -1
  30. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +1 -1
  31. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +9 -3
  32. package/app/components/01.atoms/pop-over/PopOver.vue +70 -9
  33. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +53 -0
  34. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +1 -1
  35. package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +12 -4
  36. package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +20 -16
  37. package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +124 -96
  38. package/app/components/02.molecules/action-menu/ActionMenu.vue +41 -16
  39. package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +16 -3
  40. package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +28 -0
  41. package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +3 -3
  42. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +2 -1
  43. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +7 -4
  44. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +20 -1
  45. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +1 -1
  46. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +18 -4
  47. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +87 -16
  48. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +83 -1
  49. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +2 -2
  50. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +1 -1
  51. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +1 -1
  52. package/app/components/02.molecules/opening-hours/CONSUMER-STYLING.md +11 -1
  53. package/app/components/02.molecules/opening-hours/OpeningHours.vue +71 -43
  54. package/app/components/02.molecules/opening-hours/stories/OpeningHours.stories.ts +138 -6
  55. package/app/components/02.molecules/opening-hours/tests/OpeningHours.spec.ts +53 -0
  56. package/app/components/02.molecules/opening-hours/tests/__snapshots__/OpeningHours.spec.ts.snap +1 -1
  57. package/app/components/02.molecules/opening-hours/tests/buildOpeningHoursSpecification.spec.ts +37 -1
  58. package/app/components/02.molecules/price-list/CONSUMER-STYLING.md +17 -5
  59. package/app/components/02.molecules/price-list/PriceList.vue +32 -9
  60. package/app/components/02.molecules/price-list/stories/PriceList.stories.ts +125 -29
  61. package/app/components/02.molecules/price-list/tests/PriceList.spec.ts +20 -0
  62. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +17 -5
  63. package/app/components/02.molecules/select-menu/SelectMenu.vue +51 -16
  64. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +36 -0
  65. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +9 -4
  66. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +14 -0
  67. package/app/components/03.organisms/responsive-header/NavigationItems.vue +23 -14
  68. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +58 -30
  69. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +149 -18
  70. package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +21 -0
  71. package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +66 -0
  72. package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +30 -0
  73. package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +1 -0
  74. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +35 -0
  75. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +23 -0
  76. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +1 -0
  77. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +71 -0
  78. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +386 -0
  79. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +11 -0
  80. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +79 -0
  81. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +17 -0
  82. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +64 -0
  83. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +29 -0
  84. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +32 -0
  85. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +10 -0
  86. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
  87. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +16 -0
  88. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +91 -6
  89. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +9 -9
  90. package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +1 -0
  91. package/app/components/03.organisms/site-header/SiteHeader.vue +8 -0
  92. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
  93. package/app/composables/tests/useAnchoredPopover.spec.ts +231 -0
  94. package/app/composables/tests/useTooltips.spec.ts +69 -0
  95. package/app/composables/useAnchoredPopover.ts +183 -0
  96. package/app/composables/useTooltips.ts +37 -20
  97. package/app/utils/opening-hours.ts +23 -2
  98. package/package.json +1 -1
@@ -1,16 +1,32 @@
1
1
  <template>
2
- <component :is="tag" class="deep-expanding-menu" :class="[elementClasses]">
2
+ <component :is="tag" ref="rootRef" class="deep-expanding-menu" :class="[elementClasses]">
3
3
  <div class="inner">
4
4
  <template v-for="(link, key) in navLinks" :key="key">
5
5
  <NuxtLink v-if="link.path" :to="link.path" class="navigation-link">{{ link.name }}</NuxtLink>
6
6
 
7
7
  <div v-else class="navigation-group" :style="`--_anchor-name: --anchor-${uid}-${key};`">
8
- <button :popovertarget="`popovertarget-${uid}-${key}`" class="navigation-group-toggle">
8
+ <button
9
+ :ref="(el) => setElement(triggerEls, key, el)"
10
+ :popovertarget="`popovertarget-${uid}-${key}`"
11
+ class="navigation-group-toggle"
12
+ :aria-expanded="isOpen && activeKey === key"
13
+ @click="handleGroupClick(key)"
14
+ >
9
15
  <span>{{ link.name }}</span>
10
16
  <Icon name="bi:caret-down-fill" class="icon" />
11
17
  </button>
12
18
 
13
- <div :id="`popovertarget-${uid}-${key}`" class="navigation-group-panel" popover role="menu">
19
+ <div
20
+ :id="`popovertarget-${uid}-${key}`"
21
+ :ref="(el) => setElement(panelEls, key, el)"
22
+ class="navigation-group-panel"
23
+ :class="{ 'deep-expanding-menu-panel-open': usesFallbackPopover && isOpen && activeKey === key }"
24
+ :style="activeKey === key ? positionStyle : undefined"
25
+ :data-placement="activeKey === key ? popoverPlacement : undefined"
26
+ popover
27
+ @beforetoggle="handlePanelBeforeToggle(key, $event)"
28
+ @toggle="handlePanelToggle(key, $event)"
29
+ >
14
30
  <h4 class="page-heading-4 mb-6">{{ link.childLinksTitle }}</h4>
15
31
  <ul class="navigation-group-list">
16
32
  <li v-for="childLink in link.childLinks" :key="childLink.name" class="navigation-group-item">
@@ -25,6 +41,7 @@
25
41
  </template>
26
42
 
27
43
  <script setup lang="ts">
44
+ import type { ComponentPublicInstance } from "vue";
28
45
  import type { ResponsiveHeaderNavItem } from "~/types/components";
29
46
 
30
47
  interface Props {
@@ -44,6 +61,54 @@ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.s
44
61
  // Scopes anchor-name/popovertarget ids so multiple instances on one page don't collide.
45
62
  const uid = useId();
46
63
 
64
+ // One panel is open at a time, so a single popover controller follows the active group.
65
+ const rootRef = ref<HTMLElement | null>(null);
66
+ const activeKey = ref<number | null>(null);
67
+ const triggerEls = new Map<number, HTMLElement>();
68
+ const panelEls = new Map<number, HTMLElement>();
69
+
70
+ const setElement = (map: Map<number, HTMLElement>, key: number, el: Element | ComponentPublicInstance | null) => {
71
+ if (el instanceof HTMLElement) map.set(key, el);
72
+ else map.delete(key);
73
+ };
74
+
75
+ const triggerRef = computed(() => (activeKey.value === null ? null : (triggerEls.get(activeKey.value) ?? null)));
76
+ const popoverRef = computed(() => (activeKey.value === null ? null : (panelEls.get(activeKey.value) ?? null)));
77
+
78
+ const {
79
+ isOpen,
80
+ usesFallbackPopover,
81
+ positionStyle,
82
+ popoverPlacement,
83
+ show,
84
+ hide,
85
+ handleBeforeToggle,
86
+ handleToggle,
87
+ } = useAnchoredPopover({ rootRef, triggerRef, popoverRef });
88
+
89
+ const handleGroupClick = (key: number) => {
90
+ if (!usesFallbackPopover.value) return;
91
+ const wasActive = isOpen.value && activeKey.value === key;
92
+ hide();
93
+ if (wasActive) return;
94
+ activeKey.value = key;
95
+ show();
96
+ };
97
+
98
+ const handlePanelBeforeToggle = (key: number, event: Event) => {
99
+ if ((event as ToggleEvent).newState !== "open") return;
100
+ activeKey.value = key;
101
+ handleBeforeToggle(event);
102
+ };
103
+
104
+ // Opening one auto popover closes the other, and those toggle events can arrive in either order.
105
+ const handlePanelToggle = (key: number, event: Event) => {
106
+ const opening = (event as ToggleEvent).newState === "open";
107
+ if (opening) activeKey.value = key;
108
+ else if (activeKey.value !== key) return;
109
+ handleToggle(event);
110
+ };
111
+
47
112
  watch(
48
113
  () => props.styleClassPassthrough,
49
114
  () => {
@@ -55,18 +120,6 @@ watch(
55
120
  <style lang="css">
56
121
  @layer components {
57
122
  @layer deep-expanding-menu-setup {
58
- @position-try --anchor-left {
59
- inset: auto;
60
- top: calc(anchor(bottom) + 1rem);
61
- left: calc(anchor(left) + 1rem);
62
- }
63
-
64
- @position-try-fallbacks --anchor-right {
65
- inset: auto;
66
- top: calc(anchor(bottom) + 1rem);
67
- right: calc(anchor(right) + 1rem);
68
- }
69
-
70
123
  .deep-expanding-menu {
71
124
  container-type: inline-size;
72
125
  display: grid;
@@ -152,6 +205,24 @@ watch(
152
205
  }
153
206
  }
154
207
 
208
+ /* Kept apart from :popover-open, which would invalidate a shared selector list where unsupported. */
209
+ &.deep-expanding-menu-panel-open {
210
+ display: block;
211
+ opacity: 1;
212
+ }
213
+
214
+ @supports not (anchor-name: --a) {
215
+ position: fixed;
216
+ top: calc(var(--_anchor-bottom, 0px) + 1rem);
217
+ left: var(--_anchor-left, 0px);
218
+ z-index: var(--deep-expanding-menu-panel-z-index, 999999);
219
+
220
+ &[data-placement="top"] {
221
+ top: auto;
222
+ bottom: calc(var(--_anchor-top-inverse, 0px) + 1rem);
223
+ }
224
+ }
225
+
155
226
  h4 {
156
227
  color: var(--deep-expanding-menu-panel-heading-colour, var(--slate-10));
157
228
  }
@@ -186,7 +257,7 @@ watch(
186
257
  }
187
258
  }
188
259
 
189
- &:has(.navigation-group-panel:popover-open) {
260
+ &:has(> .navigation-group-toggle[aria-expanded="true"]) {
190
261
  --_icon-transform: scaleY(-1);
191
262
  }
192
263
  }
@@ -1,5 +1,6 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { nextTick } from "vue";
3
4
  import DeepExpandingMenu from "../DeepExpandingMenu.vue";
4
5
  import type { ResponsiveHeaderNavItem } from "~/types/components";
5
6
 
@@ -89,4 +90,85 @@ describe("DeepExpandingMenu", () => {
89
90
  expect(wrapper.classes()).not.toContain("initial-class");
90
91
  expect(wrapper.classes()).toContain("updated-class");
91
92
  });
93
+
94
+ it("doesn't give the link panels a menu role", async () => {
95
+ const wrapper = await mountSuspended(DeepExpandingMenu, { props: { navLinks: defaultNavLinks } });
96
+ expect(wrapper.find(".navigation-group-panel").attributes("role")).toBeUndefined();
97
+ });
98
+
99
+ describe("open state", () => {
100
+ const twoGroups: ResponsiveHeaderNavItem[] = [
101
+ { name: "Services", childLinksTitle: "Our services", childLinks: [{ name: "Haircuts", path: "/haircuts" }] },
102
+ { name: "About", childLinksTitle: "About us", childLinks: [{ name: "Team", path: "/team" }] },
103
+ ];
104
+
105
+ const toggleEvent = (newState: "open" | "closed") => Object.assign(new Event("toggle"), { newState });
106
+
107
+ afterEach(() => {
108
+ delete (HTMLElement.prototype as unknown as Record<string, unknown>)["showPopover"];
109
+ delete (HTMLElement.prototype as unknown as Record<string, unknown>)["hidePopover"];
110
+ });
111
+
112
+ describe("with the Popover API", () => {
113
+ beforeEach(() => {
114
+ Object.defineProperty(HTMLElement.prototype, "showPopover", { value: vi.fn(), writable: true, configurable: true });
115
+ Object.defineProperty(HTMLElement.prototype, "hidePopover", { value: vi.fn(), writable: true, configurable: true });
116
+ });
117
+
118
+ it("tracks the open group from toggle events, whichever order they arrive in", async () => {
119
+ const wrapper = await mountSuspended(DeepExpandingMenu, { props: { navLinks: twoGroups } });
120
+ const [first, second] = wrapper.findAll(".navigation-group-panel");
121
+ const toggles = wrapper.findAll(".navigation-group-toggle");
122
+
123
+ first!.element.dispatchEvent(toggleEvent("open"));
124
+ await nextTick();
125
+ expect(toggles[0]!.attributes("aria-expanded")).toBe("true");
126
+
127
+ second!.element.dispatchEvent(toggleEvent("open"));
128
+ first!.element.dispatchEvent(toggleEvent("closed"));
129
+ await nextTick();
130
+ expect(toggles[0]!.attributes("aria-expanded")).toBe("false");
131
+ expect(toggles[1]!.attributes("aria-expanded")).toBe("true");
132
+
133
+ second!.element.dispatchEvent(toggleEvent("closed"));
134
+ await nextTick();
135
+ expect(toggles[1]!.attributes("aria-expanded")).toBe("false");
136
+ });
137
+ });
138
+
139
+ describe("without the Popover API", () => {
140
+ beforeEach(() => {
141
+ Object.defineProperty(HTMLElement.prototype, "showPopover", { value: undefined, writable: true, configurable: true });
142
+ });
143
+
144
+ it("opens, switches and closes groups from their toggles", async () => {
145
+ const wrapper = await mountSuspended(DeepExpandingMenu, { props: { navLinks: twoGroups } });
146
+ const toggles = wrapper.findAll(".navigation-group-toggle");
147
+ const panels = wrapper.findAll(".navigation-group-panel");
148
+
149
+ await toggles[0]!.trigger("click");
150
+ expect(panels[0]!.classes()).toContain("deep-expanding-menu-panel-open");
151
+ expect(toggles[0]!.attributes("aria-expanded")).toBe("true");
152
+
153
+ await toggles[1]!.trigger("click");
154
+ expect(panels[0]!.classes()).not.toContain("deep-expanding-menu-panel-open");
155
+ expect(panels[1]!.classes()).toContain("deep-expanding-menu-panel-open");
156
+
157
+ await toggles[1]!.trigger("click");
158
+ expect(panels[1]!.classes()).not.toContain("deep-expanding-menu-panel-open");
159
+ expect(toggles[1]!.attributes("aria-expanded")).toBe("false");
160
+ });
161
+
162
+ it("closes the open group on Escape", async () => {
163
+ const wrapper = await mountSuspended(DeepExpandingMenu, { props: { navLinks: twoGroups }, attachTo: document.body });
164
+ await wrapper.findAll(".navigation-group-toggle")[0]!.trigger("click");
165
+ await nextTick();
166
+
167
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
168
+ await nextTick();
169
+ expect(wrapper.find(".deep-expanding-menu-panel-open").exists()).toBe(false);
170
+ wrapper.unmount();
171
+ });
172
+ });
173
+ });
92
174
  });
@@ -3,8 +3,8 @@
3
3
  exports[`DeepExpandingMenu > renders correct HTML structure 1`] = `
4
4
  "<nav class="deep-expanding-menu">
5
5
  <div class="inner"><a href="/" class="navigation-link">Home</a>
6
- <div class="navigation-group" style="--_anchor-name: --anchor-v-0-0-1;"><button popovertarget="popovertarget-v-0-0-1" class="navigation-group-toggle"><span>Services</span><span class="iconify i-bi:caret-down-fill icon" aria-hidden="true"></span></button>
7
- <div id="popovertarget-v-0-0-1" class="navigation-group-panel" popover="" role="menu">
6
+ <div class="navigation-group" style="--_anchor-name: --anchor-v-0-0-1;"><button popovertarget="popovertarget-v-0-0-1" class="navigation-group-toggle" aria-expanded="false"><span>Services</span><span class="iconify i-bi:caret-down-fill icon" aria-hidden="true"></span></button>
7
+ <div id="popovertarget-v-0-0-1" class="navigation-group-panel" popover="">
8
8
  <h4 class="page-heading-4 mb-6">Our services</h4>
9
9
  <ul class="navigation-group-list">
10
10
  <li class="navigation-group-item"><a href="/services/haircuts" class="navigation-group-link">Haircuts</a></li>
@@ -531,7 +531,7 @@ watch(
531
531
  font-weight: var(--_link-weight);
532
532
  letter-spacing: var(--_link-tracking);
533
533
  text-decoration: none;
534
- text-wrap: nowrap;
534
+ white-space: nowrap;
535
535
  padding-block: 0.8rem;
536
536
  padding-inline: 0.4rem;
537
537
  position: relative;
@@ -278,7 +278,7 @@ watch(
278
278
  font-weight: var(--_link-weight);
279
279
  letter-spacing: var(--_link-tracking);
280
280
  text-decoration: none;
281
- text-wrap: nowrap;
281
+ white-space: nowrap;
282
282
  padding-block: 0.8rem;
283
283
  padding-inline: 0.4rem;
284
284
  position: relative;
@@ -25,6 +25,7 @@ deliberately matches `PriceList`.
25
25
  | `--opening-hours-day-font-size` | `1.4rem` | Day name / exception label size |
26
26
  | `--opening-hours-day-colour` | `inherit` | Day name / exception label colour |
27
27
  | `--opening-hours-hours-font-size` | `1.4rem` | Times and status text size |
28
+ | `--opening-hours-hours-max-inline-size` | `60%` | Most of the row the hours column can take. Longer content (long session labels, notes, status labels) wraps inside it |
28
29
  | `--opening-hours-closed-colour` | `inherit` | Hours colour on closed rows |
29
30
 
30
31
  ### Sessions and notes
@@ -36,6 +37,9 @@ deliberately matches `PriceList`.
36
37
  | `--opening-hours-session-label-colour` | `inherit` | Session label colour |
37
38
  | `--opening-hours-note-font-size` | `1.2rem` | Note size |
38
39
  | `--opening-hours-note-colour` | `inherit` | Note colour |
40
+ | `--opening-hours-note-line-clamp` | `none` | Cap notes at this many lines, with an ellipsis on the last. `1` is single-line ellipsis, `none` shows everything |
41
+
42
+ Each session's times stay on one line; a long session label wraps before them.
39
43
 
40
44
  ### Today
41
45
 
@@ -54,8 +58,13 @@ deliberately matches `PriceList`.
54
58
  | `--opening-hours-exceptions-heading-margin-block-end` | `1.2rem` | Space below the exceptions heading |
55
59
  | `--opening-hours-date-font-size` | `1.2rem` | Exception date size |
56
60
  | `--opening-hours-date-colour` | `inherit` | Exception date colour |
61
+ | `--opening-hours-exception-label-line-clamp` | `none` | Cap exception labels at this many lines. `1` is single-line ellipsis, `none` shows everything |
62
+
63
+ There are no private `--_` tokens. Long unbroken text anywhere in the component wraps rather than overflowing.
57
64
 
58
- There are no private `--_` tokens.
65
+ > **Changed 2026-10-05**: rows are a grid instead of a flex row, so the day column can shrink and
66
+ > the hours column is capped by `--opening-hours-hours-max-inline-size`. `.opening-hours__session`
67
+ > is no longer `nowrap`; the new `.opening-hours__session-times` wrapper is.
59
68
 
60
69
  ---
61
70
 
@@ -77,6 +86,7 @@ Inner element classes, safe to target:
77
86
  | `.opening-hours__hours` | `<dd>` |
78
87
  | `.opening-hours__session` | One opening session |
79
88
  | `.opening-hours__session-label` | Session label ("Lunch") |
89
+ | `.opening-hours__session-times` | The opens–closes pair, kept on one line |
80
90
  | `.opening-hours__status` | "Closed" / "Open 24 hours" / "By appointment only" |
81
91
  | `.opening-hours__note` | Per-day or per-exception note |
82
92
  | `.opening-hours__exceptions` | Exceptions wrapper |
@@ -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
  }