Skip to main content

max / makenotwork

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