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.
- package/.claude/skills/component-compliance-checklist.md +6 -2
- package/.claude/skills/components/action-menu.md +21 -15
- package/.claude/skills/components/deep-expanding-menu.md +18 -6
- package/.claude/skills/components/display-tooltip-defined.md +2 -1
- package/.claude/skills/components/display-tooltip.md +10 -4
- package/.claude/skills/components/navigation-items.md +1 -0
- package/.claude/skills/components/opening-hours.md +12 -0
- package/.claude/skills/components/pop-over.md +11 -8
- package/.claude/skills/components/price-list.md +10 -1
- package/.claude/skills/components/responsive-header.md +25 -0
- package/.claude/skills/components/select-menu.md +18 -8
- package/.claude/skills/components/site-header.md +2 -0
- package/.claude/skills/components/ui-block-decorated.md +7 -0
- package/.claude/skills/composable-anchored-popover.md +108 -0
- package/.claude/skills/composable-tooltips-guide.md +3 -1
- package/.claude/skills/index.md +1 -0
- package/.claude/skills/storybook-add-story.md +14 -0
- package/.vscode/srcdev-component-responsive-header.code-snippets +3 -1
- package/.vscode/srcdev-component-site-header.code-snippets +3 -1
- package/app/assets/styles/setup/06.utility-classes/animations/_animation-scroller-x.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_auto-rotate.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_entry-exit-blur.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_entry-slide-in.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_entry-zoom-reveal.css +5 -3
- package/app/components/01.atoms/animations/entry/CONSUMER-STYLING.md +6 -2
- package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +15 -3
- package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +62 -3
- package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +36 -1
- package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +1 -1
- package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +1 -1
- package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +9 -3
- package/app/components/01.atoms/pop-over/PopOver.vue +70 -9
- package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +53 -0
- package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +1 -1
- package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +12 -4
- package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +20 -16
- package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +124 -96
- package/app/components/02.molecules/action-menu/ActionMenu.vue +41 -16
- package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +16 -3
- package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +28 -0
- package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +3 -3
- package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +2 -1
- package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +7 -4
- package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +20 -1
- package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +1 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +18 -4
- package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +87 -16
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +83 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +2 -2
- package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +1 -1
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +1 -1
- package/app/components/02.molecules/opening-hours/CONSUMER-STYLING.md +11 -1
- package/app/components/02.molecules/opening-hours/OpeningHours.vue +71 -43
- package/app/components/02.molecules/opening-hours/stories/OpeningHours.stories.ts +138 -6
- package/app/components/02.molecules/opening-hours/tests/OpeningHours.spec.ts +53 -0
- package/app/components/02.molecules/opening-hours/tests/__snapshots__/OpeningHours.spec.ts.snap +1 -1
- package/app/components/02.molecules/opening-hours/tests/buildOpeningHoursSpecification.spec.ts +37 -1
- package/app/components/02.molecules/price-list/CONSUMER-STYLING.md +17 -5
- package/app/components/02.molecules/price-list/PriceList.vue +32 -9
- package/app/components/02.molecules/price-list/stories/PriceList.stories.ts +125 -29
- package/app/components/02.molecules/price-list/tests/PriceList.spec.ts +20 -0
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +17 -5
- package/app/components/02.molecules/select-menu/SelectMenu.vue +51 -16
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +36 -0
- package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +9 -4
- package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +14 -0
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +23 -14
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +58 -30
- package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +149 -18
- package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +21 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +66 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +30 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +1 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +35 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +23 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +1 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +71 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +386 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +11 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +79 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +17 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +64 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +29 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +32 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +10 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
- package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +16 -0
- package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +91 -6
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +9 -9
- package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +1 -0
- package/app/components/03.organisms/site-header/SiteHeader.vue +8 -0
- package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
- package/app/composables/tests/useAnchoredPopover.spec.ts +231 -0
- package/app/composables/tests/useTooltips.spec.ts +69 -0
- package/app/composables/useAnchoredPopover.ts +183 -0
- package/app/composables/useTooltips.ts +37 -20
- package/app/utils/opening-hours.ts +23 -2
- 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
|
|
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
|
|
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-
|
|
260
|
+
&:has(> .navigation-group-toggle[aria-expanded="true"]) {
|
|
190
261
|
--_icon-transform: scaleY(-1);
|
|
191
262
|
}
|
|
192
263
|
}
|
package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts
CHANGED
|
@@ -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=""
|
|
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>
|
|
@@ -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
|
-
|
|
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
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
150
|
-
const
|
|
151
|
-
return
|
|
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
|
|
155
|
-
|
|
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(
|
|
169
|
+
() => new Intl.DateTimeFormat(safeLocale.value, { weekday: props.dayFormat, timeZone: "UTC" })
|
|
160
170
|
);
|
|
161
171
|
const timeFormatter = computed(
|
|
162
172
|
() =>
|
|
163
|
-
new Intl.DateTimeFormat(
|
|
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(
|
|
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
|
|
179
|
-
return timeFormatter.value.format(new Date(Date.UTC(2024, 0, 1,
|
|
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) =>
|
|
229
|
-
|
|
230
|
-
|
|
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: `${
|
|
252
|
+
key: `${index}-${from}-${to ?? ""}`,
|
|
233
253
|
label: exception.label,
|
|
234
|
-
from
|
|
254
|
+
from,
|
|
235
255
|
to,
|
|
236
|
-
start:
|
|
237
|
-
end: to ?
|
|
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:
|
|
283
|
-
|
|
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:
|
|
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
|
}
|