srcdev-nuxt-components 9.5.5 → 9.5.6
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/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/pop-over.md +11 -8
- package/.claude/skills/components/select-menu.md +18 -8
- 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/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/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/responsive-header/NavigationItems.vue +2 -2
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +4 -4
- 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/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>
|
|
@@ -37,6 +37,7 @@ global token so it matches the rest of the design system out of the box.
|
|
|
37
37
|
| `--select-menu-popover-min-width` | `18rem` | Minimum popover width |
|
|
38
38
|
| `--select-menu-popover-max-height` | `32rem` | Maximum popover height before scrolling |
|
|
39
39
|
| `--select-menu-popover-shadow` | `0 0.4rem 1.6rem rgb(0 0 0 / 12%)` | Popover drop shadow |
|
|
40
|
+
| `--select-menu-popover-z-index` | `999999` | Stacking order in browsers without CSS anchor positioning (see Notes). Ignored where the popover renders in the top layer |
|
|
40
41
|
| `--select-menu-transition-duration` | `200ms` | Open/close fade and chevron-rotate duration |
|
|
41
42
|
|
|
42
43
|
### Options
|
|
@@ -69,7 +70,13 @@ global token so it matches the rest of the design system out of the box.
|
|
|
69
70
|
| Hook | Where | Meaning |
|
|
70
71
|
|---|---|---|
|
|
71
72
|
| `.normal` / `.underlined` | `.select-menu` root | The `inputVariant` prop. `underlined` removes the trigger's top and side borders and its corner radius, matching `InputSelect`'s underlined variant. |
|
|
72
|
-
|
|
|
73
|
+
| `.select-menu-trigger[aria-expanded="true"]` | trigger | Menu open (rotates the chevron). Works with and without the Popover API. |
|
|
74
|
+
| `.select-menu-popover:popover-open` | popover | Menu open, Popover API browsers. |
|
|
75
|
+
| `.select-menu-popover-open` | popover | Menu open, browsers without the Popover API (Safari 16 and older). Style both open hooks in **separate rules**: a selector list containing `:popover-open` is dropped whole where it's unsupported. |
|
|
76
|
+
| `[data-placement="top"]` | popover | Flipped above the trigger, browsers without CSS anchor positioning only. |
|
|
77
|
+
|
|
78
|
+
> Changed 2026-10-05: the chevron hook moved from `:has(.select-menu-popover:popover-open)` on the
|
|
79
|
+
> root to the trigger's `aria-expanded`, so it also works without the Popover API.
|
|
73
80
|
|
|
74
81
|
---
|
|
75
82
|
|
|
@@ -186,10 +193,15 @@ story.
|
|
|
186
193
|
|
|
187
194
|
## Notes
|
|
188
195
|
|
|
189
|
-
- **Popover API + CSS anchor positioning** — same mechanism as `ActionMenu
|
|
190
|
-
|
|
196
|
+
- **Popover API + CSS anchor positioning, with fallbacks** — same mechanism as `ActionMenu`, via the
|
|
197
|
+
shared `useAnchoredPopover` composable. Without anchor positioning (e.g. Safari 17–18), the
|
|
198
|
+
popover is placed with `position: fixed` from the trigger's measured position, re-measured on
|
|
199
|
+
scroll and resize. Without the Popover API (Safari 16 and older), the menu also opens and
|
|
200
|
+
closes in JS, with outside-click and Escape dismissal. In that mode it isn't in the top layer, so
|
|
201
|
+
`--select-menu-popover-z-index` matters, and an ancestor with `transform`, `filter` or
|
|
202
|
+
`contain` becomes its containing block and can misplace it.
|
|
191
203
|
- **Popover left-aligns with the trigger** by default (`left: anchor(left)`), unlike `ActionMenu`
|
|
192
204
|
which right-aligns — matches native `<select>` dropdown behaviour. Flips above the trigger near
|
|
193
205
|
the bottom of the viewport (`position-try-fallbacks: flip-block`).
|
|
194
|
-
- **Chevron rotation** is
|
|
195
|
-
|
|
206
|
+
- **Chevron rotation** is driven by the trigger's `aria-expanded`, which tracks the open state in
|
|
207
|
+
every browser.
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
ref="rootRef"
|
|
4
|
+
class="select-menu"
|
|
5
|
+
:class="[inputVariant, elementClasses]"
|
|
6
|
+
:style="`--_anchor-name: ${anchorName}`"
|
|
7
|
+
>
|
|
3
8
|
<button
|
|
4
9
|
ref="triggerRef"
|
|
5
10
|
:popovertarget="menuId"
|
|
@@ -9,6 +14,7 @@
|
|
|
9
14
|
:aria-label="label"
|
|
10
15
|
aria-haspopup="listbox"
|
|
11
16
|
:aria-expanded="isOpen"
|
|
17
|
+
@click="handleTriggerClick"
|
|
12
18
|
>
|
|
13
19
|
<Icon
|
|
14
20
|
v-if="showIcon && !multiple && selectedOption?.icon"
|
|
@@ -30,6 +36,10 @@
|
|
|
30
36
|
ref="popoverRef"
|
|
31
37
|
popover
|
|
32
38
|
class="select-menu-popover"
|
|
39
|
+
:class="{ 'select-menu-popover-open': usesFallbackPopover && isOpen }"
|
|
40
|
+
:style="positionStyle"
|
|
41
|
+
:data-placement="popoverPlacement"
|
|
42
|
+
@beforetoggle="handleBeforeToggle"
|
|
33
43
|
@toggle="handleToggle"
|
|
34
44
|
@keydown="handleKeydown"
|
|
35
45
|
>
|
|
@@ -103,9 +113,9 @@ const id = useId();
|
|
|
103
113
|
const menuId = `select-menu-${id}`;
|
|
104
114
|
const anchorName = `--select-menu-anchor-${id}`;
|
|
105
115
|
|
|
116
|
+
const rootRef = ref<HTMLDivElement | null>(null);
|
|
106
117
|
const triggerRef = ref<HTMLButtonElement | null>(null);
|
|
107
118
|
const popoverRef = ref<HTMLDivElement | null>(null);
|
|
108
|
-
const isOpen = ref(false);
|
|
109
119
|
|
|
110
120
|
const selectedValues = computed(() => (props.multiple && Array.isArray(modelValue.value) ? modelValue.value : []));
|
|
111
121
|
const selectedOption = computed(() =>
|
|
@@ -137,6 +147,23 @@ const isSelected = (option: SelectMenuOption): boolean =>
|
|
|
137
147
|
const getMenuItems = (): HTMLElement[] =>
|
|
138
148
|
Array.from(popoverRef.value?.querySelectorAll<HTMLElement>('[role="option"]') ?? []);
|
|
139
149
|
|
|
150
|
+
const focusInitialItem = () => {
|
|
151
|
+
const items = getMenuItems();
|
|
152
|
+
const selectedIndex = items.findIndex((el) => el.getAttribute("aria-selected") === "true");
|
|
153
|
+
items[selectedIndex === -1 ? 0 : selectedIndex]?.focus();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const {
|
|
157
|
+
isOpen,
|
|
158
|
+
usesFallbackPopover,
|
|
159
|
+
positionStyle,
|
|
160
|
+
popoverPlacement,
|
|
161
|
+
hide,
|
|
162
|
+
handleTriggerClick,
|
|
163
|
+
handleBeforeToggle,
|
|
164
|
+
handleToggle,
|
|
165
|
+
} = useAnchoredPopover({ rootRef, triggerRef, popoverRef, onOpen: focusInitialItem });
|
|
166
|
+
|
|
140
167
|
const selectOption = (option: SelectMenuOption) => {
|
|
141
168
|
if (props.multiple) {
|
|
142
169
|
const next = selectedValues.value.includes(option.value)
|
|
@@ -146,20 +173,10 @@ const selectOption = (option: SelectMenuOption) => {
|
|
|
146
173
|
return;
|
|
147
174
|
}
|
|
148
175
|
modelValue.value = option.value;
|
|
149
|
-
|
|
176
|
+
hide();
|
|
150
177
|
triggerRef.value?.focus();
|
|
151
178
|
};
|
|
152
179
|
|
|
153
|
-
const handleToggle = (event: Event) => {
|
|
154
|
-
const toggleEvent = event as ToggleEvent;
|
|
155
|
-
isOpen.value = toggleEvent.newState === "open";
|
|
156
|
-
if (isOpen.value) {
|
|
157
|
-
const items = getMenuItems();
|
|
158
|
-
const selectedIndex = items.findIndex((el) => el.getAttribute("aria-selected") === "true");
|
|
159
|
-
items[selectedIndex === -1 ? 0 : selectedIndex]?.focus();
|
|
160
|
-
}
|
|
161
|
-
};
|
|
162
|
-
|
|
163
180
|
/**
|
|
164
181
|
* Keyboard navigation following the WAI-ARIA listbox pattern.
|
|
165
182
|
*
|
|
@@ -168,7 +185,7 @@ const handleToggle = (event: Event) => {
|
|
|
168
185
|
* Enter / Space — select the focused option. Closes the menu in single-select mode;
|
|
169
186
|
* toggles the checkbox and keeps the menu open in multi-select mode.
|
|
170
187
|
* Tab — close the menu; let the browser Tab naturally.
|
|
171
|
-
* Escape —
|
|
188
|
+
* Escape — native light dismiss, or the fallback listener without the Popover API.
|
|
172
189
|
*/
|
|
173
190
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
174
191
|
const items = getMenuItems();
|
|
@@ -201,7 +218,7 @@ const handleKeydown = (event: KeyboardEvent) => {
|
|
|
201
218
|
break;
|
|
202
219
|
}
|
|
203
220
|
case "Tab":
|
|
204
|
-
|
|
221
|
+
hide();
|
|
205
222
|
break;
|
|
206
223
|
}
|
|
207
224
|
};
|
|
@@ -288,7 +305,7 @@ watch(
|
|
|
288
305
|
border-radius: 0;
|
|
289
306
|
}
|
|
290
307
|
|
|
291
|
-
|
|
308
|
+
.select-menu-trigger[aria-expanded="true"] .select-menu-trigger-chevron {
|
|
292
309
|
transform: rotate(180deg);
|
|
293
310
|
}
|
|
294
311
|
|
|
@@ -329,6 +346,24 @@ watch(
|
|
|
329
346
|
}
|
|
330
347
|
}
|
|
331
348
|
|
|
349
|
+
/* Kept apart from :popover-open, which would invalidate a shared selector list where unsupported. */
|
|
350
|
+
&.select-menu-popover-open {
|
|
351
|
+
display: block;
|
|
352
|
+
opacity: 1;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
@supports not (anchor-name: --a) {
|
|
356
|
+
position: fixed;
|
|
357
|
+
top: calc(var(--_anchor-bottom, 0px) + var(--select-menu-block-distance, 0.4rem));
|
|
358
|
+
left: var(--_anchor-left, 0px);
|
|
359
|
+
z-index: var(--select-menu-popover-z-index, 999999);
|
|
360
|
+
|
|
361
|
+
&[data-placement="top"] {
|
|
362
|
+
top: auto;
|
|
363
|
+
bottom: calc(var(--_anchor-top-inverse, 0px) + var(--select-menu-block-distance, 0.4rem));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
332
367
|
.select-menu-list {
|
|
333
368
|
display: grid;
|
|
334
369
|
grid-template-columns: auto auto 1fr;
|
|
@@ -482,4 +482,40 @@ describe("SelectMenu", () => {
|
|
|
482
482
|
expect(wrapper.classes()).toContain("select-menu");
|
|
483
483
|
});
|
|
484
484
|
});
|
|
485
|
+
|
|
486
|
+
// -------------------------
|
|
487
|
+
// Without the Popover API (Safari 16 and older)
|
|
488
|
+
// -------------------------
|
|
489
|
+
describe("Without the Popover API", () => {
|
|
490
|
+
beforeEach(() => {
|
|
491
|
+
Object.defineProperty(HTMLElement.prototype, "showPopover", { value: undefined, writable: true, configurable: true });
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("opens from the trigger with the fallback open class and aria-expanded", async () => {
|
|
495
|
+
wrapper = await createWrapper();
|
|
496
|
+
const popover = wrapper.find(".select-menu-popover");
|
|
497
|
+
expect(popover.classes()).not.toContain("select-menu-popover-open");
|
|
498
|
+
|
|
499
|
+
await wrapper.find(".select-menu-trigger").trigger("click");
|
|
500
|
+
expect(popover.classes()).toContain("select-menu-popover-open");
|
|
501
|
+
expect(wrapper.find(".select-menu-trigger").attributes("aria-expanded")).toBe("true");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("closes when a single-select option is chosen", async () => {
|
|
505
|
+
wrapper = await createWrapper();
|
|
506
|
+
await wrapper.find(".select-menu-trigger").trigger("click");
|
|
507
|
+
await wrapper.findAll('[role="option"]')[1]!.trigger("click");
|
|
508
|
+
|
|
509
|
+
expect(wrapper.emitted("update:modelValue")?.[0]).toEqual(["fr"]);
|
|
510
|
+
expect(wrapper.find(".select-menu-popover").classes()).not.toContain("select-menu-popover-open");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("stays open while toggling options in multiple mode", async () => {
|
|
514
|
+
wrapper = await createWrapper({ multiple: true, modelValue: [] });
|
|
515
|
+
await wrapper.find(".select-menu-trigger").trigger("click");
|
|
516
|
+
await wrapper.findAll('[role="option"]')[1]!.trigger("click");
|
|
517
|
+
|
|
518
|
+
expect(wrapper.find(".select-menu-popover").classes()).toContain("select-menu-popover-open");
|
|
519
|
+
});
|
|
520
|
+
});
|
|
485
521
|
});
|
|
@@ -263,7 +263,7 @@ watch(
|
|
|
263
263
|
|
|
264
264
|
.label-wrapper {
|
|
265
265
|
.overflow-navigation-text {
|
|
266
|
-
|
|
266
|
+
white-space: nowrap;
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
269
|
.icon-wrapper {
|
|
@@ -326,7 +326,7 @@ watch(
|
|
|
326
326
|
|
|
327
327
|
.label-wrapper {
|
|
328
328
|
.overflow-navigation-text {
|
|
329
|
-
|
|
329
|
+
white-space: nowrap;
|
|
330
330
|
}
|
|
331
331
|
}
|
|
332
332
|
.icon-wrapper {
|
|
@@ -782,7 +782,7 @@ watch(
|
|
|
782
782
|
.main-navigation-link {
|
|
783
783
|
display: flex;
|
|
784
784
|
gap: 6px;
|
|
785
|
-
|
|
785
|
+
white-space: nowrap;
|
|
786
786
|
font-size: var(--responsive-header-link-font-size, inherit);
|
|
787
787
|
color: var(--responsive-header-link-color, inherit);
|
|
788
788
|
text-decoration: none;
|
|
@@ -821,7 +821,7 @@ watch(
|
|
|
821
821
|
.has-toggle-icon {
|
|
822
822
|
display: flex;
|
|
823
823
|
gap: 6px;
|
|
824
|
-
|
|
824
|
+
white-space: nowrap;
|
|
825
825
|
|
|
826
826
|
/* Reserved for the same reason as .decorator-icon above — an
|
|
827
827
|
unsized chevron would widen the summary once its SVG loads. */
|
|
@@ -883,7 +883,7 @@ watch(
|
|
|
883
883
|
|
|
884
884
|
.main-navigation-sub-nav-link {
|
|
885
885
|
display: block;
|
|
886
|
-
|
|
886
|
+
white-space: nowrap;
|
|
887
887
|
text-decoration: none;
|
|
888
888
|
color: var(--responsive-header-link-color, inherit);
|
|
889
889
|
}
|
|
@@ -994,7 +994,7 @@ watch(
|
|
|
994
994
|
align-items: center;
|
|
995
995
|
justify-content: center;
|
|
996
996
|
padding-inline: 5px;
|
|
997
|
-
|
|
997
|
+
white-space: nowrap;
|
|
998
998
|
|
|
999
999
|
aspect-ratio: 1;
|
|
1000
1000
|
border-radius: 4px;
|