|
3 | 3 | // Source: https://css-tricks.com/container-adapting-tabs-with-more-button/ |
4 | 4 | // =========================================================================== |
5 | 5 |
|
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 |
47 | 40 | }) |
48 | 41 |
|
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')) |
63 | 47 | }) |
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 |
74 | 74 | item.classList.add('--hidden') |
| 75 | + hiddenItems.push(i) |
75 | 76 | } |
76 | 77 | }) |
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 |
79 | 99 |
|
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) |
83 | 102 |
|
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 | + } |
90 | 166 | } |
91 | | - el = el.parentNode |
92 | 167 | } |
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