Browse Source

RHL-022 feat(explorer): add ExplorerBreadcrumbs and SegmentDropdown components for breadcrumb navigation

Code_Uwe 1 month ago
parent
commit
9d9c4226a2

+ 165 - 0
components/explorer/breadcrumbs/ExplorerBreadcrumbs.jsx

@@ -0,0 +1,165 @@
+"use client";
+
+import React from "react";
+import Link from "next/link";
+
+import {
+	Breadcrumb,
+	BreadcrumbItem,
+	BreadcrumbLink,
+	BreadcrumbList,
+	BreadcrumbPage,
+	BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+
+import { yearPath, monthPath } from "@/lib/frontend/routes";
+import { formatMonthLabel } from "@/lib/frontend/explorer/formatters";
+import {
+	buildExplorerDropdownItems,
+	buildBranchCrumbHref,
+} from "@/lib/frontend/explorer/breadcrumbDropdowns";
+
+import SegmentDropdown from "@/components/explorer/breadcrumbs/SegmentDropdown";
+
+/**
+ * ExplorerBreadcrumbs
+ *
+ * Breadcrumb navigation for the Explorer drill-down using shadcn/ui Breadcrumb.
+ * Optional dropdowns allow switching between existing years/months/days.
+ *
+ * UX rules:
+ * - All user-facing strings must be German.
+ * - Dropdowns are only rendered when options are available (non-empty lists).
+ *
+ * @param {{
+ *   branch: string,
+ *   year?: string|null,
+ *   month?: string|null,
+ *   day?: string|null,
+ *   yearOptions?: string[]|null,
+ *   monthOptions?: string[]|null,
+ *   dayOptions?: string[]|null
+ * }} props
+ */
+export default function ExplorerBreadcrumbs({
+	branch,
+	year = null,
+	month = null,
+	day = null,
+	yearOptions = null,
+	monthOptions = null,
+	dayOptions = null,
+}) {
+	const { yearItems, monthItems, dayItems } = buildExplorerDropdownItems({
+		branch,
+		year,
+		month,
+		day,
+		yearOptions,
+		monthOptions,
+		dayOptions,
+	});
+
+	const showYear = Boolean(year);
+	const showMonth = Boolean(year && month);
+	const showDay = Boolean(year && month && day);
+
+	const isBranchCurrent = !year;
+	const isYearCurrent = Boolean(year && !month);
+	const isMonthCurrent = Boolean(year && month && !day);
+
+	return (
+		<Breadcrumb>
+			<BreadcrumbList>
+				{/* Branch */}
+				<BreadcrumbItem>
+					{isBranchCurrent ? (
+						<BreadcrumbPage>{branch}</BreadcrumbPage>
+					) : (
+						<BreadcrumbLink asChild>
+							<Link href={buildBranchCrumbHref(branch)}>{branch}</Link>
+						</BreadcrumbLink>
+					)}
+				</BreadcrumbItem>
+
+				{/* Year */}
+				{showYear ? (
+					<>
+						<BreadcrumbSeparator />
+						<BreadcrumbItem>
+							<div className="flex items-center">
+								{isYearCurrent ? (
+									<BreadcrumbPage>{year}</BreadcrumbPage>
+								) : (
+									<BreadcrumbLink asChild>
+										<Link href={yearPath(branch, year)}>{year}</Link>
+									</BreadcrumbLink>
+								)}
+
+								{yearItems ? (
+									<SegmentDropdown
+										items={yearItems}
+										currentValue={year}
+										menuLabel="Jahr wechseln"
+										triggerAriaLabel="Jahr auswählen"
+									/>
+								) : null}
+							</div>
+						</BreadcrumbItem>
+					</>
+				) : null}
+
+				{/* Month */}
+				{showMonth ? (
+					<>
+						<BreadcrumbSeparator />
+						<BreadcrumbItem>
+							<div className="flex items-center">
+								{isMonthCurrent ? (
+									<BreadcrumbPage>{formatMonthLabel(month)}</BreadcrumbPage>
+								) : (
+									<BreadcrumbLink asChild>
+										<Link href={monthPath(branch, year, month)}>
+											{formatMonthLabel(month)}
+										</Link>
+									</BreadcrumbLink>
+								)}
+
+								{monthItems ? (
+									<SegmentDropdown
+										items={monthItems}
+										currentValue={month}
+										menuLabel="Monat wechseln"
+										triggerAriaLabel="Monat auswählen"
+									/>
+								) : null}
+							</div>
+						</BreadcrumbItem>
+					</>
+				) : null}
+
+				{/* Day */}
+				{showDay ? (
+					<>
+						<BreadcrumbSeparator />
+						<BreadcrumbItem>
+							<div className="flex items-center">
+								{/* Day is always current on the leaf route */}
+								<BreadcrumbPage>{day}</BreadcrumbPage>
+
+								{dayItems ? (
+									<SegmentDropdown
+										items={dayItems}
+										currentValue={day}
+										menuLabel="Tag wechseln"
+										triggerAriaLabel="Tag auswählen"
+									/>
+								) : null}
+							</div>
+						</BreadcrumbItem>
+					</>
+				) : null}
+			</BreadcrumbList>
+		</Breadcrumb>
+	);
+}

+ 91 - 0
components/explorer/breadcrumbs/SegmentDropdown.jsx

@@ -0,0 +1,91 @@
+"use client";
+
+import React from "react";
+import { useRouter } from "next/navigation";
+import { Check, ChevronDown } from "lucide-react";
+
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuItem,
+	DropdownMenuLabel,
+	DropdownMenuSeparator,
+	DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+/**
+ * SegmentDropdown
+ *
+ * A small dropdown trigger used next to breadcrumb segments to switch between
+ * existing years/months/days.
+ *
+ * IMPORTANT:
+ * - This component MUST be declared at module scope (not inside another component render),
+ *   otherwise React will treat it as a new component type on each render and warn/error.
+ *
+ * UX rule:
+ * - All visible text is provided by the parent in German.
+ *
+ * @param {{
+ *   items: Array<{ value: string, label: string, href: string }>,
+ *   currentValue: string|null,
+ *   menuLabel: string,
+ *   triggerAriaLabel: string
+ * }} props
+ */
+export default function SegmentDropdown({
+	items,
+	currentValue,
+	menuLabel,
+	triggerAriaLabel,
+}) {
+	const router = useRouter();
+
+	if (!Array.isArray(items) || items.length === 0) return null;
+
+	return (
+		<DropdownMenu>
+			<DropdownMenuTrigger asChild>
+				<button
+					type="button"
+					className="ml-1 inline-flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-xs hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring/50"
+					aria-label={triggerAriaLabel}
+					title={triggerAriaLabel}
+				>
+					<ChevronDown className="h-4 w-4" aria-hidden="true" />
+				</button>
+			</DropdownMenuTrigger>
+
+			<DropdownMenuContent align="start" className="max-h-72 overflow-auto">
+				<DropdownMenuLabel>{menuLabel}</DropdownMenuLabel>
+				<DropdownMenuSeparator />
+
+				{items.map((it) => {
+					const isActive =
+						currentValue && String(it.value) === String(currentValue);
+
+					return (
+						<DropdownMenuItem
+							key={it.href}
+							className="cursor-pointer"
+							onSelect={(e) => {
+								// Radix fires onSelect for click + keyboard. Prevent default for consistent behavior.
+								e.preventDefault();
+								router.push(it.href);
+							}}
+						>
+							<span className="flex items-center gap-2">
+								{isActive ? (
+									<Check className="h-4 w-4" aria-hidden="true" />
+								) : (
+									<span className="h-4 w-4" aria-hidden="true" />
+								)}
+								<span>{it.label}</span>
+							</span>
+						</DropdownMenuItem>
+					);
+				})}
+			</DropdownMenuContent>
+		</DropdownMenu>
+	);
+}