Files
siftlode/frontend/src/lib/useChipOverflowFit.ts
T
peter fc32188fd2 refactor(chips): share the chip-overflow measure core between the two lanes (S3)
ActiveFilterChips (feed applied-filter row) and ChannelTagsControl both
hand-rolled the same single-line chip-fit measurer: a hidden mirror + a
layout-effect + ResizeObserver reading chip right edges, the real rem gap,
the pill width and the lane width. The policy half was already extracted
(decideChipLayout); the DOM-measurement half was still duplicated.

Promote that half to useChipOverflowFit(rowRef, chipsRef, pillRef, deps,
decide, initial): it owns the effect + observer + initial measure and reads
the common geometry, then hands it to each caller's own policy. The feed
row layers its leading label + trailing "Clear all" reservation on top and
keeps decideChipLayout; the tag cell's chips-only policy is extracted as
fitChipsWithPill (pure, unit-tested) alongside it. Add a jsdom test for the
hook (stubbed offsets, re-measure on deps). Behaviour unchanged — verified
in the browser: feed all/count modes + popover, tag "+N" fit + hover reveal.
2026-08-23 23:22:52 +02:00

80 lines
3.9 KiB
TypeScript

import { useLayoutEffect, useRef, useState, type RefObject } from "react";
// The shared DOM-measure-and-fit core for a single-line chip lane that collapses its overflow into a
// trailing "+N" pill. Owns the layout-effect + ResizeObserver + initial-measure wiring and reads the
// common geometry off a hidden mirror (each chip's right edge, the real rem gap, the pill width, and
// the lane's available width); each caller layers its OWN fit policy in `decide` and gets that result
// back. This was hand-rolled twice — the feed's applied-filter row (ActiveFilterChips, whose policy
// also reserves a leading label + a trailing "Clear all" and can collapse to a single count pill) and
// the channel tag cell (ChannelTagsControl, chips-only) — with the same measurement half copied in
// both. The policy halves live in lib/chipOverflow.ts; this owns the measurement half.
//
// Coordinate note: `chipRights` are offsets within the hidden mirror (its children's offsetParent),
// while `avail` is the VISIBLE lane's clientWidth — the two are comparable because the mirror is laid
// out `absolute left-0 top-0` over the lane's start, so a mirror-relative right edge is the position a
// chip would take in the real lane. Reads real rem boxes, so nothing is frozen in px (the text-size
// slider scales the gap and chip widths, and the fit follows).
export interface ChipRowGeometry {
/** Available lane width — the visible row's clientWidth. */
avail: number;
/** Right edge (offsetLeft + offsetWidth) of each chip in the measurer, index-aligned to the chips. */
chipRights: number[];
/** Width of the trailing sample "+N" pill in the measurer. */
pillW: number;
/** The real rendered inter-item gap (rem-scaled), read off the box between the last chip and the pill. */
gap: number;
}
export function useChipOverflowFit<R>(args: {
/** The VISIBLE lane; its clientWidth is the available width, and resize is observed on it. */
rowRef: RefObject<HTMLElement | null>;
/** The measurer's chip elements, index-aligned (a hidden mirror of the full chip set). */
chipsRef: RefObject<Array<HTMLElement | null>>;
/** The sample "+N" pill in the measurer. */
pillRef: RefObject<HTMLElement | null>;
/** Re-measure when this changes (a key over the chip set — count + labels, so a rename re-fits too). */
deps: unknown;
/** The caller's fit policy over the measured geometry. */
decide: (geom: ChipRowGeometry) => R;
/** Value used until the first measure (and while the refs aren't mounted). */
initial: R;
}): R {
const { rowRef, chipsRef, pillRef, deps, decide, initial } = args;
const [result, setResult] = useState<R>(initial);
// Read `decide` fresh each run without making it an effect dependency: the effect re-runs on `deps`
// (and resize), and a caller's decide closure is rebuilt every render — depending on it would
// resubscribe the ResizeObserver on every render.
const decideRef = useRef(decide);
decideRef.current = decide;
useLayoutEffect(() => {
const row = rowRef.current;
const pill = pillRef.current;
if (!row || !pill) return;
const rightOf = (el: HTMLElement | null | undefined) =>
el ? el.offsetLeft + el.offsetWidth : 0;
const run = () => {
const chips = (chipsRef.current ?? []).filter((c): c is HTMLElement => c != null);
const lastChip = chips[chips.length - 1];
setResult(
decideRef.current({
avail: row.clientWidth,
chipRights: chips.map(rightOf),
pillW: pill.offsetWidth,
gap: lastChip ? Math.max(0, pill.offsetLeft - rightOf(lastChip)) : 0,
}),
);
};
run();
// jsdom has no ResizeObserver — the one-shot measure above still runs there (unit tests drive it).
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(run);
ro.observe(row);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deps]);
return result;
}