srcdev-nuxt-components 9.2.9 → 9.2.11

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.
@@ -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.
@@ -24,7 +24,6 @@ html {
24
24
  color: var(--colour-text-default);
25
25
  font-family: var(--font-family);
26
26
  font-size: var(--step-4);
27
- /* min-height: 100dvh; */
28
27
  transition:
29
28
  background-color 0.4s ease,
30
29
  color 0.4s ease;
@@ -33,12 +32,11 @@ html {
33
32
 
34
33
  #__nuxt {
35
34
  height: 100%;
36
- div {
37
- .page-layout {
38
- min-block-size: 100svh;
39
- display: grid;
40
- grid-template-rows: auto 1fr auto;
41
- }
35
+ .page-layout {
36
+ min-block-size: 100svh;
37
+ display: grid;
38
+ grid-template-rows: auto 1fr auto;
39
+ align-items: start;
42
40
  }
43
41
  }
44
42
  }
@@ -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;
@@ -74,6 +74,16 @@ describe("ContentDocs", () => {
74
74
  expect(wrapper.find('[data-testid="body"]').text()).toBe("Body");
75
75
  });
76
76
 
77
+ 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 () => {
78
+ const wrapper = await mountSuspended(ContentDocs, {
79
+ props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
80
+ slots: { docsContent: "<h1>Title</h1>" },
81
+ });
82
+ const inner = wrapper.find(".content-docs-inner").element;
83
+ const children = [...inner.children].map((el) => el.className);
84
+ expect(children).toEqual(["docs-content", "docs-nav", "docs-page-nav"]);
85
+ });
86
+
77
87
  // ─── labels ─────────────────────────────────────────────────────────────
78
88
 
79
89
  it("defaults docsNavLabel to 'Navigation' and docsPageNavLabel to 'On this page'", async () => {
@@ -142,6 +152,28 @@ describe("ContentDocs", () => {
142
152
  expect(wrapper.emitted("update:activePageNavItem")?.[0]).toEqual(["/one#overview"]);
143
153
  });
144
154
 
155
+ // ─── closing on link click (mobile overlay behaviour) ─────────────────────
156
+
157
+ it("closes the docsNav panel when a link inside it is clicked", async () => {
158
+ const wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } });
159
+
160
+ await wrapper.find(".docs-nav summary").trigger("click");
161
+ expect(wrapper.find(".docs-nav details").attributes("open")).toBeDefined();
162
+
163
+ await wrapper.find(".docs-nav").findAll("a")[0]?.trigger("click");
164
+ expect(wrapper.find(".docs-nav details").attributes("open")).toBeUndefined();
165
+ });
166
+
167
+ it("closes the docsPageNav panel when a link inside it is clicked", async () => {
168
+ const wrapper = await mountSuspended(ContentDocs, { props: { docsPageNavItems: pageNavItems } });
169
+
170
+ await wrapper.find(".docs-page-nav summary").trigger("click");
171
+ expect(wrapper.find(".docs-page-nav details").attributes("open")).toBeDefined();
172
+
173
+ await wrapper.find(".docs-page-nav").find("a").trigger("click");
174
+ expect(wrapper.find(".docs-page-nav details").attributes("open")).toBeUndefined();
175
+ });
176
+
145
177
  // ─── container-width-driven forceOpened ────────────────────────────────────
146
178
 
147
179
  describe("breakpoint-driven forceOpened", () => {
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="expanding-panel" :class="[elementClasses, { 'content-is-on-top': contentIsOnTop }]">
2
+ <div ref="rootEl" class="expanding-panel" :class="[elementClasses, { 'content-is-on-top': contentIsOnTop }]">
3
3
  <details class="expanding-panel-details" :name :open @toggle="onDetailsToggle">
4
4
  <summary
5
5
  :id="`id-${name}-trigger`"
@@ -32,6 +32,8 @@
32
32
  </template>
33
33
 
34
34
  <script setup lang="ts">
35
+ import { onClickOutside } from "@vueuse/core";
36
+
35
37
  interface Props {
36
38
  name?: string;
37
39
  animationDuration?: number;
@@ -56,6 +58,17 @@ const open = computed(() => props.forceOpened || isPanelOpen.value);
56
58
 
57
59
  const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
58
60
 
61
+ // contentIsOnTop panels behave like a dropdown/overlay (e.g. ContentDocs' mobile nav) rather
62
+ // than an inline accordion — clicking outside is the expected way to dismiss those, matching
63
+ // native <select>/menu behaviour. Not wanted for ordinary accordions (forceOpened is always
64
+ // false here too — a forceOpened panel isn't meant to be dismissible by any interaction).
65
+ const rootEl = ref<HTMLElement | null>(null);
66
+ onClickOutside(rootEl, () => {
67
+ if (props.contentIsOnTop && !props.forceOpened && isPanelOpen.value) {
68
+ isPanelOpen.value = false;
69
+ }
70
+ });
71
+
59
72
  if (import.meta.dev) {
60
73
  watch(
61
74
  () => props.contentIsOnTop,
@@ -97,10 +110,9 @@ const onDetailsToggle = (event: Event) => {
97
110
  cursor: pointer;
98
111
  }
99
112
  .expanding-panel-summary {
100
- display: flex;
113
+ display: grid;
101
114
  align-items: center;
102
- justify-content: space-between;
103
- flex-direction: row;
115
+ grid-template-columns: 1fr auto;
104
116
  gap: var(--expanding-panel-summary-gap, 1rem);
105
117
  list-style: none;
106
118
  user-select: none;
@@ -427,6 +427,53 @@ describe("ExpandingPanel", () => {
427
427
  expect(vm.isPanelOpen).toBe(true);
428
428
  });
429
429
 
430
+ it("closes an open contentIsOnTop panel on outside click", async () => {
431
+ const wrapper = await mountSuspended(ExpandingPanel, {
432
+ props: { name: "content-on-top-outside-click", contentIsOnTop: true },
433
+ attachTo: document.body,
434
+ });
435
+ const vm = wrapper.vm as unknown as ExpandingPanelInstance;
436
+
437
+ await wrapper.find("summary").trigger("click");
438
+ expect(vm.isPanelOpen).toBe(true);
439
+
440
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
441
+ await nextTick();
442
+
443
+ expect(vm.isPanelOpen).toBe(false);
444
+ wrapper.unmount();
445
+ });
446
+
447
+ it("does not close on outside click when contentIsOnTop is false (ordinary accordion)", async () => {
448
+ const wrapper = await mountSuspended(ExpandingPanel, {
449
+ props: { name: "content-on-top-outside-click-inline" },
450
+ attachTo: document.body,
451
+ });
452
+ const vm = wrapper.vm as unknown as ExpandingPanelInstance;
453
+
454
+ await wrapper.find("summary").trigger("click");
455
+ expect(vm.isPanelOpen).toBe(true);
456
+
457
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
458
+ await nextTick();
459
+
460
+ expect(vm.isPanelOpen).toBe(true);
461
+ wrapper.unmount();
462
+ });
463
+
464
+ it("does not close a forceOpened contentIsOnTop panel on outside click", async () => {
465
+ const wrapper = await mountSuspended(ExpandingPanel, {
466
+ props: { name: "content-on-top-outside-click-forced", contentIsOnTop: true, forceOpened: true },
467
+ attachTo: document.body,
468
+ });
469
+
470
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
471
+ await nextTick();
472
+
473
+ expect(wrapper.find("details").attributes("open")).toBeDefined();
474
+ wrapper.unmount();
475
+ });
476
+
430
477
  // ─── name prop / useId fallback ───────────────────────────────────────────
431
478
 
432
479
  it("uses the provided name in ARIA attributes", async () => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.2.9",
4
+ "version": "9.2.11",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",