1/** 2 * Tab Navigation Mode Toggle 3 * 4 * Lets users switch between: 5 * - "wrap" mode: all tabs visible, wrapping to multiple rows (default) 6 * - "compact" mode: single row with greedy "More â¾" dropdown for overflow 7 * 8 * Preference is saved to localStorage and applies globally. 9 * 10 * Usage: 11 * import { initTabNav, destroyTabNav } from '../utils/tab-nav-mode.js'; 12 * // After rendering tab buttons: 13 * initTabNav(document.getElementById('sub-module-tabs')); 14 * // On page destroy: 15 * destroyTabNav(document.getElementById('sub-module-tabs')); 16 */ 17 18import { isMobile } from './mobile.js'; 19import { BottomSheet } from '../components/BottomSheet.js'; 20import { syncTablist } from './tablist-keyboard.js'; 21 22const STORAGE_KEY = 'sb_tab_nav_mode'; 23const DEFAULT_MODE = 'wrap'; 24 25// Track ResizeObservers per nav element 26const observers = new Map(); 27let measuring = false; 28 29// ââ Public API ââââââââââââââââââââââââââââ 30 31/** 32 * Initialize tab nav mode on a container. 33 * Call after tab buttons have been rendered. Idempotent â safe to call repeatedly. 34 * @param {HTMLElement} nav - The tab navigation container 35 */ 36export function initTabNav(nav) { 37 if (!nav) return; 38 cleanupNav(nav); 39 40 // Mobile: always compact mode, no toggle button 41 if (isMobile()) { 42 applyMode(nav, 'compact'); 43 // CA-110: the visible set is final once compact mode's overflow has run â 44 // derive the strip's single roving tab stop from it. 45 syncTablist(nav); 46 return; 47 } 48 49 const mode = getMode(); 50 const toggle = buildToggle(mode); 51 nav.appendChild(toggle); 52 53 toggle.addEventListener('click', (e) => { 54 e.stopPropagation(); 55 const next = getMode() === 'wrap' ? 'compact' : 'wrap'; 56 localStorage.setItem(STORAGE_KEY, next); 57 refreshToggle(toggle, next); 58 applyMode(nav, next); 59 // CA-110: wrap <-> compact changes WHICH tabs are visible, so the roving 60 // tab stop is re-derived against the new visible set. 61 syncTablist(nav); 62 }); 63 64 applyMode(nav, mode); 65 syncTablist(nav); 66} 67 68/** 69 * Clean up tab nav mode state. Call on page destroy. 70 * @param {HTMLElement} nav - The tab navigation container 71 */ 72export function destroyTabNav(nav) { 73 if (!nav) return; 74 cleanupNav(nav); 75} 76 77// ââ Mode Switching ââââââââââââââââââââââââ 78 79function getMode() { 80 return localStorage.getItem(STORAGE_KEY) || DEFAULT_MODE; 81} 82 83function applyMode(nav, mode) { 84 // Clean previous overflow UI 85 nav.querySelector('.tab-overflow-more')?.remove(); 86 removeDropdown(nav); 87 88 // Top-level module tabs get underline style in compact mode 89 const isTopLevel = nav.id === 'sub-module-tabs'; 90 91 if (mode === 'wrap') { 92 nav.classList.add('flex-wrap'); 93 nav.classList.remove('tab-nav-underline'); 94 getTabButtons(nav).forEach(btn => btn.classList.remove('hidden')); 95 disconnectObserver(nav); 96 } else { 97 nav.classList.remove('flex-wrap'); 98 if (isTopLevel) nav.classList.add('tab-nav-underline'); 99 greedify(nav); 100 connectObserver(nav); 101 } 102} 103 104// ââ Toggle Button âââââââââââââââââââââââââ 105 106function buildToggle(mode) { 107 const btn = document.createElement('button'); 108 btn.className = 'tab-nav-mode-toggle'; 109 refreshToggle(btn, mode); 110 if (window.lucide) lucide.createIcons({ root: btn }); 111 return btn; 112} 113 114function refreshToggle(btn, mode) { 115 // Icon represents what clicking will SWITCH TO 116 btn.innerHTML = mode === 'wrap' 117 ? '<i data-lucide="list" class="w-3.5 h-3.5"></i>' 118 : '<i data-lucide="layout-grid" class="w-3.5 h-3.5"></i>'; 119 btn.title = mode === 'wrap' ? 'Compact view' : 'Show all tabs'; 120 if (window.lucide) lucide.createIcons({ root: btn }); 121} 122 123// ââ Greedy Nav Logic ââââââââââââââââââââââ 124 125function greedify(nav) { 126 if (measuring) return; 127 measuring = true; 128 129 const toggle = nav.querySelector('.tab-nav-mode-toggle'); 130 const buttons = getTabButtons(nav); 131 if (!buttons.length) { measuring = false; return; } 132 133 // Clean previous overflow UI 134 nav.querySelector('.tab-overflow-more')?.remove(); 135 removeDropdown(nav); 136 137 // Show all buttons for measurement 138 buttons.forEach(btn => btn.classList.remove('hidden')); 139 140 // Calculate available space 141 const cs = getComputedStyle(nav); 142 const padL = parseFloat(cs.paddingLeft) || 0; 143 const padR = parseFloat(cs.paddingRight) || 0; 144 const gap = parseFloat(cs.gap) || 6; 145 const navW = nav.clientWidth - padL - padR; 146 const toggleW = toggle ? toggle.offsetWidth : 0; 147 148 // Check if everything fits without a More button 149 let total = buttons.reduce((s, b, i) => s + b.offsetWidth + (i ? gap : 0), 0) + gap + toggleW; 150 if (total <= navW) { measuring = false; return; } 151 152 // Find the cutoff point (reserve space for "More" button) 153 const moreW = 80; 154 let used = 0;
155 let cutoff = buttons.length; 156 for (let i = 0; i < buttons.length; i++) { 157 const bw = buttons[i].offsetWidth + (i ? gap : 0); 158 if (used + bw + gap + moreW + gap + toggleW > navW) { 159 cutoff = Math.max(1, i); 160 break; 161 } 162 used += bw; 163 } 164 165 // Hide overflow buttons 166 const overflow = []; 167 buttons.forEach((btn, i) => { 168 if (i >= cutoff) { 169 btn.classList.add('hidden'); 170 overflow.push(btn); 171 } 172 }); 173 174 if (!overflow.length) { measuring = false; return; } 175 176 // Build "More" button 177 const hasActive = overflow.some(b => b.classList.contains('active')); 178 const more = document.createElement('button'); 179 more.className = `tab-overflow-more${hasActive ? ' active' : ''}`; 180 181 if (isMobile()) { 182 // Mobile: command trigger style â â icon + count badge + chevron 183 const cmdIcon = document.createElement('i'); 184 cmdIcon.setAttribute('data-lucide', 'command'); 185 cmdIcon.className = 'w-3.5 h-3.5'; 186 const moreCount = document.createElement('span'); 187 moreCount.className = 'tab-overflow-count'; 188 moreCount.textContent = overflow.length; 189 const moreChevron = document.createElement('i'); 190 moreChevron.setAttribute('data-lucide', 'chevron-down'); 191 moreChevron.className = 'w-3 h-3 opacity-50'; 192 more.append(cmdIcon, moreCount, moreChevron); 193 } else { 194 // Desktop: "More N â¾" text button 195 const moreLabel = document.createElement('span'); 196 moreLabel.textContent = 'More'; 197 const moreCount = document.createElement('span'); 198 moreCount.className = 'tab-overflow-count'; 199 moreCount.textContent = overflow.length; 200 const moreIcon = document.createElement('i'); 201 moreIcon.setAttribute('data-lucide', 'chevron-down'); 202 moreIcon.className = 'w-3 h-3'; 203 more.append(moreLabel, moreCount, moreIcon); 204 } 205 206 nav.insertBefore(more, toggle); 207 208 if (isMobile()) { 209 // Mobile: open BottomSheet with overflow tabs 210 more.addEventListener('click', (e) => { 211 e.stopPropagation(); 212 213 // Determine sheet title from nav context 214 const isSubTab = nav.classList.contains('sub-tab-bar') || 215 nav.id?.includes('sub-tab') || 216 !nav.id?.includes('sub-module'); 217 const sheetTitle = nav.id === 'sub-module-tabs' ? 'Navigation' : 'Tabs'; 218 219 const items = overflow.map(orig => { 220 const iconEl = orig.querySelector('[data-lucide]'); 221 const labelEl = orig.querySelector('span'); 222 const icon = iconEl?.getAttribute('data-lucide') || 'circle'; 223 const label = labelEl?.textContent?.trim() || orig.textContent?.trim() || 'Tab'; 224 const isActive = orig.classList.contains('active'); 225 226 return { 227 icon, 228 label, 229 id: orig.dataset.tab || orig.dataset.rfpTab || orig.dataset.overviewTab || label, 230 variant: isActive ? 'primary' : undefined, 231 action: () => { 232 orig.click(); 233 greedify(nav); 234 } 235 }; 236 }); 237 238 BottomSheet.show({ title: sheetTitle, items }); 239 }); 240 } else { 241 // Desktop: floating dropdown panel 242 const dd = document.createElement('div'); 243 dd.className = 'tab-overflow-dropdown hidden'; 244 245 overflow.forEach(orig => { 246 const item = document.createElement('button'); 247 item.className = `tab-overflow-item${orig.classList.contains('active') ? ' active' : ''}`; 248 item.innerHTML = orig.innerHTML; 249 item.querySelectorAll('[id]').forEach(el => el.removeAttribute('id')); 250 item.addEventListener('click', (e) => { 251 e.stopPropagation(); 252 dd.classList.add('hidden'); 253 orig.click(); 254 greedify(nav); 255 }); 256 dd.appendChild(item); 257 }); 258 259 nav.style.position = 'relative'; 260 nav.appendChild(dd); 261 262 more.addEventListener('click', (e) => { 263 e.stopPropagation(); 264 dd.classList.toggle('hidden'); 265 if (!dd.classList.contains('hidden')) { 266 requestAnimationFrame(() => { 267 document.addEventListener('click', () => dd.classList.add('hidden'), { once: true }); 268 }); 269 } 270 }); 271 272 if (window.lucide) { 273 lucide.createIcons({ root: dd }); 274 } 275 } 276 277 // Render Lucide icons 278 if (window.lucide) { 279 lucide.createIcons({ root: more }); 280 } 281 282 measuring = false;
283} 284 285// ââ Helpers âââââââââââââââââââââââââââââââ 286 287function getTabButtons(nav) { 288 return Array.from(nav.children).filter(el => 289 el.tagName === 'BUTTON' && 290 !el.classList.contains('tab-nav-mode-toggle') && 291 !el.classList.contains('tab-overflow-more') 292 ); 293} 294 295function removeDropdown(nav) { 296 const dd = nav.querySelector('.tab-overflow-dropdown'); 297 if (dd) dd.remove(); 298 nav.style.position = ''; 299} 300 301function connectObserver(nav) { 302 if (observers.has(nav)) return; 303 const ro = new ResizeObserver(() => { 304 if (!measuring) { 305 greedify(nav); 306 // CA-110: a resize can push the selected tab into the "More" overflow. 307 // Re-derive the tab stop so it never lands on a hidden tab. 308 syncTablist(nav); 309 } 310 }); 311 ro.observe(nav); 312 observers.set(nav, ro); 313} 314 315function disconnectObserver(nav) { 316 if (!observers.has(nav)) return; 317 observers.get(nav).disconnect(); 318 observers.delete(nav); 319} 320 321function cleanupNav(nav) { 322 nav.querySelector('.tab-nav-mode-toggle')?.remove(); 323 nav.querySelector('.tab-overflow-more')?.remove(); 324 removeDropdown(nav); 325 disconnectObserver(nav); 326}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.