Sticky headers for tables prototype

I'm not convinced by this, it feels a bit flickery.
This commit is contained in:
Simon Willison 2026-07-11 12:02:30 -07:00
commit 7a848d34fa
3 changed files with 69 additions and 0 deletions

View file

@ -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;

View file

@ -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) {

View file

@ -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")