Nav Item
v0.5.0StableA single navigation link with an optional leading icon and active state. The `active` modifier is applied on the component's own root element (per BEM), so it can be dropped into any nav/sidebar layout.
registry/behivetech/atoms/nav-item
nav-item.tsx
1import { type AnchorHTMLAttributes, type ReactNode } from "react";2import { Slot } from "@radix-ui/react-slot";3import { getClassName } from "@bht-component-registry/class-names";4import styles from "./nav-item.module.scss";56export interface NavItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {7 /** Additional class names to merge with the component root element */8 className?: string;9 /** Leading icon rendered before the content */10 icon?: ReactNode;11 /**12 * Whether this item represents the current route: adds the `--active`13 * modifier and sets `aria-current="page"` unless you pass your own14 * `aria-current`.15 */16 active?: boolean;17 /**18 * Renders the item's props onto its single child element (via Radix `Slot`)19 * instead of an `<a>`, so a router link such as Next's `Link` can be the20 * element. The child supplies the content, so `icon` is ignored.21 */22 asChild?: boolean;23 /** Content to render inside the component */24 children?: ReactNode;25}2627/**28 * Single navigation link with an optional leading icon and active state.29 */30export const NavItem = ({31 className,32 icon,33 active = false,34 asChild = false,35 children,36 "aria-current": ariaCurrent,37 ...rest38}: NavItemProps) => {39 // One value drives both the attribute and the modifier, so they never40 // disagree: `active` means "page" unless the caller passes their own41 // `aria-current`, and any value other than false/"false" turns the pill on.42 const current = ariaCurrent ?? (active ? "page" : undefined);43 const [rootClass, getChildClass] = getClassName({44 className,45 rootClass: "BHT__NavItem",46 modifiers: {47 active: current !== undefined && current !== false && current !== "false",48 },49 styles,50 });5152 const Comp = asChild ? Slot : "a";5354 return (55 <Comp {...rest} aria-current={current} className={rootClass}>56 {asChild ? (57 children58 ) : (59 <>60 {icon ? <span className={getChildClass("icon")}>{icon}</span> : null}61 {children}62 </>63 )}64 </Comp>65 );66};
nav-item.module.scss
1// Material Design 3 navigation drawer destination.2//3// The active item carries the MD3 pill-shaped active indicator4// (`secondary-container`); inactive items sit directly on the drawer in5// `on-surface-variant`. Colors previously came from `--admin-sidebar-*`6// variables that only the CMS admin app defined, so the atom rendered7// unstyled anywhere else.89.BHT__NavItem {10 // `isolation` keeps the state layer's stacking context local to the item.11 position: relative;12 isolation: isolate;13 display: flex;14 align-items: center;15 gap: var(--spacing-2, 0.5rem);16 padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);17 border-radius: var(--md-sys-shape-corner-full, 9999px);18 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));19 text-decoration: none;20 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));21 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);22 font-weight: var(--md-sys-typescale-label-large-weight, 500);23 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);24 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);25 transition:26 background var(--md-sys-motion-duration-short-3, 150ms)27 var(--md-sys-motion-easing-standard, ease),28 color var(--md-sys-motion-duration-short-3, 150ms)29 var(--md-sys-motion-easing-standard, ease);3031 &__icon {32 font-size: var(--md-sys-typescale-body-large-size, 1rem);33 width: 1.125rem;34 text-align: center;35 flex-shrink: 0;36 }3738 // State layer: a veil of the content color, so hover follows the active39 // indicator's `on-secondary-container` as well as the inactive text color.40 &::after {41 content: "";42 position: absolute;43 inset: 0;44 border-radius: inherit;45 background: currentColor;46 opacity: 0;47 pointer-events: none;48 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)49 var(--md-sys-motion-easing-standard, ease);50 }5152 &:hover::after {53 opacity: var(--md-sys-state-hover-opacity, 0.08);54 }5556 &:focus-visible::after {57 opacity: var(--md-sys-state-focus-opacity, 0.1);58 }5960 &:active::after {61 opacity: var(--md-sys-state-pressed-opacity, 0.1);62 }6364 // Touch target: the drawn item is 2.25rem tall, under the 48px floor. This65 // extends the hit area without reflowing the drawer. px, not rem, because a66 // target is sized to the finger, not the type.67 &::before {68 content: "";69 position: absolute;70 left: 0;71 right: 0;72 top: 50%;73 translate: 0 -50%;74 height: 100%;75 min-height: 48px;76 }7778 &:focus-visible {79 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));80 outline-offset: 2px;81 }8283 // This compound previously read `&.NavItem`, which the markup never84 // carries, so the active state never rendered.85 // `active` also sets `aria-current="page"`, so the pill comes from the86 // modifier alone.87 &--active {88 &.BHT__NavItem {89 background: var(90 --md-sys-color-secondary-container,91 var(--color-surface-light)92 );93 color: var(94 --md-sys-color-on-secondary-container,95 var(--color-on-surface)96 );97 }98 }99}
index.ts
1export { NavItem } from "./nav-item.js";2export type { NavItemProps } from "./nav-item.js";
nav-item.example.tsx
1import { NavItem } from "./nav-item.js";23export const BasicNavItem = () => (4 <NavItem href="/pages" icon="☰">5 Pages6 </NavItem>7);89export const ActiveNavItem = () => (10 <NavItem href="/" icon="⊞" active>11 Dashboard12 </NavItem>13);1415/** With `asChild`, a router link renders as the item; `active` marks it as the current route and sets `aria-current="page"`. */16export const AsChildNavItem = () => (17 <NavItem asChild active>18 <a href="/guide">Guide</a>19 </NavItem>20);
nav-item.spec.tsx
1import { render, screen } from "@testing-library/react";2import { NavItem } from "./nav-item.js";34describe("NavItem", () => {5 it("renders children", () => {6 render(<NavItem href="/pages">Hello</NavItem>);7 expect(screen.getByText("Hello")).toBeTruthy();8 });910 it("applies additional className", () => {11 render(12 <NavItem href="/pages" className="custom">13 Content14 </NavItem>,15 );16 expect(screen.getByText("Content").className).toContain("custom");17 });1819 it("renders as a link to the given href", () => {20 render(<NavItem href="/pages">Pages</NavItem>);21 expect(screen.getByRole("link", { name: "Pages" })).toHaveAttribute(22 "href",23 "/pages",24 );25 });2627 it("renders the icon when provided", () => {28 render(29 <NavItem href="/pages" icon="☰">30 Pages31 </NavItem>,32 );33 expect(screen.getByText("☰")).toBeTruthy();34 });3536 it("applies the active modifier class when active", () => {37 render(38 <NavItem href="/" active>39 Dashboard40 </NavItem>,41 );42 expect(screen.getByRole("link").className).toContain("active");43 });4445 it("sets aria-current=page when active and leaves it off otherwise", () => {46 render(47 <>48 <NavItem href="/a" active>49 Current50 </NavItem>51 <NavItem href="/b">Other</NavItem>52 </>,53 );54 expect(screen.getByRole("link", { name: "Current" })).toHaveAttribute(55 "aria-current",56 "page",57 );58 expect(screen.getByRole("link", { name: "Other" })).not.toHaveAttribute(59 "aria-current",60 );61 });6263 it("keeps a caller's own aria-current over the active default", () => {64 render(65 <NavItem href="/a" active aria-current="location">66 Here67 </NavItem>,68 );69 expect(screen.getByRole("link", { name: "Here" })).toHaveAttribute(70 "aria-current",71 "location",72 );73 });7475 it("gives a passed aria-current=page the --active modifier too", () => {76 render(77 <NavItem href="/a" aria-current="page">78 Here79 </NavItem>,80 );81 expect(screen.getByRole("link", { name: "Here" }).className).toContain(82 "--active",83 );84 });8586 it("gives aria-current=true the --active modifier too", () => {87 render(88 <NavItem href="/a" aria-current="true">89 Here90 </NavItem>,91 );92 expect(screen.getByRole("link", { name: "Here" }).className).toContain(93 "--active",94 );95 });9697 it("leaves the --active modifier off when aria-current=false overrides active", () => {98 render(99 <NavItem href="/a" active aria-current={false}>100 Here101 </NavItem>,102 );103 const link = screen.getByRole("link", { name: "Here" });104 expect(link).toHaveAttribute("aria-current", "false");105 expect(link.className).not.toContain("--active");106 });107108 it("renders the given child element instead of an anchor when asChild", () => {109 const RouterLink = ({ to, ...rest }: { to: string; children: string }) => (110 <a data-router-link="true" href={to} {...rest} />111 );112 render(113 <NavItem asChild active>114 <RouterLink to="/guide">Guide</RouterLink>115 </NavItem>,116 );117 const link = screen.getByRole("link", { name: "Guide" });118 expect(link).toHaveAttribute("data-router-link", "true");119 expect(link).toHaveAttribute("href", "/guide");120 expect(link).toHaveAttribute("aria-current", "page");121 });122});