All files / src/components/layout more-sheet.tsx

100% Statements 34/34
95.83% Branches 23/24
100% Functions 11/11
100% Lines 31/31

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 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195                                    4x           4x           4x             4x 1x           4x                     225x                       15x 15x 15x 15x 15x 225x   180x                     15x 180x   15x       1x                           180x 180x   180x                   180x 15x         1x 1x                   165x     15x                         150x                                   2x                 1x 1x                            
import { Link, useRouterState } from "@tanstack/react-router";
import { LogOut, Monitor, Moon, Sun } from "lucide-react";
import type React from "react";
import { Drawer } from "vaul";
import { useAuth } from "../../lib/auth-context";
import { type Theme, useTheme } from "../../lib/theme-context";
import { cn } from "../../lib/utils";
import {
	BOTTOM_NAV_HREFS,
	flattenNav,
	type NavActionId,
	type NavBadges,
	type ResolvedEntry,
	type ResolvedItem,
	resolveActiveHref,
} from "./nav-config";
import { INDEX_MARK } from "./nav-rail";
 
const themeIcons: Record<Theme, typeof Sun> = {
	light: Sun,
	dark: Moon,
	system: Monitor,
};
 
const themeLabels: Record<Theme, string> = {
	light: "Light",
	dark: "Dark",
	system: "System",
};
 
const themeCycle: Record<Theme, Theme> = {
	light: "dark",
	dark: "system",
	system: "light",
};
 
/** Non-navigating rows. One place, so the sheet has no per-item branches. */
const ACTIONS: Record<NavActionId, () => void> = {
	"open-feedback": () => window.dispatchEvent(new CustomEvent("open-feedback")),
};
 
// vaul 1.x PortalProps derives from ComponentPropsWithoutRef which drops
// children in React 19 — cast once here rather than suppressing at every site.
const DrawerPortal =
	Drawer.Portal as React.ComponentType<React.PropsWithChildren>;
 
export type MoreSheetProps = {
	open: boolean;
	onClose: () => void;
	/** The resolved pro nav; the four bottom slots are filtered out. */
	entries: ResolvedEntry[];
	badges?: NavBadges;
};
 
function isSlot(item: ResolvedItem): boolean {
	return (
		item.kind !== "action" &&
		BOTTOM_NAV_HREFS.includes(item.href as (typeof BOTTOM_NAV_HREFS)[number])
	);
}
 
/**
 * More: everything the four fixed slots do not carry, read off the same nav
 * config the rail renders. Adding a destination to the config puts it here
 * too — which is the point of the config being data.
 */
export function MoreSheet({ open, onClose, entries, badges }: MoreSheetProps) {
	const pathname = useRouterState().location.pathname;
	const { signOut } = useAuth();
	const { theme, setTheme } = useTheme();
	const ThemeIcon = themeIcons[theme];
	const activeHref = resolveActiveHref(entries, pathname);
	const items = flattenNav(entries).filter((item) => !isSlot(item));
 
	const tileClass = (active: boolean) =>
		cn(
			"relative flex min-h-11 items-center gap-2 rounded-sm px-3 py-2 text-sm transition-colors",
			active
				? cn(
						"bg-foreground-default/7 font-semibold text-foreground-default",
						INDEX_MARK,
					)
				: "text-foreground-muted hover:bg-foreground-default/7",
		);
 
	const count = (item: ResolvedItem) =>
		item.kind === "link" && item.badge ? (badges?.[item.badge] ?? 0) : 0;
 
	return (
		<Drawer.Root
			open={open}
			onOpenChange={(next) => {
				Eif (!next) onClose();
			}}
		>
			<DrawerPortal>
				<Drawer.Overlay className="fixed inset-0 z-[55] bg-overlay-light" />
				<Drawer.Content className="fixed inset-x-0 bottom-0 z-[55] rounded-t-md bg-background-elevated pb-safe">
					<div className="mx-auto mt-3 mb-2 h-1 w-10 flex-shrink-0 rounded-full bg-border-default" />
					<Drawer.Title className="sr-only">More options</Drawer.Title>
					<Drawer.Description className="sr-only">
						Everything the four bottom slots do not carry.
					</Drawer.Description>
					<nav aria-label="More navigation" className="px-2 pb-4">
						<ul className="grid grid-cols-2 gap-1">
							{items.map((item) => {
								const active = item.kind === "link" && item.href === activeHref;
								const badge = count(item);
								const label = (
									<>
										<span className="truncate">{item.name}</span>
										{badge > 0 && (
											<span className="ml-auto text-xs tabular-nums">
												{badge > 99 ? "99+" : badge}
											</span>
										)}
									</>
								);
 
								if (item.kind === "action") {
									return (
										<li key={item.action}>
											<button
												type="button"
												onClick={() => {
													onClose();
													ACTIONS[item.action]();
												}}
												className={cn(tileClass(false), "w-full text-left")}
											>
												{label}
											</button>
										</li>
									);
								}
 
								if (item.kind === "external") {
									// Same tab on a phone (#372): tab switching is awkward
									// there and the portal is reachable via Back.
									return (
										<li key={item.href}>
											<a
												href={item.href}
												onClick={onClose}
												className={tileClass(false)}
											>
												{label}
											</a>
										</li>
									);
								}
 
								return (
									<li key={item.href}>
										<Link
											to={item.href}
											onClick={onClose}
											aria-current={active ? "page" : undefined}
											className={tileClass(active)}
										>
											{label}
										</Link>
									</li>
								);
							})}
						</ul>
 
						<div className="mt-3 flex gap-1 border-t border-border-default px-1 pt-3">
							<button
								type="button"
								onClick={() => setTheme(themeCycle[theme])}
								className="flex min-h-11 flex-1 items-center justify-center gap-2 rounded-sm px-2 text-sm text-foreground-muted transition-colors hover:bg-foreground-default/7"
							>
								<ThemeIcon className="size-4" aria-hidden="true" />
								{themeLabels[theme]}
							</button>
							<button
								type="button"
								onClick={() => {
									onClose();
									signOut();
								}}
								className="flex min-h-11 flex-1 items-center justify-center gap-2 rounded-sm px-2 text-sm text-error transition-colors hover:bg-error/10"
							>
								<LogOut className="size-4" aria-hidden="true" />
								Logout
							</button>
						</div>
					</nav>
				</Drawer.Content>
			</DrawerPortal>
		</Drawer.Root>
	);
}