Files
siftlode/frontend/src/lib/chipOverflow.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

90 lines
3.9 KiB
TypeScript

// Pure fit decisions for a single-line chip lane that collapses its overflow into a "+N" pill.
// Lifted out of the components so the math is unit-testable without a layout engine (jsdom reports
// 0-width boxes), the same split as lib/dateFilter and lib/columnSort. The shared DOM-measurement
// half — the layout-effect + ResizeObserver that reads real rem boxes off a hidden mirror — lives in
// lib/useChipOverflowFit; it hands the measured geometry to one of these policies:
// - decideChipLayout: the feed's applied-filter row, which also shares its width with fixed trailing
// controls (Sort/View), reserves a leading label + a trailing "Clear all", and collapses to a
// single count pill on a narrow lane.
// - fitChipsWithPill: the chips-only channel tag cell (ChannelTagsControl) — just "how many fit
// before the +N pill".
export interface ChipFitInput {
/** Number of chips. */
n: number;
/** Right edge (offsetLeft + offsetWidth) of the leading label, in the measurer. */
labelRight: number;
/** Right edge of each chip, in the measurer (index-aligned to the chips). */
chipRights: number[];
/** Width of the "+N" overflow pill. */
plusW: number;
/** Width the trailing "Clear all" button needs INCLUDING its leading gap (0 when n <= 1, since it
* only renders with more than one chip). Reserved only in the "all" branch, which renders it. */
clearW: number;
/** Available lane width. */
avail: number;
/** The real (rem-scaled) inter-item gap. */
gap: number;
/** At or below this lane width an overflow collapses to a single count pill instead of "+N". */
narrowPx: number;
}
export type ChipFit = { mode: "all" | "some" | "count"; visible: number };
export function decideChipLayout(p: ChipFitInput): ChipFit {
const { n, labelRight, chipRights, plusW, clearW, avail, gap, narrowPx } = p;
if (n <= 0) return { mode: "all", visible: 0 };
// Do all chips (plus the Clear all button it renders) fit? Use the last chip's right edge.
const allRight = chipRights[n - 1] ?? labelRight;
if (allRight + clearW <= avail) return { mode: "all", visible: n };
// Overflow. On a narrow lane, collapse everything into one count pill.
if (avail < narrowPx) return { mode: "count", visible: 0 };
// Otherwise fit as many chips as leave room for the "+N" pill after them.
let k = 0;
for (let i = 0; i < n; i++) {
const right = chipRights[i] ?? 0;
if (right + gap + plusW <= avail) k = i + 1;
else break;
}
// If not even one chip fits beside the pill, fall back to the count pill.
if (k === 0 && labelRight + gap + plusW > avail) return { mode: "count", visible: 0 };
return { mode: "some", visible: k };
}
// The chips-only policy (ChannelTagsControl): how many chips fit on the line, reserving the "+N" pill's
// width whenever any overflow. Returns the visible count.
export interface ChipsWithPillInput {
/** Right edge (offsetLeft + offsetWidth) of each chip, index-aligned. */
chipRights: number[];
/** Width of the "+N" pill. */
pillW: number;
/** Available lane width. */
avail: number;
/** The real (rem-scaled) inter-item gap. */
gap: number;
}
export function fitChipsWithPill(p: ChipsWithPillInput): number {
const { chipRights, pillW, avail, gap } = p;
const n = chipRights.length;
if (n === 0) return 0;
// Whole-set fast path: if the LAST chip's right edge already fits, every chip fits and NO pill is
// needed. The reservation loop below reserves the pill for every chip except the last and breaks at
// the first overflow, so on its own it would collapse a short trailing chip (narrower than the pill)
// that would actually have fitted, leaving "+N" beside empty space.
if ((chipRights[n - 1] ?? 0) <= avail) return n;
let count = n;
for (let i = 0; i < n; i++) {
const right = chipRights[i] ?? 0;
const moreAfter = i < n - 1;
if (right > avail - (moreAfter ? pillW + gap : 0)) {
count = i;
break;
}
}
return count;
}