From d5479f9274038d4153cd7108ff10e94226ad2b85 Mon Sep 17 00:00:00 2001 From: Karn Date: Thu, 27 Aug 2026 15:09:55 +0530 Subject: [PATCH] fix: rebuild responsive sidebar navigation --- .changeset/calm-rivers-navigate.md | 6 + apps/docs/src/components/docs-shell.tsx | 79 +++++-- apps/docs/src/components/sidebar-nav.tsx | 84 +++---- apps/docs/src/docs.css | 137 +---------- apps/docs/src/routes/__root.tsx | 2 +- apps/docs/src/routes/components/sidebar.tsx | 4 +- apps/docs/test/browser/contrast.spec.ts | 25 +- .../dowel/src/components/sidebar/index.tsx | 217 ++++++++++++++++++ .../src/components/sidebar/sidebar.stylex.ts | 150 ++++++++++++ .../src/components/sidebar/sidebar.test.tsx | 31 +++ packages/dowel/src/index.ts | 1 + 11 files changed, 527 insertions(+), 209 deletions(-) create mode 100644 .changeset/calm-rivers-navigate.md diff --git a/.changeset/calm-rivers-navigate.md b/.changeset/calm-rivers-navigate.md new file mode 100644 index 0000000..0319513 --- /dev/null +++ b/.changeset/calm-rivers-navigate.md @@ -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. diff --git a/apps/docs/src/components/docs-shell.tsx b/apps/docs/src/components/docs-shell.tsx index c100247..e4672f4 100644 --- a/apps/docs/src/components/docs-shell.tsx +++ b/apps/docs/src/components/docs-shell.tsx @@ -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"; @@ -76,31 +76,62 @@ export function DocsShell({ children }: { children: ReactNode }) { background: "var(--dowel-bg-1)", }} > -
- setNavOpen(!navOpen)} - > - {navOpen ? : } - - - - - - -
- - - - + +
+ + + + } + /> + + + + + +
+ + + + +
-
-
- setNavOpen(false)} /> -
+ + + + + + +
+ Documentation + + + + } + /> +
+ { + setNavOpen(false); + openSearch(); + }} + /> +
+ + setNavOpen(false)} + /> + +
+
+
+
+
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 ( +