srcdev-nuxt-components 9.5.4 → 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.
Files changed (55) hide show
  1. package/.claude/skills/components/action-menu.md +21 -15
  2. package/.claude/skills/components/deep-expanding-menu.md +18 -6
  3. package/.claude/skills/components/display-tooltip-defined.md +2 -1
  4. package/.claude/skills/components/display-tooltip.md +10 -4
  5. package/.claude/skills/components/pop-over.md +11 -8
  6. package/.claude/skills/components/select-menu.md +28 -8
  7. package/.claude/skills/components/triple-toggle-switch.md +7 -1
  8. package/.claude/skills/components/ui-block-decorated.md +7 -0
  9. package/.claude/skills/composable-anchored-popover.md +108 -0
  10. package/.claude/skills/composable-tooltips-guide.md +3 -1
  11. package/.claude/skills/index.md +1 -0
  12. package/app/assets/styles/setup/06.utility-classes/animations/_animation-scroller-x.css +5 -3
  13. package/app/assets/styles/setup/06.utility-classes/animations/_auto-rotate.css +5 -3
  14. package/app/assets/styles/setup/06.utility-classes/animations/_entry-exit-blur.css +5 -3
  15. package/app/assets/styles/setup/06.utility-classes/animations/_entry-slide-in.css +5 -3
  16. package/app/assets/styles/setup/06.utility-classes/animations/_entry-zoom-reveal.css +5 -3
  17. package/app/components/01.atoms/animations/entry/CONSUMER-STYLING.md +6 -2
  18. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +15 -3
  19. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +62 -3
  20. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +36 -1
  21. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +1 -1
  22. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +1 -1
  23. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +9 -3
  24. package/app/components/01.atoms/pop-over/PopOver.vue +70 -9
  25. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +53 -0
  26. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +1 -1
  27. package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +12 -4
  28. package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +20 -16
  29. package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +124 -96
  30. package/app/components/02.molecules/action-menu/ActionMenu.vue +41 -16
  31. package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +16 -3
  32. package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +28 -0
  33. package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +3 -3
  34. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +2 -1
  35. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +7 -4
  36. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +20 -1
  37. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +1 -1
  38. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +18 -4
  39. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +87 -16
  40. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +83 -1
  41. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +2 -2
  42. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +1 -1
  43. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +1 -1
  44. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +26 -6
  45. package/app/components/02.molecules/select-menu/SelectMenu.vue +90 -18
  46. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +36 -0
  47. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +9 -9
  48. package/app/components/03.organisms/responsive-header/NavigationItems.vue +2 -2
  49. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +4 -4
  50. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +11 -3
  51. package/app/composables/tests/useAnchoredPopover.spec.ts +231 -0
  52. package/app/composables/tests/useTooltips.spec.ts +69 -0
  53. package/app/composables/useAnchoredPopover.ts +183 -0
  54. package/app/composables/useTooltips.ts +37 -20
  55. 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;
@@ -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
@@ -49,6 +50,15 @@ global token so it matches the rest of the design system out of the box.
49
50
  | `--select-menu-item-text-color` | `var(--theme-text)` | Option label colour |
50
51
  | `--select-menu-item-font-size` | `1.5rem` | Option label font size |
51
52
  | `--select-menu-item-surface-hover` | `var(--theme-input-surface-hover)` | Option row background on hover/focus |
53
+ | `--select-menu-item-surface-selected` | `var(--theme-surface-subtle)` | Background of selected/checked option rows (hover/focus still wins) |
54
+ | `--select-menu-item-text-color-selected` | `--select-menu-item-text-color` | Selected row label colour (the checkmark follows via `currentcolor`) |
55
+ | `--select-menu-item-font-weight` | inherited | Option row font weight |
56
+ | `--select-menu-item-font-weight-selected` | `--select-menu-item-font-weight` | Selected row font weight; the popover reserves the bolder width, so selecting doesn't resize it |
57
+ | `--select-menu-item-border-width` | `0` | Block (top/bottom) border width on **every** row, so a selected border never shifts layout |
58
+ | `--select-menu-item-border` | `transparent` | Resting row border colour |
59
+ | `--select-menu-item-border-selected` | `--select-menu-item-border` | Selected row border colour. Two adjacent selected rows show both borders where they meet |
60
+ | `--select-menu-item-text-color-hover` | the row's current colour | Label colour on hover/focus. Unset, a selected row keeps its selected colour |
61
+ | `--select-menu-item-border-hover` | the row's current border | Border colour on hover/focus. Unset, a selected row keeps its selected border |
52
62
  | `--select-menu-item-check-size` | `1.6rem` | Checkmark icon box size |
53
63
  | `--select-menu-item-check-color` | `currentcolor` | Checkmark icon colour |
54
64
  | `--select-menu-item-icon-size` | `1.8rem` | Per-option icon size |
@@ -60,7 +70,13 @@ global token so it matches the rest of the design system out of the box.
60
70
  | Hook | Where | Meaning |
61
71
  |---|---|---|
62
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. |
63
- | `:has(.select-menu-popover:popover-open)` | `.select-menu` root | Menu open (rotates the chevron). |
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.
64
80
 
65
81
  ---
66
82
 
@@ -177,11 +193,15 @@ story.
177
193
 
178
194
  ## Notes
179
195
 
180
- - **Popover API + CSS anchor positioning** — same mechanism as `ActionMenu`. Broadly supported
181
- (Chrome 114+, Firefox 125+, Safari 17+). No polyfill is included.
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.
182
203
  - **Popover left-aligns with the trigger** by default (`left: anchor(left)`), unlike `ActionMenu`
183
204
  which right-aligns — matches native `<select>` dropdown behaviour. Flips above the trigger near
184
205
  the bottom of the viewport (`position-try-fallbacks: flip-block`).
185
- - **Chevron rotation** is pure CSS via `:has(.select-menu-popover:popover-open)` — no JS state
186
- needed for the visual, though `isOpen` is still tracked internally to drive `aria-expanded`.
187
-
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 class="select-menu" :class="[inputVariant, elementClasses]" :style="`--_anchor-name: ${anchorName}`">
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
  >
@@ -41,6 +51,7 @@
41
51
  role="option"
42
52
  tabindex="-1"
43
53
  :aria-selected="isSelected(option)"
54
+ :data-label="option.label"
44
55
  @click="selectOption(option)"
45
56
  >
46
57
  <span class="select-menu-item-check" aria-hidden="true">
@@ -102,9 +113,9 @@ const id = useId();
102
113
  const menuId = `select-menu-${id}`;
103
114
  const anchorName = `--select-menu-anchor-${id}`;
104
115
 
116
+ const rootRef = ref<HTMLDivElement | null>(null);
105
117
  const triggerRef = ref<HTMLButtonElement | null>(null);
106
118
  const popoverRef = ref<HTMLDivElement | null>(null);
107
- const isOpen = ref(false);
108
119
 
109
120
  const selectedValues = computed(() => (props.multiple && Array.isArray(modelValue.value) ? modelValue.value : []));
110
121
  const selectedOption = computed(() =>
@@ -136,6 +147,23 @@ const isSelected = (option: SelectMenuOption): boolean =>
136
147
  const getMenuItems = (): HTMLElement[] =>
137
148
  Array.from(popoverRef.value?.querySelectorAll<HTMLElement>('[role="option"]') ?? []);
138
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
+
139
167
  const selectOption = (option: SelectMenuOption) => {
140
168
  if (props.multiple) {
141
169
  const next = selectedValues.value.includes(option.value)
@@ -145,20 +173,10 @@ const selectOption = (option: SelectMenuOption) => {
145
173
  return;
146
174
  }
147
175
  modelValue.value = option.value;
148
- popoverRef.value?.hidePopover();
176
+ hide();
149
177
  triggerRef.value?.focus();
150
178
  };
151
179
 
152
- const handleToggle = (event: Event) => {
153
- const toggleEvent = event as ToggleEvent;
154
- isOpen.value = toggleEvent.newState === "open";
155
- if (isOpen.value) {
156
- const items = getMenuItems();
157
- const selectedIndex = items.findIndex((el) => el.getAttribute("aria-selected") === "true");
158
- items[selectedIndex === -1 ? 0 : selectedIndex]?.focus();
159
- }
160
- };
161
-
162
180
  /**
163
181
  * Keyboard navigation following the WAI-ARIA listbox pattern.
164
182
  *
@@ -167,7 +185,7 @@ const handleToggle = (event: Event) => {
167
185
  * Enter / Space — select the focused option. Closes the menu in single-select mode;
168
186
  * toggles the checkbox and keeps the menu open in multi-select mode.
169
187
  * Tab — close the menu; let the browser Tab naturally.
170
- * Escape — handled natively by the Popover API.
188
+ * Escape — native light dismiss, or the fallback listener without the Popover API.
171
189
  */
172
190
  const handleKeydown = (event: KeyboardEvent) => {
173
191
  const items = getMenuItems();
@@ -200,7 +218,7 @@ const handleKeydown = (event: KeyboardEvent) => {
200
218
  break;
201
219
  }
202
220
  case "Tab":
203
- popoverRef.value?.hidePopover();
221
+ hide();
204
222
  break;
205
223
  }
206
224
  };
@@ -287,7 +305,7 @@ watch(
287
305
  border-radius: 0;
288
306
  }
289
307
 
290
- &:has(.select-menu-popover:popover-open) .select-menu-trigger-chevron {
308
+ .select-menu-trigger[aria-expanded="true"] .select-menu-trigger-chevron {
291
309
  transform: rotate(180deg);
292
310
  }
293
311
 
@@ -328,6 +346,24 @@ watch(
328
346
  }
329
347
  }
330
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
+
331
367
  .select-menu-list {
332
368
  display: grid;
333
369
  grid-template-columns: auto auto 1fr;
@@ -343,15 +379,50 @@ watch(
343
379
  gap: var(--select-menu-item-gap, 0.8rem);
344
380
  padding-block: var(--select-menu-item-padding-block, 1rem);
345
381
  padding-inline: var(--select-menu-item-padding-inline, 1.2rem);
382
+ --_text-color: var(--select-menu-item-text-color, var(--theme-text));
383
+ --_border: var(--select-menu-item-border, transparent);
384
+
346
385
  cursor: pointer;
347
- color: var(--select-menu-item-text-color, var(--theme-text));
386
+ color: var(--_text-color);
348
387
  font-family: var(--font-family);
349
388
  font-size: var(--select-menu-item-font-size, 1.5rem);
350
- transition: background-color var(--select-menu-transition-duration, 200ms) ease;
389
+ /* No fallback: unset means invalid at computed time, so the weight inherits. */
390
+ font-weight: var(--select-menu-item-font-weight);
391
+ border-block: var(--select-menu-item-border-width, 0) solid var(--_border);
392
+ transition:
393
+ background-color var(--select-menu-transition-duration, 200ms) ease,
394
+ border-color var(--select-menu-transition-duration, 200ms) ease,
395
+ color var(--select-menu-transition-duration, 200ms) ease;
396
+
397
+ /* Reserves the selected label width so a bolder selected weight doesn't resize the popover. */
398
+ &::after {
399
+ content: attr(data-label);
400
+ grid-column: 3;
401
+ grid-row: 1;
402
+ block-size: 0;
403
+ overflow: hidden;
404
+ visibility: hidden;
405
+ white-space: nowrap;
406
+ font-weight: var(--select-menu-item-font-weight-selected, var(--select-menu-item-font-weight));
407
+ }
408
+
409
+ &[aria-selected="true"] {
410
+ --_text-color: var(
411
+ --select-menu-item-text-color-selected,
412
+ var(--select-menu-item-text-color, var(--theme-text))
413
+ );
414
+ --_border: var(--select-menu-item-border-selected, var(--select-menu-item-border, transparent));
415
+
416
+ background-color: var(--select-menu-item-surface-selected, var(--theme-surface-subtle));
417
+ font-weight: var(--select-menu-item-font-weight-selected, var(--select-menu-item-font-weight));
418
+ }
351
419
 
420
+ /* Hover falls back to the row's current state, so an unset hover token keeps a selected row's colours. */
352
421
  &:hover,
353
422
  &:focus-visible {
354
423
  background-color: var(--select-menu-item-surface-hover, var(--theme-input-surface-hover));
424
+ color: var(--select-menu-item-text-color-hover, var(--_text-color));
425
+ border-color: var(--select-menu-item-border-hover, var(--_border));
355
426
  }
356
427
 
357
428
  &:focus-visible {
@@ -386,6 +457,7 @@ watch(
386
457
 
387
458
  .select-menu-item-label {
388
459
  grid-column: 3;
460
+ grid-row: 1;
389
461
  white-space: nowrap;
390
462
  overflow: hidden;
391
463
  text-overflow: ellipsis;
@@ -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
  });
@@ -6,9 +6,9 @@ exports[`SelectMenu > Snapshots > default 1`] = `
6
6
  </button>
7
7
  <div id="select-menu-v-0-0" popover="" class="select-menu-popover">
8
8
  <ul class="select-menu-list" role="listbox" aria-label="Language">
9
- <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:gb-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">English</span></li>
10
- <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:fr-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">Français</span></li>
11
- <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:de-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">Deutsch</span></li>
9
+ <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false" data-label="English"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:gb-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">English</span></li>
10
+ <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false" data-label="Français"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:fr-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">Français</span></li>
11
+ <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false" data-label="Deutsch"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:de-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">Deutsch</span></li>
12
12
  </ul>
13
13
  </div>
14
14
  </div>"
@@ -21,9 +21,9 @@ exports[`SelectMenu > Snapshots > icon-only trigger 1`] = `
21
21
  </button>
22
22
  <div id="select-menu-v-0-0" popover="" class="select-menu-popover">
23
23
  <ul class="select-menu-list" role="listbox" aria-label="Language">
24
- <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="true"><span class="select-menu-item-check" aria-hidden="true"><span class="iconify i-lucide:check select-menu-item-check-icon" aria-hidden="true"></span></span><span class="iconify i-flag:gb-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">English</span></li>
25
- <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:fr-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">Français</span></li>
26
- <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:de-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">Deutsch</span></li>
24
+ <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="true" data-label="English"><span class="select-menu-item-check" aria-hidden="true"><span class="iconify i-lucide:check select-menu-item-check-icon" aria-hidden="true"></span></span><span class="iconify i-flag:gb-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">English</span></li>
25
+ <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false" data-label="Français"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:fr-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">Français</span></li>
26
+ <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false" data-label="Deutsch"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:de-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">Deutsch</span></li>
27
27
  </ul>
28
28
  </div>
29
29
  </div>"
@@ -33,9 +33,9 @@ exports[`SelectMenu > Snapshots > with a selected value 1`] = `
33
33
  "<div class="select-menu normal" style="--_anchor-name: --select-menu-anchor-v-0-0;"><button popovertarget="select-menu-v-0-0" popovertargetaction="toggle" type="button" class="select-menu-trigger" aria-label="Language" aria-haspopup="listbox" aria-expanded="false"><span class="iconify i-flag:fr-4x3 select-menu-trigger-icon" aria-hidden="true"></span><span class="select-menu-trigger-label">Français</span><span class="iconify i-lucide:chevron-down select-menu-trigger-chevron" aria-hidden="true"></span></button>
34
34
  <div id="select-menu-v-0-0" popover="" class="select-menu-popover">
35
35
  <ul class="select-menu-list" role="listbox" aria-label="Language">
36
- <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:gb-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">English</span></li>
37
- <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="true"><span class="select-menu-item-check" aria-hidden="true"><span class="iconify i-lucide:check select-menu-item-check-icon" aria-hidden="true"></span></span><span class="iconify i-flag:fr-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">Français</span></li>
38
- <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:de-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">Deutsch</span></li>
36
+ <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false" data-label="English"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:gb-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">English</span></li>
37
+ <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="true" data-label="Français"><span class="select-menu-item-check" aria-hidden="true"><span class="iconify i-lucide:check select-menu-item-check-icon" aria-hidden="true"></span></span><span class="iconify i-flag:fr-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">Français</span></li>
38
+ <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false" data-label="Deutsch"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:de-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">Deutsch</span></li>
39
39
  </ul>
40
40
  </div>
41
41
  </div>"
@@ -263,7 +263,7 @@ watch(
263
263
 
264
264
  .label-wrapper {
265
265
  .overflow-navigation-text {
266
- text-wrap: nowrap;
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
- text-wrap: nowrap;
329
+ white-space: nowrap;
330
330
  }
331
331
  }
332
332
  .icon-wrapper {