feat(design): the NavRail primitive with optional short labels, the flat surfaces onto roles (E28 S3d3, B7/B5)
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 S3d (B7): the nav rail's row. Icon-only, its name is the module's full name (with an unread
|
||||
// count, which was only ever drawn); with the labels on, the name is the short label the user SEES
|
||||
// (WCAG 2.5.3), the full one staying in the tooltip. "You are here" is aria-current plus the tint.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { Bell } from "lucide-react";
|
||||
import { NavRailItem } from "./NavRail";
|
||||
|
||||
vi.mock("../../lib/hints", () => ({ hintsEnabled: () => true, subscribeHints: () => () => {} }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("NavRailItem", () => {
|
||||
it("icon-only: the full name, the count in it, and the current page marked", () => {
|
||||
render(
|
||||
<NavRailItem
|
||||
icon={Bell}
|
||||
label="Notifications"
|
||||
shortLabel="Alerts"
|
||||
showLabel={false}
|
||||
active
|
||||
badge={3}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByRole("button", { name: "Notifications (3)" });
|
||||
expect(row.getAttribute("aria-current")).toBe("page");
|
||||
expect(row.className).toContain("bg-accent/15");
|
||||
expect(row.textContent).not.toContain("Alerts");
|
||||
});
|
||||
|
||||
it("labelled: the name is the visible short label", () => {
|
||||
render(
|
||||
<NavRailItem
|
||||
icon={Bell}
|
||||
label="Notifications"
|
||||
shortLabel="Alerts"
|
||||
showLabel
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByRole("button", { name: "Alerts" });
|
||||
expect(row.getAttribute("aria-current")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import clsx from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Pressable } from "./Pressable";
|
||||
import { SURFACE } from "./surfaces";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
// B7 (docs/DESIGN.md): the app's primary navigation rail. Slim — 3.75rem wide whether or not the
|
||||
// labels are on (they add height, never width) — with "you are here" as a soft accent tint plus a 3px
|
||||
// edge bar, calmer than a solid fill beside the page's own solid accents. Labels under the icons are
|
||||
// a per-user setting (off by default); each module has a SHORT rail label that fits, and the full name
|
||||
// is the tooltip.
|
||||
|
||||
/** The rail's column: the glass panel, the rail's width and its padding. */
|
||||
export function NavRail({
|
||||
label,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
/** The landmark's accessible name ("Primary navigation"). */
|
||||
label: string;
|
||||
/** Placement only (position, height); the look and width are the primitive's. */
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
aria-label={label}
|
||||
className={clsx(SURFACE.panel, "flex w-15 flex-col px-1 py-2.5", className)}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** A short, thick centred rule: the "new section" cue on an icon rail (the system modules). */
|
||||
export function NavRailDivider() {
|
||||
return <div className="mx-auto my-1.5 w-7 shrink-0 border-t-2 border-border" />;
|
||||
}
|
||||
|
||||
/** The shape of one rail row, for a row that is not a page (the account button). */
|
||||
export const RAIL_ROW =
|
||||
"relative flex w-full flex-col items-center justify-center gap-0.5 rounded-lg transition";
|
||||
|
||||
export function NavRailItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
shortLabel,
|
||||
showLabel,
|
||||
active = false,
|
||||
badge,
|
||||
onClick,
|
||||
testId,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
/** The full name: the tooltip, and the accessible name while the label is hidden. */
|
||||
label: string;
|
||||
/** What fits under the icon when labels are on. */
|
||||
shortLabel: string;
|
||||
showLabel: boolean;
|
||||
active?: boolean;
|
||||
/** An unread / in-progress count on the icon's corner (0 hides it). */
|
||||
badge?: number;
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
}) {
|
||||
const shown = !!badge && badge > 0;
|
||||
return (
|
||||
<Tooltip hint={label} side="right" always openDelay={350}>
|
||||
<Pressable
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
// With the label visible the row's name IS that text (WCAG 2.5.3: the spoken name contains the
|
||||
// visible one); the full name stays in the tooltip. Icon-only, the full name is the name.
|
||||
// The count rides in the name too, so "Messages, 3" is heard and not only seen.
|
||||
aria-label={showLabel ? undefined : shown ? `${label} (${badge})` : label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={clsx(
|
||||
RAIL_ROW,
|
||||
showLabel ? "py-1.5" : "h-9",
|
||||
active ? "bg-accent/15 text-accent" : "text-muted state-layer hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-1.5 bottom-1.5 -left-1 w-0.75 rounded-r-full bg-accent"
|
||||
/>
|
||||
)}
|
||||
<span className="relative shrink-0">
|
||||
<Icon className="size-4.5" aria-hidden="true" />
|
||||
{shown && (
|
||||
<span className="absolute -top-2 -right-2 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 text-3xs leading-none font-bold text-accent-fg ring-2 ring-bg">
|
||||
{badge > 9 ? "9+" : badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showLabel && (
|
||||
<span className="max-w-full truncate text-3xs font-medium tracking-tight">
|
||||
{shortLabel}
|
||||
</span>
|
||||
)}
|
||||
</Pressable>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ domain-free.
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
|
||||
|
||||
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
|
||||
link wearing the button face) over `buttonStyles`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
|
||||
Reference in New Issue
Block a user