#267: Freeze top bar, schedule heading, and table column header on scroll

Top bar and schedule-heading row already used `position: sticky` with
dynamic offsets published as `--em-header-h` / `--em-heading-h` via
ResizeObserver. The table column header is the new piece.

Approach: a floating sticky overlay (`em-sticky-thead`) rendered as a
DOM-sibling above the horizontally-scrollable wrapper, scroll-synced
in JS and column-width-measured from the first valid tbody row via
ResizeObserver. The original `<thead>` is hidden in screen mode but
kept (no testids, no Sortable) with `print:table-header-group` so
column labels still appear at the top of every printed page.

This replaces the rejected first attempt that relied on
`position:sticky` inside `overflow-x-auto`, which only worked at xl+
viewports. The floating overlay sticks reliably at mobile (414px),
tablet (~900px), desktop, and in RTL — all four covered by an
expanded sticky-header e2e spec.

Drag-reorder, resize handles, and the bulk-select tri-state checkbox
now live exclusively in the floating thead, eliminating the duplicate-
testid concern from having two interactive headers in the DOM.

Tests: 4/4 sticky-header, 5/5 bulk-actions, 6/6 edit-toggle, 7/7
keyboard editing, 1/1 touch reorder. The one failing schedule-features
case (custom highlight color) reproduces on the pre-change baseline
and is pre-existing flake unrelated to this work.

Files:
- artifacts/tx-os/src/pages/executive-meetings.tsx
- artifacts/tx-os/tests/executive-meetings-sticky-header.spec.mjs
This commit is contained in:
Riyadh
2026-05-01 09:54:18 +00:00
parent cf46487233
commit 197d21ef17
3 changed files with 283 additions and 167 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+190 -75
View File
@@ -1647,6 +1647,66 @@ function ScheduleSection({
}; };
}, []); }, []);
// #267: Floating sticky thead. The actual table sits inside an
// overflow-x-auto wrapper (so wide tables can scroll horizontally on
// narrow screens), which means a CSS-only `position: sticky` on the
// thead is trapped inside that scrolling container and won't stick
// to the viewport. Workaround: render a separate sticky overlay
// above the wrapper that mirrors the actual table's column widths +
// horizontal scroll. The actual <thead> is hidden in screen mode
// (kept for print so paginated output still has column labels).
const tableWrapperRef = useRef<HTMLDivElement | null>(null);
const [floatingScrollLeft, setFloatingScrollLeft] = useState(0);
// Live, measured column widths in current visual order. Filled by
// the ResizeObserver below from the real thead's <th> cells. Falls
// back to the stored `columns[i].width` when measurement isn't
// available yet (first paint, or the table isn't yet in the DOM).
const [floatingColWidths, setFloatingColWidths] = useState<number[]>([]);
const [floatingTableWidth, setFloatingTableWidth] = useState<number>(0);
useEffect(() => {
const wrapper = tableWrapperRef.current;
if (!wrapper) return;
const onScroll = () => setFloatingScrollLeft(wrapper.scrollLeft);
wrapper.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => wrapper.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
const wrapper = tableWrapperRef.current;
if (!wrapper) return;
const measure = () => {
const table = wrapper.querySelector("table");
if (!table) return;
// The actual <thead> is display:none in screen mode (only the
// floating thead is visible), so we measure column widths from
// the first regular tbody row whose td-count matches the
// visible-column count. Subheading / placeholder / colspan
// rows are skipped.
const rows = Array.from(table.querySelectorAll<HTMLTableRowElement>("tbody > tr"));
const sample = rows.find((r) => r.children.length === visibleColumns.length);
if (sample) {
const widths = Array.from(sample.children).map(
(td) => (td as HTMLElement).getBoundingClientRect().width,
);
setFloatingColWidths(widths);
}
setFloatingTableWidth(table.getBoundingClientRect().width);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(wrapper);
const table = wrapper.querySelector("table");
if (table) ro.observe(table);
window.addEventListener("resize", measure);
return () => {
ro.disconnect();
window.removeEventListener("resize", measure);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [orderedMeetings.length, visibleColumns.length]);
return ( return (
<div className="space-y-4 print:space-y-2"> <div className="space-y-4 print:space-y-2">
<div <div
@@ -1758,14 +1818,111 @@ function ScheduleSection({
</div> </div>
)} )}
{/* #267: drop the horizontal scroll container on ≥xl so the {/* #267: Floating sticky thead — overlays the column header at
inner thead can use position: sticky against the viewport. the top of the viewport when the schedule scrolls, with a
Below xl the table needs overflow-x-auto for narrow screens translateX kept in sync with the data table's horizontal
(it'd otherwise overflow the page); the column header scrollLeft so the columns stay aligned. Hidden in print
gracefully degrades to non-sticky there. The page header and mode; the actual <thead> below carries the labels for
schedule heading row remain sticky at every width. */} paginated output. */}
<div <div
className="bg-white border-2 border-[#0B1E3F] rounded-md overflow-x-auto xl:overflow-x-visible shadow-sm print:shadow-none print:border-black print:overflow-hidden" data-testid="em-sticky-thead"
className="sticky z-[6] overflow-hidden bg-white border-x-2 border-t-2 border-[#0B1E3F] rounded-t-md shadow-[0_4px_6px_-4px_rgba(11,30,63,0.15)] print:hidden"
style={{
top: "calc(var(--em-header-h, 0px) + var(--em-heading-h, 0px))",
}}
>
<div
style={{
// `translateX(-scrollLeft)` works in both LTR and RTL:
// RTL browsers report scrollLeft as negative when content
// is scrolled left, so negating it yields a positive
// (rightward) translate that matches the table's
// direction of motion.
transform: `translateX(${-floatingScrollLeft}px)`,
width: floatingTableWidth || undefined,
}}
>
<table
className="border-collapse text-sm md:text-[15px] table-fixed"
dir={isRtl ? "rtl" : "ltr"}
style={tableStyle}
>
<colgroup>
{visibleColumns.map((c, i) => (
<col
key={c.id}
style={{ width: floatingColWidths[i] ?? c.width }}
/>
))}
</colgroup>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onColumnDragEnd}
>
<thead>
<tr className="bg-[#0B1E3F] text-white">
<SortableContext
items={visibleColumns.map((c) => c.id)}
strategy={horizontalListSortingStrategy}
>
{visibleColumns.map((c, idx) => (
<SortableHeader
key={c.id}
col={{
...c,
width: floatingColWidths[idx] ?? c.width,
}}
isLast={idx === visibleColumns.length - 1}
isRtl={isRtl}
t={t}
showResizeHandle={showResizeHandles}
dragEnabled={effectiveCanMutate}
onResize={(delta) =>
setColumnWidth(c.id, c.width + delta)
}
selectAllOverlay={
idx === 0 &&
effectiveCanMutate &&
orderedMeetings.length > 0 ? (
<div
className={`absolute top-1 ${isRtl ? "right-1" : "left-1"} z-10 print:hidden`}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={
allSelectedState === "all"
? true
: allSelectedState === "some"
? "indeterminate"
: false
}
onCheckedChange={(v) =>
setAllMeetingsSelected(v === true)
}
aria-label={t(
"executiveMeetings.schedule.bulkSelectAll",
)}
data-testid="em-bulk-select-all"
className="h-4 w-4 bg-white shadow-sm"
/>
</div>
) : undefined
}
/>
))}
</SortableContext>
</tr>
</thead>
</DndContext>
</table>
</div>
</div>
<div
ref={tableWrapperRef}
className="bg-white border-2 border-[#0B1E3F] rounded-md overflow-x-auto shadow-sm print:shadow-none print:border-black print:overflow-hidden"
id="executive-schedule-printable" id="executive-schedule-printable"
> >
<table <table
@@ -1781,67 +1938,31 @@ function ScheduleSection({
<col key={c.id} style={{ width: c.width }} /> <col key={c.id} style={{ width: c.width }} />
))} ))}
</colgroup> </colgroup>
<DndContext {/* #267: hidden in screen mode (the floating sticky thead
sensors={sensors} above is the visible/interactive one); kept available
collisionDetection={closestCenter} in print mode so paginated output still shows column
onDragEnd={onColumnDragEnd} labels. Plain <th>s — no testids, no Sortable, no
> checkbox — to avoid duplicating those with the floating
<thead> thead (which would break getByTestId in tests). */}
<tr className="bg-[#0B1E3F] text-white"> <thead className="hidden print:table-header-group">
<SortableContext <tr className="bg-[#0B1E3F] text-white">
items={visibleColumns.map((c) => c.id)} {visibleColumns.map((c) => {
strategy={horizontalListSortingStrategy} const align =
> c.id === "number" || c.id === "time"
{visibleColumns.map((c, idx) => ( ? "text-center"
<SortableHeader : "text-start";
key={c.id} return (
col={c} <th
isLast={idx === visibleColumns.length - 1} key={c.id}
isRtl={isRtl} className={`border border-[#0B1E3F] px-2 py-2 ${align} font-semibold`}
t={t} style={{ width: c.width }}
showResizeHandle={showResizeHandles} >
dragEnabled={effectiveCanMutate} {t(`executiveMeetings.col.${c.id}`)}
onResize={(delta) => setColumnWidth(c.id, c.width + delta)} </th>
selectAllOverlay={ );
// Tri-state "select all" lives in the first })}
// visible header (typically # but falls back </tr>
// gracefully when the user has hidden #) and </thead>
// only renders in edit mode with ≥1 row to
// act on.
idx === 0 &&
effectiveCanMutate &&
orderedMeetings.length > 0 ? (
<div
className={`absolute top-1 ${isRtl ? "right-1" : "left-1"} z-10 print:hidden`}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={
allSelectedState === "all"
? true
: allSelectedState === "some"
? "indeterminate"
: false
}
onCheckedChange={(v) =>
setAllMeetingsSelected(v === true)
}
aria-label={t(
"executiveMeetings.schedule.bulkSelectAll",
)}
data-testid="em-bulk-select-all"
className="h-4 w-4 bg-white shadow-sm"
/>
</div>
) : undefined
}
/>
))}
</SortableContext>
</tr>
</thead>
</DndContext>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@@ -2178,17 +2299,11 @@ function SortableHeader({
// Only advertise grab cursor when drag is actually enabled — view // Only advertise grab cursor when drag is actually enabled — view
// mode renders the header as static text. // mode renders the header as static text.
cursor: dragEnabled ? "grab" : undefined, cursor: dragEnabled ? "grab" : undefined,
// #267: stick the column header below the page header + the schedule
// title row. Both heights are published as CSS variables on the page
// root so this stays correct as the header wraps on narrow widths.
position: "sticky",
top: "calc(var(--em-header-h, 0px) + var(--em-heading-h, 0px))",
zIndex: 5,
}; };
return ( return (
<th <th
ref={setNodeRef} ref={setNodeRef}
className={`relative bg-[#0B1E3F] border border-[#0B1E3F] px-2 py-2 ${align} font-semibold select-none print:!static`} className={`relative border border-[#0B1E3F] px-2 py-2 ${align} font-semibold select-none`}
style={style} style={style}
data-testid={`em-col-header-${col.id}`} data-testid={`em-col-header-${col.id}`}
{...(dragEnabled ? attributes : {})} {...(dragEnabled ? attributes : {})}
@@ -1,9 +1,11 @@
// #267: e2e coverage for the sticky top bar / schedule heading / table // #267: e2e coverage for the sticky top bar / schedule heading / table
// header on the Executive Meetings page. We seed enough rows on a // header on the Executive Meetings page. We seed enough rows on a
// far-future date that the page is comfortably taller than the // far-future date that the page is comfortably taller than the
// viewport, scroll the page, and assert that the three sticky // viewport, scroll the page, and assert that all three sticky
// elements line up flush with the top of the viewport (header at // elements line up flush with the top of the viewport (header at
// y=0, heading row directly under it, then the column header row). // y=0, heading row directly under it, then the column header row).
// The column header must stick at every viewport width — mobile,
// tablet, and desktop.
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import pg from "pg"; import pg from "pg";
@@ -85,15 +87,52 @@ test.afterAll(async () => {
await pool.end(); await pool.end();
}); });
test("Schedule: page header, heading row, and column header all stick to the top of the viewport on scroll", async ({ // The floating sticky thead renders before the (display:none) actual
// thead in the DOM, so .first() reliably targets the visible one.
function visibleColHeader(page, colId) {
return page.getByTestId(`em-col-header-${colId}`).first();
}
async function assertStickyAtTop(page, { allowFloatingY = 4 } = {}) {
const header = page.getByTestId("em-page-header");
const heading = page.getByTestId("em-schedule-heading-bar");
const colHeader = visibleColHeader(page, "meeting");
await expect(header).toBeVisible();
await expect(heading).toBeVisible();
await expect(colHeader).toBeVisible();
const headerBox = await header.boundingBox();
const headingBox = await heading.boundingBox();
const colBox = await colHeader.boundingBox();
expect(headerBox).not.toBeNull();
expect(headingBox).not.toBeNull();
expect(colBox).not.toBeNull();
// Page header is flush with the viewport top.
expect(headerBox.y).toBeGreaterThanOrEqual(-1);
expect(headerBox.y).toBeLessThanOrEqual(1);
// Heading row sits directly below the page header.
const expectedHeadingY = headerBox.y + headerBox.height;
expect(headingBox.y).toBeGreaterThanOrEqual(expectedHeadingY - 2);
expect(headingBox.y).toBeLessThan(expectedHeadingY + 4);
// Column header sits directly below the heading row — the
// critical assertion: it actually stuck instead of scrolling
// away with the table.
const expectedColY = headingBox.y + headingBox.height;
expect(colBox.y).toBeGreaterThanOrEqual(expectedColY - 2);
expect(colBox.y).toBeLessThan(expectedColY + allowFloatingY);
}
test("Schedule [desktop, LTR]: page header, heading row, and column header all stick to the top of the viewport on scroll", async ({
page, page,
}) => { }) => {
await setLangEn(page); await setLangEn(page);
const date = uniqueFutureDate(2); const date = uniqueFutureDate(2);
const seededIds = []; const seededIds = [];
// Seed 30 meetings on the same date so the schedule is comfortably
// taller than any reasonable viewport.
for (let i = 1; i <= 30; i++) { for (let i = 1; i <= 30; i++) {
const hh = String(7 + Math.floor(i / 4)).padStart(2, "0"); const hh = String(7 + Math.floor(i / 4)).padStart(2, "0");
const mm = String((i % 4) * 15).padStart(2, "0"); const mm = String((i % 4) * 15).padStart(2, "0");
@@ -112,74 +151,22 @@ test("Schedule: page header, heading row, and column header all stick to the top
await page.setViewportSize({ width: 1280, height: 720 }); await page.setViewportSize({ width: 1280, height: 720 });
await page.goto("/executive-meetings"); await page.goto("/executive-meetings");
// Jump to the seeded date.
const dateInput = page.locator('input[type="date"]').first(); const dateInput = page.locator('input[type="date"]').first();
await dateInput.fill(date); await dateInput.fill(date);
// Wait for our rows to render. await expect(page.getByTestId(`em-row-${seededIds[0]}`)).toBeVisible({
const firstRow = page.getByTestId(`em-row-${seededIds[0]}`); timeout: 10_000,
await expect(firstRow).toBeVisible({ timeout: 10_000 }); });
const header = page.getByTestId("em-page-header");
const heading = page.getByTestId("em-schedule-heading-bar");
// Use the meeting-title column header — guaranteed to exist as long
// as the column hasn't been hidden by the user (we just reset prefs
// by reload, so all default columns are visible).
const colHeader = page.getByTestId("em-col-header-meeting");
await expect(header).toBeVisible();
await expect(heading).toBeVisible();
await expect(colHeader).toBeVisible();
// Baseline geometry BEFORE scroll: header is at y=0 already (it's
// sticky from the start), heading is directly underneath, col
// header is below the heading row.
const headerBox0 = await header.boundingBox();
const headingBox0 = await heading.boundingBox();
const colBox0 = await colHeader.boundingBox();
expect(headerBox0).not.toBeNull();
expect(headingBox0).not.toBeNull();
expect(colBox0).not.toBeNull();
// Now scroll way down. With 30 rows + spacing the body is well
// over the viewport.
await page.evaluate(() => window.scrollTo(0, 1500)); await page.evaluate(() => window.scrollTo(0, 1500));
// Give the browser a frame to settle the sticky positions. await page.waitForTimeout(200);
await page.waitForTimeout(150);
const headerBox = await header.boundingBox(); await assertStickyAtTop(page);
const headingBox = await heading.boundingBox();
const colBox = await colHeader.boundingBox();
expect(headerBox).not.toBeNull();
expect(headingBox).not.toBeNull();
expect(colBox).not.toBeNull();
// Header should still be flush with the viewport top.
expect(headerBox.y).toBeGreaterThanOrEqual(-1);
expect(headerBox.y).toBeLessThanOrEqual(1);
// Heading row sits directly below the header.
const expectedHeadingY = headerBox.y + headerBox.height;
expect(Math.abs(headingBox.y - expectedHeadingY)).toBeLessThanOrEqual(2);
// Column header sits directly below the heading row.
const expectedColY = headingBox.y + headingBox.height;
expect(colBox.y).toBeGreaterThanOrEqual(expectedColY - 2);
// And critically, the column header is anchored near the top of
// the viewport — i.e. it actually stuck instead of scrolling away.
// (If sticky failed inside the overflow-x-auto wrapper, colBox.y
// would have been negative or far below the heading row.)
expect(colBox.y).toBeLessThan(300);
}); });
test("Schedule [ar/RTL]: page header and schedule heading still stick to the top after scroll", async ({ test("Schedule [desktop, RTL]: page header, heading row, and column header all stick after scroll in Arabic", async ({
page, page,
}) => { }) => {
// Force Arabic so the page renders RTL — confirms our sticky
// styling doesn't depend on writing direction. We deliberately
// assert only the two elements that stick at every viewport (the
// table column header degrades to non-sticky below xl, which is
// covered by the dedicated narrow-viewport scenario below).
await page.addInitScript(() => { await page.addInitScript(() => {
try { try {
window.localStorage.setItem("tx-lang", "ar"); window.localStorage.setItem("tx-lang", "ar");
@@ -220,27 +207,14 @@ test("Schedule [ar/RTL]: page header and schedule heading still stick to the top
expect(dir).toBe("rtl"); expect(dir).toBe("rtl");
await page.evaluate(() => window.scrollTo(0, 1500)); await page.evaluate(() => window.scrollTo(0, 1500));
await page.waitForTimeout(150); await page.waitForTimeout(200);
const headerBox = await page.getByTestId("em-page-header").boundingBox(); await assertStickyAtTop(page);
const headingBox = await page.getByTestId("em-schedule-heading-bar").boundingBox();
expect(headerBox).not.toBeNull();
expect(headingBox).not.toBeNull();
expect(headerBox.y).toBeGreaterThanOrEqual(-1);
expect(headerBox.y).toBeLessThanOrEqual(1);
// Heading sits flush below the header in RTL too.
expect(headingBox.y).toBeGreaterThanOrEqual(headerBox.y + headerBox.height - 1);
expect(headingBox.y).toBeLessThan(headerBox.y + headerBox.height + 4);
}); });
test("Schedule: at narrow (<xl) widths, header + heading row stick but the table column header is allowed to degrade", async ({ test("Schedule [tablet, ~900px]: column header still sticks at sub-xl widths", async ({
page, page,
}) => { }) => {
// Below the xl breakpoint (1280px) the table wrapper retains
// overflow-x-auto so the table can scroll horizontally on narrow
// screens. As a documented trade-off the column header doesn't
// stick there; the page header + schedule heading row still do.
await setLangEn(page); await setLangEn(page);
const date = uniqueFutureDate(4); const date = uniqueFutureDate(4);
@@ -251,7 +225,7 @@ test("Schedule: at narrow (<xl) widths, header + heading row stick but the table
const id = await insertMeeting({ const id = await insertMeeting({
meetingDate: date, meetingDate: date,
dailyNumber: i, dailyNumber: i,
titleEn: `StickyTest Narrow ${i}`, titleEn: `StickyTest Tablet ${i}`,
titleAr: `اجتماع ضيق ${i}`, titleAr: `اجتماع ضيق ${i}`,
startTime: `${hh}:${mm}:00`, startTime: `${hh}:${mm}:00`,
endTime: `${hh}:${mm}:00`, endTime: `${hh}:${mm}:00`,
@@ -260,7 +234,6 @@ test("Schedule: at narrow (<xl) widths, header + heading row stick but the table
} }
await loginViaUi(page, "admin", "admin123"); await loginViaUi(page, "admin", "admin123");
// Tablet-ish width: well below the xl breakpoint.
await page.setViewportSize({ width: 900, height: 700 }); await page.setViewportSize({ width: 900, height: 700 });
await page.goto("/executive-meetings"); await page.goto("/executive-meetings");
@@ -272,17 +245,45 @@ test("Schedule: at narrow (<xl) widths, header + heading row stick but the table
}); });
await page.evaluate(() => window.scrollTo(0, 1500)); await page.evaluate(() => window.scrollTo(0, 1500));
await page.waitForTimeout(150); await page.waitForTimeout(200);
const headerBox = await page.getByTestId("em-page-header").boundingBox(); await assertStickyAtTop(page);
const headingBox = await page.getByTestId("em-schedule-heading-bar").boundingBox(); });
expect(headerBox).not.toBeNull();
expect(headingBox).not.toBeNull(); test("Schedule [mobile, 414px]: column header still sticks on a narrow phone-sized viewport", async ({
page,
// Page header sticks. }) => {
expect(headerBox.y).toBeGreaterThanOrEqual(-1); await setLangEn(page);
expect(headerBox.y).toBeLessThanOrEqual(1);
// Heading row sticks just under the page header. const date = uniqueFutureDate(5);
expect(headingBox.y).toBeGreaterThanOrEqual(headerBox.y + headerBox.height - 1); const seededIds = [];
expect(headingBox.y).toBeLessThan(headerBox.y + headerBox.height + 4); for (let i = 1; i <= 20; i++) {
const hh = String(7 + Math.floor(i / 4)).padStart(2, "0");
const mm = String((i % 4) * 15).padStart(2, "0");
const id = await insertMeeting({
meetingDate: date,
dailyNumber: i,
titleEn: `StickyTest Mobile ${i}`,
titleAr: `اجتماع جوال ${i}`,
startTime: `${hh}:${mm}:00`,
endTime: `${hh}:${mm}:00`,
});
seededIds.push(id);
}
await loginViaUi(page, "admin", "admin123");
await page.setViewportSize({ width: 414, height: 800 });
await page.goto("/executive-meetings");
const dateInput = page.locator('input[type="date"]').first();
await dateInput.fill(date);
await expect(page.getByTestId(`em-row-${seededIds[0]}`)).toBeVisible({
timeout: 10_000,
});
await page.evaluate(() => window.scrollTo(0, 1500));
await page.waitForTimeout(200);
await assertStickyAtTop(page);
}); });