srcdev-nuxt-components 9.2.10 → 9.3.0

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 (29) hide show
  1. package/.claude/settings.json +14 -0
  2. package/.claude/skills/components/content-docs.md +16 -1
  3. package/.claude/skills/components/expanding-panel.md +9 -1
  4. package/app/app.config.ts +1 -1
  5. package/app/assets/styles/setup/01.config/index.css +2 -2
  6. package/app/assets/styles/setup/02.colours/index.css +8 -8
  7. package/app/assets/styles/setup/03.theming/index.css +6 -6
  8. package/app/assets/styles/setup/04.elements/forms/index.css +2 -2
  9. package/app/assets/styles/setup/04.elements/index.css +1 -1
  10. package/app/assets/styles/setup/05.typography/01.tokens/index.css +3 -3
  11. package/app/assets/styles/setup/05.typography/02.utility-classes/index.css +6 -6
  12. package/app/assets/styles/setup/05.typography/index.css +2 -2
  13. package/app/assets/styles/setup/06.utility-classes/animations/index.css +4 -4
  14. package/app/assets/styles/setup/06.utility-classes/index.css +3 -3
  15. package/app/assets/styles/setup/06.utility-classes/spacing/index.css +3 -3
  16. package/app/assets/styles/setup/a11y/index.css +1 -1
  17. package/app/assets/styles/setup/index.css +7 -7
  18. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +16 -21
  19. package/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts +35 -1
  20. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +9 -5
  21. package/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +6 -0
  22. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +9 -5
  23. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +9 -5
  24. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +5 -1
  25. package/app/components/alert-mask/tests/AlertMaskCore.spec.ts +9 -5
  26. package/app/composables/tests/useContainerBreakpoints.spec.ts +27 -7
  27. package/app/composables/tests/useWhatsApp.spec.ts +10 -4
  28. package/nuxt.config.ts +9 -0
  29. package/package.json +26 -21
@@ -106,5 +106,19 @@
106
106
  "/Users/simoncornforth/websites/nuxt-components/.claude/skills/components",
107
107
  "/Users/simoncornforth/websites/nuxt-components/.claude/skills"
108
108
  ]
109
+ },
110
+ "hooks": {
111
+ "PostToolUse": [
112
+ {
113
+ "matcher": "Write|Edit",
114
+ "hooks": [
115
+ {
116
+ "type": "command",
117
+ "command": "f=$(jq -r '.tool_input.file_path // empty'); if [[ \"$f\" == */app/components/*.vue && \"$f\" != */tests/* && \"$f\" != */stories/* ]]; then name=$(basename \"$f\" .vue); skill=$(printf '%s' \"$name\" | sed -E 's/([a-z0-9])([A-Z])/\\1-\\2/g; s/([A-Z]+)([A-Z][a-z])/\\1-\\2/g' | tr '[:upper:]' '[:lower:]'); jq -n --arg msg \"Component file $f was edited/written. Per Claude.md Development Workflow step 7, check whether .claude/skills/components/$skill.md needs updating to reflect this change (props/slots/models/defaults/behaviour).\" '{hookSpecificOutput:{hookEventName:\"PostToolUse\",additionalContext:$msg}}'; fi",
118
+ "timeout": 15
119
+ }
120
+ ]
121
+ }
122
+ ]
109
123
  }
110
124
  }
@@ -36,7 +36,7 @@ interface DocsNavItem {
36
36
  | `v-model:activeNavItem` | `string \| undefined` | `undefined` | The `to` of the currently-active `docsNav` item. Updates automatically on click; bind externally (e.g. to route matching) to control it. |
37
37
  | `v-model:activePageNavItem` | `string \| undefined` | `undefined` | Same, for `docsPageNav`. |
38
38
 
39
- Open/expanded state of the two panels is **not** exposed as a model — it's driven entirely by the container-width breakpoint logic (see below), not user-controllable.
39
+ Open/expanded state of the two panels is **not** exposed as a model to `ContentDocs`' own consumers externally it's driven entirely by the container-width breakpoint logic (see below), not user-controllable. Internally, each panel is still a controlled `ExpandingPanel` (`v-model`, not left uncontrolled) so it can be closed programmatically — see "Mobile overlay dismissal" below.
40
40
 
41
41
  ---
42
42
 
@@ -93,6 +93,21 @@ Widths are measured on the component's own root element (`useContainerBreakpoint
93
93
  | 768–1023px (tablet) | collapsible, closed by default | **forced open**, no toggle icon |
94
94
  | ≥ 1024px (desktop) | **forced open**, no toggle icon | **forced open**, no toggle icon |
95
95
 
96
+ Below 1024px (i.e. whenever a panel isn't `forceOpened`), it's also `contentIsOnTop` — an overlay, not in-flow — since a collapsible nav panel pushing page content down/up as it opens reads as janky on mobile/tablet. See "Mobile overlay dismissal" below for how these are closed.
97
+
98
+ ### Mobile overlay dismissal
99
+
100
+ Below 1024px, `docsNav`/`docsPageNav` are `contentIsOnTop` `ExpandingPanel`s (see the breakpoint table above), so they need an explicit way to close again beyond just toggling the summary:
101
+
102
+ - **Click outside** — inherited for free from `ExpandingPanel`'s own `contentIsOnTop` behaviour (see `expanding-panel.md`), no extra wiring in `ContentDocs` itself.
103
+ - **Clicking a nav link inside the open panel** — `ContentDocs` does not leave either `ExpandingPanel` uncontrolled: it holds its own `docsNavPanelOpen`/`docsPageNavPanelOpen` refs, bound via `v-model`, and each nav link's click handler sets its panel's ref to `false` in addition to updating `activeNavItem`/`activePageNavItem`. Without this, selecting a link would leave the overlay open on top of the page it just navigated to.
104
+
105
+ Both are no-ops at desktop/tablet where a panel is `forceOpened` — `open` there is `forceOpened || isPanelOpen`, so `forceOpened` wins regardless of what these refs are set to.
106
+
107
+ ### DOM order vs visual order (accessibility)
108
+
109
+ In the template, `docsContent` is placed **before** `docsNav`/`docsPageNav` — the reverse of their left-to-right visual position at desktop. This is deliberate: `docsNav`/`docsPageNav` each render an `<h3>` heading (`docsNavLabel`/`docsPageNavLabel`), and if they came first in the DOM, a screen-reader user navigating by heading would hit one of those h3s *before* the consuming page's own `h1` inside `docsContent` — a broken heading outline. Visual position is unaffected because layout comes from `grid-template-areas` (see the `<style>` block), not source order. The trade-off: default Tab order now reaches main content before the side nav — an accepted consequence, not a regression to "fix" back by reverting the order.
110
+
96
111
  ### Why the two panels share a `name` only on mobile
97
112
 
98
113
  Both panels are `ExpandingPanel`s using the native `<details name="...">` grouping feature, which makes same-named panels mutually exclusive (browser force-closes one when the other opens). That's the wanted behaviour on mobile (accordion — only one open at a time), but at tablet/desktop both panels must be open **simultaneously** — a shared name there would make the browser silently force-close one of them the moment both try to be open. So the component computes distinct names (`"docsNav"` / `"docsPageNav"`) once past mobile, and a shared name (`"docsPanelGroup"`) only while mobile.
@@ -13,7 +13,7 @@
13
13
  | `name` | `string` | `useId()` | Identifies the panel. Used in ARIA attributes (`id-{name}-trigger`, `id-{name}-content`). If omitted, a unique id is generated automatically. |
14
14
  | `animationDuration` | `number` | `400` | Expand/collapse transition duration in milliseconds. Pass `0` to disable animation. |
15
15
  | `forceOpened` | `boolean` | `false` | When `true`, the panel is always open. The toggle icon is hidden and clicks do not close the panel. |
16
- | `contentIsOnTop` | `boolean` | `false` | When `true`, the content region is taken out of flow and absolutely positioned directly below the summary, raised above surrounding page content via `z-index` — instead of pushing layout down when it opens. Applies a `content-is-on-top` class to the root `.expanding-panel` element (not the content div). |
16
+ | `contentIsOnTop` | `boolean` | `false` | When `true`, the content region is taken out of flow and absolutely positioned directly below the summary, raised above surrounding page content via `z-index` — instead of pushing layout down when it opens. Applies a `content-is-on-top` class to the root `.expanding-panel` element (not the content div). Also enables click-outside-to-close (see below) — not applied when `forceOpened` is `true`. |
17
17
  | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the root `.expanding-panel` element. |
18
18
 
19
19
  ## Model
@@ -157,6 +157,14 @@ Why: `.expanding-panel-content` collapses via `grid-template-rows: 0fr → 1fr`,
157
157
 
158
158
  ---
159
159
 
160
+ ## Click-outside-to-close (contentIsOnTop only)
161
+
162
+ When `contentIsOnTop` is `true` and the panel is open, clicking anywhere outside the panel's root element closes it (via `@vueuse/core`'s `onClickOutside`, sets `v-model` to `false`) — matching the dismissal behaviour of a native `<select>` or dropdown menu, since that's what an overlay panel functionally is. This does **not** apply to ordinary in-flow panels (`contentIsOnTop: false`, the default) — an inline accordion staying open when you click elsewhere on the page is expected, not a bug. It also never applies when `forceOpened` is `true`, regardless of `contentIsOnTop` — a forced-open panel isn't dismissible by any interaction.
163
+
164
+ If you also want the panel to close when something *inside* it is activated (e.g. a nav link) — clicking outside doesn't cover that case — set `v-model` to `false` from that element's own click handler, same as any other controlled usage. See `ContentDocs`' `docsNav`/`docsPageNav` panels for a real example: both are `contentIsOnTop` on mobile, and each nav link closes its panel on click in addition to relying on click-outside.
165
+
166
+ ---
167
+
160
168
  ## CSS Token Customization
161
169
 
162
170
  All `--expanding-panel-*` tokens can be overridden at global, page, or instance scope. See `CONSUMER-STYLING.md` in the component directory for full token documentation and examples.
package/app/app.config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { SemanticTheme, DisplayPromptTheme, DisplayToastTheme, DisplayToastPosition, DisplayToastAlignment } from "~/types/components";
1
+ import type { SemanticTheme, DisplayPromptTheme, DisplayToastTheme, DisplayToastPosition, DisplayToastAlignment } from "./types/components";
2
2
 
3
3
  export default defineAppConfig({
4
4
  srcdev: {
@@ -1,2 +1,2 @@
1
- @import "./_normalise";
2
- @import "./_head";
1
+ @import "./_normalise.css";
2
+ @import "./_head.css";
@@ -1,8 +1,8 @@
1
- @import "./_theme-params";
2
- @import "./_amber";
3
- @import "./_blue";
4
- @import "./_green";
5
- @import "./_orange";
6
- @import "./_red";
7
- @import "./_slate";
8
- @import "./_sunset";
1
+ @import "./_theme-params.css";
2
+ @import "./_amber.css";
3
+ @import "./_blue.css";
4
+ @import "./_green.css";
5
+ @import "./_orange.css";
6
+ @import "./_red.css";
7
+ @import "./_slate.css";
8
+ @import "./_sunset.css";
@@ -1,6 +1,6 @@
1
- @import "./theme-ramp";
2
- @import "./_theme-slots";
3
- @import "./_default";
4
- @import "./_error";
5
- @import "./_success";
6
- @import "./_warning";
1
+ @import "./theme-ramp.css";
2
+ @import "./_theme-slots.css";
3
+ @import "./_default.css";
4
+ @import "./_error.css";
5
+ @import "./_success.css";
6
+ @import "./_warning.css";
@@ -1,7 +1,7 @@
1
- @import "./00.element-defaults";
1
+ @import "./00.element-defaults.css";
2
2
  @import "./01.field-layout-container-level.css";
3
3
  @import "./02.typography.css";
4
- @import "./03.generic-input-geometry";
4
+ @import "./03.generic-input-geometry.css";
5
5
  @import "./04.slot-icon-system.css";
6
6
  @import "./05.checkbox-radio-geometry.css";
7
7
  @import "./06.button-geometry.css";
@@ -1 +1 @@
1
- @import "./forms/";
1
+ @import "./forms/index.css";
@@ -1,3 +1,3 @@
1
- @import "./_font-family";
2
- @import "./_reponsive-font-sizes";
3
- @import "./_timing-functions";
1
+ @import "./_font-family.css";
2
+ @import "./_reponsive-font-sizes.css";
3
+ @import "./_timing-functions.css";
@@ -1,9 +1,9 @@
1
- @import "./_font-classes-page-heading";
2
- @import "./_font-classes-page-body";
3
- @import "./_font-classes-page-link";
4
- @import "./_font-classes-section";
5
- @import "./_font-classes-article";
6
- @import "./_font-classes-card";
1
+ @import "./_font-classes-page-heading.css";
2
+ @import "./_font-classes-page-body.css";
3
+ @import "./_font-classes-page-link.css";
4
+ @import "./_font-classes-section.css";
5
+ @import "./_font-classes-article.css";
6
+ @import "./_font-classes-card.css";
7
7
  @import "./_generic-font-classes.css";
8
8
  @import "./_generic-font-variation-settings.css";
9
9
  @import "./_generic-font-weights.css";
@@ -1,2 +1,2 @@
1
- @import "./01.tokens";
2
- @import "./02.utility-classes";
1
+ @import "./01.tokens/index.css";
2
+ @import "./02.utility-classes/index.css";
@@ -1,5 +1,5 @@
1
- @import "./_entry-zoom-reveal";
2
- @import "./_entry-slide-in";
3
- @import "./_entry-exit-blur";
4
- @import "./_auto-rotate";
1
+ @import "./_entry-zoom-reveal.css";
2
+ @import "./_entry-slide-in.css";
3
+ @import "./_entry-exit-blur.css";
4
+ @import "./_auto-rotate.css";
5
5
  @import "./_animation-scroller-x.css";
@@ -1,4 +1,4 @@
1
- @import "./animations/";
2
- @import "./spacing/";
1
+ @import "./animations/index.css";
2
+ @import "./spacing/index.css";
3
3
  /* @import "./layout/"; */
4
- @import "./_a11y";
4
+ @import "./_a11y.css";
@@ -1,3 +1,3 @@
1
- @import "./_fluid-spacing";
2
- @import "./_margin";
3
- @import "./_padding";
1
+ @import "./_fluid-spacing.css";
2
+ @import "./_margin.css";
3
+ @import "./_padding.css";
@@ -1 +1 @@
1
- @import "./_variables";
1
+ @import "./_variables.css";
@@ -1,9 +1,9 @@
1
1
  @layer reset, colours, theming, form-tokens, typography, a11y, components, utilities, consumer;
2
2
 
3
- @import "./01.config/" layer(reset);
4
- @import "./02.colours/" layer(colours);
5
- @import "./03.theming/" layer(theming);
6
- @import "./04.elements/" layer(form-tokens);
7
- @import "./05.typography/" layer(typography);
8
- @import "./a11y/" layer(a11y);
9
- @import "./06.utility-classes/" layer(utilities);
3
+ @import "./01.config/index.css" layer(reset);
4
+ @import "./02.colours/index.css" layer(colours);
5
+ @import "./03.theming/index.css" layer(theming);
6
+ @import "./04.elements/index.css" layer(form-tokens);
7
+ @import "./05.typography/index.css" layer(typography);
8
+ @import "./a11y/index.css" layer(a11y);
9
+ @import "./06.utility-classes/index.css" layer(utilities);
@@ -1,8 +1,12 @@
1
1
  <template>
2
2
  <component :is="tag" ref="rootEl" class="content-docs" :class="[elementClasses]">
3
3
  <div class="content-docs-inner">
4
+ <div v-if="hasDocsContent" class="docs-content">
5
+ <slot name="docsContent"></slot>
6
+ </div>
4
7
  <div v-if="hasDocsNav" class="docs-nav">
5
8
  <ExpandingPanel
9
+ v-model="docsNavPanelOpen"
6
10
  :name="docsNavPanelName"
7
11
  :animation-duration="200"
8
12
  :force-opened="docsNavForceOpen"
@@ -21,7 +25,10 @@
21
25
  class="docs-nav-link"
22
26
  :class="{ 'is-active': item.to === activeNavItem }"
23
27
  :aria-current="item.to === activeNavItem ? 'page' : undefined"
24
- @click="activeNavItem = item.to"
28
+ @click="
29
+ activeNavItem = item.to;
30
+ docsNavPanelOpen = false;
31
+ "
25
32
  >
26
33
  <Icon v-if="item.icon" :name="item.icon" class="docs-nav-link-icon" aria-hidden="true" />
27
34
  <span class="docs-nav-link-label">{{ item.label }}</span>
@@ -32,11 +39,9 @@
32
39
  </template>
33
40
  </ExpandingPanel>
34
41
  </div>
35
- <div v-if="hasDocsContent" class="docs-content">
36
- <slot name="docsContent"></slot>
37
- </div>
38
42
  <div v-if="hasDocsPageNav" class="docs-page-nav">
39
43
  <ExpandingPanel
44
+ v-model="docsPageNavPanelOpen"
40
45
  :name="docsPageNavPanelName"
41
46
  :animation-duration="200"
42
47
  :force-opened="docsPageNavForceOpen"
@@ -55,7 +60,10 @@
55
60
  class="docs-page-nav-link"
56
61
  :class="{ 'is-active': item.to === activePageNavItem }"
57
62
  :aria-current="item.to === activePageNavItem ? 'page' : undefined"
58
- @click="activePageNavItem = item.to"
63
+ @click="
64
+ activePageNavItem = item.to;
65
+ docsPageNavPanelOpen = false;
66
+ "
59
67
  >
60
68
  <Icon v-if="item.icon" :name="item.icon" class="docs-page-nav-link-icon" aria-hidden="true" />
61
69
  <span class="docs-page-nav-link-label">{{ item.label }}</span>
@@ -121,9 +129,9 @@ const docsNavOnTop = computed(() => !isDesktop.value);
121
129
  const docsPageNavForceOpen = computed(() => !isMobile.value);
122
130
  const docsPageNavOnTop = computed(() => isMobile.value);
123
131
 
124
- // Shared `name` groups the two <details> into a mutually-exclusive native accordion —
125
- // wanted on mobile (neither is forceOpened), wrong on tablet/desktop where both are
126
- // forceOpened simultaneously: a shared name would make the browser force-close one.
132
+ const docsNavPanelOpen = ref(false);
133
+ const docsPageNavPanelOpen = ref(false);
134
+
127
135
  const panelGroupId = useId();
128
136
  const docsNavPanelName = computed(() => (isMobile.value ? panelGroupId : `${panelGroupId}-docsNav`));
129
137
  const docsPageNavPanelName = computed(() => (isMobile.value ? panelGroupId : `${panelGroupId}-docsPageNav`));
@@ -132,7 +140,6 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? panelGroupId : `${
132
140
  <style lang="css">
133
141
  @layer components {
134
142
  .content-docs {
135
- /* Shared defaults — theme both docsNav and docsPageNav at once */
136
143
  --_heading-font-size: var(--content-docs-heading-font-size, 1.4rem);
137
144
  --_heading-font-weight: var(--content-docs-heading-font-weight, 700);
138
145
  --_heading-color: var(--content-docs-heading-color, light-dark(var(--slate-10), var(--slate-01)));
@@ -146,8 +153,6 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? panelGroupId : `${
146
153
  --_panel-padding-inline: var(--content-docs-panel-padding-inline, 0.8rem);
147
154
  --_panel-border-radius: var(--content-docs-panel-border-radius, 0.5rem);
148
155
 
149
- /* Fixed-width grid tracks — the docsNav/docsPageNav columns at tablet/desktop.
150
- docsPageNav gets a narrower track at tablet since docsNav is full-width there. */
151
156
  --_nav-column-width: var(--content-docs-nav-column-width, 23rem);
152
157
  --_page-nav-column-width: var(--content-docs-page-nav-column-width, 22rem);
153
158
  --_page-nav-column-width-tablet: var(--content-docs-page-nav-column-width-tablet, 20rem);
@@ -164,7 +169,6 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? panelGroupId : `${
164
169
  --_link-active-bg: var(--content-docs-link-active-bg, light-dark(var(--green-01), var(--green-09)));
165
170
  --_link-active-color: var(--content-docs-link-active-color, light-dark(var(--green-10), var(--green-04)));
166
171
 
167
- /* Per-side overrides — each falls back to the shared token above */
168
172
  --_nav-heading-font-size: var(--content-docs-nav-heading-font-size, var(--_heading-font-size));
169
173
  --_nav-heading-font-weight: var(--content-docs-nav-heading-font-weight, var(--_heading-font-weight));
170
174
  --_nav-heading-color: var(--content-docs-nav-heading-color, var(--_heading-color));
@@ -313,15 +317,6 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? panelGroupId : `${
313
317
  }
314
318
  }
315
319
 
316
- /* Grid (not flex) so the label column stays aligned at a fixed offset whether or
317
- not an item has an icon — an icon-less item's label still starts in column 2,
318
- instead of collapsing back to column 1 like it would with flex.
319
-
320
- Icon at the end: set --docs-nav-link-icon-order to rtl. This mirrors which
321
- physical side each grid column renders on without touching column sizing —
322
- swapping grid-column values directly would leave the label squeezed into the
323
- icon-sized track instead. The label/icon direction resets further down undo
324
- the mirroring for their own content so text and icon glyphs don't visually flip. */
325
320
  .docs-nav-link {
326
321
  display: grid;
327
322
  grid-template-columns: var(--docs-nav-link-icon-size, 1.6rem) 1fr;
@@ -9,7 +9,9 @@ beforeEach(() => {
9
9
  resizeCallback = null;
10
10
  vi.stubGlobal(
11
11
  "ResizeObserver",
12
- vi.fn((callback: ResizeObserverCallback) => {
12
+ // Vitest 4: a mock's implementation must be a regular function (not an
13
+ // arrow function) to remain constructible via `new`.
14
+ vi.fn(function (callback: ResizeObserverCallback) {
13
15
  resizeCallback = callback;
14
16
  return {
15
17
  observe: vi.fn(),
@@ -74,6 +76,16 @@ describe("ContentDocs", () => {
74
76
  expect(wrapper.find('[data-testid="body"]').text()).toBe("Body");
75
77
  });
76
78
 
79
+ it("places docsContent before docsNav and docsPageNav in DOM order, so a consumer's h1 is reachable before either nav panel's h3 heading (visual position is unaffected — grid-template-areas places all three, not source order)", async () => {
80
+ const wrapper = await mountSuspended(ContentDocs, {
81
+ props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
82
+ slots: { docsContent: "<h1>Title</h1>" },
83
+ });
84
+ const inner = wrapper.find(".content-docs-inner").element;
85
+ const children = [...inner.children].map((el) => el.className);
86
+ expect(children).toEqual(["docs-content", "docs-nav", "docs-page-nav"]);
87
+ });
88
+
77
89
  // ─── labels ─────────────────────────────────────────────────────────────
78
90
 
79
91
  it("defaults docsNavLabel to 'Navigation' and docsPageNavLabel to 'On this page'", async () => {
@@ -142,6 +154,28 @@ describe("ContentDocs", () => {
142
154
  expect(wrapper.emitted("update:activePageNavItem")?.[0]).toEqual(["/one#overview"]);
143
155
  });
144
156
 
157
+ // ─── closing on link click (mobile overlay behaviour) ─────────────────────
158
+
159
+ it("closes the docsNav panel when a link inside it is clicked", async () => {
160
+ const wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } });
161
+
162
+ await wrapper.find(".docs-nav summary").trigger("click");
163
+ expect(wrapper.find(".docs-nav details").attributes("open")).toBeDefined();
164
+
165
+ await wrapper.find(".docs-nav").findAll("a")[0]?.trigger("click");
166
+ expect(wrapper.find(".docs-nav details").attributes("open")).toBeUndefined();
167
+ });
168
+
169
+ it("closes the docsPageNav panel when a link inside it is clicked", async () => {
170
+ const wrapper = await mountSuspended(ContentDocs, { props: { docsPageNavItems: pageNavItems } });
171
+
172
+ await wrapper.find(".docs-page-nav summary").trigger("click");
173
+ expect(wrapper.find(".docs-page-nav details").attributes("open")).toBeDefined();
174
+
175
+ await wrapper.find(".docs-page-nav").find("a").trigger("click");
176
+ expect(wrapper.find(".docs-page-nav details").attributes("open")).toBeUndefined();
177
+ });
178
+
145
179
  // ─── container-width-driven forceOpened ────────────────────────────────────
146
180
 
147
181
  describe("breakpoint-driven forceOpened", () => {
@@ -2,11 +2,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import AlertMaskedContent from "../AlertMaskedContent.vue";
4
4
 
5
- const mockResizeObserver = vi.fn(() => ({
6
- observe: vi.fn(),
7
- unobserve: vi.fn(),
8
- disconnect: vi.fn(),
9
- }));
5
+ // Vitest 4: a mock's implementation must be a regular function (not an
6
+ // arrow function) to remain constructible via `new`.
7
+ const mockResizeObserver = vi.fn(function () {
8
+ return {
9
+ observe: vi.fn(),
10
+ unobserve: vi.fn(),
11
+ disconnect: vi.fn(),
12
+ };
13
+ });
10
14
 
11
15
  vi.stubGlobal("ResizeObserver", mockResizeObserver);
12
16
 
@@ -437,6 +437,12 @@ describe("ExpandingPanel", () => {
437
437
  await wrapper.find("summary").trigger("click");
438
438
  expect(vm.isPanelOpen).toBe(true);
439
439
 
440
+ // vueuse's onClickOutside debounces re-entrant clicks via a real setTimeout(0) that
441
+ // flips an internal "isProcessingClick" flag back off. With fake timers active
442
+ // (test/vitest.setup.ts) that flag never resets on its own, so the summary click
443
+ // above would otherwise permanently suppress the very next click. Advance past it.
444
+ vi.advanceTimersByTime(0);
445
+
440
446
  document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
441
447
  await nextTick();
442
448
 
@@ -5,11 +5,15 @@ import SiteNavigation from "../SiteNavigation.vue";
5
5
  import type { NavItemData } from "~/types/components/navigation-horizontal.d";
6
6
 
7
7
  // useResizeObserver (from @vueuse/core) requires ResizeObserver
8
- const mockResizeObserver = vi.fn(() => ({
9
- observe: vi.fn(),
10
- unobserve: vi.fn(),
11
- disconnect: vi.fn(),
12
- }));
8
+ // Vitest 4: a mock's implementation must be a regular function (not an
9
+ // arrow function) to remain constructible via `new`.
10
+ const mockResizeObserver = vi.fn(function () {
11
+ return {
12
+ observe: vi.fn(),
13
+ unobserve: vi.fn(),
14
+ disconnect: vi.fn(),
15
+ };
16
+ });
13
17
 
14
18
  beforeEach(() => {
15
19
  vi.stubGlobal("ResizeObserver", mockResizeObserver);
@@ -5,11 +5,15 @@ import TabNavigation from "../TabNavigation.vue";
5
5
  import type { NavItemData } from "~/types/components/navigation-horizontal.d";
6
6
 
7
7
  // useResizeObserver (from @vueuse/core) requires ResizeObserver
8
- const mockResizeObserver = vi.fn(() => ({
9
- observe: vi.fn(),
10
- unobserve: vi.fn(),
11
- disconnect: vi.fn(),
12
- }));
8
+ // Vitest 4: a mock's implementation must be a regular function (not an
9
+ // arrow function) to remain constructible via `new`.
10
+ const mockResizeObserver = vi.fn(function () {
11
+ return {
12
+ observe: vi.fn(),
13
+ unobserve: vi.fn(),
14
+ disconnect: vi.fn(),
15
+ };
16
+ });
13
17
 
14
18
  beforeEach(() => {
15
19
  vi.stubGlobal("ResizeObserver", mockResizeObserver);
@@ -63,7 +63,11 @@ describe("SliderGallery", () => {
63
63
  mockImage = { src: "", onload: null, onerror: null };
64
64
  vi.stubGlobal(
65
65
  "Image",
66
- vi.fn(() => mockImage)
66
+ // Vitest 4: a mock's implementation must be a regular function (not an
67
+ // arrow function) to remain constructible via `new`.
68
+ vi.fn(function () {
69
+ return mockImage;
70
+ })
67
71
  );
68
72
  });
69
73
 
@@ -23,11 +23,15 @@ interface AlertMaskCoreInstance {
23
23
  }
24
24
 
25
25
  // Mock ResizeObserver
26
- const mockResizeObserver = vi.fn(() => ({
27
- observe: vi.fn(),
28
- unobserve: vi.fn(),
29
- disconnect: vi.fn(),
30
- }));
26
+ // Vitest 4: a mock's implementation must be a regular function (not an
27
+ // arrow function) to remain constructible via `new`.
28
+ const mockResizeObserver = vi.fn(function () {
29
+ return {
30
+ observe: vi.fn(),
31
+ unobserve: vi.fn(),
32
+ disconnect: vi.fn(),
33
+ };
34
+ });
31
35
 
32
36
  vi.stubGlobal("ResizeObserver", mockResizeObserver);
33
37
 
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { nextTick, ref } from "vue";
3
3
  import { useContainerBreakpoints } from "../useContainerBreakpoints";
4
4
 
@@ -8,7 +8,9 @@ beforeEach(() => {
8
8
  resizeCallback = null;
9
9
  vi.stubGlobal(
10
10
  "ResizeObserver",
11
- vi.fn((callback: ResizeObserverCallback) => {
11
+ // Vitest 4: a mock's implementation must be a regular function (not an
12
+ // arrow function) to remain constructible via `new`.
13
+ vi.fn(function (callback: ResizeObserverCallback) {
12
14
  resizeCallback = callback;
13
15
  return {
14
16
  observe: vi.fn(),
@@ -19,6 +21,10 @@ beforeEach(() => {
19
21
  );
20
22
  });
21
23
 
24
+ afterEach(() => {
25
+ document.body.innerHTML = "";
26
+ });
27
+
22
28
  function triggerResize(el: HTMLElement, width: number) {
23
29
  Object.defineProperty(el, "offsetWidth", { value: width, configurable: true });
24
30
  resizeCallback?.(
@@ -27,15 +33,29 @@ function triggerResize(el: HTMLElement, width: number) {
27
33
  );
28
34
  }
29
35
 
36
+ // @vueuse/core's useElementSize reads the element's initial width in onMounted via
37
+ // `offsetWidth - computed padding/border`, ahead of any ResizeObserver callback. happy-dom's
38
+ // getComputedStyle() only resolves real "0px" values (rather than "") for padding/border on an
39
+ // element that's actually attached to the document — a detached `createElement` div keeps
40
+ // returning "", so parseFloat() turns the subtraction into NaN. Zero the box model AND attach
41
+ // the element so that initial-width read is a real, non-NaN value.
42
+ function createEl(): HTMLElement {
43
+ const el = document.createElement("div");
44
+ el.style.padding = "0px";
45
+ el.style.border = "0px";
46
+ document.body.appendChild(el);
47
+ return el;
48
+ }
49
+
30
50
  describe("useContainerBreakpoints", () => {
31
51
  it("defaults active to base when narrower than the smallest breakpoint", () => {
32
- const el = ref<HTMLElement | null>(document.createElement("div"));
52
+ const el = ref<HTMLElement | null>(createEl());
33
53
  const { active } = useContainerBreakpoints(undefined, el);
34
54
  expect(active.value).toBe("base");
35
55
  });
36
56
 
37
57
  it("updates active as the observed element's width crosses breakpoints", async () => {
38
- const el = ref<HTMLElement | null>(document.createElement("div"));
58
+ const el = ref<HTMLElement | null>(createEl());
39
59
  const { active } = useContainerBreakpoints(undefined, el);
40
60
 
41
61
  triggerResize(el.value as HTMLElement, 700);
@@ -48,7 +68,7 @@ describe("useContainerBreakpoints", () => {
48
68
  });
49
69
 
50
70
  it("greaterOrEqual/smaller reflect the current width against a named breakpoint", async () => {
51
- const el = ref<HTMLElement | null>(document.createElement("div"));
71
+ const el = ref<HTMLElement | null>(createEl());
52
72
  const { greaterOrEqual, smaller } = useContainerBreakpoints(undefined, el);
53
73
  const isLg = greaterOrEqual("lg");
54
74
  const isSmallerThanMd = smaller("md");
@@ -63,7 +83,7 @@ describe("useContainerBreakpoints", () => {
63
83
  });
64
84
 
65
85
  it("between returns true only within the [min, max) range", async () => {
66
- const el = ref<HTMLElement | null>(document.createElement("div"));
86
+ const el = ref<HTMLElement | null>(createEl());
67
87
  const { between } = useContainerBreakpoints(undefined, el);
68
88
  const isTablet = between("sm", "lg");
69
89
 
@@ -77,7 +97,7 @@ describe("useContainerBreakpoints", () => {
77
97
  });
78
98
 
79
99
  it("supports a custom breakpoints map", async () => {
80
- const el = ref<HTMLElement | null>(document.createElement("div"));
100
+ const el = ref<HTMLElement | null>(createEl());
81
101
  const { active } = useContainerBreakpoints({ narrow: 400, wide: 900 }, el);
82
102
 
83
103
  triggerResize(el.value as HTMLElement, 500);
@@ -6,8 +6,14 @@ const PHONE_NUMBER = "447700900000";
6
6
 
7
7
  // mockNuxtImport intercepts Nuxt's auto-import resolution — vi.stubGlobal cannot.
8
8
  // Use vi.hoisted so the mock function is available before module evaluation.
9
+ // Nuxt's own router plugin also calls useRuntimeConfig() during app init and
10
+ // reads `.app.baseURL` — the mock must include that shape too, or app
11
+ // initialization throws before the test body runs.
9
12
  const { useRuntimeConfigMock } = vi.hoisted(() => ({
10
- useRuntimeConfigMock: vi.fn(() => ({ public: { whatsappNumber: PHONE_NUMBER } })),
13
+ useRuntimeConfigMock: vi.fn(() => ({
14
+ public: { whatsappNumber: PHONE_NUMBER },
15
+ app: { baseURL: "/" },
16
+ })),
11
17
  }));
12
18
 
13
19
  mockNuxtImport("useRuntimeConfig", () => useRuntimeConfigMock);
@@ -17,7 +23,7 @@ describe("useWhatsApp", () => {
17
23
 
18
24
  beforeEach(() => {
19
25
  windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
20
- useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: PHONE_NUMBER } });
26
+ useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: PHONE_NUMBER }, app: { baseURL: "/" } });
21
27
  });
22
28
 
23
29
  afterEach(() => {
@@ -85,14 +91,14 @@ describe("useWhatsApp", () => {
85
91
  });
86
92
 
87
93
  it("does not call window.open when number is not configured", () => {
88
- useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: "" } });
94
+ useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: "" }, app: { baseURL: "/" } });
89
95
  const { openWhatsApp } = useWhatsApp();
90
96
  openWhatsApp([{ label: "Name", value: "Jane" }]);
91
97
  expect(windowOpenSpy).not.toHaveBeenCalled();
92
98
  });
93
99
 
94
100
  it("logs a warning when number is not configured", () => {
95
- useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: "" } });
101
+ useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: "" }, app: { baseURL: "/" } });
96
102
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
97
103
  const { openWhatsApp } = useWhatsApp();
98
104
  openWhatsApp([{ label: "Name", value: "Jane" }]);
package/nuxt.config.ts CHANGED
@@ -130,6 +130,15 @@ export default defineNuxtConfig({
130
130
  ...(isStandalone ? ["vue-qrcode-reader"] : []),
131
131
  ],
132
132
  },
133
+ // Vite 8 (pulled in by Nuxt 4.5) defaults build.cssMinify to "lightningcss" instead of
134
+ // "esbuild". lightningcss has an open bug where it drops the unprefixed `backdrop-filter`
135
+ // once autoprefixer adds `-webkit-backdrop-filter` alongside it, breaking glass/blur effects
136
+ // (see https://github.com/vitejs/vite/issues/22649) — this repo relies on backdrop-filter in
137
+ // several components (GlassPanel, SiteNavigation, TabNavigation, DisplayDialog, ColourFinder,
138
+ // MagneticNavigation). Pin back to esbuild until upstream fixes it.
139
+ build: {
140
+ cssMinify: "esbuild",
141
+ },
133
142
  },
134
143
  vue: {
135
144
  runtimeCompiler: true,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.2.10",
4
+ "version": "9.3.0",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",
@@ -90,33 +90,35 @@
90
90
  "@iconify-json/akar-icons": "1.2.7",
91
91
  "@iconify-json/bi": "1.2.7",
92
92
  "@iconify-json/bitcoin-icons": "1.2.5",
93
- "@iconify-json/gravity-ui": "1.2.12",
93
+ "@iconify-json/gravity-ui": "1.2.14",
94
94
  "@iconify-json/ic": "*",
95
- "@iconify-json/logos": "1.2.11",
96
- "@iconify-json/lucide": "*",
97
- "@iconify-json/material-symbols": "1.2.73",
95
+ "@iconify-json/logos": "1.2.13",
96
+ "@iconify-json/lucide": "1.2.124",
97
+ "@iconify-json/material-symbols": "1.2.89",
98
98
  "@iconify-json/mdi": "1.2.3",
99
99
  "@iconify-json/radix-icons": "*",
100
- "@nuxt/eslint": "1.15.2",
100
+ "@nuxt/eslint": "1.17.0",
101
101
  "@nuxt/schema": "4.4.6",
102
- "@nuxt/test-utils": "3.23.0",
102
+ "@nuxt/test-utils": "4.1.0",
103
103
  "@nuxtjs/storybook": "9.1.0-29374011.dab79ae",
104
- "@playwright/test": "1.60.0",
104
+ "@playwright/test": "1.62.1",
105
105
  "@storybook/addon-a11y": "10.0.7",
106
106
  "@storybook/addon-docs": "10.0.7",
107
107
  "@storybook/builder-vite": "10.0.7",
108
- "@vue/test-utils": "2.4.10",
109
- "@vueuse/core": "14.3.0",
110
- "eslint": "10.4.0",
108
+ "@vue/test-utils": "2.4.11",
109
+ "@vueuse/core": "14.4.0",
110
+ "eslint": "10.8.1",
111
111
  "eslint-plugin-storybook": "10.0.7",
112
- "happy-dom": "20.9.0",
112
+ "happy-dom": "20.11.2",
113
113
  "nuxt-qrcode": "0.4.10",
114
+ "oxc-parser": "0.131.0",
114
115
  "patch-package": "8.0.1",
115
- "release-it": "20.0.1",
116
+ "release-it": "21.0.2",
116
117
  "storybook": "10.0.7",
117
- "typescript": "~5.8.3",
118
- "vitest": "3.2.4",
119
- "vue-tsc": "3.3.1"
118
+ "typescript": "5.9.3",
119
+ "vitest": "4.1.11",
120
+ "vue-router": "5.2.0",
121
+ "vue-tsc": "3.3.10"
120
122
  },
121
123
  "release-it": {
122
124
  "$schema": "https://unpkg.com/release-it/schema/release-it.json",
@@ -130,15 +132,18 @@
130
132
  },
131
133
  "dependencies": {
132
134
  "@nuxt/fonts": "0.14.0",
133
- "@nuxt/icon": "2.2.2",
134
- "@nuxt/image": "2.0.0",
135
- "@oddbird/css-anchor-positioning": "0.9.0",
136
- "@pinia/nuxt": "0.11.3",
135
+ "@nuxt/icon": "2.5.0",
136
+ "@nuxt/image": "2.1.0",
137
+ "@oddbird/css-anchor-positioning": "0.10.2",
138
+ "@pinia/nuxt": "1.0.2",
137
139
  "@vueuse/motion": "3.0.3",
138
140
  "focus-trap-vue": "4.1.0",
139
141
  "nuxt": "4.4.6",
140
- "pinia": "3.0.4",
142
+ "pinia": "4.0.3",
141
143
  "pinia-plugin-persistedstate": "4.7.1",
142
144
  "zod": "4.4.3"
145
+ },
146
+ "overrides": {
147
+ "vue-router": "5.2.0"
143
148
  }
144
149
  }