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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user