Files
mastodon/app/javascript/flavours/glitch/hooks/useOverflow.ts
2026-02-25 19:11:38 +01:00

253 lines
7.4 KiB
TypeScript

import type { MutableRefObject, RefCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
import { useMutationObserver, useResizeObserver } from './useObserver';
/**
* Hook to manage overflow of items in a container with a "more" button.
*
* To use, wire up the `wrapperRef` to the container element, and the `listRef` to the
* child element that contains the items to be measured. If autoResize is true,
* the list element will have its max-width set to prevent wrapping. The listRef element
* requires both position:relative and overflow:hidden styles to work correctly.
*/
export function useOverflowButton({
autoResize,
padding = 4,
}: { autoResize?: boolean; padding?: number } = {}) {
const [hiddenIndex, setHiddenIndex] = useState(-1);
const [hiddenCount, setHiddenCount] = useState(0);
const [maxWidth, setMaxWidth] = useState<number | 'none'>('none');
// This is the item container element.
const listRef = useRef<HTMLElement | null>(null);
// The main recalculation function.
const handleRecalculate = useCallback(() => {
const listEle = listRef.current;
if (!listEle) return;
const reset = () => {
setHiddenIndex(-1);
setHiddenCount(0);
setMaxWidth('none');
};
// Calculate the width via the parent element, minus the more button, minus the padding.
const maxWidth =
(listEle.parentElement?.offsetWidth ?? 0) -
(listEle.nextElementSibling?.scrollWidth ?? 0) -
padding;
if (maxWidth <= 0) {
reset();
return;
}
// Iterate through children until we exceed max width.
let visible = 0;
let index = 0;
let totalWidth = 0;
for (const child of listEle.children) {
if (child instanceof HTMLElement) {
const rightOffset = child.offsetLeft + child.offsetWidth;
if (rightOffset <= maxWidth) {
visible += 1;
totalWidth = rightOffset;
} else {
break;
}
}
index++;
}
// All are visible, so remove max-width restriction.
if (visible === listEle.children.length) {
reset();
return;
}
// Set the width to avoid wrapping, and set hidden count.
setHiddenIndex(index);
setHiddenCount(listEle.children.length - visible);
setMaxWidth(totalWidth);
}, [padding]);
useEffect(() => {
if (listRef.current && autoResize) {
listRef.current.style.maxWidth =
typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth;
}
}, [autoResize, maxWidth]);
const { listRefCallback, wrapperRefCallback } = useOverflowObservers({
onRecalculate: handleRecalculate,
onListRef: listRef,
});
return {
hiddenCount,
hasOverflow: hiddenCount > 0,
wrapperRef: wrapperRefCallback,
hiddenIndex,
maxWidth,
listRef: listRefCallback,
recalculate: handleRecalculate,
};
}
export function useOverflowScroll({
widthOffset = 200,
absoluteDistance = false,
} = {}) {
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const bodyRef = useRef<HTMLElement | null>(null);
// Recalculate scrollable state
const handleRecalculate = useCallback(() => {
if (!bodyRef.current) {
return;
}
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
setCanScrollLeft(
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
bodyRef.current.scrollWidth,
);
setCanScrollRight(bodyRef.current.scrollLeft < 0);
} else {
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight(
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
bodyRef.current.scrollWidth,
);
}
}, []);
const { wrapperRefCallback } = useOverflowObservers({
onRecalculate: handleRecalculate,
onWrapperRef: bodyRef,
});
useEffect(() => {
handleRecalculate();
}, [handleRecalculate]);
// Handle scroll event using requestAnimationFrame to avoid excessive recalculations.
const handleScroll = useCallback(() => {
requestAnimationFrame(handleRecalculate);
}, [handleRecalculate]);
// Jump a full screen minus the width offset so that we don't skip a lot.
const handleLeftNav = useCallback(() => {
if (!bodyRef.current) {
return;
}
bodyRef.current.scrollLeft -= absoluteDistance
? widthOffset
: Math.max(widthOffset, bodyRef.current.clientWidth - widthOffset);
}, [absoluteDistance, widthOffset]);
const handleRightNav = useCallback(() => {
if (!bodyRef.current) {
return;
}
bodyRef.current.scrollLeft += absoluteDistance
? widthOffset
: Math.max(widthOffset, bodyRef.current.clientWidth - widthOffset);
}, [absoluteDistance, widthOffset]);
return {
bodyRef: wrapperRefCallback,
canScrollLeft,
canScrollRight,
handleLeftNav,
handleRightNav,
handleScroll,
};
}
export function useOverflowObservers({
onRecalculate,
onListRef,
onWrapperRef,
}: {
onRecalculate: () => void;
onListRef?: RefCallback<HTMLElement> | MutableRefObject<HTMLElement | null>;
onWrapperRef?:
| RefCallback<HTMLElement>
| MutableRefObject<HTMLElement | null>;
}) {
// This is the item container element.
const listRef = useRef<HTMLElement | null>(null);
const resizeObserver = useResizeObserver(onRecalculate);
// Iterate through children and observe them for size changes.
const handleChildrenChange = useCallback(() => {
const listEle = listRef.current;
if (listEle) {
for (const child of listEle.children) {
if (child instanceof HTMLElement) {
resizeObserver.observe(child);
}
}
}
onRecalculate();
}, [onRecalculate, resizeObserver]);
const mutationObserver = useMutationObserver(handleChildrenChange);
// Set up observers.
const handleObserve = useCallback(() => {
if (wrapperRef.current) {
resizeObserver.observe(wrapperRef.current);
}
if (listRef.current) {
mutationObserver.observe(listRef.current, { childList: true });
handleChildrenChange();
}
}, [handleChildrenChange, mutationObserver, resizeObserver]);
// Watch the wrapper for size changes, and recalculate when it resizes.
const wrapperRef = useRef<HTMLElement | null>(null);
const wrapperRefCallback = useCallback(
(node: HTMLElement | null) => {
if (node) {
wrapperRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955
handleObserve();
if (typeof onWrapperRef === 'function') {
onWrapperRef(node);
} else if (onWrapperRef && 'current' in onWrapperRef) {
onWrapperRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955
}
}
},
[handleObserve, onWrapperRef],
);
// If there are changes to the children, recalculate which are visible.
const listRefCallback = useCallback(
(node: HTMLElement | null) => {
if (node) {
listRef.current = node;
handleObserve();
if (typeof onListRef === 'function') {
onListRef(node);
} else if (onListRef && 'current' in onListRef) {
onListRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955
}
}
},
[handleObserve, onListRef],
);
return {
wrapperRefCallback,
listRefCallback,
};
}