Skip to content

Commit 64ec037

Browse files
authored
Merge pull request #400 from MITLibraries/use-591
USE-591: Always show active tab, even if it would be hidden in More menu
2 parents b06d206 + 13a2b36 commit 64ec037

2 files changed

Lines changed: 183 additions & 82 deletions

File tree

app/assets/stylesheets/partials/_results.scss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,9 @@
156156
// --------------------------------------
157157

158158
.result.use, .result.primo {
159-
padding: 48px 0;
159+
padding: 32px 0 32px;
160160
margin: 0;
161-
border-bottom: 1px solid $color-gray-400;
161+
border-bottom: 1px solid $color-gray-200;
162162
border-top: none;
163163

164164
a:link, a:visited {

app/javascript/source_tabs.js

Lines changed: 181 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -3,93 +3,194 @@
33
// Source: https://css-tricks.com/container-adapting-tabs-with-more-button/
44
// ===========================================================================
55

6-
// Store references to relevant selectors
7-
const container = document.querySelector('#tabs')
8-
const primary = container.querySelector('.primary')
9-
const primaryItems = container.querySelectorAll('.primary > li:not(.-more)')
10-
11-
// Add a class to turn off graceful degradation style
12-
container.classList.add('has-js')
13-
14-
// insert "more" button and duplicate the original tab bar items
15-
primary.insertAdjacentHTML('beforeend', `
16-
<li class="-more">
17-
<button type="button" aria-haspopup="true" aria-expanded="false" aria-controls="more-options">
18-
More <i class="fa-light fa-chevron-down"></i>
19-
</button>
20-
<ul class="-secondary" id="more-options" aria-label="More options">
21-
${primary.innerHTML}
22-
</ul>
23-
</li>
24-
`)
25-
const secondary = container.querySelector('.-secondary')
26-
const secondaryItems = secondary.querySelectorAll('li')
27-
const allItems = container.querySelectorAll('li')
28-
const moreLi = primary.querySelector('.-more')
29-
const moreBtn = moreLi.querySelector('button')
30-
31-
// When the more button is clicked, toggle classes to indicate the secondary menu is open
32-
moreBtn.addEventListener('click', (e) => {
33-
e.preventDefault()
34-
container.classList.toggle('--show-secondary')
35-
moreBtn.setAttribute('aria-expanded', container.classList.contains('--show-secondary'))
36-
})
37-
38-
// Maximum number of tabs to show in the primary tab bar at once
39-
const MAX_TABS = 10
40-
41-
// adapt tabs
42-
const doAdapt = () => {
43-
44-
// reveal all items for the calculation
45-
allItems.forEach((item) => {
46-
item.classList.remove('--hidden')
6+
// Initialize tab bar functionality
7+
const initTabs = () => {
8+
// Store references to relevant selectors
9+
const container = document.querySelector('#tabs')
10+
if (!container) return // Exit if tabs element doesn't exist
11+
if (container.classList.contains('has-js')) return // Already initialized
12+
13+
const primary = container.querySelector('.primary')
14+
15+
// Add a class to turn off graceful degradation style
16+
container.classList.add('has-js')
17+
18+
// insert "more" button and duplicate the original tab bar items
19+
primary.insertAdjacentHTML('beforeend', `
20+
<li class="-more">
21+
<button type="button" aria-haspopup="true" aria-expanded="false" aria-controls="more-options">
22+
More <i class="fa-light fa-chevron-down"></i>
23+
</button>
24+
<ul class="-secondary" id="more-options" aria-label="More options">
25+
${primary.innerHTML}
26+
</ul>
27+
</li>
28+
`)
29+
const secondary = container.querySelector('.-secondary')
30+
const allItems = container.querySelectorAll('li')
31+
const moreLi = primary.querySelector('.-more')
32+
const moreBtn = moreLi.querySelector('button')
33+
34+
// Store original DOM order by index for each li
35+
Array.from(primary.querySelectorAll('li:not(.-more)')).forEach((li, index) => {
36+
li.dataset.originalIndex = index
37+
})
38+
Array.from(secondary.querySelectorAll('li:not(.-more)')).forEach((li, index) => {
39+
li.dataset.originalIndex = index
4740
})
4841

49-
// hide items that won't fit in the Primary tab bar, or exceed MAX_TABS
50-
// once a tab is hidden, all subsequent tabs are also hidden to preserve order
51-
let stopWidth = moreBtn.offsetWidth
52-
let hiddenItems = []
53-
let shouldHide = false
54-
const primaryWidth = primary.offsetWidth
55-
primaryItems.forEach((item, i) => {
56-
if(!shouldHide && i < MAX_TABS && primaryWidth >= stopWidth + item.offsetWidth) {
57-
stopWidth += item.offsetWidth
58-
} else {
59-
shouldHide = true
60-
item.classList.add('--hidden')
61-
hiddenItems.push(i)
62-
}
42+
// When the more button is clicked, toggle classes to indicate the secondary menu is open
43+
moreBtn.addEventListener('click', (e) => {
44+
e.preventDefault()
45+
container.classList.toggle('--show-secondary')
46+
moreBtn.setAttribute('aria-expanded', container.classList.contains('--show-secondary'))
6347
})
64-
65-
// toggle the visibility of More button and items in Secondary menu
66-
if(!hiddenItems.length) {
67-
moreLi.classList.add('--hidden')
68-
container.classList.remove('--show-secondary')
69-
moreBtn.setAttribute('aria-expanded', false)
70-
}
71-
else {
72-
secondaryItems.forEach((item, i) => {
73-
if(!hiddenItems.includes(i)) {
48+
49+
// Maximum number of tabs to show in the primary tab bar at once
50+
const MAX_TABS = 10
51+
52+
// adapt tabs
53+
const doAdapt = () => {
54+
55+
// reveal all items for the calculation
56+
allItems.forEach((item) => {
57+
item.classList.remove('--hidden')
58+
})
59+
60+
// Get primary items in current DOM order (re-query each time, direct children only)
61+
const currentPrimaryItems = Array.from(primary.querySelectorAll(':scope > li:not(.-more)'))
62+
63+
// hide items that won't fit in the Primary tab bar, or exceed MAX_TABS
64+
// once a tab is hidden, all subsequent tabs are also hidden to preserve order
65+
let stopWidth = moreBtn.offsetWidth
66+
let hiddenItems = []
67+
let shouldHide = false
68+
const primaryWidth = primary.offsetWidth
69+
currentPrimaryItems.forEach((item, i) => {
70+
if(!shouldHide && i < MAX_TABS && primaryWidth >= stopWidth + item.offsetWidth) {
71+
stopWidth += item.offsetWidth
72+
} else {
73+
shouldHide = true
7474
item.classList.add('--hidden')
75+
hiddenItems.push(i)
7576
}
7677
})
77-
}
78-
}
78+
79+
// toggle the visibility of More button and items in Secondary menu
80+
if(!hiddenItems.length) {
81+
moreLi.classList.add('--hidden')
82+
container.classList.remove('--show-secondary')
83+
moreBtn.setAttribute('aria-expanded', false)
84+
}
85+
else {
86+
// Re-query secondary items in current DOM order (direct children only)
87+
const currentSecondaryItems = Array.from(secondary.querySelectorAll(':scope > li:not(.-more)'))
88+
currentSecondaryItems.forEach((item, i) => {
89+
if(!hiddenItems.includes(i)) {
90+
item.classList.add('--hidden')
91+
}
92+
})
93+
}
94+
95+
// Handle active hidden tab - move it to position 2 (right after "All")
96+
const activeLink = primary.querySelector('a.active')
97+
if (activeLink) {
98+
const activeLi = activeLink.parentElement
7999

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

84-
// hide Secondary menu on the outside click
85-
document.addEventListener('click', (e) => {
86-
let el = e.target
87-
while(el) {
88-
if(el === moreBtn) {
89-
return;
103+
// If active tab is not at position 0 (All) or position 1, move it to position 1
104+
if (activeIndex > 1) {
105+
const liAtPos1 = currentPrimaryItems[1]
106+
107+
// Swap in primary by moving active before position 1
108+
primary.insertBefore(activeLi, liAtPos1)
109+
110+
// Find matching items in secondary by original index
111+
const activeOriginalIndex = parseInt(activeLi.dataset.originalIndex)
112+
const pos1OriginalIndex = parseInt(liAtPos1.dataset.originalIndex)
113+
114+
const currentSecondaryItems = Array.from(secondary.querySelectorAll(':scope > li:not(.-more)'))
115+
const secondaryActive = currentSecondaryItems.find(li =>
116+
parseInt(li.dataset.originalIndex) === activeOriginalIndex
117+
)
118+
const secondaryPos1 = currentSecondaryItems.find(li =>
119+
parseInt(li.dataset.originalIndex) === pos1OriginalIndex
120+
)
121+
122+
// Swap in secondary
123+
if (secondaryActive && secondaryPos1) {
124+
secondary.insertBefore(secondaryActive, secondaryPos1)
125+
}
126+
127+
// Recalculate visibility after swap
128+
const updatedPrimaryItems = Array.from(primary.querySelectorAll(':scope > li:not(.-more)'))
129+
const updatedSecondaryItems = Array.from(secondary.querySelectorAll(':scope > li:not(.-more)'))
130+
131+
// Clear hidden from all items before recalculating
132+
updatedPrimaryItems.forEach(item => item.classList.remove('--hidden'))
133+
updatedSecondaryItems.forEach(item => item.classList.remove('--hidden'))
134+
moreLi.classList.remove('--hidden')
135+
136+
// Recalculate which items fit
137+
let newStopWidth = moreBtn.offsetWidth
138+
let newHiddenItems = []
139+
let newShouldHide = false
140+
141+
updatedPrimaryItems.forEach((item, i) => {
142+
if (!newShouldHide && i < MAX_TABS && primaryWidth >= newStopWidth + item.offsetWidth) {
143+
newStopWidth += item.offsetWidth
144+
} else {
145+
newShouldHide = true
146+
item.classList.add('--hidden')
147+
newHiddenItems.push(i)
148+
}
149+
})
150+
151+
// Update secondary visibility
152+
if (!newHiddenItems.length) {
153+
moreLi.classList.add('--hidden')
154+
container.classList.remove('--show-secondary')
155+
moreBtn.setAttribute('aria-expanded', false)
156+
} else {
157+
moreLi.classList.remove('--hidden')
158+
updatedSecondaryItems.forEach((item, i) => {
159+
if (!newHiddenItems.includes(i)) {
160+
item.classList.add('--hidden')
161+
}
162+
})
163+
}
164+
}
165+
}
90166
}
91-
el = el.parentNode
92167
}
93-
container.classList.remove('--show-secondary')
94-
moreBtn.setAttribute('aria-expanded', false)
95-
})
168+
169+
// Adapt the tabs to fit the viewport
170+
doAdapt() // immediately on load
171+
window.addEventListener('resize', doAdapt) // on window resize
172+
173+
// hide Secondary menu on the outside click
174+
document.addEventListener('click', (e) => {
175+
let el = e.target
176+
while(el) {
177+
if(el === moreBtn) {
178+
return;
179+
}
180+
el = el.parentNode
181+
}
182+
container.classList.remove('--show-secondary')
183+
moreBtn.setAttribute('aria-expanded', false)
184+
})
185+
}
186+
187+
// Initialize on page load and after Turbo navigates
188+
// turbo:load fires on both initial page load and subsequent Turbo navigations
189+
if (!window.__timdexSourceTabsTurboListenerAdded) {
190+
window.__timdexSourceTabsTurboListenerAdded = true
191+
document.addEventListener('turbo:load', initTabs)
192+
}
193+
194+
// Run immediately in case turbo:load already fired before this script loaded
195+
// (happens on first search when Turbo Drive loads the page containing this script)
196+
initTabs()

0 commit comments

Comments
 (0)