avo
avo copied to clipboard
Add option to make table headers on index view sticky
Feature
Add option to make table headers on index view sticky. If we have many rows on the index table, after scrolling down the header becomes hidden, and although it's not crucial, it would be a nice UX improvement to be able to see the column names.
Current workarounds
None
Screenshots

Additional context
Initially, I thought this would be as simple as adding position: sticky to the table header, but it's not enough, because the whole table is inside a div with overflow-auto, and that doesn't play nicely with sticky. This article explains this problem well.
Any ideas on how best to implement this?
Hi @adrianthedev is this issue open to the public? I would love to handle it.
Hey @Benmuiruri. Definitely. Go have a try at it and let me know if I can help you navigate the codebase.
Thanks, Adrian. Just a clarification about the issue.
The column names in the table headers are visible in this first screenshot.

In this second screenshot, the column names in the table scroll under the page's main navbar.

Is the suggestion to maintain the column names below the navbar as the user scrolls?
That's correct. To keep the header visible even when the user scrolls and it would regularly be pushed off the top of the page.
Great, I'll work on it this week. If I need any help navigating the codebase, I will ping you. Gracias!
Hi @adrianthedev, Happy New Year. With the holiday over, it's back to the grind. I have cloned the repo locally and set it up successfully.
Time to dig into the codebase and fix the issue. If I need any help with the codebase, I will ping you. Thanks.
Thanks @Benmuiruri. Can't wait to have it merged. Let me know if I can help.
I've created sample code that works as expected. The code could be improved by dynamically calculating the height of the sticky header or setting it manually.
(() => {
const HEADER_SELECTOR = 'thead[data-component-name="avo/partials/table_header"]';
const CLONE_ID = 'sticky-header-clone';
const HEADER_VISIBILITY_THRESHOLD = 66;
let scrollHandler, resizeHandler;
let originalHeader, headerClone, table, originalThs, cloneThs;
function createStickyHeader() {
cleanupExistingClone();
originalHeader = document.querySelector(HEADER_SELECTOR);
if (!originalHeader) return;
table = originalHeader.closest('table');
if (!table) return;
headerClone = createHeaderClone();
synchronizeHeaderColumns();
setupEventListeners();
updateHeaderVisibility();
}
function cleanupExistingClone() {
const existingClone = document.getElementById(CLONE_ID);
if (existingClone) {
existingClone.remove();
removeEventListeners();
}
}
function createHeaderClone() {
const clone = originalHeader.cloneNode(true);
clone.id = CLONE_ID;
const tableRect = table.getBoundingClientRect();
Object.assign(clone.style, {
position: 'fixed',
top: '0',
left: `${tableRect.left}px`,
width: `${tableRect.width}px`,
zIndex: '1000',
backgroundColor: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
display: 'none',
paddingBottom: '5px'
});
document.body.appendChild(clone);
return clone;
}
function synchronizeHeaderColumns() {
originalThs = originalHeader.querySelectorAll('th');
cloneThs = headerClone.querySelectorAll('th');
originalThs.forEach((th, i) => {
if (!cloneThs[i]) return;
const width = th.getBoundingClientRect().width;
Object.assign(cloneThs[i].style, {
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
height: '60px',
verticalAlign: 'middle'
});
});
}
function setupEventListeners() {
scrollHandler = updateHeaderVisibility;
resizeHandler = handleResize;
window.addEventListener('scroll', scrollHandler, { passive: true });
window.addEventListener('resize', resizeHandler, { passive: true });
}
function removeEventListeners() {
if (scrollHandler) window.removeEventListener('scroll', scrollHandler);
if (resizeHandler) window.removeEventListener('resize', resizeHandler);
}
function updateHeaderVisibility() {
if (!originalHeader || !table || !headerClone) return;
const headerRect = originalHeader.getBoundingClientRect();
const tableRect = table.getBoundingClientRect();
const shouldShowClone = headerRect.bottom <= HEADER_VISIBILITY_THRESHOLD &&
tableRect.bottom > HEADER_VISIBILITY_THRESHOLD;
headerClone.style.display = shouldShowClone ? 'table-header-group' : 'none';
if (shouldShowClone) headerClone.style.left = `${tableRect.left}px`;
}
function handleResize() {
if (!table || !headerClone) return;
const tableRect = table.getBoundingClientRect();
headerClone.style.width = `${tableRect.width}px`;
headerClone.style.left = `${tableRect.left}px`;
synchronizeHeaderColumns();
updateHeaderVisibility();
}
function initStickyHeader() {
setTimeout(createStickyHeader, 100);
}
const pageEvents = ['DOMContentLoaded', 'turbo:load', 'turbo:frame-load', 'turbo:render'];
pageEvents.forEach(event => document.addEventListener(event, initStickyHeader));
document.addEventListener('turbo:before-cache', cleanupExistingClone);
let observerTimeout;
const observeDOM = new MutationObserver(() => {
if (observerTimeout) clearTimeout(observerTimeout);
observerTimeout = setTimeout(() => {
if (document.querySelector(HEADER_SELECTOR)) initStickyHeader();
}, 100);
});
const tableContainer = document.querySelector('.main-content-wrapper') || document.body;
observeDOM.observe(tableContainer, { childList: true, subtree: true });
})();
Awesome! How does that work when the table is scrolling horizontally?
Thank you! I hadn't considered horizontal scrolling. The code below takes horizontal scrolling into account.
(() => {
const HEADER_SELECTOR = 'thead[data-component-name="avo/partials/table_header"]';
const CLONE_ID = 'sticky-header-clone';
const HEADER_VISIBILITY_THRESHOLD = 66;
const PANEL_SELECTOR = 'div[data-target="panel-body"]';
let scrollHandler;
let resizeHandler;
let originalHeader;
let headerClone;
let table;
let originalThs;
let cloneThs;
let panelContainer;
let cloneContainer;
function createStickyHeader() {
cleanupExistingClone();
// Find the original header
originalHeader = document.querySelector(HEADER_SELECTOR);
if (!originalHeader) return;
// Find the table
table = originalHeader.closest('table');
if (!table) return;
// Find the panel container
panelContainer = document.querySelector(PANEL_SELECTOR);
if (!panelContainer) {
console.warn('Panel container not found, using document body');
panelContainer = document.body;
}
// Create container and clone
createCloneContainer();
headerClone = createHeaderClone();
synchronizeHeaderColumns();
// Set up listeners
setupEventListeners();
// Initial update
updateHeaderVisibility();
updateHeaderPosition();
}
function cleanupExistingClone() {
const existingContainer = document.getElementById(`${CLONE_ID}-container`);
if (existingContainer) {
existingContainer.remove();
removeEventListeners();
}
}
function createCloneContainer() {
// Create container for overflow handling
cloneContainer = document.createElement('div');
cloneContainer.id = `${CLONE_ID}-container`;
const panelRect = panelContainer.getBoundingClientRect();
Object.assign(cloneContainer.style, {
position: 'fixed',
top: '0',
left: `${panelRect.left}px`,
width: `${panelRect.width}px`,
height: '60px',
zIndex: '1000',
overflow: 'hidden',
display: 'none'
});
document.body.appendChild(cloneContainer);
}
function createHeaderClone() {
const clone = originalHeader.cloneNode(true);
clone.id = CLONE_ID;
// Apply styles but don't set left position here
Object.assign(clone.style, {
backgroundColor: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
paddingBottom: '5px',
display: 'table-header-group' // Important for proper table header display
});
// Create a table to contain the header
const tableClone = document.createElement('table');
tableClone.style.position = 'absolute'; // This will allow horizontal positioning
tableClone.appendChild(clone);
cloneContainer.appendChild(tableClone);
return clone;
}
function synchronizeHeaderColumns() {
originalThs = originalHeader.querySelectorAll('th');
cloneThs = headerClone.querySelectorAll('th');
originalThs.forEach((th, i) => {
if (!cloneThs[i]) return;
const width = th.getBoundingClientRect().width;
Object.assign(cloneThs[i].style, {
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
height: '60px',
verticalAlign: 'middle'
});
});
// Make sure parent table has correct width
const tableClone = headerClone.parentElement;
if (tableClone) {
const totalWidth = Array.from(originalThs).reduce((sum, th) => {
return sum + th.getBoundingClientRect().width;
}, 0);
tableClone.style.width = `${totalWidth}px`;
}
}
function setupEventListeners() {
// Use named functions to handle events
scrollHandler = function () {
updateHeaderVisibility();
updateHeaderPosition();
};
resizeHandler = function () {
updateCloneContainerSize();
synchronizeHeaderColumns();
updateHeaderPosition();
updateHeaderVisibility();
};
// Add event listeners with passive option for performance
window.addEventListener('scroll', scrollHandler, { passive: true });
window.addEventListener('resize', resizeHandler, { passive: true });
}
function removeEventListeners() {
if (scrollHandler) {
window.removeEventListener('scroll', scrollHandler);
}
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler);
}
}
function updateHeaderVisibility() {
if (!originalHeader || !table || !headerClone || !cloneContainer) return;
const headerRect = originalHeader.getBoundingClientRect();
const tableRect = table.getBoundingClientRect();
// Show when original header is out of view but table is still visible
const shouldShowClone = headerRect.bottom <= HEADER_VISIBILITY_THRESHOLD &&
tableRect.bottom > HEADER_VISIBILITY_THRESHOLD;
cloneContainer.style.display = shouldShowClone ? 'block' : 'none';
}
function updateCloneContainerSize() {
if (!panelContainer || !cloneContainer) return;
const panelRect = panelContainer.getBoundingClientRect();
// Update container position and size to match panel
cloneContainer.style.left = `${panelRect.left}px`;
cloneContainer.style.width = `${panelRect.width}px`;
}
function updateHeaderPosition() {
if (!headerClone || !table || !panelContainer || !cloneContainer) return;
// Get position information
const tableRect = table.getBoundingClientRect();
const panelRect = panelContainer.getBoundingClientRect();
// Update container size
updateCloneContainerSize();
// Get the parent table element of the clone
const tableClone = headerClone.parentElement;
if (!tableClone) return;
// Calculate how much to offset the header based on horizontal scroll
// This is the key part: match position relative to the panel
const offsetX = tableRect.left - panelRect.left;
tableClone.style.left = `${offsetX}px`;
}
function initStickyHeader() {
setTimeout(createStickyHeader, 100);
}
// Set up page event listeners
const pageEvents = ['DOMContentLoaded', 'turbo:load', 'turbo:frame-load', 'turbo:render'];
pageEvents.forEach(event => document.addEventListener(event, initStickyHeader));
// Remove clone on page cache
document.addEventListener('turbo:before-cache', cleanupExistingClone);
// Set up mutation observer to reinitialize when DOM changes
let observerTimeout;
const observeDOM = new MutationObserver(() => {
if (observerTimeout) clearTimeout(observerTimeout);
observerTimeout = setTimeout(() => {
if (document.querySelector(HEADER_SELECTOR)) {
initStickyHeader();
}
}, 100);
});
// Start observing
const tableContainer = document.querySelector('.main-content-wrapper') || document.body;
observeDOM.observe(tableContainer, { childList: true, subtree: true });
})();