diff --git a/datasette/static/app.css b/datasette/static/app.css index d101e4b7..79fb1a73 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -497,7 +497,10 @@ table.rows-and-columns td em { color: #aaa; } table.rows-and-columns th { + background: #F8FAFB; padding-right: 1em; + position: relative; + z-index: 1; } table.rows-and-columns a:link { text-decoration: none; diff --git a/datasette/static/table.js b/datasette/static/table.js index 74a96d8e..2bc9fda6 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -506,6 +506,45 @@ function renderActionLink(itemConfig) { return newLink; } +function initializeStickyTableHeader(tableWrapper) { + var table = tableWrapper.querySelector("table.rows-and-columns"); + var tableHead = table && table.querySelector("thead"); + if (!tableHead || !window.requestAnimationFrame) { + return; + } + + var animationFrame = null; + function updatePosition() { + animationFrame = null; + if (window.matchMedia("(max-width: 576px)").matches) { + tableHead.style.transform = ""; + return; + } + // overflow-x: auto makes tableWrapper the sticky scroll container, even + // though the page handles vertical scrolling. Counter the page scroll so + // the real header stays aligned with both the viewport and the table. + var wrapperTop = tableWrapper.getBoundingClientRect().top; + var maximumOffset = Math.max( + 0, + table.offsetHeight - tableHead.offsetHeight, + ); + var offset = Math.min(Math.max(-wrapperTop, 0), maximumOffset); + tableHead.style.transform = offset + ? `translateY(${offset}px)` + : ""; + } + + function queuePositionUpdate() { + if (animationFrame === null) { + animationFrame = window.requestAnimationFrame(updatePosition); + } + } + + window.addEventListener("scroll", queuePositionUpdate, { passive: true }); + window.addEventListener("resize", queuePositionUpdate); + queuePositionUpdate(); +} + /** Main initialization function for Datasette Table interactions */ const initDatasetteTable = function (manager) { // Feature detection @@ -521,6 +560,7 @@ const initDatasetteTable = function (manager) { if (tableWrapper) { tableWrapper.addEventListener("scroll", closeMenu); } + window.addEventListener("scroll", closeMenu, { passive: true }); document.body.addEventListener("click", (ev) => { /* was this click outside the menu? */ var target = ev.target; @@ -631,6 +671,9 @@ const initDatasetteTable = function (manager) { icon.addEventListener("click", onTableHeaderClick); th.appendChild(icon); }); + if (tableWrapper) { + initializeStickyTableHeader(tableWrapper); + } }; function filterRowSelector(manager) { diff --git a/tests/test_playwright.py b/tests/test_playwright.py index eb1edb57..ab9d3568 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -410,6 +410,29 @@ def test_datasette_homepage_contains_datasette(page, datasette_server): assert "Datasette" in page.locator("body").inner_text() +@pytest.mark.playwright +def test_table_header_stays_visible_while_scrolling(page, datasette_server): + page.set_viewport_size({"width": 600, "height": 400}) + page.goto(f"{datasette_server}fixtures/sortable?_size=100") + + wrapper = page.locator(".table-wrapper") + header = wrapper.locator("table.rows-and-columns th").first + assert wrapper.evaluate("node => node.scrollWidth > node.clientWidth") + + wrapper.evaluate("""node => window.scrollTo( + 0, + node.getBoundingClientRect().top + window.scrollY + 100 + )""") + page.wait_for_function("""() => Math.abs(document.querySelector( + 'table.rows-and-columns th' + ).getBoundingClientRect().top) < 1""") + assert abs(header.bounding_box()["y"]) < 1 + + wrapper.evaluate("node => node.scrollLeft = 100") + assert wrapper.evaluate("node => node.scrollLeft") == 100 + assert abs(header.bounding_box()["y"]) < 1 + + @pytest.mark.playwright def test_create_table_flow(page, datasette_server): page.goto(f"{datasette_server}data")