From c099d26bfbd8779d57e73a0bc914f87d8afbad11 Mon Sep 17 00:00:00 2001 From: djanelle-mit Date: Wed, 3 Jun 2026 09:03:59 -0400 Subject: [PATCH 1/3] Preserved position of active tab when otherwise hidden --- app/javascript/source_tabs.js | 256 +++++++++++++++++++++++----------- 1 file changed, 176 insertions(+), 80 deletions(-) diff --git a/app/javascript/source_tabs.js b/app/javascript/source_tabs.js index 67f0a411..bf2cb1df 100644 --- a/app/javascript/source_tabs.js +++ b/app/javascript/source_tabs.js @@ -3,93 +3,189 @@ // Source: https://css-tricks.com/container-adapting-tabs-with-more-button/ // =========================================================================== -// Store references to relevant selectors -const container = document.querySelector('#tabs') -const primary = container.querySelector('.primary') -const primaryItems = container.querySelectorAll('.primary > li:not(.-more)') - -// Add a class to turn off graceful degradation style -container.classList.add('has-js') - -// insert "more" button and duplicate the original tab bar items -primary.insertAdjacentHTML('beforeend', ` -
  • - - -
  • -`) -const secondary = container.querySelector('.-secondary') -const secondaryItems = secondary.querySelectorAll('li') -const allItems = container.querySelectorAll('li') -const moreLi = primary.querySelector('.-more') -const moreBtn = moreLi.querySelector('button') - -// When the more button is clicked, toggle classes to indicate the secondary menu is open -moreBtn.addEventListener('click', (e) => { - e.preventDefault() - container.classList.toggle('--show-secondary') - moreBtn.setAttribute('aria-expanded', container.classList.contains('--show-secondary')) -}) - -// Maximum number of tabs to show in the primary tab bar at once -const MAX_TABS = 10 - -// adapt tabs -const doAdapt = () => { - - // reveal all items for the calculation - allItems.forEach((item) => { - item.classList.remove('--hidden') +// Initialize tab bar functionality +const initTabs = () => { + // Store references to relevant selectors + const container = document.querySelector('#tabs') + if (!container) return // Exit if tabs element doesn't exist + if (container.classList.contains('has-js')) return // Already initialized + + const primary = container.querySelector('.primary') + const primaryItems = container.querySelectorAll('.primary > li:not(.-more)') + + // Add a class to turn off graceful degradation style + container.classList.add('has-js') + + // insert "more" button and duplicate the original tab bar items + primary.insertAdjacentHTML('beforeend', ` +
  • + + +
  • + `) + const secondary = container.querySelector('.-secondary') + const secondaryItems = secondary.querySelectorAll('li') + const allItems = container.querySelectorAll('li') + const moreLi = primary.querySelector('.-more') + const moreBtn = moreLi.querySelector('button') + + // Store original DOM order by index for each li + Array.from(primary.querySelectorAll('li:not(.-more)')).forEach((li, index) => { + li.dataset.originalIndex = index + }) + Array.from(secondary.querySelectorAll('li:not(.-more)')).forEach((li, index) => { + li.dataset.originalIndex = index }) - // hide items that won't fit in the Primary tab bar, or exceed MAX_TABS - // once a tab is hidden, all subsequent tabs are also hidden to preserve order - let stopWidth = moreBtn.offsetWidth - let hiddenItems = [] - let shouldHide = false - const primaryWidth = primary.offsetWidth - primaryItems.forEach((item, i) => { - if(!shouldHide && i < MAX_TABS && primaryWidth >= stopWidth + item.offsetWidth) { - stopWidth += item.offsetWidth - } else { - shouldHide = true - item.classList.add('--hidden') - hiddenItems.push(i) - } + // When the more button is clicked, toggle classes to indicate the secondary menu is open + moreBtn.addEventListener('click', (e) => { + e.preventDefault() + container.classList.toggle('--show-secondary') + moreBtn.setAttribute('aria-expanded', container.classList.contains('--show-secondary')) }) - - // toggle the visibility of More button and items in Secondary menu - if(!hiddenItems.length) { - moreLi.classList.add('--hidden') - container.classList.remove('--show-secondary') - moreBtn.setAttribute('aria-expanded', false) - } - else { - secondaryItems.forEach((item, i) => { - if(!hiddenItems.includes(i)) { + + // Maximum number of tabs to show in the primary tab bar at once + const MAX_TABS = 10 + + // adapt tabs + const doAdapt = () => { + + // reveal all items for the calculation + allItems.forEach((item) => { + item.classList.remove('--hidden') + }) + + // Get primary items in current DOM order (re-query each time, direct children only) + const currentPrimaryItems = Array.from(primary.querySelectorAll(':scope > li:not(.-more)')) + + // hide items that won't fit in the Primary tab bar, or exceed MAX_TABS + // once a tab is hidden, all subsequent tabs are also hidden to preserve order + let stopWidth = moreBtn.offsetWidth + let hiddenItems = [] + let shouldHide = false + const primaryWidth = primary.offsetWidth + currentPrimaryItems.forEach((item, i) => { + if(!shouldHide && i < MAX_TABS && primaryWidth >= stopWidth + item.offsetWidth) { + stopWidth += item.offsetWidth + } else { + shouldHide = true item.classList.add('--hidden') + hiddenItems.push(i) } }) - } -} + + // toggle the visibility of More button and items in Secondary menu + if(!hiddenItems.length) { + moreLi.classList.add('--hidden') + container.classList.remove('--show-secondary') + moreBtn.setAttribute('aria-expanded', false) + } + else { + // Re-query secondary items in current DOM order (direct children only) + const currentSecondaryItems = Array.from(secondary.querySelectorAll(':scope > li:not(.-more)')) + currentSecondaryItems.forEach((item, i) => { + if(!hiddenItems.includes(i)) { + item.classList.add('--hidden') + } + }) + } -// Adapt the tabs to fit the viewport -doAdapt() // immediately on load -window.addEventListener('resize', doAdapt) // on window resize + // Handle active hidden tab - move it to position 2 (right after "All") + const activeLink = primary.querySelector('a.active') + if (activeLink) { + const activeLi = activeLink.parentElement -// hide Secondary menu on the outside click -document.addEventListener('click', (e) => { - let el = e.target - while(el) { - if(el === moreBtn) { - return; + if (activeLi.classList.contains('--hidden')) { + const activeIndex = currentPrimaryItems.indexOf(activeLi) + + // If active tab is not at position 0 (All) or position 1, move it to position 1 + if (activeIndex > 1) { + const liAtPos1 = currentPrimaryItems[1] + + // Swap in primary by moving active before position 1 + primary.insertBefore(activeLi, liAtPos1) + + // Find matching items in secondary by original index + const activeOriginalIndex = parseInt(activeLi.dataset.originalIndex) + const pos1OriginalIndex = parseInt(liAtPos1.dataset.originalIndex) + + const currentSecondaryItems = Array.from(secondary.querySelectorAll(':scope > li:not(.-more)')) + const secondaryActive = currentSecondaryItems.find(li => + parseInt(li.dataset.originalIndex) === activeOriginalIndex + ) + const secondaryPos1 = currentSecondaryItems.find(li => + parseInt(li.dataset.originalIndex) === pos1OriginalIndex + ) + + // Swap in secondary + if (secondaryActive && secondaryPos1) { + secondary.insertBefore(secondaryActive, secondaryPos1) + } + + // Recalculate visibility after swap + const updatedPrimaryItems = Array.from(primary.querySelectorAll(':scope > li:not(.-more)')) + const updatedSecondaryItems = Array.from(secondary.querySelectorAll(':scope > li:not(.-more)')) + + // Clear hidden from all items before recalculating + updatedPrimaryItems.forEach(item => item.classList.remove('--hidden')) + updatedSecondaryItems.forEach(item => item.classList.remove('--hidden')) + moreLi.classList.remove('--hidden') + + // Recalculate which items fit + let newStopWidth = moreBtn.offsetWidth + let newHiddenItems = [] + let newShouldHide = false + + updatedPrimaryItems.forEach((item, i) => { + if (!newShouldHide && i < MAX_TABS && primaryWidth >= newStopWidth + item.offsetWidth) { + newStopWidth += item.offsetWidth + } else { + newShouldHide = true + item.classList.add('--hidden') + newHiddenItems.push(i) + } + }) + + // Update secondary visibility + if (!newHiddenItems.length) { + moreLi.classList.add('--hidden') + container.classList.remove('--show-secondary') + moreBtn.setAttribute('aria-expanded', false) + } else { + moreLi.classList.remove('--hidden') + updatedSecondaryItems.forEach((item, i) => { + if (!newHiddenItems.includes(i)) { + item.classList.add('--hidden') + } + }) + } + } + } } - el = el.parentNode } - container.classList.remove('--show-secondary') - moreBtn.setAttribute('aria-expanded', false) -}) + + // Adapt the tabs to fit the viewport + doAdapt() // immediately on load + window.addEventListener('resize', doAdapt) // on window resize + + // hide Secondary menu on the outside click + document.addEventListener('click', (e) => { + let el = e.target + while(el) { + if(el === moreBtn) { + return; + } + el = el.parentNode + } + container.classList.remove('--show-secondary') + moreBtn.setAttribute('aria-expanded', false) + }) +} + +// Initialize on page load and after Turbo navigates +// turbo:load fires on both initial page load and subsequent Turbo navigations +document.addEventListener('turbo:load', initTabs) From a53cb9ae646ed25df6c12254e79bc9f6161c1d1d Mon Sep 17 00:00:00 2001 From: djanelle-mit Date: Thu, 4 Jun 2026 10:52:14 -0400 Subject: [PATCH 2/3] Making sure initial tab load happens on turbo:load --- app/javascript/source_tabs.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/javascript/source_tabs.js b/app/javascript/source_tabs.js index bf2cb1df..233914a6 100644 --- a/app/javascript/source_tabs.js +++ b/app/javascript/source_tabs.js @@ -189,3 +189,7 @@ const initTabs = () => { // Initialize on page load and after Turbo navigates // turbo:load fires on both initial page load and subsequent Turbo navigations document.addEventListener('turbo:load', initTabs) + +// Run immediately in case turbo:load already fired before this script loaded +// (happens on first search when Turbo Drive loads the page containing this script) +initTabs() From 13a2b36eeaed4a522c43c49e7816bb75967da0a5 Mon Sep 17 00:00:00 2001 From: djanelle-mit Date: Thu, 4 Jun 2026 12:19:53 -0400 Subject: [PATCH 3/3] Fixes based on CoPilot review --- app/assets/stylesheets/partials/_results.scss | 4 ++-- app/javascript/source_tabs.js | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/partials/_results.scss b/app/assets/stylesheets/partials/_results.scss index db73f250..0d9ea95f 100644 --- a/app/assets/stylesheets/partials/_results.scss +++ b/app/assets/stylesheets/partials/_results.scss @@ -156,9 +156,9 @@ // -------------------------------------- .result.use, .result.primo { - padding: 48px 0; + padding: 32px 0 32px; margin: 0; - border-bottom: 1px solid $color-gray-400; + border-bottom: 1px solid $color-gray-200; border-top: none; &:first-child { diff --git a/app/javascript/source_tabs.js b/app/javascript/source_tabs.js index 233914a6..65813048 100644 --- a/app/javascript/source_tabs.js +++ b/app/javascript/source_tabs.js @@ -11,7 +11,6 @@ const initTabs = () => { if (container.classList.contains('has-js')) return // Already initialized const primary = container.querySelector('.primary') - const primaryItems = container.querySelectorAll('.primary > li:not(.-more)') // Add a class to turn off graceful degradation style container.classList.add('has-js') @@ -28,7 +27,6 @@ const initTabs = () => { `) const secondary = container.querySelector('.-secondary') - const secondaryItems = secondary.querySelectorAll('li') const allItems = container.querySelectorAll('li') const moreLi = primary.querySelector('.-more') const moreBtn = moreLi.querySelector('button') @@ -188,7 +186,10 @@ const initTabs = () => { // Initialize on page load and after Turbo navigates // turbo:load fires on both initial page load and subsequent Turbo navigations -document.addEventListener('turbo:load', initTabs) + if (!window.__timdexSourceTabsTurboListenerAdded) { + window.__timdexSourceTabsTurboListenerAdded = true + document.addEventListener('turbo:load', initTabs) + } // Run immediately in case turbo:load already fired before this script loaded // (happens on first search when Turbo Drive loads the page containing this script)