All files / src/components/layout bottom-nav.tsx

100% Statements 16/16
100% Branches 24/24
100% Functions 8/8
100% Lines 15/15

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143                                                                    4x           12x             64x                             16x 16x 16x       16x     240x             160x         16x                   48x 48x 48x                                           1x                                   1x              
import { Link, useRouterState } from "@tanstack/react-router";
import {
	Home,
	LayoutGrid,
	type LucideIcon,
	MoreHorizontal,
	Target,
} from "lucide-react";
import { useState } from "react";
import { cn } from "../../lib/utils";
import { MoreSheet } from "./more-sheet";
import {
	BOTTOM_NAV_HREFS,
	flattenNav,
	type NavBadgeKey,
	type NavBadges,
	type ResolvedEntry,
	resolveActiveHref,
} from "./nav-config";
import { INDEX_MARK_TOP } from "./nav-rail";
 
/**
 * Four fixed slots plus More (plan §1.9) — the same four for everyone, because
 * there is one role shape in scope: no supervisor rail, no procurement rail.
 * Everything else is behind More.
 *
 * It does NOT hide on scroll. The header does; this is the only way out of a
 * screen.
 */
const SLOTS: {
	name: string;
	href: string;
	icon: LucideIcon;
	badge?: NavBadgeKey;
}[] = [
	{ name: "Today", href: BOTTOM_NAV_HREFS[0], icon: Home, badge: "today" },
	{ name: "Leads", href: BOTTOM_NAV_HREFS[1], icon: Target, badge: "leads" },
	{ name: "Projects", href: BOTTOM_NAV_HREFS[2], icon: LayoutGrid },
];
 
const SLOT_ENTRIES: ResolvedEntry[] = SLOTS.map((slot) => ({
	kind: "link",
	name: slot.name,
	href: slot.href,
}));
 
function slotClass(active: boolean): string {
	return cn(
		"relative flex h-14 w-full flex-col items-center justify-center gap-0.5 text-xs transition-colors",
		active
			? cn("font-semibold text-foreground-default", INDEX_MARK_TOP)
			: "text-foreground-muted",
	);
}
 
export type BottomNavProps = {
	/** The resolved pro nav — what is not in the four slots is in More. */
	entries: ResolvedEntry[];
	badges?: NavBadges;
};
 
export function BottomNav({ entries, badges }: BottomNavProps) {
	const [moreOpen, setMoreOpen] = useState(false);
	const pathname = useRouterState().location.pathname;
	const activeHref = resolveActiveHref(SLOT_ENTRIES, pathname);
 
	// Everything behind More rolls its counts up onto the More slot, so a
	// count is never hidden by the sheet being closed.
	const moreCount = flattenNav(entries)
		.filter(
			(item) =>
				item.kind === "link" &&
				!BOTTOM_NAV_HREFS.includes(
					item.href as (typeof BOTTOM_NAV_HREFS)[number],
				),
		)
		.reduce(
			(total, item) =>
				total +
				(item.kind === "link" && item.badge ? (badges?.[item.badge] ?? 0) : 0),
			0,
		);
 
	return (
		<>
			<nav
				className="fixed inset-x-0 bottom-0 z-50 grid grid-flow-col auto-cols-fr border-t border-border-strong bg-background-elevated pb-safe sm:hidden"
				// Not "Main navigation": the rail carries that name, and two
				// landmarks with the same name are two landmarks you cannot tell
				// apart in a screen reader's list.
				aria-label="Bottom navigation"
			>
				{SLOTS.map((slot) => {
					const active = slot.href === activeHref;
					const count = slot.badge ? (badges?.[slot.badge] ?? 0) : 0;
					return (
						<Link
							key={slot.href}
							to={slot.href}
							aria-current={active ? "page" : undefined}
							className={slotClass(active)}
						>
							<slot.icon className="size-5" aria-hidden="true" />
							<span>
								{slot.name}
								{count > 0 && (
									<span className="ml-1 tabular-nums">
										{count > 99 ? "99+" : count}
									</span>
								)}
							</span>
						</Link>
					);
				})}
 
				<button
					type="button"
					onClick={() => setMoreOpen(true)}
					aria-expanded={moreOpen}
					className={slotClass(activeHref === null)}
				>
					<MoreHorizontal className="size-5" aria-hidden="true" />
					<span>
						More
						{moreCount > 0 && (
							<span className="ml-1 tabular-nums">
								{moreCount > 99 ? "99+" : moreCount}
							</span>
						)}
					</span>
				</button>
			</nav>
 
			<MoreSheet
				open={moreOpen}
				onClose={() => setMoreOpen(false)}
				entries={entries}
				badges={badges}
			/>
		</>
	);
}