Behive Tech Registry

Nav Item

v0.4.0

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 · 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";
4
5export 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}
15
16/**
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 ...rest
25}: NavItemProps) => {
26 const [rootClass, getChildClass] = getClassName({
27 className,
28 rootClass: "BHT__NavItem",
29 modifiers: { active },
30 styles,
31 });
32
33 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 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 {
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";
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);
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});