import "./Toolbar.css";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Link, type To } from "react-router";
/**
* The named regions of the site toolbar an app can render into.
*
* `menu-toggle` sits at the very start of the toolbar, before the logo: it is where an app that
* has a navigation drawer of its own hangs the button that opens it. That belongs on the same
* edge as the drawer it controls — a toggle in `actions`, at the opposite end of the toolbar,
* reads as unrelated to the panel that slides in on the left.
*/
export type ToolbarPortalSlot = "menu-toggle" | "breadcrumbs" | "search" | "actions";
export type ToolbarPortalProps = {
slot: ToolbarPortalSlot;
children: React.ReactNode;
};
export function ToolbarPortal({ slot, children }: ToolbarPortalProps) {
// Target element IDs: ob-toolbar-menu-toggle, ob-toolbar-breadcrumbs, ob-toolbar-search,
// ob-toolbar-actions. Older templates do not render every slot, so a missing one is not an
// error — the app simply renders nothing there.
const target = document.getElementById(`ob-toolbar-${slot}`);
if (!target) return null;
return createPortal(children, target);
}
export type BreadcrumbItem = {
label: string;
/**
* A route inside the app, followed without reloading the page. Resolved through the router, so
* it is relative to the app's basename — the article root is `"/"`, not `"/my-article"`.
*
* Requires the breadcrumbs to be rendered inside a router, which the portal preserves.
*/
to?: To;
/**
* A plain URL, followed with a full page load. Use it to leave the app — the front page, or
* another article. `to` wins when both are given.
*/
href?: string;
};
/**
* A crumb the user can follow. `to` goes through the router; `href` is an ordinary link.
*
* Either way it renders a real anchor with a real `href`, so ctrl-click, middle-click, "open in
* new tab" and the status bar all keep working — `Link` only intercepts a plain left click.
*/
function BreadcrumbLink({ item }: { item: BreadcrumbItem }) {
if (item.to != null) return {item.label};
if (item.href) return {item.label};
return {item.label};
}
export type ToolbarBreadcrumbsProps = {
items: BreadcrumbItem[];
};
function BreadcrumbsInner({ items }: ToolbarBreadcrumbsProps) {
let [firstVisible, setFirstVisible] = useState(0);
let [overflowOpen, setOverflowOpen] = useState(false);
let navRef = useRef(null);
let listRef = useRef(null);
let overflowItemRef = useRef(null);
// Detect overflow: increment firstVisible until the list fits within the nav.
// Runs after every render so it keeps pushing items into overflow until the
// list stops overflowing or only the last item (current page) remains.
useLayoutEffect(() => {
let nav = navRef.current;
let list = listRef.current;
if (!nav || !list) return;
if (list.scrollWidth > nav.clientWidth && firstVisible < items.length - 1) {
setFirstVisible((prev) => prev + 1);
}
});
// ResizeObserver resets firstVisible to 0 on container resize so that the
// overflow calculation starts fresh at the new container width.
useEffect(() => {
let nav = navRef.current;
if (!nav) return;
let observer = new ResizeObserver(() => {
setFirstVisible(0);
});
observer.observe(nav);
return () => observer.disconnect();
}, []);
// Close the overflow dropdown when the user clicks outside of it.
useEffect(() => {
if (!overflowOpen) return;
function handleClickOutside(event: MouseEvent) {
if (overflowItemRef.current && !overflowItemRef.current.contains(event.target as Node)) {
setOverflowOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [overflowOpen]);
let hiddenItems = items.slice(0, firstVisible);
let visibleItems = items.slice(firstVisible);
return (
);
}
export function ToolbarBreadcrumbs({ items }: ToolbarBreadcrumbsProps) {
return (
);
}