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}
/>
</>
);
}
|