diff --git a/apps/docs/src/components/sidebar-nav.tsx b/apps/docs/src/components/sidebar-nav.tsx
index df53e8b..23b9154 100644
--- a/apps/docs/src/components/sidebar-nav.tsx
+++ b/apps/docs/src/components/sidebar-nav.tsx
@@ -1,21 +1,23 @@
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(() => {
@@ -23,69 +25,55 @@ function NavSectionDisclosure({
}, [active]);
return (
-
-
-
-
+
+
+ {section.title}
+
+
+
{section.items.map((item) => (
- -
-
- {item.title}
-
-
+
+ }
+ >
+ {item.title}
+
))}
-
-
-
+
+
+
);
}
-/**
- * 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 (
-
+
);
}
diff --git a/apps/docs/src/docs.css b/apps/docs/src/docs.css
index 6664ccf..91f28f6 100644
--- a/apps/docs/src/docs.css
+++ b/apps/docs/src/docs.css
@@ -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 {
@@ -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) {
@@ -556,8 +430,7 @@ body {
display: flex;
}
- .docs-mobile-bar,
- :root[data-nav-open] .docs-mobile-nav {
+ .docs-mobile-bar {
display: none;
}
}
diff --git a/apps/docs/src/routes/__root.tsx b/apps/docs/src/routes/__root.tsx
index fb6aab8..bdccf0f 100644
--- a/apps/docs/src/routes/__root.tsx
+++ b/apps/docs/src/routes/__root.tsx
@@ -98,7 +98,7 @@ function RootDocument() {
);
return (
-
+
diff --git a/apps/docs/src/routes/components/sidebar.tsx b/apps/docs/src/routes/components/sidebar.tsx
index 8c94d09..1b81bfe 100644
--- a/apps/docs/src/routes/components/sidebar.tsx
+++ b/apps/docs/src/routes/components/sidebar.tsx
@@ -92,7 +92,9 @@ function SidebarDocs() {
Root owns width state. Panel can contain Header, Body, and Footer.
ResizeHandle exposes separator semantics and current pixel bounds.
Content always keeps a zero minimum width so dense application views
- can shrink safely.
+ can shrink safely. Nav, Section, SectionTrigger, SectionPanel, and
+ Item provide the shared navigation structure for desktop rails and
+ mobile drawers.
diff --git a/apps/docs/test/browser/contrast.spec.ts b/apps/docs/test/browser/contrast.spec.ts
index 10325ef..5c3504f 100644
--- a/apps/docs/test/browser/contrast.spec.ts
+++ b/apps/docs/test/browser/contrast.spec.ts
@@ -101,11 +101,30 @@ test("component navigation opens the active category and toggles sections", asyn
).toBeVisible();
await expect(actions).toHaveAttribute("aria-expanded", "true");
+ const actionsSection = actions.locator("xpath=..");
await actions.click();
await expect(actions).toHaveAttribute("aria-expanded", "false");
- await expect(
- actions.locator("xpath=following-sibling::*[1]"),
- ).toHaveAttribute("aria-hidden", "true");
+ await expect(actionsSection).toHaveAttribute("data-closed", "");
+});
+
+test("mobile navigation opens as a focused drawer and closes after navigation", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/components/sidebar");
+ await page.waitForLoadState("networkidle");
+
+ const trigger = page.getByRole("button", { name: "Open navigation" });
+ await trigger.click();
+
+ const drawer = page.getByRole("dialog", { name: "Documentation" });
+ await expect(drawer).toBeVisible();
+ await expect(drawer.locator('[aria-expanded="true"]')).toHaveCount(1);
+
+ await drawer.getByRole("button", { name: "Library" }).click();
+ await drawer.getByRole("link", { name: "Dependencies" }).click();
+ await expect(page).toHaveURL(/\/dependencies$/);
+ await expect(drawer).toBeHidden();
});
test("toast notifications stack and expand on hover", async ({ page }) => {
diff --git a/packages/dowel/src/components/sidebar/index.tsx b/packages/dowel/src/components/sidebar/index.tsx
index 8e3cb47..bc2e953 100644
--- a/packages/dowel/src/components/sidebar/index.tsx
+++ b/packages/dowel/src/components/sidebar/index.tsx
@@ -1,5 +1,9 @@
+import { Collapsible as BaseCollapsible } from "@base-ui/react/collapsible";
+import { useRender } from "@base-ui/react/use-render";
+import { ChevronRightIcon } from "@heroicons/react/16/solid";
import * as stylex from "@stylexjs/stylex";
import {
+ cloneElement,
createContext,
forwardRef,
useCallback,
@@ -11,13 +15,20 @@ import {
import type {
ComponentPropsWithoutRef,
CSSProperties,
+ MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
+ ReactElement,
+ ReactNode,
} from "react";
import * as styles from "./sidebar.stylex";
type DivProps = ComponentPropsWithoutRef<"div">;
type AsideProps = ComponentPropsWithoutRef<"aside">;
+type PublicElementProps
= Omit<
+ ComponentPropsWithoutRef,
+ "className" | "style"
+>;
type SidebarContextValue = {
defaultWidth: number;
@@ -215,6 +226,206 @@ const Content = forwardRef(function SidebarContent(
);
});
+const Nav = forwardRef>(
+ function SidebarNav(props, ref) {
+ const resolved = stylex.props(styles.navigation.root);
+ return (
+
+ );
+ },
+);
+
+type SectionProps = Omit<
+ ComponentPropsWithoutRef,
+ "className" | "style"
+>;
+
+const Section = forwardRef(
+ function SidebarSection(props, ref) {
+ const resolved = stylex.props(styles.navigation.section);
+ return (
+
+ );
+ },
+);
+
+type SectionTriggerProps = Omit<
+ ComponentPropsWithoutRef,
+ "children" | "className" | "render" | "style"
+> & { active?: boolean; children?: ReactNode };
+
+const SectionTrigger = forwardRef(
+ function SidebarSectionTrigger({ active = false, children, ...props }, ref) {
+ const resolved = stylex.props(styles.navigation.trigger);
+ return (
+ {
+ const icon = stylex.props(
+ styles.navigation.chevron,
+ state.open && styles.navigation.chevronOpen,
+ );
+ return (
+
+ );
+ }}
+ />
+ );
+ },
+);
+
+type SectionPanelProps = Omit<
+ ComponentPropsWithoutRef,
+ "className" | "style"
+>;
+
+const SectionPanel = forwardRef(
+ function SidebarSectionPanel(props, ref) {
+ const resolved = stylex.props(styles.navigation.panel);
+ return (
+
+ );
+ },
+);
+
+const SectionContent = forwardRef>(
+ function SidebarSectionContent(props, ref) {
+ const resolved = stylex.props(styles.navigation.sectionContent);
+ return (
+
+ );
+ },
+);
+
+type ItemState = { active: boolean; disabled: boolean };
+
+export interface SidebarItemProps
+ extends Omit<
+ useRender.ComponentProps<"a", ItemState>,
+ "className" | "style"
+ > {
+ active?: boolean;
+ disabled?: boolean;
+ icon?: ReactNode;
+ nested?: boolean;
+ suffix?: ReactNode;
+}
+
+const Item = forwardRef(
+ function SidebarItem(
+ {
+ active = false,
+ children,
+ disabled = false,
+ icon,
+ nested = false,
+ onClick,
+ render,
+ suffix,
+ ...props
+ },
+ ref,
+ ) {
+ const resolved = stylex.props(
+ styles.navigation.item,
+ nested && styles.navigation.itemNested,
+ );
+ const element = useRender({
+ defaultTagName: "a",
+ render,
+ ref,
+ state: { active, disabled },
+ props: {
+ ...props,
+ "aria-current": active ? "page" : undefined,
+ "aria-disabled": disabled || undefined,
+ className: resolved.className,
+ onClick: (event: ReactMouseEvent) => {
+ onClick?.(event);
+ if (disabled && !event.defaultPrevented) event.preventDefault();
+ },
+ style: resolved.style,
+ children: (
+ <>
+ {icon ? (
+
+ {icon}
+
+ ) : null}
+
+ {children}
+
+ {suffix ? (
+
+ {suffix}
+
+ ) : null}
+ >
+ ),
+ },
+ });
+
+ return cloneElement(
+ element as ReactElement<{
+ className?: string;
+ style?: CSSProperties;
+ "data-dowel-part"?: string;
+ }>,
+ {
+ className: resolved.className,
+ style: resolved.style,
+ "data-dowel-part": "sidebar-item",
+ },
+ );
+ },
+);
+
export interface SidebarResizeHandleProps
extends Omit {
/** Pixel change used by each arrow-key press. */
@@ -336,4 +547,10 @@ export const Sidebar = {
Footer,
ResizeHandle,
Content,
+ Nav,
+ Section,
+ SectionTrigger,
+ SectionPanel,
+ SectionContent,
+ Item,
};
diff --git a/packages/dowel/src/components/sidebar/sidebar.stylex.ts b/packages/dowel/src/components/sidebar/sidebar.stylex.ts
index 0b44247..3b42c3a 100644
--- a/packages/dowel/src/components/sidebar/sidebar.stylex.ts
+++ b/packages/dowel/src/components/sidebar/sidebar.stylex.ts
@@ -3,6 +3,8 @@ import * as stylex from "@stylexjs/stylex";
import { tokens } from "../../theme/tokens.stylex";
const COMPACT = "@media (max-width: 1023px)";
+const MOBILE = "@media (max-width: 639px)";
+const COARSE = "@media (pointer: coarse)";
export const parts = stylex.create({
root: {
@@ -155,3 +157,151 @@ export const parts = stylex.create({
overflow: "visible",
},
});
+
+export const navigation = stylex.create({
+ root: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "0.125rem",
+ minWidth: 0,
+ width: "100%",
+ },
+ section: {
+ minWidth: 0,
+ width: "100%",
+ },
+ trigger: {
+ alignItems: "center",
+ backgroundColor: "transparent",
+ borderColor: "transparent",
+ borderRadius: tokens["--dowel-radius-sm"],
+ borderStyle: "solid",
+ borderWidth: tokens["--dowel-hairline"],
+ color: tokens["--dowel-text-secondary"],
+ cursor: "default",
+ display: "flex",
+ fontFamily: tokens["--dowel-font-sans"],
+ fontSize: "0.75rem",
+ fontWeight: 500,
+ gap: "0.375rem",
+ letterSpacing: "-0.00625rem",
+ minHeight: {
+ default: tokens["--dowel-control-md"],
+ [COARSE]: "2.75rem",
+ },
+ outline: "none",
+ paddingInline: "0.5rem",
+ textAlign: "start",
+ width: "100%",
+ ":hover:not(:disabled)": {
+ backgroundColor: tokens["--dowel-bg-hover"],
+ color: tokens["--dowel-text-primary"],
+ },
+ ":focus-visible": {
+ outlineColor: tokens["--dowel-focus-ring"],
+ outlineOffset: "-1px",
+ outlineStyle: "solid",
+ outlineWidth: "1px",
+ },
+ "[data-active]": {
+ color: tokens["--dowel-text-primary"],
+ },
+ },
+ triggerLabel: {
+ minWidth: 0,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ },
+ chevron: {
+ color: tokens["--dowel-text-tertiary"],
+ flexShrink: 0,
+ height: "0.75rem",
+ marginInlineStart: "auto",
+ transform: "rotate(0deg)",
+ width: "0.75rem",
+ },
+ chevronOpen: {
+ transform: "rotate(90deg)",
+ },
+ panel: {
+ height: "var(--collapsible-panel-height)",
+ overflow: "hidden",
+ "[data-starting-style]": { height: 0 },
+ "[data-ending-style]": { height: 0 },
+ },
+ sectionContent: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "0.125rem",
+ paddingBlock: "0.125rem 0.5rem",
+ },
+ item: {
+ alignItems: "center",
+ backgroundColor: "transparent",
+ borderRadius: tokens["--dowel-radius-sm"],
+ color: tokens["--dowel-text-secondary"],
+ cursor: "default",
+ display: "flex",
+ fontFamily: tokens["--dowel-font-sans"],
+ fontSize: {
+ default: "0.8125rem",
+ [MOBILE]: "1rem",
+ },
+ fontWeight: 450,
+ gap: "0.5rem",
+ letterSpacing: "-0.0125rem",
+ minHeight: {
+ default: tokens["--dowel-control-md"],
+ [COARSE]: "2.75rem",
+ },
+ minWidth: 0,
+ outline: "none",
+ paddingInline: "0.5rem",
+ textDecoration: "none",
+ width: "100%",
+ ':hover:not(:disabled):not([aria-disabled="true"])': {
+ backgroundColor: tokens["--dowel-bg-hover"],
+ color: tokens["--dowel-text-primary"],
+ },
+ ":focus-visible": {
+ outlineColor: tokens["--dowel-focus-ring"],
+ outlineOffset: "-1px",
+ outlineStyle: "solid",
+ outlineWidth: "1px",
+ },
+ "[data-active]": {
+ backgroundColor: tokens["--dowel-bg-active"],
+ color: tokens["--dowel-text-primary"],
+ },
+ '[aria-disabled="true"]': {
+ color: tokens["--dowel-text-disabled"],
+ cursor: "not-allowed",
+ },
+ },
+ itemNested: {
+ paddingInlineStart: "1.75rem",
+ },
+ itemIcon: {
+ alignItems: "center",
+ color: tokens["--dowel-text-tertiary"],
+ display: "inline-flex",
+ flexShrink: 0,
+ height: "1rem",
+ justifyContent: "center",
+ width: "1rem",
+ },
+ itemLabel: {
+ minWidth: 0,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ },
+ itemSuffix: {
+ alignItems: "center",
+ color: tokens["--dowel-text-tertiary"],
+ display: "inline-flex",
+ flexShrink: 0,
+ marginInlineStart: "auto",
+ },
+});
diff --git a/packages/dowel/src/components/sidebar/sidebar.test.tsx b/packages/dowel/src/components/sidebar/sidebar.test.tsx
index 4cd8d9f..0749fea 100644
--- a/packages/dowel/src/components/sidebar/sidebar.test.tsx
+++ b/packages/dowel/src/components/sidebar/sidebar.test.tsx
@@ -112,4 +112,35 @@ describe("Sidebar", () => {
const { container } = render();
await expectNoA11yViolations(container);
});
+
+ it("renders collapsible navigation sections and active items", async () => {
+ render(
+
+
+ Projects
+
+
+
+ Active
+
+ Archive
+
+ Disabled
+
+
+
+
+ ,
+ );
+
+ expect(
+ screen.getByRole("link", { name: "Active" }).getAttribute("aria-current"),
+ ).toBe("page");
+ const disabled = screen.getByRole("link", { name: "Disabled" });
+ expect(disabled.getAttribute("aria-disabled")).toBe("true");
+
+ const trigger = screen.getByRole("button", { name: "Projects" });
+ await userEvent.click(trigger);
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ });
});
diff --git a/packages/dowel/src/index.ts b/packages/dowel/src/index.ts
index b77bbc3..64ac677 100644
--- a/packages/dowel/src/index.ts
+++ b/packages/dowel/src/index.ts
@@ -39,6 +39,7 @@ export type { MenuItemProps, MenuItemTone } from "./components/menu";
export { Tooltip } from "./components/tooltip";
export { Sidebar } from "./components/sidebar";
export type {
+ SidebarItemProps,
SidebarResizeHandleProps,
SidebarRootProps,
SidebarVariant,