Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/demo/src/content/docs/components/nav.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<NavListDemo client:only="react" variant="docs-sidebar" />

Expand Down
32 changes: 32 additions & 0 deletions packages/docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<details>` 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
Expand Down
8 changes: 8 additions & 0 deletions packages/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 5 additions & 4 deletions packages/docs/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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": {
Expand All @@ -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"
Expand All @@ -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"
}
}
67 changes: 67 additions & 0 deletions packages/docs/src/runtime/chrome/DocsNavList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<NavList>
<NavListGroup>
<Rows nodes={nodes} depth={0} />
</NavListGroup>
</NavList>
);
}

function Rows({ nodes, depth }: { nodes: NavNode[]; depth: number }) {
return (
<>
{nodes.map((node, index) => {
const prefix = depth === 0 && node.icon ? <Icon icon={node.icon} size="sm" /> : undefined;
const suffix = node.badge && (
<Badge variant={node.badge.variant} size="sm">
{node.badge.text}
</Badge>
);

return node.kind === 'group' && node.children?.length ? (
<NavListExpandable key={`${index}:${node.kind}:${node.name}`} defaultOpen={node.open}>
<NavListExpandableTrigger label={node.label} prefix={prefix} suffix={suffix} />
<NavListExpandableContent>
<Rows nodes={node.children} depth={depth + 1} />
</NavListExpandableContent>
</NavListExpandable>
) : (
<NavListItem
key={`${index}:${node.kind}:${node.name}`}
label={node.label}
href={node.href}
current={node.current}
prefix={prefix}
suffix={suffix}
target={node.external ? '_blank' : undefined}
{...linkAttrs(node.attrs)}
/>
);
})}
</>
);
}

/** 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)])
);
}
11 changes: 5 additions & 6 deletions packages/docs/src/runtime/chrome/GlobalSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
118 changes: 21 additions & 97 deletions packages/docs/src/runtime/chrome/NavTree.astro
Original file line number Diff line number Diff line change
@@ -1,108 +1,32 @@
---
/**
* Recursive sidebar list. Groups are <details>, 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;
---

<ul class={list} data-depth={depth}>
{
nodes.map((node) => (
<li class="list-none">
{node.kind === 'group' && node.children?.length ? (
<details class="block" data-eq-nav-group open={node.open}>
{/* pointer-focus undoes Equality's hover firing on plain `:focus`, which
left a clicked header filled as if it were the current page. */}
<summary
class={`${row} ${groupText} justify-between [&::-webkit-details-marker]:hidden pointer-focus:bg-transparent! ${groupPointerFocus} pointer-focus:shadow-none!`}
>
<span class="flex min-w-0 flex-1 items-center gap-2">
{node.icon && <Icon icon={node.icon} size="sm" />}
<span>{node.label}</span>
{node.badge && (
<Badge variant={node.badge.variant} size="sm" className="shrink-0">
{node.badge.text}
</Badge>
)}
</span>
{/* The direct-child selector is load-bearing: a plain `group-open`
would also rotate a closed child's chevron inside an open parent. */}
<span
class="text-text-secondary shrink-0 transition-transform duration-200 [details[open]>summary_&]:rotate-90"
aria-hidden="true"
>
<Icon icon="ChevronRight" size="xs" />
</span>
</summary>
<NavTree nodes={node.children} depth={depth + 1} />
</details>
) : (
<a
class={`${row} text-sm ${node.current ? current : 'text-text-secondary'}`}
href={node.href}
aria-current={node.current ? 'page' : undefined}
data-current={node.current ? '' : undefined}
target={node.external ? '_blank' : undefined}
rel={node.external ? 'noopener noreferrer' : undefined}
{...(node.attrs ?? {})}
>
{node.icon && <Icon icon={node.icon} size="sm" />}
<span class="min-w-0 flex-1">{node.label}</span>
{node.badge && (
<Badge variant={node.badge.variant} size="sm" className="shrink-0">
{node.badge.text}
</Badge>
)}
{node.external && (
<span class="text-text-secondary shrink-0 opacity-60" aria-hidden="true">
<Icon icon="ArrowUpRight" size="xs" />
</span>
)}
</a>
)}
</li>
))
}
</ul>

{
/** Imported here, so one import covers both the sidebar and the drawer. */
}
<script>
import '@eqtylab/docs/scripts/eq-nav-group.ts';
</script>
<DocsNavList nodes={nodes} client:idle={{ timeout: 500 }} />

{/* Without JavaScript a folder's header never opens it, so every folder shows open */}
<noscript>
<style is:inline>
[data-slot='nav-list'] li[data-state='closed'] > div {
grid-template-rows: 1fr !important;
visibility: visible !important;
}
[data-slot='nav-list'] li[data-state='closed'] > div > div > ul {
opacity: 1 !important;
translate: none !important;
}
</style>
</noscript>
2 changes: 1 addition & 1 deletion packages/docs/src/runtime/chrome/Sidebar.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 6 additions & 5 deletions packages/docs/src/runtime/chrome/SiteCardControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/runtime/components/Landing.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/runtime/scripts/eq-nav-drawer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('[data-current]');
const current = drawer.querySelector<HTMLElement>('[aria-current="page"]');
if (open && current) {
const top = current.getBoundingClientRect().top - drawer.getBoundingClientRect().top;
if (top < 0 || top + current.offsetHeight > drawer.clientHeight) {
Expand Down
Loading
Loading