111 lines
4.6 KiB
TypeScript
111 lines
4.6 KiB
TypeScript
import { useMatches } from "react-router";
|
|
|
|
function cleanTitle(raw: unknown): string {
|
|
return typeof raw === "string" ? (raw.split("|")[0]?.trim() ?? "") : "";
|
|
}
|
|
|
|
export function useTitleData(): string {
|
|
try {
|
|
const titles = useMatches()
|
|
.map((match) => match.data)
|
|
.filter((data: any) => Boolean(data.title) || Boolean(data.meta?.title))
|
|
.map((data: any) => data.title ?? data.meta?.title);
|
|
if (!titles || titles.length === 0) {
|
|
return "";
|
|
}
|
|
return cleanTitle(titles[titles.length - 1]);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export type BreadcrumbTrailEntry = { label: string; href: string };
|
|
|
|
function normalizeCrumbPath(path: string): string {
|
|
return path.length > 1 ? path.replace(/\/+$/, "") : path;
|
|
}
|
|
|
|
/**
|
|
* Should the responsible sidebar entry be rendered as an extra parent crumb in
|
|
* front of the loader-derived trail?
|
|
*
|
|
* No, if it is the current page, or if the trail already covers it — either by
|
|
* label or by target path. The path check matters because a nav entry may carry
|
|
* a section name ("CRM") while the trail names the page it links to
|
|
* ("Kontakte"): rendering both yields two crumbs for one route, and the section
|
|
* crumb then looks like a level that does not exist.
|
|
*
|
|
* Also no, when the current page is itself one of the entry's `contextItems` —
|
|
* i.e. it has its own row in the inner sub-sidenav. That rail already shows
|
|
* which area the page belongs to, so repeating the area as a crumb adds a level
|
|
* the user never navigated through (e.g. "Leads > Lead-Statistiken", where
|
|
* "Leads" just leads back to a sibling of the current page).
|
|
*
|
|
* And no, when the trail itself already links to one of those `contextItems`.
|
|
* A sub-page below such a row is reached through the row, not through the nav
|
|
* entry, so the trail already names the path the user took — the nav crumb only
|
|
* adds the rail's own area on top ("Termine > Buchungen > Buchung" for what is
|
|
* really "Buchungen > Buchung").
|
|
*/
|
|
export function shouldShowParentNavCrumb(args: {
|
|
navPath: string | null | undefined;
|
|
navTitle: string;
|
|
currentPathname: string;
|
|
currentCrumbLabel: string;
|
|
trail: readonly BreadcrumbTrailEntry[];
|
|
navContextPaths?: readonly (string | undefined)[];
|
|
}): boolean {
|
|
if (!args.navPath) return false;
|
|
const navPath = normalizeCrumbPath(args.navPath);
|
|
const currentPath = normalizeCrumbPath(args.currentPathname);
|
|
if (navPath === currentPath) return false;
|
|
const contextPaths = (args.navContextPaths ?? []).filter((path): path is string => Boolean(path)).map(normalizeCrumbPath);
|
|
if (contextPaths.includes(currentPath)) return false;
|
|
if (args.trail.some((crumb) => contextPaths.includes(normalizeCrumbPath(crumb.href ?? "")))) return false;
|
|
if (args.navTitle === args.currentCrumbLabel) return false;
|
|
return !args.trail.some((crumb) => crumb.label === args.navTitle || normalizeCrumbPath(crumb.href ?? "") === navPath);
|
|
}
|
|
|
|
function pushCrumb(trail: BreadcrumbTrailEntry[], entry: BreadcrumbTrailEntry): void {
|
|
if (!entry.label) return;
|
|
// Collapse consecutive duplicates (layout + index route often share a title,
|
|
// and an ancestor may repeat the parent it points at).
|
|
if (trail.length > 0 && trail[trail.length - 1].label === entry.label) {
|
|
trail[trail.length - 1] = entry;
|
|
return;
|
|
}
|
|
trail.push(entry);
|
|
}
|
|
|
|
/**
|
|
* Breadcrumb trail derived from route matches that expose a loader `title`
|
|
* (or `meta.title`). Each entry links to its match's pathname, so sub-pages
|
|
* get a clickable path back to their parent page.
|
|
*
|
|
* A match may also expose `breadcrumbAncestors: { label, href }[]` in its
|
|
* loader data — extra crumbs inserted before the match's own crumb. Sub-areas
|
|
* without their own sidebar entry (e.g. the booking-page editor under
|
|
* /bookings/pages) use this to name the list page they were reached through,
|
|
* since the flat route convention gives them no shared layout to hang a crumb on.
|
|
*/
|
|
export function useBreadcrumbTrail(): BreadcrumbTrailEntry[] {
|
|
try {
|
|
const trail: BreadcrumbTrailEntry[] = [];
|
|
for (const match of useMatches()) {
|
|
const data = match.data as any;
|
|
const ancestors = Array.isArray(data?.breadcrumbAncestors) ? (data.breadcrumbAncestors as BreadcrumbTrailEntry[]) : [];
|
|
for (const ancestor of ancestors) {
|
|
if (typeof ancestor?.label === "string" && typeof ancestor?.href === "string") {
|
|
pushCrumb(trail, { label: ancestor.label, href: ancestor.href });
|
|
}
|
|
}
|
|
const label = cleanTitle(data?.title ?? data?.meta?.title);
|
|
if (!label) continue;
|
|
pushCrumb(trail, { label, href: match.pathname });
|
|
}
|
|
return trail;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|