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.
This commit is contained in:
2026-08-23 23:22:52 +02:00
parent 9538fb1b7a
commit fc32188fd2
6 changed files with 313 additions and 109 deletions
+27 -42
View File
@@ -1,8 +1,9 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Filter, X } from "lucide-react";
import type { ActiveFilter } from "../lib/useActiveFilters";
import { decideChipLayout } from "../lib/chipOverflow";
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
@@ -131,52 +132,36 @@ export default function ActiveFilterChips({
const chipRefs = useRef<Array<HTMLSpanElement | null>>([]);
const plusRef = useRef<HTMLSpanElement | null>(null);
const clearRef = useRef<HTMLSpanElement | null>(null);
// How the row is currently laid out: all chips, a fit-count + "+N", or a single count pill.
const [layout, setLayout] = useState<{ mode: "all" | "some" | "count"; visible: number }>({
mode: "all",
visible: filters.length,
});
const n = filters.length;
// Re-measure whenever the chip set changes (labels or count) — captures both in one dep.
const chipsKey = filters.map((f) => f.key + f.label).join("|");
useLayoutEffect(() => {
const lane = laneRef.current;
const labelEl = labelRef.current;
if (!lane || !labelEl || n === 0) return;
const measure = () => {
const rightOf = (el: HTMLElement | null | undefined) =>
el ? el.offsetLeft + el.offsetWidth : 0;
const chips = chipRefs.current.slice(0, n);
const plusEl = plusRef.current;
// Measure the real rendered gap off the box between the last chip and the "+N" sample, so it
// scales with the text-size slider (gap-1.5 is rem) rather than being frozen in px.
const lastChip = chips[n - 1];
const gap = plusEl && lastChip ? Math.max(0, plusEl.offsetLeft - rightOf(lastChip)) : 6;
// "all" mode also renders a Clear all button after the chips (when n > 1), so reserve its width.
const clearW = n > 1 ? gap + (clearRef.current?.offsetWidth ?? 0) : 0;
setLayout(
decideChipLayout({
n,
labelRight: rightOf(labelEl),
chipRights: chips.map((c) => rightOf(c)),
plusW: plusEl?.offsetWidth ?? 0,
clearW,
avail: lane.clientWidth,
gap,
narrowPx: NARROW_PX,
}),
);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(lane);
return () => ro.disconnect();
}, [n, chipsKey]);
// The shared measurement core hands over the chip right edges + gap + pill width + lane width; this
// policy adds the feed row's own framing (the leading label's right edge, and the trailing "Clear
// all" button's width, reserved only when it renders — n > 1) before deciding all/some/count.
const layout = useChipOverflowFit<{ mode: "all" | "some" | "count"; visible: number }>({
rowRef: laneRef,
chipsRef: chipRefs,
pillRef: plusRef,
deps: `${n}|${chipsKey}`,
initial: { mode: "all", visible: n },
decide: (g) => {
const labelEl = labelRef.current;
const labelRight = labelEl ? labelEl.offsetLeft + labelEl.offsetWidth : 0;
const clearW = n > 1 ? g.gap + (clearRef.current?.offsetWidth ?? 0) : 0;
return decideChipLayout({
n,
labelRight,
chipRights: g.chipRights,
plusW: g.pillW,
clearW,
avail: g.avail,
gap: g.gap,
narrowPx: NARROW_PX,
});
},
});
if (n === 0) return null;
+34 -58
View File
@@ -1,9 +1,11 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Check, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
@@ -154,62 +156,25 @@ export function ChannelTagsControl({
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
// natural widths; we read each chip's right edge and compute EXACTLY how many fit, reserving the
// pill's width whenever any overflow. One pass (no iterative re-render), so the "+N" count is always
// right as tags are added/removed. Re-measures on width/scale change via ResizeObserver — off real
// rem boxes, never a px guess.
// natural widths; the shared measure core (useChipOverflowFit) reads each chip's right edge, the rem
// gap and the pill width off real boxes, and fitChipsWithPill computes EXACTLY how many fit (reserving
// the pill whenever any overflow). One pass — the count is right as tags are added/removed/renamed —
// and it re-measures on width/scale change via the hook's ResizeObserver.
const rowRef = useRef<HTMLDivElement | null>(null);
const measureRef = useRef<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(attached.length);
const chipRefs = useRef<Array<HTMLElement | null>>([]);
const pillRef = useRef<HTMLSpanElement | null>(null);
// Key on id AND name so a RENAME (which changes a chip's width) re-runs the fit, not just add/remove.
const tagsKey = attached.map((tg) => `${tg.id}:${tg.name}`).join("|");
useLayoutEffect(() => {
const row = rowRef.current;
const measure = measureRef.current;
if (!row || !measure) return;
const compute = () => {
const nodes = Array.from(measure.children) as HTMLElement[];
const pill = nodes[nodes.length - 1];
if (!pill) return;
const pillW = pill.offsetWidth;
const chips = nodes.slice(0, -1);
// Measure the real rendered gap (gap-1 is rem — it scales with the font slider) off the box
// between the last chip and the sample pill, rather than assuming a px constant.
const lastChip = chips[chips.length - 1];
const gap = lastChip
? Math.max(0, pill.offsetLeft - (lastChip.offsetLeft + lastChip.offsetWidth))
: 0;
const avail = row.clientWidth;
// Whole-set fast path: if the LAST chip's right edge already fits, every chip fits and no "+N"
// pill is needed. The reservation loop below reserves the pill's width for every chip except
// the last and breaks at the first overflow, so on its own it never reaches the only "does the
// whole set fit?" test — it would collapse chips that would have fitted (e.g. a short trailing
// chip narrower than the pill), leaving "+N" beside empty space.
const lastRight = lastChip ? lastChip.offsetLeft + lastChip.offsetWidth : 0;
if (lastRight <= avail) {
setVisible(chips.length);
return;
}
let count = chips.length;
for (let i = 0; i < chips.length; i++) {
const chip = chips[i];
if (!chip) break;
const right = chip.offsetLeft + chip.offsetWidth; // right edge within the measurer
const moreAfter = i < chips.length - 1;
if (right > avail - (moreAfter ? pillW + gap : 0)) {
count = i;
break;
}
}
setVisible(count);
};
compute();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(compute);
ro.observe(row);
return () => ro.disconnect();
}, [tagsKey]);
const visible = useChipOverflowFit<number>({
rowRef,
chipsRef: chipRefs,
pillRef,
deps: tagsKey,
initial: attached.length,
decide: (g) =>
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
});
const hidden = attached.length - visible;
@@ -249,16 +214,27 @@ export function ChannelTagsControl({
<div
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
>
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */}
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. Each
chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its right edge;
the pill is the sample the hook reserves room for. */}
<div
ref={measureRef}
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
{attached.map((tg) => (
<TagChip key={tg.id} tag={tg} />
{attached.map((tg, i) => (
<span
key={tg.id}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<TagChip tag={tg} />
</span>
))}
<span className={pillClass}>+{attached.length}</span>
<span ref={pillRef} className={pillClass}>
+{attached.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">