avo icon indicating copy to clipboard operation
avo copied to clipboard

Add option to make table headers on index view sticky

Open OlexYakov opened this issue 3 years ago • 10 comments
trafficstars

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

image

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?

OlexYakov avatar Aug 01 '22 15:08 OlexYakov

Hi @adrianthedev is this issue open to the public? I would love to handle it.

Benmuiruri avatar Dec 13 '22 13:12 Benmuiruri

Hey @Benmuiruri. Definitely. Go have a try at it and let me know if I can help you navigate the codebase.

adrianthedev avatar Dec 13 '22 13:12 adrianthedev

Thanks, Adrian. Just a clarification about the issue.

The column names in the table headers are visible in this first screenshot. Screenshot from 2022-12-13 16-03-19

In this second screenshot, the column names in the table scroll under the page's main navbar. Screenshot from 2022-12-13 16-03-42

Is the suggestion to maintain the column names below the navbar as the user scrolls?

Benmuiruri avatar Dec 13 '22 13:12 Benmuiruri

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.

adrianthedev avatar Dec 13 '22 13:12 adrianthedev

Great, I'll work on it this week. If I need any help navigating the codebase, I will ping you. Gracias!

Benmuiruri avatar Dec 13 '22 13:12 Benmuiruri

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.

Benmuiruri avatar Jan 03 '23 06:01 Benmuiruri

Thanks @Benmuiruri. Can't wait to have it merged. Let me know if I can help.

adrianthedev avatar Jan 03 '23 13:01 adrianthedev

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.

Image

(() => {
  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 });
})();

yuki-yogi avatar Apr 15 '25 14:04 yuki-yogi

Awesome! How does that work when the table is scrolling horizontally?

adrianthedev avatar Apr 15 '25 15:04 adrianthedev

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 });
})();

yuki-yogi avatar Apr 15 '25 15:04 yuki-yogi