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

Executive Meetings page: keep the page header, the schedule title row
(heading + edit toggle + date picker), and the table column header
visible while the user scrolls a long meetings list.

Implementation:
- ExecutiveMeetingsPage publishes the live header height as a CSS
  variable `--em-header-h` on the page root (data-em-root) via a
  ResizeObserver. The <header> is now `sticky top-0 z-40` with
  print:hidden so the print layout is untouched.
- ScheduleSection wraps the title row in a `sticky top:var(--em-header-h)
  z-30` div with a soft full-bleed background and bottom shadow, and
  publishes its own height as `--em-heading-h` on the page root via a
  second ResizeObserver. Cleanup resets the var to "0px" so other
  sections without a heading don't inherit a stale offset.
- SortableHeader's <th> is now `position:sticky` with
  `top: calc(var(--em-header-h) + var(--em-heading-h)); zIndex:5` and a
  solid `bg-[#0B1E3F]` so scrolled rows don't bleed through.
- The table wrapper changes from `overflow-x-auto` to
  `overflow-x-auto xl:overflow-x-visible` so at >=xl the inner thead's
  nearest scrolling ancestor is the viewport (sticky works). Below xl
  the wrapper retains horizontal scroll and the column header
  gracefully degrades to non-sticky; the page header + heading row
  still stick at every width.
- Print mode is preserved via `print:hidden` (sticky bars) and
  `print:!static` / `print:!shadow-none` overrides (sticky headers).

Tests:
- New e2e spec executive-meetings-sticky-header.spec.mjs covers three
  scenarios: desktop LTR (all three layers stick flush), Arabic RTL
  (header + heading stick), and narrow viewport (header + heading
  stick, column header documented to degrade).
- Re-ran related schedule e2e specs (bulk-actions 5/5, edit-toggle
  6/6, schedule-features 4/4) and the full API suite (226/226
  sequential) — all pass.

New testids: `em-page-header`, `em-schedule-heading-bar`. Existing
`em-schedule-heading` testid retained on the inner h2.

No drift from task plan.
This commit is contained in:
riyadhafraa
2026-05-01 09:23:36 +00:00
parent 307552317e
commit 69b5deef19
2 changed files with 366 additions and 4 deletions
@@ -552,12 +552,44 @@ export default function ExecutiveMeetingsPage() {
const meetings = data?.meetings ?? [];
// #267: Measure the header so the in-section sticky rows (page title +
// table thead) can sit flush below it on every breakpoint. The header
// wraps on narrow widths, so a static Tailwind `top-N` would either
// gap or overlap. We expose the live height as `--em-header-h` on the
// page root and let descendant sticky elements read it via `top:
// var(--em-header-h)`.
const rootRef = useRef<HTMLDivElement | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const header = headerRef.current;
const root = rootRef.current;
if (!header || !root) return;
const update = () => {
root.style.setProperty("--em-header-h", `${header.offsetHeight}px`);
};
update();
const ro = new ResizeObserver(update);
ro.observe(header);
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
};
}, []);
return (
<div
ref={rootRef}
data-em-root
className="min-h-screen bg-[#f4f6fb] text-foreground"
dir={isRtl ? "rtl" : "ltr"}
style={{ ["--em-header-h" as string]: "0px", ["--em-heading-h" as string]: "0px" }}
>
<header className="bg-white border-b border-gray-200 shadow-sm print:hidden">
<header
ref={headerRef}
data-testid="em-page-header"
className="sticky top-0 z-40 bg-white border-b border-gray-200 shadow-sm print:static print:shadow-none print:hidden"
>
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 py-3 flex items-center gap-3 sm:gap-4 flex-wrap">
<Button
variant="ghost"
@@ -1590,9 +1622,39 @@ function ScheduleSection({
[reorderRows],
);
// #267: Same dynamic-measurement trick as the page header — the title
// row wraps on narrow widths (heading + edit toggle + date picker), so
// we publish its live height so the table thead can sit flush below.
const headingRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const heading = headingRef.current;
if (!heading) return;
const root = heading.closest<HTMLElement>("[data-em-root]");
if (!root) return;
const update = () => {
root.style.setProperty("--em-heading-h", `${heading.offsetHeight}px`);
};
update();
const ro = new ResizeObserver(update);
ro.observe(heading);
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
// Reset so other sections (which don't render this heading) don't
// inherit a stale offset for any sticky descendants.
root.style.setProperty("--em-heading-h", "0px");
};
}, []);
return (
<div className="space-y-4 print:space-y-2">
<div className="flex items-center justify-between gap-3 flex-wrap print:hidden">
<div
ref={headingRef}
data-testid="em-schedule-heading-bar"
className="sticky z-30 bg-[#f4f6fb] -mx-3 sm:-mx-6 px-3 sm:px-6 py-2 shadow-[0_4px_6px_-4px_rgba(11,30,63,0.15)] flex items-center justify-between gap-3 flex-wrap print:hidden print:!static print:!shadow-none print:!mx-0 print:!px-0"
style={{ top: "var(--em-header-h, 0px)" }}
>
<h2
className="text-xl sm:text-2xl font-bold text-[#0B1E3F]"
data-testid="em-schedule-heading"
@@ -1696,8 +1758,14 @@ function ScheduleSection({
</div>
)}
{/* #267: drop the horizontal scroll container on ≥xl so the
inner thead can use position: sticky against the viewport.
Below xl the table needs overflow-x-auto for narrow screens
(it'd otherwise overflow the page); the column header
gracefully degrades to non-sticky there. The page header and
schedule heading row remain sticky at every width. */}
<div
className="bg-white border-2 border-[#0B1E3F] rounded-md overflow-x-auto shadow-sm print:shadow-none print:border-black print:overflow-hidden"
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"
id="executive-schedule-printable"
>
<table
@@ -2110,11 +2178,17 @@ function SortableHeader({
// Only advertise grab cursor when drag is actually enabled — view
// mode renders the header as static text.
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 (
<th
ref={setNodeRef}
className={`relative border border-[#0B1E3F] px-2 py-2 ${align} font-semibold select-none`}
className={`relative bg-[#0B1E3F] border border-[#0B1E3F] px-2 py-2 ${align} font-semibold select-none print:!static`}
style={style}
data-testid={`em-col-header-${col.id}`}
{...(dragEnabled ? attributes : {})}
@@ -0,0 +1,288 @@
// #267: e2e coverage for the sticky top bar / schedule heading / table
// header on the Executive Meetings page. We seed enough rows on a
// far-future date that the page is comfortably taller than the
// viewport, scroll the page, and assert that the three sticky
// elements line up flush with the top of the viewport (header at
// y=0, heading row directly under it, then the column header row).
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run the sticky-header UI test",
);
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
async function loginViaUi(page, username, password) {
await page.goto("/login");
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
async function setLangEn(page) {
await page.addInitScript(() => {
try {
window.localStorage.setItem("tx-lang", "en");
} catch {
/* ignore */
}
});
}
const RANDOM_DATE_BASE_DAYS =
365 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
function uniqueFutureDate(offsetDays) {
const d = new Date();
d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7);
return d.toISOString().slice(0, 10);
}
async function insertMeeting({
meetingDate,
dailyNumber,
titleEn,
titleAr,
startTime,
endTime,
}) {
const { rows } = await pool.query(
`INSERT INTO executive_meetings
(daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
VALUES ($1, $2, $3, $4, $5, $6, 'scheduled')
RETURNING id`,
[dailyNumber, titleAr, titleEn, meetingDate, startTime, endTime],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return id;
}
test.afterAll(async () => {
if (createdMeetingIds.length > 0) {
await pool.query(
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
[createdMeetingIds],
);
}
await pool.end();
});
test("Schedule: page header, heading row, and column header all stick to the top of the viewport on scroll", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(2);
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++) {
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 Meeting ${i}`,
titleAr: `اجتماع ثبات ${i}`,
startTime: `${hh}:${mm}:00`,
endTime: `${hh}:${mm}:00`,
});
seededIds.push(id);
}
await loginViaUi(page, "admin", "admin123");
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto("/executive-meetings");
// Jump to the seeded date.
const dateInput = page.locator('input[type="date"]').first();
await dateInput.fill(date);
// Wait for our rows to render.
const firstRow = page.getByTestId(`em-row-${seededIds[0]}`);
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));
// Give the browser a frame to settle the sticky positions.
await page.waitForTimeout(150);
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();
// 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 ({
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(() => {
try {
window.localStorage.setItem("tx-lang", "ar");
} catch {
/* ignore */
}
});
const date = uniqueFutureDate(3);
const seededIds = [];
for (let i = 1; i <= 30; 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 RTL ${i}`,
titleAr: `اجتماع ثبات RTL ${i}`,
startTime: `${hh}:${mm}:00`,
endTime: `${hh}:${mm}:00`,
});
seededIds.push(id);
}
await loginViaUi(page, "admin", "admin123");
await page.setViewportSize({ width: 1280, height: 720 });
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,
});
// Sanity-check the document is actually mirrored.
const dir = await page.evaluate(() => document.documentElement.dir);
expect(dir).toBe("rtl");
await page.evaluate(() => window.scrollTo(0, 1500));
await page.waitForTimeout(150);
const headerBox = await page.getByTestId("em-page-header").boundingBox();
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 ({
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);
const date = uniqueFutureDate(4);
const seededIds = [];
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 Narrow ${i}`,
titleAr: `اجتماع ضيق ${i}`,
startTime: `${hh}:${mm}:00`,
endTime: `${hh}:${mm}:00`,
});
seededIds.push(id);
}
await loginViaUi(page, "admin", "admin123");
// Tablet-ish width: well below the xl breakpoint.
await page.setViewportSize({ width: 900, height: 700 });
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(150);
const headerBox = await page.getByTestId("em-page-header").boundingBox();
const headingBox = await page.getByTestId("em-schedule-heading-bar").boundingBox();
expect(headerBox).not.toBeNull();
expect(headingBox).not.toBeNull();
// Page header sticks.
expect(headerBox.y).toBeGreaterThanOrEqual(-1);
expect(headerBox.y).toBeLessThanOrEqual(1);
// Heading row sticks just under the page header.
expect(headingBox.y).toBeGreaterThanOrEqual(headerBox.y + headerBox.height - 1);
expect(headingBox.y).toBeLessThan(headerBox.y + headerBox.height + 4);
});