feat: Make header breadcrumb responsive to available width

Replace the fixed item count (max=2) in Breadcrumb with width-based
overflow: a hidden measurement row provides each item's natural width,
a ResizeObserver tracks the space available from the parent container,
and as many items as fit are shown - the remainder collapse into the
existing "..." overflow menu after the first item, keeping the items
nearest the current document visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AokwaNHEC8bnYXLgqhRbRo
This commit is contained in:
Claude
2026-07-26 01:10:06 +00:00
parent 42cd66f79f
commit 8cd78a6738
+244 -15
View File
@@ -1,8 +1,9 @@
import { GoToIcon } from "outline-icons";
import { observer } from "mobx-react";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { Link } from "react-router-dom";
import styled from "styled-components";
import styled, { css } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
@@ -18,12 +19,38 @@ type TopLevelAction =
type Props = React.PropsWithChildren<{
actions: InternalLinkAction[];
/**
* Maximum number of items to display regardless of available space. When
* undefined the number of visible items is based solely on the width
* available to the component.
*/
max?: number;
highlightFirstItem?: boolean;
}>;
/** Width of the slash separator icon, in pixels. */
const SLASH_WIDTH = 24;
/** Space occupied by the overflow menu button 32px wide with -4px inline margins. */
const MENU_WIDTH = 24;
/** Total negative inline margin applied to a breadcrumb item. */
const ITEM_MARGIN = 8;
/** Negative inline margin of the first item, which has no start margin. */
const FIRST_ITEM_MARGIN = 4;
/** Buffer to account for sub-pixel rounding when measuring. */
const SAFETY_MARGIN = 1;
/**
* A responsive breadcrumb that renders a list of link actions separated by
* slashes. Items that do not fit into the available horizontal space are
* collapsed into an overflow menu after the first item, keeping the items
* closest to the current location visible.
*/
function Breadcrumb(
{ actions, highlightFirstItem, children, max = 2 }: Props,
{ actions, highlightFirstItem, children, max }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const actionContext = useActionContext({ isMenu: true });
@@ -38,22 +65,153 @@ function Breadcrumb(
[actions, actionContext]
);
const totalVisibleActions = visibleActions.length;
const hasChildren = !!children;
const topLevelActions: TopLevelAction[] = [...visibleActions];
const rootRef = React.useRef<HTMLDivElement | null>(null);
const measurerRef = React.useRef<HTMLDivElement | null>(null);
const childrenRef = React.useRef<HTMLSpanElement | null>(null);
const itemRefs = React.useRef<(HTMLElement | null)[]>([]);
const countRef = React.useRef(0);
const hasChildrenRef = React.useRef(false);
const maxRef = React.useRef<number | undefined>(undefined);
// chop middle breadcrumbs and present a "..." menu instead
if (totalVisibleActions > max) {
const halfMax = Math.floor(max / 2);
const menuActions = topLevelActions.splice(
halfMax,
totalVisibleActions - max
// Number of trailing items displayed after the overflow menu, or null when
// every item fits and no overflow menu is needed.
const [overflowTail, setOverflowTail] = React.useState<number | null>(null);
React.useLayoutEffect(() => {
countRef.current = totalVisibleActions;
hasChildrenRef.current = hasChildren;
maxRef.current = max;
});
// Measures the natural width of each item from the hidden measurement row
// and the space available from the parent container, then determines how
// many items can be shown before collapsing the remainder into a menu.
const recompute = React.useCallback(() => {
const root = rootRef.current;
const parent = root?.parentElement;
const count = countRef.current;
if (!root || !parent || count === 0) {
setOverflowTail(null);
return;
}
const widths: number[] = [];
for (let i = 0; i < count; i++) {
const el = itemRefs.current[i];
if (!el) {
return;
}
widths.push(el.getBoundingClientRect().width);
}
const parentStyle = window.getComputedStyle(parent);
let available =
parent.clientWidth -
parseFloat(parentStyle.paddingLeft) -
parseFloat(parentStyle.paddingRight) -
SAFETY_MARGIN;
// Subtract space taken by siblings, ex: the mobile sidebar menu button.
for (const sibling of Array.from(parent.children)) {
if (sibling !== root && sibling instanceof HTMLElement) {
const siblingStyle = window.getComputedStyle(sibling);
available -=
sibling.getBoundingClientRect().width +
parseFloat(siblingStyle.marginLeft) +
parseFloat(siblingStyle.marginRight);
}
}
const itemWidth = (index: number) =>
widths[index] - (index === 0 ? FIRST_ITEM_MARGIN : ITEM_MARGIN);
const childrenWidth =
hasChildrenRef.current && childrenRef.current
? SLASH_WIDTH + childrenRef.current.getBoundingClientRect().width
: 0;
const maxItems = maxRef.current ?? Infinity;
let total = childrenWidth;
for (let i = 0; i < count; i++) {
total += (i === 0 ? 0 : SLASH_WIDTH) + itemWidth(i);
}
let next: number | null = null;
// Collapsing is only worthwhile with more than two items with two or
// fewer the menu would be empty, so items shrink and truncate instead.
if ((total > available || count > maxItems) && count > 2) {
let used = itemWidth(0) + SLASH_WIDTH + MENU_WIDTH + childrenWidth;
let tail = 0;
// Keep the items nearest the current location, walking backwards while
// they fit. Index 1 always remains in the menu, otherwise showing every
// item would have been possible in less space.
for (let i = count - 1; i >= 2; i--) {
const width = used + SLASH_WIDTH + itemWidth(i);
if (width > available || tail + 2 > maxItems) {
break;
}
used = width;
tail++;
}
// Always show the item closest to the current location, even if it must
// shrink to fit.
next = Math.max(tail, 1);
}
setOverflowTail((prev) => (prev === next ? prev : next));
}, []);
React.useLayoutEffect(() => {
recompute();
}, [recompute, totalVisibleActions, max, hasChildren]);
React.useLayoutEffect(() => {
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(() => recompute());
const parent = rootRef.current?.parentElement;
if (parent) {
observer.observe(parent);
}
if (measurerRef.current) {
observer.observe(measurerRef.current);
}
return () => observer.disconnect();
}, [recompute]);
const topLevelActions: TopLevelAction[] = React.useMemo(() => {
if (
overflowTail === null ||
totalVisibleActions <= 2 ||
overflowTail > totalVisibleActions - 2
) {
return [...visibleActions];
}
// Chop the middle breadcrumbs and present a "…" menu instead, keeping the
// first item and the items closest to the current location.
const result: TopLevelAction[] = [...visibleActions];
const menuActions = result.splice(
1,
totalVisibleActions - 1 - overflowTail
) as InternalLinkAction[];
topLevelActions.splice(halfMax, 0, {
result.splice(1, 0, {
type: "menu",
actions: menuActions,
});
}
return result;
}, [visibleActions, overflowTail, totalVisibleActions]);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
@@ -87,7 +245,11 @@ function Breadcrumb(
);
return (
<Flex justify="flex-start" align="center" ref={ref}>
<Wrapper
justify="flex-start"
align="center"
ref={mergeRefs([ref, rootRef])}
>
{topLevelActions.map((action, index) => (
<React.Fragment key={action.type === "menu" ? "menu" : `item-${index}`}>
{toBreadcrumb(action, index)}
@@ -96,17 +258,48 @@ function Breadcrumb(
) : null}
</React.Fragment>
))}
{children}
</Flex>
{children ? (
<ChildrenGroup ref={childrenRef}>{children}</ChildrenGroup>
) : null}
{totalVisibleActions > 0 ? (
<MeasurerClip aria-hidden>
<Measurer ref={measurerRef}>
{visibleActions.map((action, index) => {
const item = actionToMenuItem(
action,
actionContext
) as MenuInternalLink;
return (
<MeasureItem
key={`measure-${index}`}
ref={(el) => {
itemRefs.current[index] = el;
}}
$highlight={!!highlightFirstItem && index === 0}
>
{item.icon}
<Title>{item.title}</Title>
</MeasureItem>
);
})}
</Measurer>
</MeasurerClip>
) : null}
</Wrapper>
);
}
const Wrapper = styled(Flex)`
position: relative;
`;
const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${s("divider")};
`;
const Item = styled(Link)<{ $highlight: boolean }>`
const itemStyle = css<{ $highlight: boolean }>`
${undraggableOnDesktop()}
display: flex;
@@ -136,6 +329,42 @@ const Item = styled(Link)<{ $highlight: boolean }>`
}
`;
const Item = styled(Link)<{ $highlight: boolean }>`
${itemStyle}
`;
// Hidden copy of a breadcrumb item used to measure its natural width, it must
// share styles with Item so that measurements match the real thing.
const MeasureItem = styled.span<{ $highlight: boolean }>`
${itemStyle}
flex-shrink: 0;
`;
// Zero-sized clipping container so the measurement row never contributes to
// the scrollable overflow of the page.
const MeasurerClip = styled.div`
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
overflow: hidden;
`;
const Measurer = styled.div`
display: flex;
align-items: center;
width: max-content;
visibility: hidden;
pointer-events: none;
`;
const ChildrenGroup = styled.span`
display: inline-flex;
align-items: center;
flex-shrink: 0;
`;
const Title = styled.span`
${ellipsis()}
min-width: 0;