Skip to content

Commit 94e3184

Browse files
committed
feat(content-docs): lock docsNav/docsPageNav to data-driven ExpandingPanels
Replaces the docsNav/docsPageNav slots with prop-driven nav items (docsNavItems/docsPageNavItems, DocsNavItem incl. optional icon) and active-item v-models, since every consumer would otherwise have to re-wire the same ExpandingPanel + breakpoint force-open/name-grouping logic themselves. Adds default heading/panel/link styling with a shared + per-side CSS token API, grid-based link layout so icon-less items stay aligned, and start/end icon ordering via a direction flip. Documents the component in .claude/skills/components/content-docs.md.
1 parent 5f9627e commit 94e3184

4 files changed

Lines changed: 223 additions & 22 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# ContentDocs Component
2+
3+
## Overview
4+
5+
`ContentDocs` is a docs-page layout shell: a primary nav column, a main content column, and an "on this page" nav column, arranged via CSS `@container` queries (not viewport breakpoints) so the layout adapts to the space actually available — correct even when a consumer page has its own decoration (e.g. a site-wide left nav) eating into the viewport.
6+
7+
`docsNav` and `docsPageNav` are **not** slots — they're rendered internally as `ExpandingPanel` accordions driven by `docsNavItems`/`docsPageNavItems` props. Only `docsContent` remains a slot, since it's arbitrary page content. The two side panels auto force-open/collapse based on the component's own measured width via `useContainerBreakpoints`, and share a native `<details name>` accordion group only on mobile (see "Breakpoint behaviour" below).
8+
9+
---
10+
11+
## Props reference
12+
13+
| Prop | Type | Default | Notes |
14+
|------|------|---------|-------|
15+
| `tag` | `"div" \| "section" \| "article" \| "main"` | `"div"` | Root element tag. |
16+
| `docsNavItems` | `DocsNavItem[]` | `[]` | Items rendered in the `docsNav` panel. Panel (and its `.docs-nav` wrapper) is omitted entirely when empty. |
17+
| `docsPageNavItems` | `DocsNavItem[]` | `[]` | Items rendered in the `docsPageNav` panel. Panel omitted entirely when empty. |
18+
| `docsNavLabel` | `string` | `"Navigation"` | Heading text for the `docsNav` panel's `#summary`. |
19+
| `docsPageNavLabel` | `string` | `"On this page"` | Heading text for the `docsPageNav` panel's `#summary`. |
20+
| `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the root `.content-docs` element. |
21+
22+
`DocsNavItem` (from `~/types/components`):
23+
24+
```ts
25+
interface DocsNavItem {
26+
label: string;
27+
to: string;
28+
icon?: string; // Icon name, e.g. "lucide:rocket"
29+
}
30+
```
31+
32+
## Model
33+
34+
| Model | Type | Default | Notes |
35+
|-------|------|---------|-------|
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+
| `v-model:activePageNavItem` | `string \| undefined` | `undefined` | Same, for `docsPageNav`. |
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.
40+
41+
---
42+
43+
## Slots
44+
45+
| Slot | Purpose |
46+
|------|---------|
47+
| `#docsContent` | Main page content. Only slot this component exposes. |
48+
49+
---
50+
51+
## Usage example
52+
53+
```vue
54+
<script setup lang="ts">
55+
import type { DocsNavItem } from "~/types/components";
56+
57+
const docsNavItems: DocsNavItem[] = [
58+
{ label: "Getting started", to: "/docs", icon: "lucide:rocket" },
59+
{ label: "Installation", to: "/docs/install" },
60+
];
61+
const docsPageNavItems: DocsNavItem[] = [
62+
{ label: "Overview", to: "/docs#overview" },
63+
];
64+
65+
const activeNavItem = ref<string | undefined>(docsNavItems[0]?.to);
66+
const activePageNavItem = ref<string | undefined>(undefined);
67+
</script>
68+
69+
<template>
70+
<ContentDocs
71+
v-model:active-nav-item="activeNavItem"
72+
v-model:active-page-nav-item="activePageNavItem"
73+
:docs-nav-items="docsNavItems"
74+
:docs-page-nav-items="docsPageNavItems"
75+
>
76+
<template #docsContent>
77+
<h1>Page title</h1>
78+
<p>Page content.</p>
79+
</template>
80+
</ContentDocs>
81+
</template>
82+
```
83+
84+
---
85+
86+
## Breakpoint behaviour
87+
88+
Widths are measured on the component's own root element (`useContainerBreakpoints`, container-name `contentDocs`), **not** the viewport — this is deliberate, so nested page decoration (nav rails, sidebars) that shrinks the actual available space is correctly accounted for. Thresholds: `tablet: 768px`, `desktop: 1024px`, matching the `@container contentDocs` queries in this component's own `<style>` block. If you change one, change the other.
89+
90+
| Width | `docsNav` | `docsPageNav` |
91+
|-------|-----------|----------------|
92+
| < 768px (mobile) | collapsible, closed by default | collapsible, closed by default |
93+
| 768–1023px (tablet) | collapsible, closed by default | **forced open**, no toggle icon |
94+
| ≥ 1024px (desktop) | **forced open**, no toggle icon | **forced open**, no toggle icon |
95+
96+
### Why the two panels share a `name` only on mobile
97+
98+
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.
99+
100+
### Related fix in ExpandingPanel.vue
101+
102+
Building this component's forced-open/forced-closed cycling surfaced a real bug in `ExpandingPanel.vue`: `forceOpened` driving the native `open` attribute fires the element's own `toggle` event, which — before the fix — leaked into `isPanelOpen` (the user-click model), leaving the panel permanently "remembered open" even after `forceOpened` reverted to `false`. Fixed by ignoring toggle events while `forceOpened` is `true`. See `expanding-panel.md` and the `ExpandingPanel.spec.ts` regression test ("does not leak forceOpened into isPanelOpen...").
103+
104+
---
105+
106+
## Icons
107+
108+
`DocsNavItem.icon` is optional (any icon name resolvable by `<Icon>`, e.g. Lucide set: `"lucide:rocket"`). Items without an icon still align correctly with icon-bearing items — the link is `display: grid` with a fixed-width icon column (`--docs-nav-link-icon-size`), not `flex`, so an absent icon doesn't collapse the label leftward.
109+
110+
To move the icon to the end of the link instead of the start, set `--docs-nav-link-icon-order` (or `--docs-page-nav-link-icon-order`) to `rtl` (default `ltr`). This uses a `direction` flip to mirror which physical side the fixed-width column renders on, rather than swapping `grid-column` values directly — swapping columns would put the label into the icon-sized track and squeeze it. The icon and label content reset `direction: ltr` internally so text/glyphs don't visually mirror.
111+
112+
---
113+
114+
## CSS Token Customization
115+
116+
All `--content-docs-*` tokens can be overridden at global, page, or instance scope. Each has a **shared** version (applies to both `docsNav` and `docsPageNav`) and a **per-side** override (`content-docs-nav-*` / `content-docs-page-nav-*`) that falls back to the shared token if unset.
117+
118+
**Heading tokens** (shared: `--content-docs-heading-*`, per-side: `--content-docs-{nav,page-nav}-heading-*`):
119+
- `-font-size`, `-font-weight`, `-color`, `-bg`, `-margin`, `-padding-block`, `-padding-inline`
120+
121+
**Panel tokens** (shared: `--content-docs-panel-*`, per-side: `--content-docs-{nav,page-nav}-panel-bg`):
122+
- `-bg` (default: `light-dark(var(--slate-00), var(--slate-10))`, the project's standard card-surface token), `-padding-block`, `-padding-inline`, `-border-radius`
123+
124+
**Link tokens** (shared: `--content-docs-link-*`, per-side: `--content-docs-{nav,page-nav}-link-*`):
125+
- `-font-size`, `-padding-block`, `-padding-inline`, `-margin-block`, `-border-radius`, `-color`, `-bg`, `-hover-bg`, `-hover-color`, `-active-bg`, `-active-color`
126+
127+
**Column-width tokens** (fixed-width grid tracks at tablet/desktop):
128+
- `--content-docs-nav-column-width` (default `23rem`, desktop `docsNav` track)
129+
- `--content-docs-page-nav-column-width` (default `22rem`, desktop `docsPageNav` track)
130+
- `--content-docs-page-nav-column-width-tablet` (default `20rem`, tablet's single fixed track — `docsNav` is full-width at tablet)
131+
132+
**Icon tokens** (not `content-docs-` prefixed — shared with the link, not per-side by default):
133+
- `--docs-nav-link-icon-gap`, `--docs-nav-link-icon-size`, `--docs-nav-link-icon-order` (`ltr`/`rtl`), `--docs-page-nav-link-icon-order`
134+
135+
---
136+
137+
## Local style override scaffold
138+
139+
```vue
140+
<ContentDocs :style-class-passthrough="['my-docs']" ...>
141+
...
142+
</ContentDocs>
143+
144+
<style>
145+
.content-docs {
146+
&.my-docs {
147+
--content-docs-panel-bg: var(--surface-2);
148+
--content-docs-link-active-bg: var(--brand-01);
149+
--content-docs-link-active-color: var(--brand-10);
150+
}
151+
}
152+
</style>
153+
```
154+
155+
---
156+
157+
## Notes
158+
159+
- `docsContent` visibility is slot-detected (`useSlots().docsContent`); `docsNav`/`docsPageNav` visibility is item-array-length-detected (`docsNavItems.length > 0`) — different mechanisms, since only `docsContent` is still a real slot.
160+
- `NuxtLink` resolved via `resolveComponent("NuxtLink")`, not imported from `#components` — required so this component works inside Storybook (see `feedback_no_components_import_storybook`).
161+
- Auto-imported in Nuxt — no manual import needed.
162+
- File: `app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue`
163+
- Types: `app/types/components/content-docs.d.ts` (`DocsNavItem`)
164+
- Tests: `app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts`
165+
- Demo page: `app/pages/ui/layout-content-docs.vue`

.claude/skills/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
7373
├── contact-section.md — ContactSection props (stepperIndicatorSize pass-through), 3-item info+form layout, slot API
7474
├── stepper-list.md — StepperList dynamic slots (item-{n}/indicator-{n}), props, connector behaviour
7575
├── expanding-panel.md — ExpandingPanel v-model, forceOpened, contentIsOnTop overlay mode, slots (summary/icon/content), ARIA wiring, CSS token API
76+
├── content-docs.md — ContentDocs docs-page shell: prop-driven docsNav/docsPageNav (not slots), DocsNavItem icons, container-width breakpoint behaviour, shared/per-side CSS token API
7677
├── glass-panel.md — GlassPanel props, slots, CSS token API (--glass-panel-bg/border-color/shadow/highlight), theming override
7778
├── navigation-horizontal.md — NavigationHorizontal props, NavItemData type, CSS token API, import path gotcha
7879
├── pricing-card.md — PricingCard: SaaS-style plan card with highlight, feature list, #cta slot for button customization, CSS token API

.vscode/srcdev-component-content-docs.code-snippets

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@
9999
" --content-docs-link-hover-bg: $11;",
100100
" --content-docs-link-active-bg: $12;",
101101
" --content-docs-link-active-color: $13;",
102+
"",
103+
" --content-docs-nav-column-width: $14;",
104+
" --content-docs-page-nav-column-width: $15;",
105+
" --content-docs-page-nav-column-width-tablet: $16;",
102106
" }",
103107
"}"
104108
]

app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
22
<component :is="tag" ref="rootEl" class="content-docs" :class="[elementClasses]">
3-
<div class="inner">
3+
<div class="content-docs-inner">
44
<div v-if="hasDocsNav" class="docs-nav">
55
<ExpandingPanel
66
:name="docsNavPanelName"
@@ -145,6 +145,12 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? "docsPanelGroup" :
145145
--_panel-padding-inline: var(--content-docs-panel-padding-inline, 0.8rem);
146146
--_panel-border-radius: var(--content-docs-panel-border-radius, 0.5rem);
147147
148+
/* Fixed-width grid tracks — the docsNav/docsPageNav columns at tablet/desktop.
149+
docsPageNav gets a narrower track at tablet since docsNav is full-width there. */
150+
--_nav-column-width: var(--content-docs-nav-column-width, 23rem);
151+
--_page-nav-column-width: var(--content-docs-page-nav-column-width, 22rem);
152+
--_page-nav-column-width-tablet: var(--content-docs-page-nav-column-width-tablet, 20rem);
153+
148154
--_link-font-size: var(--content-docs-link-font-size, 1.4rem);
149155
--_link-padding-block: var(--content-docs-link-padding-block, 0.6rem);
150156
--_link-padding-inline: var(--content-docs-link-padding-inline, 0.8rem);
@@ -198,7 +204,7 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? "docsPanelGroup" :
198204
container-type: inline-size;
199205
container-name: contentDocs;
200206
201-
.inner {
207+
.content-docs-inner {
202208
display: grid;
203209
grid-template-areas:
204210
"docsNav"
@@ -212,14 +218,14 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? "docsPanelGroup" :
212218
"docsNav docsPageNav"
213219
"docsContent docsPageNav";
214220
gap: 1.6rem;
215-
grid-template-columns: 1fr 200px;
221+
grid-template-columns: 1fr var(--_page-nav-column-width-tablet);
216222
grid-template-rows: auto 1fr;
217223
}
218224
219225
@container contentDocs (width >= 1024px) {
220226
grid-template-areas: "docsNav docsContent docsPageNav";
221227
gap: 1.6rem;
222-
grid-template-columns: 23rem 1fr 22rem;
228+
grid-template-columns: var(--_nav-column-width) 1fr var(--_page-nav-column-width);
223229
}
224230
225231
.docs-nav {
@@ -255,11 +261,11 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? "docsPanelGroup" :
255261
padding-inline: var(--_page-nav-heading-padding-inline);
256262
}
257263
258-
.docs-nav-list,
259-
.docs-page-nav-list {
264+
.docs-nav-list {
260265
border-radius: var(--_panel-border-radius);
261266
padding-block: var(--_panel-padding-block);
262267
padding-inline: var(--_panel-padding-inline);
268+
background-color: var(--_nav-panel-bg);
263269
264270
ul {
265271
list-style: none;
@@ -270,18 +276,32 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? "docsPanelGroup" :
270276
gap: 0.4rem;
271277
}
272278
}
273-
.docs-nav-list {
274-
background-color: var(--_nav-panel-bg);
275-
}
276279
.docs-page-nav-list {
280+
border-radius: var(--_panel-border-radius);
281+
padding-block: var(--_panel-padding-block);
282+
padding-inline: var(--_panel-padding-inline);
277283
background-color: var(--_page-nav-panel-bg);
284+
285+
ul {
286+
list-style: none;
287+
margin: 0;
288+
padding: 0;
289+
display: flex;
290+
flex-direction: column;
291+
gap: 0.4rem;
292+
}
278293
}
279294
280295
/* Grid (not flex) so the label column stays aligned at a fixed offset whether or
281296
not an item has an icon — an icon-less item's label still starts in column 2,
282-
instead of collapsing back to column 1 like it would with flex. */
283-
.docs-nav-link,
284-
.docs-page-nav-link {
297+
instead of collapsing back to column 1 like it would with flex.
298+
299+
Icon at the end: set --docs-nav-link-icon-order to rtl. This mirrors which
300+
physical side each grid column renders on without touching column sizing —
301+
swapping grid-column values directly would leave the label squeezed into the
302+
icon-sized track instead. The label/icon direction resets further down undo
303+
the mirroring for their own content so text and icon glyphs don't visually flip. */
304+
.docs-nav-link {
285305
display: grid;
286306
grid-template-columns: var(--docs-nav-link-icon-size, 1.6rem) 1fr;
287307
align-items: center;
@@ -293,6 +313,8 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? "docsPanelGroup" :
293313
margin-block: var(--_link-margin-block);
294314
background-color: var(--_link-bg);
295315
text-decoration: none;
316+
direction: var(--docs-nav-link-icon-order, ltr);
317+
color: var(--_nav-link-color);
296318
transition:
297319
background-color var(--control-transition-duration, 200ms) var(--control-transition-ease, ease),
298320
color var(--control-transition-duration, 200ms) var(--control-transition-ease, ease);
@@ -301,16 +323,6 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? "docsPanelGroup" :
301323
outline: 0.2rem solid var(--theme-ring, currentcolor);
302324
outline-offset: -0.2rem;
303325
}
304-
}
305-
306-
/* Icon at the end: set --docs-nav-link-icon-order to rtl. This mirrors which
307-
physical side each grid column renders on without touching column sizing —
308-
swapping grid-column values directly would leave the label squeezed into the
309-
icon-sized track instead. The label/icon direction resets below undo the
310-
mirroring for their own content so text and icon glyphs don't visually flip. */
311-
.docs-nav-link {
312-
direction: var(--docs-nav-link-icon-order, ltr);
313-
color: var(--_nav-link-color);
314326
315327
&:hover {
316328
background-color: var(--_nav-link-hover-bg);
@@ -324,8 +336,27 @@ const docsPageNavPanelName = computed(() => (isMobile.value ? "docsPanelGroup" :
324336
}
325337
}
326338
.docs-page-nav-link {
339+
display: grid;
340+
grid-template-columns: var(--docs-nav-link-icon-size, 1.6rem) 1fr;
341+
align-items: center;
342+
gap: var(--docs-nav-link-icon-gap, 0.6rem);
343+
border-radius: var(--_link-border-radius);
344+
font-size: var(--_link-font-size);
345+
padding-block: var(--_link-padding-block);
346+
padding-inline: var(--_link-padding-inline);
347+
margin-block: var(--_link-margin-block);
348+
background-color: var(--_link-bg);
349+
text-decoration: none;
327350
direction: var(--docs-page-nav-link-icon-order, ltr);
328351
color: var(--_page-nav-link-color);
352+
transition:
353+
background-color var(--control-transition-duration, 200ms) var(--control-transition-ease, ease),
354+
color var(--control-transition-duration, 200ms) var(--control-transition-ease, ease);
355+
356+
&:focus-visible {
357+
outline: 0.2rem solid var(--theme-ring, currentcolor);
358+
outline-offset: -0.2rem;
359+
}
329360
330361
&:hover {
331362
background-color: var(--_page-nav-link-hover-bg);

0 commit comments

Comments
 (0)