PageSourceSearch

https://smrtbld.ai/js/utils/tab-nav-mode.js

js smrtbld.ai collected 2026-10-05 15:10:49 UTC 11,146 bytes, 326 lines download raw bytes

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.