Nav Item
v0.4.0A 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 · depends on @behivetech/get-class-name
nav-item.tsx
1import { type AnchorHTMLAttributes, type ReactNode } from "react";2import { getClassName } from "@behivetech/get-class-name";3import styles from "./nav-item.module.scss";45export interface NavItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {6 /** Additional class names to merge with the component root element */7 className?: string;8 /** Leading icon rendered before the content */9 icon?: ReactNode;10 /** Whether this item represents the current route */11 active?: boolean;12 /** Content to render inside the component */13 children?: ReactNode;14}1516/**17 * Single navigation link with an optional leading icon and active state.18 */19export const NavItem = ({20 className,21 icon,22 active = false,23 children,24 ...rest25}: NavItemProps) => {26 const [rootClass, getChildClass] = getClassName({27 className,28 rootClass: "BHT__NavItem",29 modifiers: { active },30 styles,31 });3233 return (34 <a {...rest} className={rootClass}>35 {icon ? <span className={getChildClass("icon")}>{icon}</span> : null}36 {children}37 </a>38 );39};
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 {86 &.BHT__NavItem {87 background: var(88 --md-sys-color-secondary-container,89 var(--color-surface-light)90 );91 color: var(92 --md-sys-color-on-secondary-container,93 var(--color-on-surface)94 );95 }96 }97}
index.ts
1export { NavItem } from "./nav-item.js";2export type { NavItemProps } from "./nav-item.js";
nav-item.composition.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);
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 });44});