#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 (
|
||||
<div className="space-y-4 print:space-y-2">
|
||||
<div
|
||||
@@ -1758,14 +1818,111 @@ 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. */}
|
||||
{/* #267: Floating sticky thead — overlays the column header at
|
||||
the top of the viewport when the schedule scrolls, with a
|
||||
translateX kept in sync with the data table's horizontal
|
||||
scrollLeft so the columns stay aligned. Hidden in print
|
||||
mode; the actual <thead> below carries the labels for
|
||||
paginated output. */}
|
||||
<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"
|
||||
>
|
||||
<table
|
||||
@@ -1781,67 +1938,31 @@ function ScheduleSection({
|
||||
<col key={c.id} style={{ width: 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}
|
||||
isLast={idx === visibleColumns.length - 1}
|
||||
isRtl={isRtl}
|
||||
t={t}
|
||||
showResizeHandle={showResizeHandles}
|
||||
dragEnabled={effectiveCanMutate}
|
||||
onResize={(delta) => setColumnWidth(c.id, c.width + delta)}
|
||||
selectAllOverlay={
|
||||
// Tri-state "select all" lives in the first
|
||||
// visible header (typically # but falls back
|
||||
// gracefully when the user has hidden #) and
|
||||
// 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>
|
||||
{/* #267: hidden in screen mode (the floating sticky thead
|
||||
above is the visible/interactive one); kept available
|
||||
in print mode so paginated output still shows column
|
||||
labels. Plain <th>s — no testids, no Sortable, no
|
||||
checkbox — to avoid duplicating those with the floating
|
||||
thead (which would break getByTestId in tests). */}
|
||||
<thead className="hidden print:table-header-group">
|
||||
<tr className="bg-[#0B1E3F] text-white">
|
||||
{visibleColumns.map((c) => {
|
||||
const align =
|
||||
c.id === "number" || c.id === "time"
|
||||
? "text-center"
|
||||
: "text-start";
|
||||
return (
|
||||
<th
|
||||
key={c.id}
|
||||
className={`border border-[#0B1E3F] px-2 py-2 ${align} font-semibold`}
|
||||
style={{ width: c.width }}
|
||||
>
|
||||
{t(`executiveMeetings.col.${c.id}`)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
@@ -2178,17 +2299,11 @@ 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 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}
|
||||
data-testid={`em-col-header-${col.id}`}
|
||||
{...(dragEnabled ? attributes : {})}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// #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
|
||||
// viewport, scroll the page, and assert that all 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).
|
||||
// The column header must stick at every viewport width — mobile,
|
||||
// tablet, and desktop.
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
@@ -85,15 +87,52 @@ test.afterAll(async () => {
|
||||
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,
|
||||
}) => {
|
||||
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");
|
||||
@@ -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.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 });
|
||||
await expect(page.getByTestId(`em-row-${seededIds[0]}`)).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);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
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);
|
||||
await assertStickyAtTop(page);
|
||||
});
|
||||
|
||||
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,
|
||||
}) => {
|
||||
// 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");
|
||||
@@ -220,27 +207,14 @@ test("Schedule [ar/RTL]: page header and schedule heading still stick to the top
|
||||
expect(dir).toBe("rtl");
|
||||
|
||||
await page.evaluate(() => window.scrollTo(0, 1500));
|
||||
await page.waitForTimeout(150);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
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);
|
||||
await assertStickyAtTop(page);
|
||||
});
|
||||
|
||||
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,
|
||||
}) => {
|
||||
// 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);
|
||||
@@ -251,7 +225,7 @@ test("Schedule: at narrow (<xl) widths, header + heading row stick but the table
|
||||
const id = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: i,
|
||||
titleEn: `StickyTest Narrow ${i}`,
|
||||
titleEn: `StickyTest Tablet ${i}`,
|
||||
titleAr: `اجتماع ضيق ${i}`,
|
||||
startTime: `${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");
|
||||
// Tablet-ish width: well below the xl breakpoint.
|
||||
await page.setViewportSize({ width: 900, height: 700 });
|
||||
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.waitForTimeout(150);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
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);
|
||||
await assertStickyAtTop(page);
|
||||
});
|
||||
|
||||
test("Schedule [mobile, 414px]: column header still sticks on a narrow phone-sized viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setLangEn(page);
|
||||
|
||||
const date = uniqueFutureDate(5);
|
||||
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 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