Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/assets/stylesheets/partials/_results.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
261 changes: 181 additions & 80 deletions app/javascript/source_tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,93 +3,194 @@
// 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', `
<li class="-more">
<button type="button" aria-haspopup="true" aria-expanded="false" aria-controls="more-options">
More <i class="fa-light fa-chevron-down"></i>
</button>
<ul class="-secondary" id="more-options" aria-label="More options">
${primary.innerHTML}
</ul>
</li>
`)
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')

Comment thread
JPrevost marked this conversation as resolved.
// 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', `
<li class="-more">
<button type="button" aria-haspopup="true" aria-expanded="false" aria-controls="more-options">
More <i class="fa-light fa-chevron-down"></i>
</button>
<ul class="-secondary" id="more-options" aria-label="More options">
${primary.innerHTML}
</ul>
</li>
`)
const secondary = container.querySelector('.-secondary')
const allItems = container.querySelectorAll('li')
Comment thread
JPrevost marked this conversation as resolved.
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
})
Comment on lines +34 to 40
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was seeing a minor visual bug that become way more common with this, so avoiding for now.


// 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')
}
})
}

// Handle active hidden tab - move it to position 2 (right after "All")
const activeLink = primary.querySelector('a.active')
if (activeLink) {
const activeLi = activeLink.parentElement

// Adapt the tabs to fit the viewport
doAdapt() // immediately on load
window.addEventListener('resize', doAdapt) // on window resize
if (activeLi.classList.contains('--hidden')) {
const activeIndex = currentPrimaryItems.indexOf(activeLi)

// hide Secondary menu on the outside click
document.addEventListener('click', (e) => {
let el = e.target
while(el) {
if(el === moreBtn) {
return;
// 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)
Comment thread
JPrevost marked this conversation as resolved.
}

// 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)
Comment thread
JPrevost marked this conversation as resolved.
}
})

// 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')
}
})
Comment thread
JPrevost marked this conversation as resolved.
}
}
}
}
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)
})
Comment thread
JPrevost marked this conversation as resolved.
}

// Initialize on page load and after Turbo navigates
// turbo:load fires on both initial page load and subsequent Turbo navigations
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)
initTabs()
Comment thread
JPrevost marked this conversation as resolved.