diff --git a/packages/demo/src/content/docs/components/nav.mdx b/packages/demo/src/content/docs/components/nav.mdx index d24cadfb..ea8ca998 100644 --- a/packages/demo/src/content/docs/components/nav.mdx +++ b/packages/demo/src/content/docs/components/nav.mdx @@ -374,7 +374,7 @@ function goToLevel(level) { ### Docs Sidebar -Rows built from a tree of folders and pages. Folders around the `current` row open themselves. +Rows built from a tree of folders and pages. Folders around the `current` row open themselves. `@eqtylab/docs` builds its sidebar this way. diff --git a/packages/docs/CHANGELOG.md b/packages/docs/CHANGELOG.md index 312f1f18..2e761db1 100644 --- a/packages/docs/CHANGELOG.md +++ b/packages/docs/CHANGELOG.md @@ -4,6 +4,38 @@ Notable changes to Equality's Docsite Generator are recorded here, following [Ke ## Unreleased +## 0.16.0 - 2026-10-29 + +Needs `@eqtylab/equality` 5.1.0 or later. Sites on Equality 4 bump it with this release; none of +our docs sites use the components 5.0 removed. + +### Changed + +- The sidebar is built from Equality's Nav parts. Rows are 36px tall (were 32px), every folder is a + group that opens and closes, and only top-level rows show their icon. Top-level folders lead in + primary text; folders inside them read as rows. +- The current page's row is grey instead of lilac, and search's selected result matches it. +- A group's header works once the sidebar's script has loaded. The sidebar starts it at the page's + first idle moment, or half a second after the page is ready at most; before that, a click does + nothing. Groups that hold the current page still render open. With JavaScript off, every group + shows open. +- A sidebar link's `attrs` pass only `data-*`, `aria-*` and `id`; anything else is dropped. An + `aria-*` set to `true` renders as `"true"`. +- A sidebar link to another site shows Equality's ↗ and tells screen readers it opens in a new + tab. +- The site card's rows, the header's links and the landing page's links hover with the same tint + as the sidebar's rows, and the card's arrows darken with them. They used the page colour, which + was darker than its surface in dark mode. + +### Removed + +- `scripts/eq-nav-group.ts`, which animated the old `
` groups. + +### Fixed + +- Links and inline code inside tables and alerts look like links and code again. Both had lost + their styling, so a link in a table read as plain text. + ## 0.15.3 - 2026-10-28 ### Added diff --git a/packages/docs/README.md b/packages/docs/README.md index 9b08fe93..54ce4a91 100644 --- a/packages/docs/README.md +++ b/packages/docs/README.md @@ -218,6 +218,14 @@ collapsed: false The content root's `index.mdx` is the site landing page and stays out of the sidebar. - A typo in `order` produces a build **warning naming the file and the token** rather than silently reordering, and the schema is `.strict()` so an unknown key fails the build. +- `icon` on a top-level folder (`_group.yaml`) or page (frontmatter) shows in front of its label. + Icons show on top-level rows only. Give every top-level folder and page one, or none: a mix + leaves the rows' text out of line. +- `badge` marks a folder or page with a status, such as `{ text: Beta, variant: warning }`. It + shows after the label at any depth, and screen readers read it with the label. `deprecated: true` + adds one for you. +- A link in a folder's `links` can carry `attrs`, such as `{ data-track: status }`. Only `data-*`, + `aria-*` and `id` reach the page; anything else is dropped. ## Linking between pages diff --git a/packages/docs/package.json b/packages/docs/package.json index bc0f743c..6c08decd 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,7 +1,7 @@ { "name": "@eqtylab/docs", "description": "Astro documentation framework built on the Equality design system", - "version": "0.15.3", + "version": "0.16.0", "license": "Apache-2.0", "homepage": "https://equality.eqtylab.io/", "repository": { @@ -93,7 +93,7 @@ "build:types": "tsc -p tsconfig.build.json", "dev": "node scripts/dev.mjs", "check:source": "node scripts/check-tailwind-source.mjs", - "test": "node --test tests/*.test.ts", + "test": "node --test tests/*.test.ts && vitest run", "release": "pnpm run build && pnpm publish --access public --no-git-checks" }, "dependencies": { @@ -118,7 +118,7 @@ "peerDependencies": { "@astrojs/mdx": "^8.0.0", "@astrojs/react": "^7.0.0", - "@eqtylab/equality": "^4.1.0", + "@eqtylab/equality": "^5.1.0", "astro": "^7.2.10", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0" @@ -133,6 +133,7 @@ "react-dom": "^19.2.0", "tsup": "^8.5.0", "typescript": ">=5.4.x", - "vite": "^8.3.1" + "vite": "^8.3.1", + "vitest": "^5.0.1" } } diff --git a/packages/docs/src/runtime/chrome/DocsNavList.tsx b/packages/docs/src/runtime/chrome/DocsNavList.tsx new file mode 100644 index 00000000..7f37535c --- /dev/null +++ b/packages/docs/src/runtime/chrome/DocsNavList.tsx @@ -0,0 +1,67 @@ +import type { NavNode } from '@eqtylab/docs/types'; +import { + Badge, + Icon, + NavList, + NavListExpandable, + NavListExpandableContent, + NavListExpandableTrigger, + NavListGroup, + NavListItem, +} from '@eqtylab/equality'; + +export function DocsNavList({ nodes }: { nodes: NavNode[] }) { + return ( + + + + + + ); +} + +function Rows({ nodes, depth }: { nodes: NavNode[]; depth: number }) { + return ( + <> + {nodes.map((node, index) => { + const prefix = depth === 0 && node.icon ? : undefined; + const suffix = node.badge && ( + + {node.badge.text} + + ); + + return node.kind === 'group' && node.children?.length ? ( + + + + + + + ) : ( + + ); + })} + + ); +} + +/** Only data-, aria- and id pass. React renders the row: a `style` string from config throws and + * fails the site's build, and a `class` would replace the row's own. */ +function linkAttrs(attrs: NavNode['attrs']) { + return Object.fromEntries( + Object.entries(attrs ?? {}) + .filter(([key]) => key === 'id' || key.startsWith('data-') || key.startsWith('aria-')) + .filter(([, value]) => value !== false) + .map(([key, value]) => [key, value === true && !key.startsWith('aria-') ? '' : String(value)]) + ); +} diff --git a/packages/docs/src/runtime/chrome/GlobalSearch.tsx b/packages/docs/src/runtime/chrome/GlobalSearch.tsx index e6ed1d1b..0740f0b4 100644 --- a/packages/docs/src/runtime/chrome/GlobalSearch.tsx +++ b/packages/docs/src/runtime/chrome/GlobalSearch.tsx @@ -26,14 +26,13 @@ import styles from './GlobalSearch.module.css'; * shrinks. Utilities setting a property Equality leaves alone need nothing. */ -/** Equality styles the hover but not cmdk's selected row. */ -const ITEM = - 'cursor-pointer data-[selected=true]:bg-lilac-300/50 dark:data-[selected=true]:bg-lilac-600/50'; +/** Equality styles the hover but not cmdk's selected row. It takes the sidebar's current-row fill, + * so the row a reader lands on looks the same in both places. */ +const ITEM = 'cursor-pointer data-[selected=true]:bg-list-item-current'; /** - * Equality has no highlight token, so these name steps off the option scale directly. Not - * `brand-primary`: it is the selected row's colour, so a mark painted with it vanishes exactly - * when a row is selected. Each theme names its own step because `lilac-*` carries no dark values. + * Equality has no highlight token, so these name steps off the option scale directly. Each theme + * names its own step because `lilac-*` carries no dark values. */ const MARK = 'rounded bg-lilac-200 px-0.5 text-lilac-800 dark:bg-lilac-700 dark:text-lilac-200'; diff --git a/packages/docs/src/runtime/chrome/NavTree.astro b/packages/docs/src/runtime/chrome/NavTree.astro index 1f9b453e..a7d091f9 100644 --- a/packages/docs/src/runtime/chrome/NavTree.astro +++ b/packages/docs/src/runtime/chrome/NavTree.astro @@ -1,108 +1,32 @@ --- /** - * Recursive sidebar list. Groups are
, so they open with no script and - * nothing remounts on a page load. `eq-nav-group.ts` adds the animation. - * - * Every value here comes from Equality: the dropdown item's geometry and hover, and - * the Separator's colour for the rails. + * The sidebar's rows, one React island on Equality's NavList parts. Groups render open or closed + * from the server, and closed groups keep their links in the HTML. Until the island hydrates, + * a group's header does nothing, so the timeout caps that wait on a busy page. */ import type { NavNode } from '@eqtylab/docs/types'; -import { Badge, Icon } from '@eqtylab/equality'; + +import { DocsNavList } from './DocsNavList.tsx'; interface Props { nodes: NavNode[]; - depth?: number; } -const { nodes, depth = 0 } = Astro.props; -const NavTree = Astro.self; - -// 25px per level. A Tailwind default, not a token: Equality has no nesting value. -const list = - depth === 0 ? 'space-y-1' : 'border-border ml-3 mt-1 space-y-0.5 border-l pl-3'; - -// Equality's dropdown item, minus its `outline-hidden`: a link list needs keyboard -// focus visible, so `focus-ring` stands in. -const row = - 'dropdown-item-hover focus-ring flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 no-underline transition-colors'; - -// `eq-docs-label` sets its own size and colour, so top-level groups must not also get -// `text-sm` or `text-text-primary`. -const groupText = depth === 0 ? 'eq-docs-label' : 'text-sm text-text-primary'; -const groupPointerFocus = - depth === 0 ? 'pointer-focus:text-text-secondary!' : 'pointer-focus:text-text-primary!'; - -// Equality has no selected-row colour, so the current page borrows the hover one. -// Search's selected row must keep matching it. The idle colour is left off a current -// row rather than overridden: two unvariated colour utilities on one element resolve -// by Tailwind's own order, not by the order they are written. -const current = - 'bg-lilac-300/50 text-lilac-700 dark:bg-lilac-600/50 dark:text-lilac-100 font-medium shadow-sm'; +const { nodes } = Astro.props; --- -
    - { - nodes.map((node) => ( -
  • - {node.kind === 'group' && node.children?.length ? ( -
    - {/* pointer-focus undoes Equality's hover firing on plain `:focus`, which - left a clicked header filled as if it were the current page. */} - - - {node.icon && } - {node.label} - {node.badge && ( - - {node.badge.text} - - )} - - {/* The direct-child selector is load-bearing: a plain `group-open` - would also rotate a closed child's chevron inside an open parent. */} - - - -
    - ) : ( - - {node.icon && } - {node.label} - {node.badge && ( - - {node.badge.text} - - )} - {node.external && ( - - )} - - )} -
  • - )) - } -
- -{ - /** Imported here, so one import covers both the sidebar and the drawer. */ -} - + + +{/* Without JavaScript a folder's header never opens it, so every folder shows open */} + diff --git a/packages/docs/src/runtime/chrome/Sidebar.astro b/packages/docs/src/runtime/chrome/Sidebar.astro index 324cdf4e..3174b449 100644 --- a/packages/docs/src/runtime/chrome/Sidebar.astro +++ b/packages/docs/src/runtime/chrome/Sidebar.astro @@ -44,7 +44,7 @@ const { nodes, switcher } = Astro.props; } catch {} }); - const current = sidebar.querySelector('[data-current]'); + const current = sidebar.querySelector('[aria-current="page"]'); if (!current) return; let saved = null; try { diff --git a/packages/docs/src/runtime/chrome/SiteCardControls.tsx b/packages/docs/src/runtime/chrome/SiteCardControls.tsx index f7ab45da..43feccc8 100644 --- a/packages/docs/src/runtime/chrome/SiteCardControls.tsx +++ b/packages/docs/src/runtime/chrome/SiteCardControls.tsx @@ -26,13 +26,14 @@ const NAME = 'eq-display-gradient-sm -my-[0.05em] block w-fit text-base leading-tight font-semibold text-balance forced-colors:text-inherit'; /* Equality's Icon colours and sizes itself in unlayered CSS, which beats any utility; hence `!`. */ -const QUIET_ICON = 'text-text-tertiary! [&_svg]:size-3.5!'; +const QUIET_ICON = + 'text-(color:--eq-list-item-muted-color)! group-hover:text-current! [&_svg]:size-3.5!'; const TAG_ICON = 'text-text-secondary! [&_svg]:size-3.5!'; -/* Side padding must match NavTree's row `px-2` (7px + 1px border), or the text drifts off the - nav labels' left edge. */ -const BOX = 'rounded-lg border border-transparent px-[7px] py-[5px]'; -const ROW = `${BOX} focus-ring hover:bg-background data-[state=open]:border-brand-primary data-[state=open]:bg-background flex w-full items-center text-left transition-colors`; +/* Side padding and border must match NavListItem's, or the text drifts off the nav labels' left + edge. */ +const BOX = 'rounded-lg border border-transparent px-(--eq-list-item-padding-x) py-[5px]'; +const ROW = `${BOX} group focus-ring hover:bg-list-item-hover data-[state=open]:border-brand-primary data-[state=open]:bg-background flex w-full items-center text-left transition-colors`; /* Trigger width, not a 16rem floor: the sidebar's border leaves each row 255px on desktop, and a 16rem menu overhangs its row by a pixel. */ diff --git a/packages/docs/src/runtime/components/Landing.astro b/packages/docs/src/runtime/components/Landing.astro index 049506cc..31f0a574 100644 --- a/packages/docs/src/runtime/components/Landing.astro +++ b/packages/docs/src/runtime/components/Landing.astro @@ -27,7 +27,7 @@ const target = (to: string) => : { href: siteHref(to, paths), external: false }; const linkRow = - 'group/link text-text-secondary hover:text-text-primary hover:bg-background focus-ring -mx-2 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-sm font-medium no-underline transition-colors'; + 'group/link text-text-secondary hover:text-text-primary hover:bg-list-item-hover focus-ring -mx-2 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-sm font-medium no-underline transition-colors'; --- {/* No outline on a landing page, so it owns its width: room for cards two or three across. diff --git a/packages/docs/src/runtime/scripts/eq-nav-drawer.ts b/packages/docs/src/runtime/scripts/eq-nav-drawer.ts index f10d3fc0..0fd6a39b 100644 --- a/packages/docs/src/runtime/scripts/eq-nav-drawer.ts +++ b/packages/docs/src/runtime/scripts/eq-nav-drawer.ts @@ -23,7 +23,7 @@ if (drawer && trigger) { trigger.setAttribute('aria-expanded', String(open)); // The same centring as the sidebar's inline script in Sidebar.astro; keep the two in sync. - const current = drawer.querySelector('[data-current]'); + const current = drawer.querySelector('[aria-current="page"]'); if (open && current) { const top = current.getBoundingClientRect().top - drawer.getBoundingClientRect().top; if (top < 0 || top + current.offsetHeight > drawer.clientHeight) { diff --git a/packages/docs/src/runtime/scripts/eq-nav-group.ts b/packages/docs/src/runtime/scripts/eq-nav-group.ts deleted file mode 100644 index 17e77775..00000000 --- a/packages/docs/src/runtime/scripts/eq-nav-group.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Animates a navigation group open and closed, on top of `
`. - * - * Do not swap this for Equality's `MotionCollapsibleContent`. It measures after mount - * and animates up from zero, so a group that starts open expands on every page load. - * - * Duration and easing are that component's, so the motion still matches. - */ -const DURATION = 300; -const EASING = 'ease-in-out'; - -function wire(group: HTMLDetailsElement) { - const summary = group.querySelector('summary'); - const panel = summary?.nextElementSibling; - if (!summary || !(panel instanceof HTMLElement)) return; - - let running: Animation | null = null; - - summary.addEventListener('click', (event) => { - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; - - // The browser would toggle `open` immediately; the close has to outlive that. - event.preventDefault(); - running?.cancel(); - - const opening = !group.open; - // Measured while open in both directions, so interrupting mid-animation does not - // snap to a stale height. - if (opening) group.open = true; - const height = panel.scrollHeight; - const from = opening ? 0 : panel.getBoundingClientRect().height; - - panel.style.overflow = 'hidden'; - running = panel.animate( - { - height: [`${from}px`, `${opening ? height : 0}px`], - opacity: [opening ? 0 : 1, opening ? 1 : 0], - }, - { duration: DURATION, easing: EASING } - ); - - running.onfinish = () => { - running = null; - panel.style.removeProperty('overflow'); - if (!opening) group.open = false; - }; - }); -} - -for (const group of document.querySelectorAll('details[data-eq-nav-group]')) { - wire(group); -} diff --git a/packages/docs/src/runtime/styles/chrome.css b/packages/docs/src/runtime/styles/chrome.css index ba2b0492..3e7c0f0b 100644 --- a/packages/docs/src/runtime/styles/chrome.css +++ b/packages/docs/src/runtime/styles/chrome.css @@ -32,6 +32,12 @@ ); } +/* Overrides Equality's switcher fade in the docs' own frame, so the site card matches the page + title. Demos in the page keep Equality's, or they would document the wrong default */ +[data-eq-chrome] { + --eq-switcher-name-fade: var(--eq-docs-display-gradient-sm); +} + body { @apply bg-background text-text-primary; } diff --git a/packages/docs/src/runtime/styles/prose.css b/packages/docs/src/runtime/styles/prose.css index 9261a0da..ed64b362 100644 --- a/packages/docs/src/runtime/styles/prose.css +++ b/packages/docs/src/runtime/styles/prose.css @@ -124,4 +124,16 @@ @apply border-border mb-4; } } + + /* Tables and alerts render through Equality, so the guard above skips them. Their cells are + still the page's own markdown: a link there must read as a link. */ + & :where(table[data-eq-chrome], aside[data-eq-chrome]) :where([data-eq-md]) { + &:is(a) { + @apply text-text-primary hover:text-text-link decoration-text-link font-semibold underline decoration-1 underline-offset-4 transition-all; + } + + :not(pre) > &:is(code) { + @apply bg-badge-background-neutral/50 text-badge-text-neutral rounded-sm px-1 py-0.5 font-mono text-[0.875em]; + } + } } diff --git a/packages/docs/src/runtime/styles/utilities.css b/packages/docs/src/runtime/styles/utilities.css index 4d9f7f3e..cf1c63e9 100644 --- a/packages/docs/src/runtime/styles/utilities.css +++ b/packages/docs/src/runtime/styles/utilities.css @@ -10,7 +10,7 @@ * toggle is a one-cell grid, and a display utility in here would collide with theirs. */ @utility eq-nav-control { - @apply text-text-secondary hover:text-text-primary hover:bg-background; + @apply text-text-secondary hover:text-text-primary hover:bg-list-item-hover; @apply h-10 items-center gap-1.5 rounded-md px-3; @apply text-sm font-medium no-underline transition-colors; @apply focus-ring; @@ -48,8 +48,8 @@ } } -/** The display fade for text under 18.66px bold, such as the sidebar card's name. */ +/** The sidebar card's name: Equality's switcher fade, which the docs set to their small display fade. */ @utility eq-display-gradient-sm { @apply eq-display-gradient; - --eq-docs-display-gradient: var(--eq-docs-display-gradient-sm); + --eq-docs-display-gradient: var(--eq-switcher-name-fade); } diff --git a/packages/docs/tests/docs-nav-list.test.tsx b/packages/docs/tests/docs-nav-list.test.tsx new file mode 100644 index 00000000..1e97ad4f --- /dev/null +++ b/packages/docs/tests/docs-nav-list.test.tsx @@ -0,0 +1,142 @@ +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { DocsNavList } from '../src/runtime/chrome/DocsNavList.tsx'; +import type { NavNode } from '../src/types.ts'; + +const nodes: NavNode[] = [ + { kind: 'page', name: 'index', label: 'Introduction', href: '/', icon: 'House' }, + { + kind: 'group', + name: 'components', + label: 'Components', + open: true, + children: [ + { + kind: 'page', + name: 'button', + label: 'Button', + href: '/components/button', + icon: 'MousePointerClick', + current: true, + badge: { text: 'New', variant: 'primary' }, + }, + { + kind: 'group', + name: 'forms', + label: 'Forms', + icon: 'Layers', + open: true, + children: [ + { + kind: 'page', + name: 'input', + label: 'Input', + href: '/components/forms/input', + icon: 'TextCursorInput', + }, + ], + }, + ], + }, + { + kind: 'group', + name: 'guides', + label: 'Guides', + icon: 'BookOpen', + open: false, + children: [{ kind: 'page', name: 'theming', label: 'Theming', href: '/guides/theming' }], + }, + { + kind: 'link', + name: 'github', + label: 'GitHub', + href: 'https://github.com/eqtylab/equality', + external: true, + attrs: { 'data-analytics': 'github' }, + }, +]; + +const html = renderToString(); + +function rowHtml(label: string): string { + const end = html.indexOf(`>${label}`); + const start = html.lastIndexOf('', end)); +} + +function triggerHtml(label: string): string { + const end = html.indexOf(`>${label}`); + return html.slice(html.lastIndexOf('', end)); +} + +describe('DocsNavList', () => { + it('renders the docs tree on NavList parts', () => { + expect(html).toMatch( + /]*aria-current="page"[^>]*href="\/components\/button"|]*href="\/components\/button"[^>]*aria-current="page"/ + ); + expect(html).toContain('data-analytics="github"'); + }); + + it('makes every folder a group that opens and closes, as far down as it goes', () => { + expect(triggerHtml('Components')).toContain('aria-expanded="true"'); + expect(triggerHtml('Forms')).toContain('aria-expanded="true"'); + expect(triggerHtml('Guides')).toContain('aria-expanded="false"'); + expect(html).toContain('data-depth="2"'); + }); + + it('keeps a closed group’s links in the HTML', () => { + expect(html).toContain('href="/guides/theming"'); + }); + + it('puts icons on top-level rows and groups only', () => { + expect(rowHtml('Introduction')).toContain(' { + expect(rowHtml('Button')).toContain('New'); + expect(html).not.toMatch(/]*aria-hidden="true"[^>]*>(?:(?!<\/span>)[\s\S])*New/); + }); + + it('passes only data-, aria- and id attributes from config to a link', () => { + const link = renderToString( + + ); + expect(link).toContain('data-track="status"'); + expect(link).toContain('id="status"'); + expect(link).toContain('aria-describedby="status-note"'); + expect(link).toContain('aria-disabled="true"'); + expect(link).not.toContain('data-off'); + expect(link).not.toContain('loud'); + expect(link).not.toContain('color:red'); + }); + + it('marks an external link once, for sight and for screen readers', () => { + const row = rowHtml('GitHub'); + expect(row.match(/