fix(e10): stop the hidden chip measurer scrolling the whole app sideways
The four chip lanes render an intentionally unbounded hidden "mirror" so the shared fit core can read each chip's natural width. An absolutely positioned box still contributes to its ancestors' SCROLLABLE overflow, and `visibility: hidden` does not exempt it — and nothing between the toolbar band and <html> clipped it. At the 1024x768 floor that meant the channel manager's document scroll width was 1258px: the whole app scrolled 249px sideways into dead space and dragged the fixed header to x=-177. The same mirror was also 228px wide inside the Tags column's 144px cell, which pushed the table to 990px inside its 906px container — i.e. it broke DataTable's "the table never exceeds its container" contract and truncated channel names to "The...". One cause, two symptoms. The lane now carries a shared `chipLaneClass` = `relative overflow-clip`, so the contract lives in one constant instead of four hand-rolled class strings. `clip` rather than `hidden` because it creates no scrollport, so nothing can ever scroll the mirror into view. Measurement is untouched: offsetLeft/offsetWidth are layout values and a clip does not change layout. Measured after: document 1258 -> 1024, sideways scroll 249 -> 0, table 990 -> 921 with no cell overflow, and the "+N" collapse still fits exactly as before. A source-level test asserts every component rendering a mirror uses the lane class — the drift a fifth hand-rolled lane would introduce, which no DOM test can catch since jsdom has no layout engine to overflow.
This commit is contained in:
@@ -3,7 +3,7 @@ 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 { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||
@@ -172,7 +172,7 @@ export default function ActiveFilterChips({
|
||||
<div
|
||||
ref={laneRef}
|
||||
data-testid="active-filter-chips"
|
||||
className={`relative flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
|
||||
className={`${chipLaneClass} flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
|
||||
aria-label={t("feed.chips.label")}
|
||||
>
|
||||
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type Tag } from "../lib/api";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { chipLaneClass, 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:
|
||||
@@ -129,7 +129,7 @@ function SuggestedChipsLane({
|
||||
}, [hidden, overflowOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1 min-w-0 flex-1">
|
||||
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
|
||||
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -374,7 +374,7 @@ export function ChannelTagsControl({
|
||||
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
|
||||
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
|
||||
<div
|
||||
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
className={`${chipLaneClass} flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
>
|
||||
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
|
||||
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { filterChipClass } from "./ui/FilterChipGroup";
|
||||
import { useAnchoredPopover, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function TagFilterChips({
|
||||
const moreLabel = t("channels.tags.moreCount", { count: hidden });
|
||||
|
||||
return (
|
||||
<div className={`relative flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
|
||||
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
|
||||
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
|
||||
read each chip's right edge, the rem gap and the pill width off real boxes. */}
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chipLaneClass } from "./useChipOverflowFit";
|
||||
|
||||
// E10 S4. The chip lanes' hidden measurer is laid out UNBOUNDED on purpose (it exists to read natural
|
||||
// widths), and an absolutely-positioned box still feeds its ancestors' SCROLLABLE overflow even while
|
||||
// `visibility: hidden`. Un-clipped, that leaked to <html>: at a 1024px viewport the channel manager's
|
||||
// document scrolled 249px sideways and the fixed header slid off. `chipLaneClass` carries the clip that
|
||||
// contains it — so the invariant worth locking is not "this lane looks right" but "every mirror lives
|
||||
// inside a lane that clips". A fifth lane hand-rolling `relative` is exactly the drift this catches,
|
||||
// and no DOM test can: jsdom has no layout, so the overflow it guards against is unobservable there.
|
||||
const MIRROR = "invisible absolute left-0 top-0";
|
||||
|
||||
function componentSources(): Array<[string, string]> {
|
||||
const dir = new URL("../components/", import.meta.url);
|
||||
const walk = (u: URL, prefix: string): Array<[string, string]> =>
|
||||
readdirSync(u, { withFileTypes: true }).flatMap((e) => {
|
||||
if (e.isDirectory()) return walk(new URL(`${e.name}/`, u), `${prefix}${e.name}/`);
|
||||
if (!e.name.endsWith(".tsx") || e.name.includes(".test.")) return [];
|
||||
return [
|
||||
[`${prefix}${e.name}`, readFileSync(new URL(e.name, u), "utf-8")] as [string, string],
|
||||
];
|
||||
});
|
||||
return walk(dir, "");
|
||||
}
|
||||
|
||||
describe("chip lane containment contract", () => {
|
||||
it("keeps both halves of the lane class — the offsetParent AND the clip", () => {
|
||||
expect(chipLaneClass.split(/\s+/)).toEqual(
|
||||
expect.arrayContaining(["relative", "overflow-clip"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("every component rendering a hidden chip measurer uses chipLaneClass for its lane", () => {
|
||||
const offenders = componentSources()
|
||||
.filter(([, src]) => src.includes(MIRROR))
|
||||
.filter(([, src]) => !src.includes("chipLaneClass"))
|
||||
.map(([name]) => name);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds the measurers it claims to guard (the marker has not been renamed away)", () => {
|
||||
const withMirror = componentSources().filter(([, src]) => src.includes(MIRROR));
|
||||
expect(withMirror.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,25 @@ import { useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||
// 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).
|
||||
|
||||
/**
|
||||
* The class the chip LANE must carry — both halves are load-bearing.
|
||||
*
|
||||
* `relative` is the measurement contract: the hidden mirror is `absolute left-0 top-0`, so the lane is
|
||||
* its offsetParent and a mirror-relative right edge is directly comparable to the lane's clientWidth
|
||||
* (see the coordinate note above).
|
||||
*
|
||||
* `overflow-clip` is the containment half. The mirror is laid out UNBOUNDED — that is the whole point,
|
||||
* it reads natural widths — so its box sticks out far past the lane. An absolutely-positioned box still
|
||||
* contributes to its ancestors' SCROLLABLE overflow, and `visibility: hidden` does NOT exempt it, so
|
||||
* without a clip the mirror leaks all the way up to <html>. Measured on the channel manager at a 1024px
|
||||
* viewport (E10 S4): the mirror dragged the document's scroll width to 1258px — the whole app scrolled
|
||||
* 249px sideways and the fixed header slid to x=-177 — and widened the Tags cell 84px past the width its
|
||||
* fixed-layout column had assigned it. `clip`, not `hidden`, because clip creates no scrollport, so
|
||||
* nothing can ever scroll the mirror into view. The clip cannot disturb the measurement: offsetLeft and
|
||||
* offsetWidth are layout values, and clipping does not change layout.
|
||||
*/
|
||||
export const chipLaneClass = "relative overflow-clip";
|
||||
|
||||
export interface ChipRowGeometry {
|
||||
/** Available lane width — the visible row's clientWidth. */
|
||||
avail: number;
|
||||
|
||||
Reference in New Issue
Block a user