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.
Files changed (52) 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 +18 -8
  7. package/.claude/skills/components/ui-block-decorated.md +7 -0
  8. package/.claude/skills/composable-anchored-popover.md +108 -0
  9. package/.claude/skills/composable-tooltips-guide.md +3 -1
  10. package/.claude/skills/index.md +1 -0
  11. package/app/assets/styles/setup/06.utility-classes/animations/_animation-scroller-x.css +5 -3
  12. package/app/assets/styles/setup/06.utility-classes/animations/_auto-rotate.css +5 -3
  13. package/app/assets/styles/setup/06.utility-classes/animations/_entry-exit-blur.css +5 -3
  14. package/app/assets/styles/setup/06.utility-classes/animations/_entry-slide-in.css +5 -3
  15. package/app/assets/styles/setup/06.utility-classes/animations/_entry-zoom-reveal.css +5 -3
  16. package/app/components/01.atoms/animations/entry/CONSUMER-STYLING.md +6 -2
  17. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +15 -3
  18. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +62 -3
  19. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +36 -1
  20. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +1 -1
  21. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +1 -1
  22. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +9 -3
  23. package/app/components/01.atoms/pop-over/PopOver.vue +70 -9
  24. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +53 -0
  25. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +1 -1
  26. package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +12 -4
  27. package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +20 -16
  28. package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +124 -96
  29. package/app/components/02.molecules/action-menu/ActionMenu.vue +41 -16
  30. package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +16 -3
  31. package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +28 -0
  32. package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +3 -3
  33. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +2 -1
  34. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +7 -4
  35. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +20 -1
  36. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +1 -1
  37. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +18 -4
  38. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +87 -16
  39. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +83 -1
  40. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +2 -2
  41. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +1 -1
  42. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +1 -1
  43. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +17 -5
  44. package/app/components/02.molecules/select-menu/SelectMenu.vue +51 -16
  45. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +36 -0
  46. package/app/components/03.organisms/responsive-header/NavigationItems.vue +2 -2
  47. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +4 -4
  48. package/app/composables/tests/useAnchoredPopover.spec.ts +231 -0
  49. package/app/composables/tests/useTooltips.spec.ts +69 -0
  50. package/app/composables/useAnchoredPopover.ts +183 -0
  51. package/app/composables/useTooltips.ts +37 -20
  52. 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
@@ -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
- | `: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.
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`. Broadly supported
190
- (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.
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 pure CSS via `:has(.select-menu-popover:popover-open)` — no JS state
195
- needed for the visual, though `isOpen` is still tracked internally to drive `aria-expanded`.
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
  >
@@ -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
- popoverRef.value?.hidePopover();
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 — handled natively by the Popover API.
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
- popoverRef.value?.hidePopover();
221
+ hide();
205
222
  break;
206
223
  }
207
224
  };
@@ -288,7 +305,7 @@ watch(
288
305
  border-radius: 0;
289
306
  }
290
307
 
291
- &:has(.select-menu-popover:popover-open) .select-menu-trigger-chevron {
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
- 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 {
@@ -782,7 +782,7 @@ watch(
782
782
  .main-navigation-link {
783
783
  display: flex;
784
784
  gap: 6px;
785
- text-wrap-mode: nowrap;
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
- text-wrap-mode: nowrap;
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
- text-wrap-mode: nowrap;
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
- text-wrap: nowrap;
997
+ white-space: nowrap;
998
998
 
999
999
  aspect-ratio: 1;
1000
1000
  border-radius: 4px;