#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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user