Skip to main content

max / makenotwork

5.7 KB · 171 lines History Blame Raw
1 // Tab navigation + overflow ("More" dropdown), ported from mnw.js:85-275.
2
3 function debounce(fn: () => void, ms: number): () => void {
4 let timer: ReturnType<typeof setTimeout>;
5 return () => {
6 clearTimeout(timer);
7 timer = setTimeout(fn, ms);
8 };
9 }
10
11 const tabOverflow = (() => {
12 let containers: HTMLElement[] = [];
13
14 function updateHighlight(tabsEl: Element): void {
15 const moreWrap = tabsEl.querySelector('.tab-more-wrap');
16 if (!moreWrap) return;
17 const moreBtn = moreWrap.querySelector('.tab-more-btn');
18 const menu = moreWrap.querySelector('.tab-overflow-menu');
19 if (!moreBtn || !menu) return;
20 const hasActive = menu.querySelector('.tab.is-selected');
21 moreBtn.classList.toggle('is-selected', !!hasActive);
22 }
23
24 function reflow(tabsEl: HTMLElement): void {
25 const moreWrap = tabsEl.querySelector<HTMLElement>('.tab-more-wrap');
26 if (!moreWrap) return;
27 const menu = moreWrap.querySelector<HTMLElement>('.tab-overflow-menu');
28 if (!menu) return;
29
30 // Move all tabs back from the menu into the row (before moreWrap).
31 Array.from(menu.children).forEach((t) => tabsEl.insertBefore(t, moreWrap));
32 moreWrap.style.display = 'none';
33
34 const tabs = Array.from(tabsEl.querySelectorAll<HTMLElement>(':scope > .tab'));
35 if (tabs.length === 0) return;
36
37 const available = tabsEl.clientWidth;
38 let totalWidth = 0;
39 tabs.forEach((t) => {
40 totalWidth += t.offsetWidth;
41 });
42 if (totalWidth <= available) return;
43
44 const moreBtnWidth = 90;
45 let used = 0;
46 let cutoff = tabs.length;
47 for (let i = 0; i < tabs.length; i++) {
48 used += tabs[i].offsetWidth;
49 if (used + moreBtnWidth > available) {
50 cutoff = i;
51 break;
52 }
53 }
54 if (cutoff < 1) cutoff = 1;
55 for (let j = cutoff; j < tabs.length; j++) menu.appendChild(tabs[j]);
56 moreWrap.style.display = '';
57 updateHighlight(tabsEl);
58 }
59
60 function setup(tabsEl: HTMLElement): void {
61 if (tabsEl.dataset.overflowInit) return;
62 tabsEl.dataset.overflowInit = '1';
63
64 const moreWrap = document.createElement('div');
65 moreWrap.className = 'tab-more-wrap';
66 moreWrap.style.display = 'none';
67
68 const moreBtn = document.createElement('button');
69 moreBtn.className = 'tab tab-more-btn';
70 moreBtn.type = 'button';
71 moreBtn.textContent = 'More';
72 moreBtn.setAttribute('aria-haspopup', 'true');
73 moreBtn.setAttribute('aria-expanded', 'false');
74 moreBtn.addEventListener('click', (e) => {
75 e.stopPropagation();
76 const m = moreWrap.querySelector<HTMLElement>('.tab-overflow-menu');
77 if (!m) return;
78 const open = m.style.display === 'block';
79 m.style.display = open ? 'none' : 'block';
80 moreBtn.setAttribute('aria-expanded', open ? 'false' : 'true');
81 });
82
83 const menu = document.createElement('div');
84 menu.className = 'tab-overflow-menu';
85 menu.style.display = 'none';
86
87 moreWrap.appendChild(moreBtn);
88 moreWrap.appendChild(menu);
89
90 const spinner = tabsEl.querySelector('.htmx-indicator');
91 if (spinner) tabsEl.insertBefore(moreWrap, spinner);
92 else tabsEl.appendChild(moreWrap);
93
94 reflow(tabsEl);
95 }
96
97 function reflowAll(): void {
98 containers.forEach(reflow);
99 }
100
101 function init(): void {
102 containers = Array.from(document.querySelectorAll<HTMLElement>('.tabs[role="tablist"]'));
103 containers.forEach(setup);
104 window.addEventListener('resize', debounce(reflowAll, 150));
105 }
106
107 return { init, updateHighlight };
108 })();
109
110 /** Activate a tab button: toggle selected state, wire aria, sync the hash, and
111 * update the overflow highlight. Ported from mnw.js:85. */
112 export function setActiveTab(btn: HTMLElement): void {
113 const container = btn.closest('.tabs');
114 if (!container) return;
115 container.querySelectorAll('.tab').forEach((tab) => {
116 tab.classList.remove('is-selected');
117 tab.setAttribute('aria-selected', 'false');
118 });
119 btn.classList.add('is-selected');
120 btn.setAttribute('aria-selected', 'true');
121 const panel = document.getElementById('tab-content');
122 if (panel) panel.setAttribute('aria-labelledby', btn.id);
123 if (btn.id) history.replaceState(null, '', '#' + btn.id);
124 const menu = btn.closest<HTMLElement>('.tab-overflow-menu');
125 if (menu) menu.style.display = 'none';
126 tabOverflow.updateHighlight(container);
127 }
128
129 /** One-time tab wiring: hover preload, overflow init, hash restore, outside-
130 * click close, cart-count badge. Ported from mnw.js:227. */
131 export function initTabs(): void {
132 document.querySelectorAll<HTMLElement>('.tab').forEach((btn) => {
133 btn.addEventListener('mouseenter', function (this: HTMLElement) {
134 if (this.dataset.preloaded) return;
135 const url = this.getAttribute('hx-get');
136 if (!url) return;
137 this.dataset.preloaded = '1';
138 fetch(url, { headers: { 'HX-Request': 'true' } }).catch(() => {});
139 });
140 });
141
142 tabOverflow.init();
143
144 const hash = location.hash.replace('#', '');
145 if (hash) {
146 const tab = document.getElementById(hash);
147 if (tab && tab.classList.contains('tab')) tab.click();
148 }
149
150 document.addEventListener('click', () => {
151 document.querySelectorAll<HTMLElement>('.tab-overflow-menu').forEach((m) => {
152 m.style.display = 'none';
153 });
154 document.querySelectorAll('.tab-more-btn').forEach((b) => b.setAttribute('aria-expanded', 'false'));
155 });
156
157 const cartLink = document.getElementById('nav-cart-link');
158 if (cartLink) {
159 fetch('/api/cart/count', { credentials: 'same-origin' })
160 .then((r) => (r.ok ? r.json() : null))
161 .then((data: { count?: number } | null) => {
162 if (data && typeof data.count === 'number' && data.count > 0) {
163 cartLink.classList.remove('hidden');
164 const badge = document.getElementById('cart-badge');
165 if (badge) badge.textContent = ' (' + data.count + ')';
166 }
167 })
168 .catch(() => {});
169 }
170 }
171