|
| 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` |
0 commit comments