srcdev-nuxt-components 9.5.5 → 9.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.claude/skills/component-compliance-checklist.md +6 -2
  2. package/.claude/skills/components/action-menu.md +21 -15
  3. package/.claude/skills/components/deep-expanding-menu.md +18 -6
  4. package/.claude/skills/components/display-tooltip-defined.md +2 -1
  5. package/.claude/skills/components/display-tooltip.md +10 -4
  6. package/.claude/skills/components/navigation-items.md +1 -0
  7. package/.claude/skills/components/opening-hours.md +12 -0
  8. package/.claude/skills/components/pop-over.md +11 -8
  9. package/.claude/skills/components/price-list.md +10 -1
  10. package/.claude/skills/components/responsive-header.md +25 -0
  11. package/.claude/skills/components/select-menu.md +18 -8
  12. package/.claude/skills/components/site-header.md +2 -0
  13. package/.claude/skills/components/ui-block-decorated.md +7 -0
  14. package/.claude/skills/composable-anchored-popover.md +108 -0
  15. package/.claude/skills/composable-tooltips-guide.md +3 -1
  16. package/.claude/skills/index.md +1 -0
  17. package/.claude/skills/storybook-add-story.md +14 -0
  18. package/.vscode/srcdev-component-responsive-header.code-snippets +3 -1
  19. package/.vscode/srcdev-component-site-header.code-snippets +3 -1
  20. package/app/assets/styles/setup/06.utility-classes/animations/_animation-scroller-x.css +5 -3
  21. package/app/assets/styles/setup/06.utility-classes/animations/_auto-rotate.css +5 -3
  22. package/app/assets/styles/setup/06.utility-classes/animations/_entry-exit-blur.css +5 -3
  23. package/app/assets/styles/setup/06.utility-classes/animations/_entry-slide-in.css +5 -3
  24. package/app/assets/styles/setup/06.utility-classes/animations/_entry-zoom-reveal.css +5 -3
  25. package/app/components/01.atoms/animations/entry/CONSUMER-STYLING.md +6 -2
  26. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +15 -3
  27. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +62 -3
  28. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +36 -1
  29. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +1 -1
  30. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +1 -1
  31. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +9 -3
  32. package/app/components/01.atoms/pop-over/PopOver.vue +70 -9
  33. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +53 -0
  34. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +1 -1
  35. package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +12 -4
  36. package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +20 -16
  37. package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +124 -96
  38. package/app/components/02.molecules/action-menu/ActionMenu.vue +41 -16
  39. package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +16 -3
  40. package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +28 -0
  41. package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +3 -3
  42. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +2 -1
  43. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +7 -4
  44. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +20 -1
  45. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +1 -1
  46. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +18 -4
  47. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +87 -16
  48. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +83 -1
  49. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +2 -2
  50. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +1 -1
  51. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +1 -1
  52. package/app/components/02.molecules/opening-hours/CONSUMER-STYLING.md +11 -1
  53. package/app/components/02.molecules/opening-hours/OpeningHours.vue +71 -43
  54. package/app/components/02.molecules/opening-hours/stories/OpeningHours.stories.ts +138 -6
  55. package/app/components/02.molecules/opening-hours/tests/OpeningHours.spec.ts +53 -0
  56. package/app/components/02.molecules/opening-hours/tests/__snapshots__/OpeningHours.spec.ts.snap +1 -1
  57. package/app/components/02.molecules/opening-hours/tests/buildOpeningHoursSpecification.spec.ts +37 -1
  58. package/app/components/02.molecules/price-list/CONSUMER-STYLING.md +17 -5
  59. package/app/components/02.molecules/price-list/PriceList.vue +32 -9
  60. package/app/components/02.molecules/price-list/stories/PriceList.stories.ts +125 -29
  61. package/app/components/02.molecules/price-list/tests/PriceList.spec.ts +20 -0
  62. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +17 -5
  63. package/app/components/02.molecules/select-menu/SelectMenu.vue +51 -16
  64. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +36 -0
  65. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +9 -4
  66. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +14 -0
  67. package/app/components/03.organisms/responsive-header/NavigationItems.vue +23 -14
  68. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +58 -30
  69. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +149 -18
  70. package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +21 -0
  71. package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +66 -0
  72. package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +30 -0
  73. package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +1 -0
  74. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +35 -0
  75. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +23 -0
  76. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +1 -0
  77. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +71 -0
  78. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +386 -0
  79. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +11 -0
  80. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +79 -0
  81. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +17 -0
  82. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +64 -0
  83. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +29 -0
  84. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +32 -0
  85. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +10 -0
  86. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
  87. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +16 -0
  88. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +91 -6
  89. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +9 -9
  90. package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +1 -0
  91. package/app/components/03.organisms/site-header/SiteHeader.vue +8 -0
  92. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
  93. package/app/composables/tests/useAnchoredPopover.spec.ts +231 -0
  94. package/app/composables/tests/useTooltips.spec.ts +69 -0
  95. package/app/composables/useAnchoredPopover.ts +183 -0
  96. package/app/composables/useTooltips.ts +37 -20
  97. package/app/utils/opening-hours.ts +23 -2
  98. package/package.json +1 -1
@@ -1,7 +1,9 @@
1
1
  @media (prefers-reduced-motion: no-preference) {
2
- .entry-exit-blur {
3
- animation: entryExitBlurAnimation linear both;
4
- animation-timeline: view();
2
+ @supports (animation-timeline: view()) {
3
+ .entry-exit-blur {
4
+ animation: entryExitBlurAnimation linear both;
5
+ animation-timeline: view();
6
+ }
5
7
  }
6
8
  }
7
9
  @keyframes entryExitBlurAnimation {
@@ -8,8 +8,10 @@
8
8
  }
9
9
 
10
10
  @media (prefers-reduced-motion: no-preference) {
11
- .entry-slide-in {
12
- animation: entrySlideInAnimation forwards;
13
- animation-timeline: view(70% 5%);
11
+ @supports (animation-timeline: view()) {
12
+ .entry-slide-in {
13
+ animation: entrySlideInAnimation forwards;
14
+ animation-timeline: view(70% 5%);
15
+ }
14
16
  }
15
17
  }
@@ -10,8 +10,10 @@
10
10
  }
11
11
 
12
12
  @media (prefers-reduced-motion: no-preference) {
13
- .entry-zoom-reveal {
14
- animation: entryZoomRevealAnimation both;
15
- animation-timeline: view(70% 5%);
13
+ @supports (animation-timeline: view()) {
14
+ .entry-zoom-reveal {
15
+ animation: entryZoomRevealAnimation both;
16
+ animation-timeline: view(70% 5%);
17
+ }
16
18
  }
17
19
  }
@@ -31,8 +31,12 @@ any passthrough classes.
31
31
  ## Motion
32
32
 
33
33
  - All three classes only animate inside `@media (prefers-reduced-motion: no-preference)`.
34
- - They use `animation-timeline: view(...)` (scroll-driven). In a browser without scroll-driven
35
- animation support the animation completes immediately, so the content just appears in place.
34
+ - They use `animation-timeline: view(...)` (scroll-driven), inside an
35
+ `@supports (animation-timeline: view())` guard, so a browser without scroll-driven animations
36
+ (Safari before 26, Firefox) shows the content in place with no animation.
37
+
38
+ > Changed 2026-10-05: the guard is new. Before it, an unsupported browser ran the animation with a
39
+ > zero duration and kept its end keyframe, which for `entry-exit-blur` left the content blurred.
36
40
 
37
41
  ---
38
42
 
@@ -17,6 +17,8 @@
17
17
  | `--display-tooltip-popover-width` | `30rem` | Popover panel width |
18
18
  | `--display-tooltip-popover-padding` | `1.2rem` | Popover panel content padding |
19
19
  | `--display-tooltip-popover-content-gap` | `1.2rem` | Gap between stacked children in the `tooltipContent` slot |
20
+ | `--display-tooltip-popover-font-size` | `1.4rem` | Popover text size (`DisplayTooltipDefined` sets its own per-element sizes) |
21
+ | `--display-tooltip-popover-line-height` | `1.4` | Popover text line height |
20
22
  | `--display-tooltip-popover-outline-width` | `0.1rem` | Popover panel outline (border) width |
21
23
  | `--display-tooltip-popover-outline-colour` | `var(--slate-02)` | Popover panel outline (border) colour |
22
24
  | `--display-tooltip-popover-text-colour` | `var(--slate-09)` | Popover panel text colour |
@@ -24,6 +26,7 @@
24
26
  | `--display-tooltip-popover-border-radius` | `0.8rem` | Popover panel corner radius |
25
27
  | `--display-tooltip-popover-shadow` | `0 0.4rem 1.6rem rgba(0,0,0,.12)` | Popover panel elevation shadow |
26
28
  | `--display-tooltip-popover-offset` | `0.1rem` | Gap between the trigger and the popover panel |
29
+ | `--display-tooltip-popover-z-index` | `999999` | Panel stacking order in browsers without CSS anchor positioning. Ignored where the panel renders in the top layer |
27
30
  | `--display-tooltip-close-button-colour` | `var(--slate-09)` | Close button text colour (used by `DisplayTooltipDefined`) |
28
31
  | `--display-tooltip-close-button-border-width` | `0.1rem` | Close button border width |
29
32
  | `--display-tooltip-close-button-border-colour` | `var(--slate-03)` | Close button border colour |
@@ -61,13 +64,22 @@ Set the tokens above on an element you own (a page or section class, or a class
61
64
 
62
65
  ## Notes
63
66
 
67
+ - Direct children of the `tooltipContent` slot have their block margins reset to `0` (inside
68
+ `:where()`, so any margin you set on them still wins): the gap token is the only spacing between
69
+ them. Before 2026-10-05 a slotted `<p>` kept its browser `1em` margins on top of the padding.
70
+
64
71
  - The trigger button is vertically centred on the first line of `triggerContent` text, using
65
72
  `--display-tooltip-trigger-icon-box-size`, `-padding` and `-border-width` to work out its height.
66
73
  Give `--display-tooltip-trigger-padding` a single length (e.g. `0.4rem`): a two-value shorthand
67
74
  breaks that calculation, and the button is square anyway.
68
- - Built on the native Popover API (`popover`/`popovertarget`) and CSS anchor-positioning
69
- (`anchor-name`/`position-anchor`/`anchor()`) — check current browser support before relying on
70
- it where broad support is a hard requirement.
75
+ - Built on the native Popover API and CSS anchor positioning, with fallbacks: without anchor
76
+ positioning (e.g. Safari 17–18) the panel is placed with `position: fixed` from the trigger's
77
+ measured position and flips left (`.display-tooltip-popover[data-placement="left"]`) when there's
78
+ no room on the right. Without the Popover API (Safari 16) it opens and closes in JS, shows via
79
+ `.display-tooltip-popover-open` (style it in a separate rule from `:popover-open`: a selector list
80
+ containing `:popover-open` is dropped whole where unsupported), isn't in the top layer (so the
81
+ z-index token applies), and an ancestor with `transform`, `filter` or `contain` can misplace it.
82
+ The trigger's `aria-expanded` tracks the open state in every browser.
71
83
  - The `.display-tooltip-close-button` styled here is rendered by `DisplayTooltipDefined`, not this
72
84
  component directly — `DisplayTooltip` only provides the `tooltipContent` slot, so a plain
73
85
  `DisplayTooltip` usage supplies its own close affordance if it wants one (give it
@@ -1,20 +1,38 @@
1
1
  <template>
2
- <div class="display-tooltip-core" :class="[elementClasses]" :style="{ '--_anchor-name': tooltipAnchorName }">
2
+ <div
3
+ ref="rootRef"
4
+ class="display-tooltip-core"
5
+ :class="[elementClasses]"
6
+ :style="{ '--_anchor-name': tooltipAnchorName }"
7
+ >
3
8
  <div class="display-tooltip-trigger-wrapper body-md">
4
9
  <slot v-if="$slots.triggerContent" name="triggerContent"></slot>
5
10
  <button
11
+ ref="triggerRef"
6
12
  :popovertarget="tooltipId"
7
13
  popovertargetaction="toggle"
8
14
  class="display-tooltip-trigger-button"
9
15
  :class="{ hide: hideTrigger }"
10
16
  :aria-label="triggerAriaLabel"
17
+ :aria-expanded="isOpen"
18
+ @click="handleTriggerClick"
11
19
  >
12
20
  <Icon name="fa7-solid:circle-question" class="display-tooltip-trigger-icon" aria-hidden="true" />
13
21
  </button>
14
22
  </div>
15
- <div :id="tooltipId" popover class="display-tooltip-popover">
23
+ <div
24
+ :id="tooltipId"
25
+ ref="popoverRef"
26
+ popover
27
+ class="display-tooltip-popover"
28
+ :class="{ 'display-tooltip-popover-open': usesFallbackPopover && isOpen }"
29
+ :style="positionStyle"
30
+ :data-placement="popoverPlacement"
31
+ @beforetoggle="handleBeforeToggle"
32
+ @toggle="handleToggle"
33
+ >
16
34
  <div class="display-tooltip-popover-content">
17
- <slot name="tooltipContent"></slot>
35
+ <slot name="tooltipContent" :close="hide"></slot>
18
36
  </div>
19
37
  </div>
20
38
  </div>
@@ -39,6 +57,21 @@ const props = withDefaults(defineProps<Props>(), {
39
57
  const tooltipId = computed(() => (props.tooltipId.length ? props.tooltipId : `nuxt-tooltip-${useId()}`));
40
58
  const tooltipAnchorName = `--tooltip-anchor-${useId()}`;
41
59
 
60
+ const rootRef = ref<HTMLElement | null>(null);
61
+ const triggerRef = ref<HTMLElement | null>(null);
62
+ const popoverRef = ref<HTMLElement | null>(null);
63
+
64
+ const {
65
+ isOpen,
66
+ usesFallbackPopover,
67
+ positionStyle,
68
+ popoverPlacement,
69
+ hide,
70
+ handleTriggerClick,
71
+ handleBeforeToggle,
72
+ handleToggle,
73
+ } = useAnchoredPopover({ rootRef, triggerRef, popoverRef, side: "right" });
74
+
42
75
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
43
76
 
44
77
  watch(
@@ -141,6 +174,13 @@ watch(
141
174
  display: flex;
142
175
  flex-direction: column;
143
176
  gap: var(--display-tooltip-popover-content-gap, 1.2rem);
177
+ font-size: var(--display-tooltip-popover-font-size, 1.4rem);
178
+ line-height: var(--display-tooltip-popover-line-height, 1.4);
179
+
180
+ /* Gap is the only spacing between slotted elements; :where keeps consumer margins winning. */
181
+ > :where(*) {
182
+ margin-block: 0;
183
+ }
144
184
 
145
185
  .display-tooltip-close-button {
146
186
  all: unset;
@@ -174,6 +214,25 @@ watch(
174
214
  opacity: 0;
175
215
  }
176
216
  }
217
+
218
+ /* Kept apart from :popover-open, which would invalidate a shared selector list where unsupported. */
219
+ &.display-tooltip-popover-open {
220
+ display: flex;
221
+ opacity: 1;
222
+ flex-direction: column;
223
+ }
224
+
225
+ @supports not (anchor-name: --a) {
226
+ position: fixed;
227
+ top: var(--_anchor-top, 0px);
228
+ left: calc(var(--_anchor-right, 0px) + var(--display-tooltip-popover-offset, 0.1rem));
229
+ z-index: var(--display-tooltip-popover-z-index, 999999);
230
+
231
+ &[data-placement="left"] {
232
+ left: auto;
233
+ right: calc(var(--_anchor-left-inverse, 0px) + var(--display-tooltip-popover-offset, 0.1rem));
234
+ }
235
+ }
177
236
  }
178
237
  }
179
238
  }
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import DisplayTooltip from "../DisplayTooltip.vue";
4
4
 
@@ -72,4 +72,39 @@ describe("DisplayTooltip", () => {
72
72
  expect(wrapper.classes()).not.toContain("initial-class");
73
73
  expect(wrapper.classes()).toContain("updated-class");
74
74
  });
75
+
76
+ describe("without the Popover API", () => {
77
+ beforeEach(() => {
78
+ Object.defineProperty(HTMLElement.prototype, "showPopover", { value: undefined, writable: true, configurable: true });
79
+ });
80
+
81
+ afterEach(() => {
82
+ delete (HTMLElement.prototype as unknown as Record<string, unknown>)["showPopover"];
83
+ });
84
+
85
+ it("opens and closes from the trigger", async () => {
86
+ const wrapper = await mountSuspended(DisplayTooltip);
87
+ const trigger = wrapper.find(".display-tooltip-trigger-button");
88
+ const popover = wrapper.find(".display-tooltip-popover");
89
+
90
+ await trigger.trigger("click");
91
+ expect(popover.classes()).toContain("display-tooltip-popover-open");
92
+ expect(trigger.attributes("aria-expanded")).toBe("true");
93
+
94
+ await trigger.trigger("click");
95
+ expect(popover.classes()).not.toContain("display-tooltip-popover-open");
96
+ expect(trigger.attributes("aria-expanded")).toBe("false");
97
+ });
98
+
99
+ it("passes a close function to the tooltipContent slot", async () => {
100
+ const wrapper = await mountSuspended(DisplayTooltip, {
101
+ slots: {
102
+ tooltipContent: `<template #tooltipContent="{ close }"><button class="custom-close" @click="close">Done</button></template>`,
103
+ },
104
+ });
105
+ await wrapper.find(".display-tooltip-trigger-button").trigger("click");
106
+ await wrapper.find(".custom-close").trigger("click");
107
+ expect(wrapper.find(".display-tooltip-popover").classes()).not.toContain("display-tooltip-popover-open");
108
+ });
109
+ });
75
110
  });
@@ -3,7 +3,7 @@
3
3
  exports[`DisplayTooltip > renders correct HTML structure 1`] = `
4
4
  "<div class="display-tooltip-core" style="--_anchor-name: --tooltip-anchor-v-0-0;">
5
5
  <div class="display-tooltip-trigger-wrapper body-md">
6
- <!--v-if--><button popovertarget="fixed-id" popovertargetaction="toggle" class="display-tooltip-trigger-button" aria-label="Toggle the popover"><span class="iconify i-fa7-solid:circle-question display-tooltip-trigger-icon" aria-hidden="true"></span></button>
6
+ <!--v-if--><button popovertarget="fixed-id" popovertargetaction="toggle" class="display-tooltip-trigger-button" aria-label="Toggle the popover" aria-expanded="false"><span class="iconify i-fa7-solid:circle-question display-tooltip-trigger-icon" aria-hidden="true"></span></button>
7
7
  </div>
8
8
  <div id="fixed-id" popover="" class="display-tooltip-popover">
9
9
  <div class="display-tooltip-popover-content"></div>
@@ -81,7 +81,7 @@ watch(
81
81
  color: var(--skip-links-text-colour, white);
82
82
  text-decoration: none;
83
83
  padding: var(--skip-links-link-padding-block, 0.8rem) var(--skip-links-link-padding-inline, 1.2rem);
84
- text-wrap: nowrap;
84
+ white-space: nowrap;
85
85
 
86
86
  &:focus-visible {
87
87
  outline: var(--skip-links-focus-outline-width, 2px) solid var(--skip-links-focus-outline-colour, white);
@@ -15,6 +15,7 @@
15
15
  | `--pop-over-text-colour` | `var(--slate-09)` | Popover panel text colour |
16
16
  | `--pop-over-shadow` | `0 0.4rem 1.6rem rgba(0,0,0,.12)` | Popover panel elevation shadow |
17
17
  | `--pop-over-transition-duration` | `200ms` | Open/close fade duration |
18
+ | `--pop-over-z-index` | `999999` | Panel stacking order in browsers without CSS anchor positioning. Ignored where the panel renders in the top layer |
18
19
  | `--pop-over-content-padding` | `1.6rem` | Padding around the `content` slot |
19
20
  | `--pop-over-content-padding-block-start` | `3.2rem` | Extra top padding so content clears the close button |
20
21
  | `--pop-over-close-button-offset` | `0.8rem` | Distance of the close button from the panel's top/right edges |
@@ -54,9 +55,14 @@ Set the tokens above on an element you own (a page or section class, or a class
54
55
 
55
56
  ## Notes
56
57
 
57
- - Built on the native Popover API (`popover`/`popovertarget`) and CSS anchor-positioning
58
- (`anchor-name`/`position-anchor`/`anchor()`) — check current browser support before relying on
59
- it where broad support is a hard requirement.
58
+ - Built on the native Popover API and CSS anchor positioning, with fallbacks: without anchor
59
+ positioning (e.g. Safari 17–18) the panel is placed with `position: fixed` from the trigger's
60
+ measured position, and `.pop-over-popover[data-placement]` holds the resolved side (the
61
+ `placement` prop's side, or its opposite when that has no room). Without the Popover API
62
+ (Safari 16) it opens and closes in JS and shows via `.pop-over-popover-open` (style it in a
63
+ separate rule from `:popover-open`: a selector list containing `:popover-open` is dropped whole
64
+ where unsupported). It isn't in the top layer there, so the z-index token applies, and an
65
+ ancestor with `transform`, `filter` or `contain` can misplace it.
60
66
  - `placement` (`"top" | "right" | "bottom" | "left"`, default `"right"`) picks which side of the
61
67
  trigger the panel opens on; each side gets a `position-try-fallbacks` so the browser flips it
62
68
  automatically if it would overflow the viewport.
@@ -1,6 +1,12 @@
1
1
  <template>
2
2
  <ClientOnly>
3
- <div class="pop-over" :class="[elementClasses]" :style="{ '--_anchor-name': anchorName }" :data-placement="placement">
3
+ <div
4
+ ref="rootRef"
5
+ class="pop-over"
6
+ :class="[elementClasses]"
7
+ :style="{ '--_anchor-name': anchorName }"
8
+ :data-placement="placement"
9
+ >
4
10
  <button
5
11
  ref="triggerRef"
6
12
  :popovertarget="popoverId"
@@ -8,11 +14,24 @@
8
14
  type="button"
9
15
  class="pop-over-trigger"
10
16
  :aria-label="triggerAriaLabel || undefined"
17
+ :aria-expanded="isOpen"
18
+ @click="handleTriggerClick"
11
19
  >
12
20
  <slot name="trigger"></slot>
13
21
  </button>
14
22
 
15
- <div :id="popoverId" ref="popoverRef" popover class="pop-over-popover" :aria-label="popoverAriaLabel || undefined" @toggle="handleToggle">
23
+ <div
24
+ :id="popoverId"
25
+ ref="popoverRef"
26
+ popover
27
+ class="pop-over-popover"
28
+ :class="{ 'pop-over-popover-open': usesFallbackPopover && isOpen }"
29
+ :style="positionStyle"
30
+ :data-placement="popoverPlacement"
31
+ :aria-label="popoverAriaLabel || undefined"
32
+ @beforetoggle="handleBeforeToggle"
33
+ @toggle="handleToggle"
34
+ >
16
35
  <button
17
36
  ref="closeButtonRef"
18
37
  :popovertarget="popoverId"
@@ -20,6 +39,7 @@
20
39
  type="button"
21
40
  class="pop-over-close-button"
22
41
  :aria-label="closeButtonAriaLabel"
42
+ @click="hide"
23
43
  >
24
44
  <Icon name="lucide:x" class="pop-over-close-button-icon" aria-hidden="true" />
25
45
  </button>
@@ -55,17 +75,28 @@ const id = useId();
55
75
  const popoverId = `pop-over-${id}`;
56
76
  const anchorName = `--pop-over-anchor-${id}`;
57
77
 
78
+ const rootRef = ref<HTMLDivElement | null>(null);
58
79
  const triggerRef = ref<HTMLButtonElement | null>(null);
59
80
  const popoverRef = ref<HTMLDivElement | null>(null);
60
81
  const closeButtonRef = ref<HTMLButtonElement | null>(null);
61
82
 
62
- /** Move focus into the popover on open; the Popover API restores focus to the trigger on close. */
63
- const handleToggle = (event: Event) => {
64
- const toggleEvent = event as ToggleEvent;
65
- if (toggleEvent.newState === "open") {
66
- closeButtonRef.value?.focus();
67
- }
68
- };
83
+ // Focus moves to the close button on open; closing returns it to the trigger in every mode.
84
+ const {
85
+ isOpen,
86
+ usesFallbackPopover,
87
+ positionStyle,
88
+ popoverPlacement,
89
+ hide,
90
+ handleTriggerClick,
91
+ handleBeforeToggle,
92
+ handleToggle,
93
+ } = useAnchoredPopover({
94
+ rootRef,
95
+ triggerRef,
96
+ popoverRef,
97
+ side: () => props.placement,
98
+ onOpen: () => closeButtonRef.value?.focus(),
99
+ });
69
100
 
70
101
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
71
102
 
@@ -128,6 +159,12 @@ watch(
128
159
  }
129
160
  }
130
161
 
162
+ /* Kept apart from :popover-open, which would invalidate a shared selector list where unsupported. */
163
+ &.pop-over-popover-open {
164
+ display: block;
165
+ opacity: 1;
166
+ }
167
+
131
168
  .pop-over-close-button {
132
169
  all: unset;
133
170
  cursor: pointer;
@@ -196,6 +233,30 @@ watch(
196
233
  left: anchor(left);
197
234
  position-try-fallbacks: flip-block;
198
235
  }
236
+
237
+ /* After the placement rules above, so the resolved (possibly flipped) side wins over their insets. */
238
+ @supports not (anchor-name: --a) {
239
+ .pop-over-popover {
240
+ position: fixed;
241
+ z-index: var(--pop-over-z-index, 999999);
242
+
243
+ &[data-placement="right"] {
244
+ inset: var(--_anchor-top, 0px) auto auto calc(var(--_anchor-right, 0px) + var(--pop-over-gap, 1rem));
245
+ }
246
+
247
+ &[data-placement="left"] {
248
+ inset: var(--_anchor-top, 0px) calc(var(--_anchor-left-inverse, 0px) + var(--pop-over-gap, 1rem)) auto auto;
249
+ }
250
+
251
+ &[data-placement="bottom"] {
252
+ inset: calc(var(--_anchor-bottom, 0px) + var(--pop-over-gap, 1rem)) auto auto var(--_anchor-left, 0px);
253
+ }
254
+
255
+ &[data-placement="top"] {
256
+ inset: auto auto calc(var(--_anchor-top-inverse, 0px) + var(--pop-over-gap, 1rem)) var(--_anchor-left, 0px);
257
+ }
258
+ }
259
+ }
199
260
  }
200
261
  }
201
262
  </style>
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import PopOver from "../PopOver.vue";
4
+ import { nextTick } from "vue";
4
5
 
5
6
  interface PopOverInstance {
6
7
  popoverId: string;
@@ -192,4 +193,56 @@ describe("PopOver", () => {
192
193
  expect(wrapper.find(".pop-over").classes()).toContain("updated-class");
193
194
  });
194
195
  });
196
+
197
+ describe("Without the Popover API", () => {
198
+ beforeEach(() => {
199
+ Object.defineProperty(HTMLElement.prototype, "showPopover", { value: undefined, writable: true, configurable: true });
200
+ });
201
+
202
+ it("opens from the trigger and closes from the close button, returning focus to the trigger", async () => {
203
+ wrapper = await mountSuspended(PopOver, {
204
+ slots: { trigger: "<span>Open</span>", content: "<p>Body</p>" },
205
+ attachTo: document.body,
206
+ });
207
+ const trigger = wrapper.find(".pop-over-trigger");
208
+ const popover = wrapper.find(".pop-over-popover");
209
+
210
+ await trigger.trigger("click");
211
+ await nextTick();
212
+ expect(popover.classes()).toContain("pop-over-popover-open");
213
+ expect(trigger.attributes("aria-expanded")).toBe("true");
214
+ expect(document.activeElement).toBe(wrapper.find(".pop-over-close-button").element);
215
+
216
+ await wrapper.find(".pop-over-close-button").trigger("click");
217
+ expect(popover.classes()).not.toContain("pop-over-popover-open");
218
+ expect(document.activeElement).toBe(trigger.element);
219
+ });
220
+ });
221
+
222
+ describe("Without anchor positioning", () => {
223
+ const originalCSS = globalThis.CSS;
224
+
225
+ beforeEach(() => {
226
+ Object.defineProperty(globalThis, "CSS", { value: { supports: () => false }, writable: true, configurable: true });
227
+ });
228
+
229
+ afterEach(() => {
230
+ Object.defineProperty(globalThis, "CSS", { value: originalCSS, writable: true, configurable: true });
231
+ });
232
+
233
+ it("places the popover on the current placement prop's side", async () => {
234
+ wrapper = await createWrapper({ placement: "left" });
235
+ const vm = wrapper.vm as unknown as PopOverInstance;
236
+ const popover = wrapper.find(".pop-over-popover");
237
+
238
+ vm.handleToggle(Object.assign(new Event("toggle"), { newState: "open" }));
239
+ await nextTick();
240
+ expect(popover.attributes("data-placement")).toBe("left");
241
+
242
+ await wrapper.setProps({ placement: "top" });
243
+ vm.handleToggle(Object.assign(new Event("toggle"), { newState: "open" }));
244
+ await nextTick();
245
+ expect(popover.attributes("data-placement")).toBe("top");
246
+ });
247
+ });
195
248
  });
@@ -1,7 +1,7 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
3
  exports[`PopOver > renders correct HTML structure 1`] = `
4
- "<div class="pop-over" style="--_anchor-name: --pop-over-anchor-v-0-0;" data-placement="right"><button popovertarget="pop-over-v-0-0" popovertargetaction="toggle" type="button" class="pop-over-trigger"><span>Open</span></button>
4
+ "<div class="pop-over" style="--_anchor-name: --pop-over-anchor-v-0-0;" data-placement="right"><button popovertarget="pop-over-v-0-0" popovertargetaction="toggle" type="button" class="pop-over-trigger" aria-expanded="false"><span>Open</span></button>
5
5
  <div id="pop-over-v-0-0" popover="" class="pop-over-popover"><button popovertarget="pop-over-v-0-0" popovertargetaction="hide" type="button" class="pop-over-close-button" aria-label="Close"><span class="iconify i-lucide:x pop-over-close-button-icon" aria-hidden="true"></span></button>
6
6
  <div class="pop-over-content">
7
7
  <p>Details</p>
@@ -4,17 +4,25 @@
4
4
 
5
5
  Each strength level (border 1-6, shadow 1-6, inner shadow 1-4) has its own token, defaulting to a
6
6
  built-in elevation/border scale. Only the tokens for the strength levels actually in use need
7
- overriding.
7
+ overriding. To recolour every level at once, set the colour tokens instead.
8
+
9
+ The colour tokens are private-wrapped (`--_border-colour`, `--_shadow-colour`, `--_inner-shadow-colour`)
10
+ because each is reused across every strength level; set the public token, not the `--_` one.
8
11
 
9
12
  | Token | Default | Controls |
10
13
  |---|---|---|
11
- | `--ui-block-decorated-border-1` .. `-6` | `1px` .. `6px solid var(--theme-border)` | Border at each strength level |
12
- | `--ui-block-decorated-shadow-1` .. `-6` | `0 1px 2px rgba(0,0,0,0.08)` .. `0 24px 32px rgba(0,0,0,0.18)` | Drop shadow at each strength level |
13
- | `--ui-block-decorated-inner-shadow-1` .. `-4` | `inset 0 1px 2px rgba(0,0,0,0.08)` .. `inset 0 6px 8px rgba(0,0,0,0.14)` | Inset shadow at each strength level |
14
+ | `--ui-block-decorated-border-colour` | `var(--theme-border)` | Border colour for every strength level |
15
+ | `--ui-block-decorated-shadow-colour` | `#000` | Drop shadow colour for every strength level; each level mixes in its own opacity (8% .. 18%) |
16
+ | `--ui-block-decorated-inner-shadow-colour` | `--ui-block-decorated-shadow-colour` | Inner shadow colour for every strength level (8% .. 14% opacity) |
17
+ | `--ui-block-decorated-border-1` .. `-6` | `1px` .. `6px solid` the border colour | Whole border at each strength level |
18
+ | `--ui-block-decorated-shadow-1` .. `-6` | `0 1px 2px` .. `0 24px 32px` in the shadow colour | Whole drop shadow at each strength level |
19
+ | `--ui-block-decorated-inner-shadow-1` .. `-4` | `inset 0 1px 2px` .. `inset 0 6px 8px` in the inner shadow colour | Whole inset shadow at each strength level |
14
20
  | `--ui-block-decorated-inner-shadow-highlight` | `inset 0 1px 0 rgba(255,255,255,0.15)` | Highlight layered on top of every inner shadow strength |
15
21
 
16
22
  ```css
17
23
  .my-page {
24
+ --ui-block-decorated-border-colour: #7c3aed;
25
+ --ui-block-decorated-shadow-colour: #7c3aed;
18
26
  --ui-block-decorated-shadow-3: 0 6px 12px rgba(0, 0, 0, 0.2);
19
27
  --ui-block-decorated-border-2: 2px dashed hotpink;
20
28
  }
@@ -43,48 +43,52 @@ watch(
43
43
  <style lang="css">
44
44
  @layer components {
45
45
  .ui-block-decorated {
46
+ --_border-colour: var(--ui-block-decorated-border-colour, var(--theme-border));
47
+ --_shadow-colour: var(--ui-block-decorated-shadow-colour, #000);
48
+ --_inner-shadow-colour: var(--ui-block-decorated-inner-shadow-colour, var(--_shadow-colour));
49
+
46
50
  /*
47
51
  * Border strength classes
48
52
  */
49
53
  &.ui-block-decorated-border-1 {
50
- border: var(--ui-block-decorated-border-1, 1px solid var(--theme-border));
54
+ border: var(--ui-block-decorated-border-1, 1px solid var(--_border-colour));
51
55
  }
52
56
  &.ui-block-decorated-border-2 {
53
- border: var(--ui-block-decorated-border-2, 2px solid var(--theme-border));
57
+ border: var(--ui-block-decorated-border-2, 2px solid var(--_border-colour));
54
58
  }
55
59
  &.ui-block-decorated-border-3 {
56
- border: var(--ui-block-decorated-border-3, 3px solid var(--theme-border));
60
+ border: var(--ui-block-decorated-border-3, 3px solid var(--_border-colour));
57
61
  }
58
62
  &.ui-block-decorated-border-4 {
59
- border: var(--ui-block-decorated-border-4, 4px solid var(--theme-border));
63
+ border: var(--ui-block-decorated-border-4, 4px solid var(--_border-colour));
60
64
  }
61
65
  &.ui-block-decorated-border-5 {
62
- border: var(--ui-block-decorated-border-5, 5px solid var(--theme-border));
66
+ border: var(--ui-block-decorated-border-5, 5px solid var(--_border-colour));
63
67
  }
64
68
  &.ui-block-decorated-border-6 {
65
- border: var(--ui-block-decorated-border-6, 6px solid var(--theme-border));
69
+ border: var(--ui-block-decorated-border-6, 6px solid var(--_border-colour));
66
70
  }
67
71
 
68
72
  /*
69
73
  * Shadow strength classes
70
74
  */
71
75
  &.ui-block-decorated-shadow-1 {
72
- box-shadow: var(--ui-block-decorated-shadow-1, 0 1px 2px rgba(0, 0, 0, 0.08));
76
+ box-shadow: var(--ui-block-decorated-shadow-1, 0 1px 2px color-mix(in srgb, var(--_shadow-colour) 8%, transparent));
73
77
  }
74
78
  &.ui-block-decorated-shadow-2 {
75
- box-shadow: var(--ui-block-decorated-shadow-2, 0 2px 4px rgba(0, 0, 0, 0.1));
79
+ box-shadow: var(--ui-block-decorated-shadow-2, 0 2px 4px color-mix(in srgb, var(--_shadow-colour) 10%, transparent));
76
80
  }
77
81
  &.ui-block-decorated-shadow-3 {
78
- box-shadow: var(--ui-block-decorated-shadow-3, 0 4px 8px rgba(0, 0, 0, 0.12));
82
+ box-shadow: var(--ui-block-decorated-shadow-3, 0 4px 8px color-mix(in srgb, var(--_shadow-colour) 12%, transparent));
79
83
  }
80
84
  &.ui-block-decorated-shadow-4 {
81
- box-shadow: var(--ui-block-decorated-shadow-4, 0 8px 16px rgba(0, 0, 0, 0.14));
85
+ box-shadow: var(--ui-block-decorated-shadow-4, 0 8px 16px color-mix(in srgb, var(--_shadow-colour) 14%, transparent));
82
86
  }
83
87
  &.ui-block-decorated-shadow-5 {
84
- box-shadow: var(--ui-block-decorated-shadow-5, 0 16px 24px rgba(0, 0, 0, 0.16));
88
+ box-shadow: var(--ui-block-decorated-shadow-5, 0 16px 24px color-mix(in srgb, var(--_shadow-colour) 16%, transparent));
85
89
  }
86
90
  &.ui-block-decorated-shadow-6 {
87
- box-shadow: var(--ui-block-decorated-shadow-6, 0 24px 32px rgba(0, 0, 0, 0.18));
91
+ box-shadow: var(--ui-block-decorated-shadow-6, 0 24px 32px color-mix(in srgb, var(--_shadow-colour) 18%, transparent));
88
92
  }
89
93
 
90
94
  /*
@@ -115,25 +119,25 @@ watch(
115
119
 
116
120
  &.ui-block-decorated-inner-shadow-1::before {
117
121
  box-shadow:
118
- var(--ui-block-decorated-inner-shadow-1, inset 0 1px 2px rgba(0, 0, 0, 0.08)),
122
+ var(--ui-block-decorated-inner-shadow-1, inset 0 1px 2px color-mix(in srgb, var(--_inner-shadow-colour) 8%, transparent)),
119
123
  var(--ui-block-decorated-inner-shadow-highlight, inset 0 1px 0 rgba(255, 255, 255, 0.15));
120
124
  }
121
125
 
122
126
  &.ui-block-decorated-inner-shadow-2::before {
123
127
  box-shadow:
124
- var(--ui-block-decorated-inner-shadow-2, inset 0 2px 4px rgba(0, 0, 0, 0.1)),
128
+ var(--ui-block-decorated-inner-shadow-2, inset 0 2px 4px color-mix(in srgb, var(--_inner-shadow-colour) 10%, transparent)),
125
129
  var(--ui-block-decorated-inner-shadow-highlight, inset 0 1px 0 rgba(255, 255, 255, 0.15));
126
130
  }
127
131
 
128
132
  &.ui-block-decorated-inner-shadow-3::before {
129
133
  box-shadow:
130
- var(--ui-block-decorated-inner-shadow-3, inset 0 4px 6px rgba(0, 0, 0, 0.12)),
134
+ var(--ui-block-decorated-inner-shadow-3, inset 0 4px 6px color-mix(in srgb, var(--_inner-shadow-colour) 12%, transparent)),
131
135
  var(--ui-block-decorated-inner-shadow-highlight, inset 0 1px 0 rgba(255, 255, 255, 0.15));
132
136
  }
133
137
 
134
138
  &.ui-block-decorated-inner-shadow-4::before {
135
139
  box-shadow:
136
- var(--ui-block-decorated-inner-shadow-4, inset 0 6px 8px rgba(0, 0, 0, 0.14)),
140
+ var(--ui-block-decorated-inner-shadow-4, inset 0 6px 8px color-mix(in srgb, var(--_inner-shadow-colour) 14%, transparent)),
137
141
  var(--ui-block-decorated-inner-shadow-highlight, inset 0 1px 0 rgba(255, 255, 255, 0.15));
138
142
  }
139
143
  }