Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { useLayoutEffect, useRef, useState } from "react";
// Widths can differ by a tiny fraction of a pixel without anything actually
// changing, so only re-measure when a width really changes.
const WIDTH_EPSILON = 0.5;
/**
* How many of the items fit in containerWidth. The More button's width is only
* subtracted when it is actually rendered.
*/
function getVisibleItemCount(containerWidth, { widths, gap, controlWidth }) {
const rowWidth =
widths.reduce((sum, width) => sum + width, 0) + gap * (widths.length - 1);
if (rowWidth <= containerWidth) {
return widths.length;
}
const available = containerWidth - controlWidth - gap;
let used = 0;
for (let i = 0; i < widths.length; i++) {
used += widths[i] + (i ? gap : 0);
if (used > available) {
return i;
}
}
return widths.length;
}
/**
* Splits a single row of items into the ones that fit and the ones that
* overflow into a More button. Only the items that fit are rendered, so a
* partly cut off item never appears.
*
* Widths can only be read while every item is rendered, which is what a null
* count asks for: render everything, laid out but not painted. After that a
* resize is just arithmetic against the cached widths.
*
* moz-button renders its contents asynchronously, so an early read finds zero
* widths. The count stays null until they are real, and the items and More
* button stay observed so a later width change starts a fresh pass.
*
* @param {string[]} labels - The item labels, which re-measure when they change
* @returns {object} Refs to attach, and visibleCount, null while measuring
*/
function useOverflowSplit(labels) {
const containerRef = useRef(null);
const listRef = useRef(null);
const itemsRef = useRef([]);
const overflowRef = useRef(null);
const metricsRef = useRef(null);
const [visibleCount, setVisibleCount] = useState(null);
const itemCount = labels.length;
const cacheKey = labels.join();
useLayoutEffect(() => {
const container = containerRef.current;
const list = listRef.current;
if (!container || !list) {
return undefined;
}
const measure = () => {
const metrics = {
widths: itemsRef.current
.slice(0, itemCount)
.map(el => el?.getBoundingClientRect().width ?? 0),
gap: parseFloat(globalThis.getComputedStyle(list).columnGap) || 0,
controlWidth: overflowRef.current?.getBoundingClientRect().width ?? 0,
};
const isReady =
metrics.controlWidth > 0 && metrics.widths.every(width => width > 0);
// Null rather than stale, so a rejected read can't feed the update below.
metricsRef.current = isReady ? metrics : null;
};
// Only valid while every item is rendered.
if (visibleCount === null) {
measure();
}
// A resize lands here every frame but the count only changes at a
// breakpoint, so returning prev skips a re-render on all other frames.
const update = containerWidth => {
if (!metricsRef.current) {
return;
}
setVisibleCount(prev => {
const next = getVisibleItemCount(containerWidth, metricsRef.current);
return next === prev ? prev : next;
});
};
// Rect, not clientWidth, to avoid rounding.
update(container.getBoundingClientRect().width);
const observer = new ResizeObserver(entries => {
// Check each element width against its cached width, so it only reads as
// changed when the width really differs.
const cachedWidth = target => {
if (target === overflowRef.current) {
return metricsRef.current?.controlWidth;
}
const index = itemsRef.current.indexOf(target);
return index === -1 ? undefined : metricsRef.current?.widths[index];
};
const staleWidths = entries.some(entry => {
if (entry.target === container) {
return false;
}
const { width } = entry.target.getBoundingClientRect();
// Zero means the item mounted this frame and hasn't rendered yet;
// counting it would restart measuring every time the strip widens.
if (width === 0) {
return false;
}
const cached = cachedWidth(entry.target);
return cached === undefined || Math.abs(cached - width) > WIDTH_EPSILON;
});
if (staleWidths) {
if (visibleCount === null) {
measure();
} else {
// Items are missing, so ask for a fresh measuring pass instead.
setVisibleCount(null);
return;
}
}
update(container.getBoundingClientRect().width);
});
observer.observe(container);
// Every mounted item, since a measuring pass is waiting on the ones that
// just mounted, and an item that rendered earlier won't resize again.
itemsRef.current.slice(0, itemCount).forEach(el => {
if (el) {
observer.observe(el);
}
});
if (overflowRef.current) {
observer.observe(overflowRef.current);
}
return () => observer.disconnect();
}, [itemCount, cacheKey, visibleCount]);
return { containerRef, listRef, itemsRef, overflowRef, visibleCount };
}
export { useOverflowSplit, getVisibleItemCount };