#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:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user