Behive Tech Component Registry

Nav Item

v0.5.0Stable

A 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.

pnpm add @behivetech/atoms.nav-item

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";
5
6export 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 own
14 * `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 the
20 * 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}
26
27/**
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 ...rest
38}: NavItemProps) => {
39 // One value drives both the attribute and the modifier, so they never
40 // disagree: `active` means "page" unless the caller passes their own
41 // `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 });
51
52 const Comp = asChild ? Slot : "a";
53
54 return (
55 <Comp {...rest} aria-current={current} className={rootClass}>
56 {asChild ? (
57 children
58 ) : (
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 indicator
4// (`secondary-container`); inactive items sit directly on the drawer in
5// `on-surface-variant`. Colors previously came from `--admin-sidebar-*`
6// variables that only the CMS admin app defined, so the atom rendered
7// unstyled anywhere else.
8
9.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);
30
31 &__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 }
37
38 // State layer: a veil of the content color, so hover follows the active
39 // 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 }
51
52 &:hover::after {
53 opacity: var(--md-sys-state-hover-opacity, 0.08);
54 }
55
56 &:focus-visible::after {
57 opacity: var(--md-sys-state-focus-opacity, 0.1);
58 }
59
60 &:active::after {
61 opacity: var(--md-sys-state-pressed-opacity, 0.1);
62 }
63
64 // Touch target: the drawn item is 2.25rem tall, under the 48px floor. This
65 // extends the hit area without reflowing the drawer. px, not rem, because a
66 // 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 }
77
78 &:focus-visible {
79 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));
80 outline-offset: 2px;
81 }
82
83 // This compound previously read `&.NavItem`, which the markup never
84 // carries, so the active state never rendered.
85 // `active` also sets `aria-current="page"`, so the pill comes from the
86 // 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";
2
3export const BasicNavItem = () => (
4 <NavItem href="/pages" icon="☰">
5 Pages
6 </NavItem>
7);
8
9export const ActiveNavItem = () => (
10 <NavItem href="/" icon="⊞" active>
11 Dashboard
12 </NavItem>
13);
14
15/** 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";
3
4describe("NavItem", () => {
5 it("renders children", () => {
6 render(<NavItem href="/pages">Hello</NavItem>);
7 expect(screen.getByText("Hello")).toBeTruthy();
8 });
9
10 it("applies additional className", () => {
11 render(
12 <NavItem href="/pages" className="custom">
13 Content
14 </NavItem>,
15 );
16 expect(screen.getByText("Content").className).toContain("custom");
17 });
18
19 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 });
26
27 it("renders the icon when provided", () => {
28 render(
29 <NavItem href="/pages" icon="☰">
30 Pages
31 </NavItem>,
32 );
33 expect(screen.getByText("☰")).toBeTruthy();
34 });
35
36 it("applies the active modifier class when active", () => {
37 render(
38 <NavItem href="/" active>
39 Dashboard
40 </NavItem>,
41 );
42 expect(screen.getByRole("link").className).toContain("active");
43 });
44
45 it("sets aria-current=page when active and leaves it off otherwise", () => {
46 render(
47 <>
48 <NavItem href="/a" active>
49 Current
50 </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 });
62
63 it("keeps a caller's own aria-current over the active default", () => {
64 render(
65 <NavItem href="/a" active aria-current="location">
66 Here
67 </NavItem>,
68 );
69 expect(screen.getByRole("link", { name: "Here" })).toHaveAttribute(
70 "aria-current",
71 "location",
72 );
73 });
74
75 it("gives a passed aria-current=page the --active modifier too", () => {
76 render(
77 <NavItem href="/a" aria-current="page">
78 Here
79 </NavItem>,
80 );
81 expect(screen.getByRole("link", { name: "Here" }).className).toContain(
82 "--active",
83 );
84 });
85
86 it("gives aria-current=true the --active modifier too", () => {
87 render(
88 <NavItem href="/a" aria-current="true">
89 Here
90 </NavItem>,
91 );
92 expect(screen.getByRole("link", { name: "Here" }).className).toContain(
93 "--active",
94 );
95 });
96
97 it("leaves the --active modifier off when aria-current=false overrides active", () => {
98 render(
99 <NavItem href="/a" active aria-current={false}>
100 Here
101 </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 });
107
108 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});