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
6 changes: 6 additions & 0 deletions .changeset/calm-rivers-navigate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@karnstack/dowel": minor
---

Add reusable sidebar navigation sections and items, including active states,
touch-sized targets, icons, suffixes, and nested rows.
79 changes: 55 additions & 24 deletions apps/docs/src/components/docs-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Link, useRouterState } from "@tanstack/react-router";
import { IconButton, Sidebar, Tooltip } from "@karnstack/dowel";
import { Drawer, IconButton, Sidebar, Tooltip } from "@karnstack/dowel";
import type { ReactNode } from "react";

import { SearchTrigger, ThemeToggle, Wordmark } from "./docs-chrome";
Expand Down Expand Up @@ -76,31 +76,62 @@ export function DocsShell({ children }: { children: ReactNode }) {
background: "var(--dowel-bg-1)",
}}
>
<div className="docs-mobile-bar">
<IconButton
label={navOpen ? "Close navigation" : "Open navigation"}
aria-expanded={navOpen}
aria-controls="docs-mobile-nav"
onClick={() => setNavOpen(!navOpen)}
>
{navOpen ? <CloseIcon /> : <MenuIcon />}
</IconButton>

<Link to="/" aria-label="Homepage" className="docs-brand">
<Wordmark />
</Link>

<div className="docs-mobile-actions">
<IconButton label="Search documentation" onClick={openSearch}>
<SearchIcon />
</IconButton>
<ThemeToggle theme={theme} onToggle={toggleTheme} />
<Drawer.Root open={navOpen} onOpenChange={setNavOpen} side="left">
<div className="docs-mobile-bar">
<Drawer.Trigger
render={
<IconButton label="Open navigation">
<MenuIcon />
</IconButton>
}
/>

<Link to="/" aria-label="Homepage" className="docs-brand">
<Wordmark />
</Link>

<div className="docs-mobile-actions">
<IconButton label="Search documentation" onClick={openSearch}>
<SearchIcon />
</IconButton>
<ThemeToggle theme={theme} onToggle={toggleTheme} />
</div>
</div>
</div>

<div className="docs-mobile-nav" id="docs-mobile-nav">
<SidebarNav onNavigate={() => setNavOpen(false)} />
</div>
<Drawer.Portal>
<Drawer.Backdrop />
<Drawer.Viewport>
<Drawer.Popup>
<Drawer.Content>
<Drawer.Header>
<div className="docs-mobile-drawer-heading">
<Drawer.Title>Documentation</Drawer.Title>
<Drawer.Close
render={
<IconButton label="Close navigation">
<CloseIcon />
</IconButton>
}
/>
</div>
<SearchTrigger
onClick={() => {
setNavOpen(false);
openSearch();
}}
/>
</Drawer.Header>
<Drawer.Body>
<SidebarNav
collapseInactive
onNavigate={() => setNavOpen(false)}
/>
</Drawer.Body>
</Drawer.Content>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>

<div className="docs-workspace-bar">
<WorkspaceTitle />
Expand Down
84 changes: 36 additions & 48 deletions apps/docs/src/components/sidebar-nav.tsx
Original file line number Diff line number Diff line change
@@ -1,91 +1,79 @@
import { Link, useRouterState } from "@tanstack/react-router";
import { Sidebar } from "@karnstack/dowel";
import { useEffect, useId, useState } from "react";

import { ChevronRightIcon } from "./icons";
import { nav } from "../lib/nav";
import type { NavSection } from "../lib/nav";

function NavSectionDisclosure({
collapseInactive,
section,
pathname,
onNavigate,
}: {
collapseInactive: boolean;
section: NavSection;
pathname: string;
onNavigate?: () => void;
}) {
const active = section.items.some((item) => item.to === pathname);
const [open, setOpen] = useState(true);
const [open, setOpen] = useState(collapseInactive ? active : true);
const itemsId = useId();

useEffect(() => {
if (active) setOpen(true);
}, [active]);

return (
<section
className="docs-nav-section"
data-open={open ? "" : undefined}
data-active={active ? "" : undefined}
>
<button
type="button"
className="docs-nav-heading"
aria-expanded={open}
aria-controls={itemsId}
onClick={() => setOpen((value) => !value)}
>
<span>{section.title}</span>
<span className="docs-nav-chevron">
<ChevronRightIcon />
</span>
</button>
<div
id={itemsId}
className="docs-nav-items"
aria-hidden={!open}
inert={open ? undefined : true}
>
<ul role="list">
<Sidebar.Section open={open} onOpenChange={setOpen}>
<Sidebar.SectionTrigger active={active} aria-controls={itemsId}>
{section.title}
</Sidebar.SectionTrigger>
<Sidebar.SectionPanel id={itemsId}>
<Sidebar.SectionContent>
{section.items.map((item) => (
<li key={item.to}>
<Link
to={item.to}
onClick={onNavigate}
// Without `exact`, "/" matches every route and the
// Introduction link stays highlighted on every page.
activeOptions={{ exact: true }}
>
{item.title}
</Link>
</li>
<Sidebar.Item
key={item.to}
active={item.to === pathname}
render={
<Link
to={item.to}
onClick={onNavigate}
activeOptions={{ exact: true }}
/>
}
>
{item.title}
</Sidebar.Item>
))}
</ul>
</div>
</section>
</Sidebar.SectionContent>
</Sidebar.SectionPanel>
</Sidebar.Section>
);
}

/**
* One nav, rendered twice: once in the sticky desktop sidebar and once in the
* mobile disclosure panel. Sharing the component is what stops the two from
* listing different components, which is the usual way a mobile menu rots.
*/
export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
export function SidebarNav({
collapseInactive = false,
onNavigate,
}: {
collapseInactive?: boolean;
onNavigate?: () => void;
}) {
const pathname = useRouterState({
select: (state) => state.location.pathname,
});

return (
<nav className="docs-nav" aria-label="Documentation">
<Sidebar.Nav aria-label="Documentation">
{nav.map((section) => (
<NavSectionDisclosure
key={section.title}
collapseInactive={collapseInactive}
section={section}
pathname={pathname}
onNavigate={onNavigate}
/>
))}
</nav>
</Sidebar.Nav>
);
}
137 changes: 5 additions & 132 deletions apps/docs/src/docs.css
Original file line number Diff line number Diff line change
Expand Up @@ -181,124 +181,6 @@ body {
gap: var(--dowel-space-3);
}

/* ------------------------------------------------------------------- nav */

.docs-nav-section {
margin-block-end: var(--dowel-space-1);
transition: margin-block-end var(--dowel-dur-fast) var(--dowel-ease);
}

.docs-nav-section[data-open] {
margin-block-end: var(--dowel-space-4);
}

.docs-nav-heading {
display: flex;
align-items: center;
gap: var(--dowel-space-2);
inline-size: 100%;
min-block-size: 1.75rem;
margin: 0;
padding: 0 var(--dowel-space-3);
border: 0;
border-radius: 6px;
background: transparent;
color: var(--dowel-text-2);
font: inherit;
font-size: 0.6875rem;
font-weight: var(--dowel-fw-medium);
letter-spacing: 0.04em;
text-align: start;
text-transform: uppercase;
cursor: pointer;
}

.docs-nav-heading:hover {
color: var(--dowel-text-1);
background: var(--dowel-bg-2);
}

.docs-nav-heading:focus-visible {
outline: 2px solid var(--dowel-focus);
outline-offset: -2px;
}

.docs-nav-section[data-active] > .docs-nav-heading {
color: var(--dowel-text-1);
}

.docs-nav-chevron {
display: inline-flex;
flex: none;
margin-inline-start: auto;
color: var(--dowel-text-3);
transition: transform var(--dowel-dur-fast) var(--dowel-ease);
}

.docs-nav-chevron svg {
block-size: 0.75rem;
inline-size: 0.75rem;
}

.docs-nav-section[data-open] .docs-nav-chevron {
transform: rotate(90deg);
}

.docs-nav-items {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows 180ms cubic-bezier(0.22, 1, 0.36, 1),
opacity var(--dowel-dur-fast) var(--dowel-ease);
}

.docs-nav-section[data-open] .docs-nav-items {
grid-template-rows: 1fr;
opacity: 1;
}

.docs-nav ul {
display: flex;
flex-direction: column;
gap: 1px;
min-block-size: 0;
margin: 0;
overflow: hidden;
padding: var(--dowel-space-1) 0 0 var(--dowel-space-5);
list-style: none;
}

@media (prefers-reduced-motion: reduce) {
.docs-nav-section,
.docs-nav-chevron,
.docs-nav-items {
transition-duration: 0.01ms;
}
}

.docs-nav a {
display: flex;
align-items: center;
min-block-size: 1.875rem;
padding: var(--dowel-space-3) var(--dowel-space-4);
border-radius: 6px;
font-size: var(--dowel-fs-small);
color: var(--dowel-text-2);
text-decoration: none;
}

.docs-nav a:hover {
color: var(--dowel-text-1);
background-color: var(--dowel-bg-2);
}

/* The current page uses only a quiet surface and text contrast. */
.docs-nav a[data-status="active"] {
color: var(--dowel-text-1);
background-color: var(--dowel-bg-2);
}

/* ----------------------------------------------------------------- shell */

.docs-shell {
Expand Down Expand Up @@ -524,19 +406,11 @@ body {
white-space: nowrap;
}

.docs-mobile-nav {
position: absolute;
z-index: 30;
inset: var(--docs-mobile-bar-h) 0 0;
display: none;
flex-direction: column;
overflow-y: auto;
padding: var(--dowel-space-6) var(--dowel-space-4);
background: var(--dowel-bg-1);
}

:root[data-nav-open] .docs-mobile-nav {
.docs-mobile-drawer-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--dowel-space-6);
}

@media (max-width: 63.999rem) {
Expand All @@ -556,8 +430,7 @@ body {
display: flex;
}

.docs-mobile-bar,
:root[data-nav-open] .docs-mobile-nav {
.docs-mobile-bar {
display: none;
}
}
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ function RootDocument() {
);

return (
<html lang="en" data-nav-open={navOpen ? "" : undefined}>
<html lang="en">
<head>
<HeadContent />
</head>
Expand Down
Loading
Loading