@atlaskit/breadcrumbs
Version:
Breadcrumbs are a navigation system used to show a user's location in a site or app.
152 lines (145 loc) • 5.26 kB
JavaScript
import { useCallback, useEffect, useLayoutEffect as useRealLayoutEffect, useRef, useState } from 'react';
// The ellipsis trigger is a compact IconButton (24px) plus the trailing separator (24px).
const ELLIPSIS_WIDTH_ESTIMATE = 48;
// Measurements need layout timing in the browser, but should be a no-op for SSR.
const useLayoutEffect = typeof window === 'undefined' ? useEffect : useRealLayoutEffect;
function setsEqual(a, b) {
if (a.size !== b.size) {
return false;
}
for (const v of a) {
if (!b.has(v)) {
return false;
}
}
return true;
}
function getMiddleOutCollapseOrder(itemCount) {
const lastIndex = itemCount - 1;
const middleIndex = Math.floor((itemCount - 1) / 2);
const collapseOrder = [];
if (middleIndex > 0 && middleIndex < lastIndex) {
collapseOrder.push(middleIndex);
}
for (let distance = 1; distance < itemCount; distance++) {
const leftIndex = middleIndex - distance;
const rightIndex = middleIndex + distance;
if (leftIndex > 0) {
collapseOrder.push(leftIndex);
}
if (rightIndex < lastIndex) {
collapseOrder.push(rightIndex);
}
}
return collapseOrder;
}
// eslint-disable-next-line @repo/internal/react/require-jsdoc
export default function useOverflowCollapse(itemCount) {
const itemRefs = useRef([]);
const containerRef = useRef(null);
const naturalWidths = useRef([]);
const naturalWidthsReady = useRef(false);
const [naturalWidthsReadyState, setNaturalWidthsReadyState] = useState(false);
const [collapsedIndices, setCollapsedIndices] = useState(new Set());
// Intentionally runs after each render until refs have committed and natural widths are measured.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
if (naturalWidthsReady.current) {
return;
}
const widths = itemRefs.current.map(el => {
var _el$offsetWidth;
return (_el$offsetWidth = el === null || el === void 0 ? void 0 : el.offsetWidth) !== null && _el$offsetWidth !== void 0 ? _el$offsetWidth : 0;
});
if (widths.some(w => w > 0)) {
naturalWidths.current = widths;
naturalWidthsReady.current = true;
setNaturalWidthsReadyState(true);
}
});
const recalculate = useCallback(() => {
const container = containerRef.current;
if (!container || !naturalWidthsReady.current) {
return;
}
const availableWidth = container.offsetWidth;
const widths = naturalWidths.current;
if (itemCount <= 2) {
setCollapsedIndices(prev => prev.size === 0 ? prev : new Set());
return;
}
const totalWidth = widths.reduce((sum, w) => sum + w, 0);
if (totalWidth <= availableWidth) {
setCollapsedIndices(prev => prev.size === 0 ? prev : new Set());
return;
}
let usedWidth = totalWidth;
const newCollapsed = new Set();
for (const index of getMiddleOutCollapseOrder(itemCount)) {
var _widths$index;
if (newCollapsed.size === 0) {
usedWidth += ELLIPSIS_WIDTH_ESTIMATE;
}
newCollapsed.add(index);
usedWidth -= (_widths$index = widths[index]) !== null && _widths$index !== void 0 ? _widths$index : 0;
if (usedWidth <= availableWidth) {
break;
}
}
// At the smallest sizes, even first + ellipsis + last may not fit.
// In that case, also collapse the first item so only ellipsis + last is shown.
// Note: this branch is only reachable when itemCount > 2 (the early return above
// handles itemCount <= 2), so collapsing index 0 here always leaves at least one
// visible item (the last) plus the ellipsis trigger.
if (usedWidth > availableWidth) {
newCollapsed.add(0);
}
setCollapsedIndices(prev => setsEqual(prev, newCollapsed) ? prev : newCollapsed);
}, [itemCount]);
const registerItem = useCallback(index => el => {
itemRefs.current[index] = el;
}, []);
const observerRef = useRef(null);
const registerContainer = useCallback(el => {
var _observerRef$current;
// Disconnect any existing observer
(_observerRef$current = observerRef.current) === null || _observerRef$current === void 0 ? void 0 : _observerRef$current.disconnect();
containerRef.current = el;
if (!el) {
return;
}
if (typeof ResizeObserver === 'undefined') {
return;
}
// Set up ResizeObserver immediately when container mounts
const observer = new ResizeObserver(() => {
recalculate();
});
observer.observe(el);
observerRef.current = observer;
}, [recalculate]);
useEffect(() => {
if (naturalWidthsReadyState) {
recalculate();
}
}, [naturalWidthsReadyState, recalculate]);
// Cleanup observer on unmount
useEffect(() => {
return () => {
var _observerRef$current2;
(_observerRef$current2 = observerRef.current) === null || _observerRef$current2 === void 0 ? void 0 : _observerRef$current2.disconnect();
};
}, []);
useEffect(() => {
naturalWidthsReady.current = false;
naturalWidths.current = [];
setNaturalWidthsReadyState(false);
setCollapsedIndices(new Set());
}, [itemCount]);
return {
collapsedIndices,
naturalWidthsReady: naturalWidthsReadyState,
registerItem,
registerContainer
};
}