commit a3a8276a06c025e9c0a34d48bf2208873937f43c
parent 9278a39fab8b8cd830652a9d9ed506a707b9ba3d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 24 Sep 2026 19:05:14 -0400
channels: a group header's controls stay on screen when the rack scrolls across
Audit finding (desktop/mobile grouped-scrolled): the group header is one
th spanning every column, so its name, description and five station
buttons scrolled off to the left with the table — the group's controls
out of reach exactly when the operator had scrolled across to read a
column; only "16 channels" at the far right was left.
The header's content now sits in a sticky left-2 box capped at the
region's visible width, which ChannelsRack measures into --rack-w beside
--thead-h (the ResizeObserver now watches the region too). The layers
spec asserts the Sync station is inside the region and owns its centre
after a bottom-right scroll.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 59 insertions(+), 23 deletions(-)
diff --git a/editor/app/channels/components/ChannelGroupHeaderRow.tsx b/editor/app/channels/components/ChannelGroupHeaderRow.tsx
@@ -47,32 +47,43 @@ export function ChannelGroupHeaderRow({
`md:sticky md:top-[var(--thead-h,2.25rem)] ${RACK_LAYERS.groupHeader}`
}
>
- <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
- <span
- data-testid="group-name"
- className="text-xs font-semibold uppercase tracking-wider"
- >
- {name}
- </span>
- {/* Rendered because it is true and currently invisible: a visitor to
- the public site does not get this group preselected. */}
- {!group.selectedByDefault && (
- <span className="text-[10px] uppercase tracking-wide text-muted-foreground border border-border rounded px-1">
- off by default
+ {/* THE CONTENT PINS LEFT. The th spans every column, so its name and
+ five stations used to scroll off to the left with the table — the
+ group's controls out of reach exactly when the operator had
+ scrolled across to read a column. Capped at the region's visible
+ width (`--rack-w`, measured by ChannelsRack; 100% before the first
+ measure) less the th's padding, so the count on the right stays
+ in view too. */}
+ <div className="sticky left-2 max-w-[calc(var(--rack-w,100%)-1rem)]">
+ <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
+ <span
+ data-testid="group-name"
+ className="text-xs font-semibold uppercase tracking-wider"
+ >
+ {name}
</span>
- )}
- {/* The authored description. Written by SiteForm, parsed by
+ {/* Rendered because it is true and currently invisible: a visitor to
+ the public site does not get this group preselected. */}
+ {!group.selectedByDefault && (
+ <span className="text-[10px] uppercase tracking-wide text-muted-foreground border border-border rounded px-1">
+ off by default
+ </span>
+ )}
+ {/* The authored description. Written by SiteForm, parsed by
parseChannelGroup, and until now rendered nowhere in the editor. */}
- {group.description && (
- <span className="text-xs text-muted-foreground">
- {group.description}
+ {group.description && (
+ <span className="text-xs text-muted-foreground">
+ {group.description}
+ </span>
+ )}
+ <span className="ml-auto text-xs text-muted-foreground whitespace-nowrap">
+ {channels.length === 1
+ ? "1 channel"
+ : `${channels.length} channels`}
</span>
- )}
- <span className="ml-auto text-xs text-muted-foreground whitespace-nowrap">
- {channels.length === 1 ? "1 channel" : `${channels.length} channels`}
- </span>
+ </div>
+ <ChannelGroupLine section={section} siteId={siteId} />
</div>
- <ChannelGroupLine section={section} siteId={siteId} />
</th>
</tr>
);
diff --git a/editor/app/channels/components/ChannelsRack.tsx b/editor/app/channels/components/ChannelsRack.tsx
@@ -134,11 +134,18 @@ export function ChannelsRack({
const region = regionRef.current;
const thead = theadRef.current;
if (!region || !thead || typeof ResizeObserver === "undefined") return;
- const measure = () =>
+ // `--rack-w` is the region's visible width: a group header's content pins
+ // to the region's left edge and is capped at this, so its name and
+ // stations stay on screen however far the columns scroll across
+ // (ChannelGroupHeaderRow).
+ const measure = () => {
region.style.setProperty("--thead-h", `${thead.offsetHeight}px`);
+ region.style.setProperty("--rack-w", `${region.clientWidth}px`);
+ };
measure();
const observer = new ResizeObserver(measure);
observer.observe(thead);
+ observer.observe(region);
return () => observer.disconnect();
}, []);
diff --git a/editor/e2e/channels-rack-layers.spec.ts b/editor/e2e/channels-rack-layers.spec.ts
@@ -103,6 +103,24 @@ test("scrolled to the bottom-right, the pinned group header paints over the pinn
l.evaluate((el) => Number(getComputedStyle(el).zIndex) || 0);
expect(await zOf(header)).toBeGreaterThan(await zOf(slugCell));
+ // (2b) The group's controls stayed on screen: the header's content pins to
+ // the region's left edge, so scrolled all the way across, its Sync station
+ // is inside the region and a click on it lands on it.
+ const sync = page.getByLabel("sync group All channels");
+ const syncBox = (await sync.boundingBox())!;
+ expect(syncBox.x).toBeGreaterThanOrEqual(regionBox.x);
+ expect(syncBox.x + syncBox.width).toBeLessThanOrEqual(
+ regionBox.x + regionBox.width,
+ );
+ expect(
+ await hitsInside(
+ page,
+ sync,
+ syncBox.x + syncBox.width / 2,
+ syncBox.y + syncBox.height / 2,
+ ),
+ ).toBe(true);
+
// (3) The column header still owns its own centre over everything scrolling
// under it.
const slugTh = page.locator("th", {