Skip to content

Commit c099d26

Browse files
committed
Preserved position of active tab when otherwise hidden
1 parent aeefa53 commit c099d26

1 file changed

Lines changed: 176 additions & 80 deletions

File tree

app/javascript/source_tabs.js

Lines changed: 176 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -3,93 +3,189 @@
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+
const primaryItems = container.querySelectorAll('.primary > li:not(.-more)')
15+
16+
// Add a class to turn off graceful degradation style
17+
container.classList.add('has-js')
18+
19+
// insert "more" button and duplicate the original tab bar items
20+
primary.insertAdjacentHTML('beforeend', `
21+
<li class="-more">
22+
<button type="button" aria-haspopup="true" aria-expanded="false" aria-controls="more-options">
23+
More <i class="fa-light fa-chevron-down"></i>
24+
</button>
25+
<ul class="-secondary" id="more-options" aria-label="More options">
26+
${primary.innerHTML}
27+
</ul>
28+
</li>
29+
`)
30+
const secondary = container.querySelector('.-secondary')
31+
const secondaryItems = secondary.querySelectorAll('li')
32+
const allItems = container.querySelectorAll('li')
33+
const moreLi = primary.querySelector('.-more')
34+
const moreBtn = moreLi.querySelector('button')
35+
36+
// Store original DOM order by index for each li
37+
Array.from(primary.querySelectorAll('li:not(.-more)')).forEach((li, index) => {
38+
li.dataset.originalIndex = index
39+
})
40+
Array.from(secondary.querySelectorAll('li:not(.-more)')).forEach((li, index) => {
41+
li.dataset.originalIndex = index
4742
})
4843

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

80-
// Adapt the tabs to fit the viewport
81-
doAdapt() // immediately on load
82-
window.addEventListener('resize', doAdapt) // on window resize
97+
// Handle active hidden tab - move it to position 2 (right after "All")
98+
const activeLink = primary.querySelector('a.active')
99+
if (activeLink) {
100+
const activeLi = activeLink.parentElement
83101

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

0 commit comments

Comments
 (0)