feat(#994): tabbar overflow menu — settings and logs in ⋮ by default

Add a ⋮ overflow button at the right end of the dashboard tab strip.

SETTINGS and LOGS always live in the overflow dropdown (marked
data-overflow="default" in index.html — never rendered in the main bar).

Dynamic overflow: when the bar is too narrow to fit all remaining tabs,
rightmost tabs spill into the dropdown right-to-left. Implemented via
JS measurement + ResizeObserver; no CSS-only relayout trick needed.

The ⋮ button:
- hidden when the dropdown is empty (wide screens with only the default
  tabs overflowed, which are always there anyway → button always shown)
- gets .tabbar-overflow-active when the current hash tab is inside
- dropdown items clone the tab label + count badge from the original
- closes on outside click / Escape

MutationObserver on the tabbar catches P33RS/M4TR1X hidden-attribute
changes so the overflow recalculates when those tabs are shown/hidden.
This commit is contained in:
iris 2026-06-01 19:39:17 +02:00
commit e9d22ef2d3
3 changed files with 271 additions and 14 deletions

View file

@ -3382,6 +3382,8 @@ window.marked = marked;
// on hashchange without waiting for the next SSE update.
document.body.dataset.activeTab = target;
renderSelectionBar(Array.from(containersState.values()));
// Keep overflow button active state in sync after tab change.
updateTabbarOverflow();
// Schedules pane has no SSE channel for mutations, so re-fetch
// on activation so the operator never lands on stale data.
if (target === 'schedules') refreshSchedules();
@ -3390,9 +3392,152 @@ window.marked = marked;
const h = (window.location.hash || '#swarm').replace(/^#/, '');
activateTab(h);
}
window.addEventListener('hashchange', syncTabFromHash);
window.addEventListener('hashchange', () => {
syncTabFromHash();
updateTabbarOverflow();
});
syncTabFromHash();
// ─── tabbar overflow menu ────────────────────────────────────────────────
// Tabs with `data-overflow="default"` (LOGS, SETTINGS) always live in
// the ⋮ dropdown. When the bar is too narrow to show all remaining tabs,
// rightmost tabs spill into the dropdown too (right-to-left).
//
// Implementation: all tabs stay in the DOM. The ⋮ wrapper sits at the
// flex end. `tab-overflowed` hides a tab from the bar (display:none).
// The dropdown is rebuilt from scratch on every call — it holds cloned
// <a>/<button> items, not the originals.
const overflowWrap = $('tabbar-overflow');
const overflowBtn = $('tabbar-overflow-btn');
const overflowDrop = $('tabbar-overflow-dropdown');
let overflowOpen = false;
function closeOverflowMenu() {
if (!overflowOpen) return;
overflowOpen = false;
if (overflowDrop) overflowDrop.hidden = true;
if (overflowBtn) overflowBtn.setAttribute('aria-expanded', 'false');
}
if (overflowBtn) {
overflowBtn.addEventListener('click', (e) => {
e.stopPropagation();
overflowOpen = !overflowOpen;
overflowDrop.hidden = !overflowOpen;
overflowBtn.setAttribute('aria-expanded', String(overflowOpen));
});
}
document.addEventListener('click', (e) => {
if (overflowOpen && !overflowWrap?.contains(e.target)) closeOverflowMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overflowOpen) { closeOverflowMenu(); e.stopImmediatePropagation(); }
}, true);
function updateTabbarOverflow() {
const tabbar = $('tabbar');
if (!tabbar || !overflowBtn || !overflowDrop) return;
// Collect all tabs that are not JS-hidden (P33RS/M4TR1X may be hidden
// by feature-gating) and not already removed from the DOM.
const allTabs = [...tabbar.querySelectorAll('.tab')];
// Separate default-overflow tabs from dynamic ones.
const defaultOverflow = allTabs.filter(t => t.dataset.overflow === 'default');
const dynamic = allTabs.filter(t => t.dataset.overflow !== 'default' && !t.hidden);
// Step 1: un-overflow all dynamic tabs so we can measure natural widths.
dynamic.forEach(t => t.classList.remove('tab-overflowed'));
// Step 2: measure available width (bar width minus overflow-btn width).
// We must read layout AFTER restoring all dynamic tabs.
const availWidth = tabbar.clientWidth - overflowBtn.offsetWidth - 8;
// Step 3: walk dynamic tabs left-to-right, accumulating widths.
// Any that go over the available width get overflowed.
let cumWidth = 0;
const dynamicOverflow = [];
for (const tab of dynamic) {
cumWidth += tab.offsetWidth;
if (cumWidth > availWidth) {
dynamicOverflow.push(tab);
}
}
dynamicOverflow.forEach(t => t.classList.add('tab-overflowed'));
// Step 4: rebuild the dropdown from the two overflow sets.
const overflowedTabs = [...dynamicOverflow, ...defaultOverflow];
overflowDrop.innerHTML = '';
for (const tab of overflowedTabs) {
const li = document.createElement('li');
li.setAttribute('role', 'presentation');
// Build a menu item that mirrors the tab's link/button behaviour.
const href = tab.getAttribute('href');
const item = href
? document.createElement('a')
: document.createElement('button');
if (href) {
item.href = href;
} else {
item.type = 'button';
item.addEventListener('click', () => {
closeOverflowMenu();
window.location.hash = tab.dataset.tab || '';
});
}
item.className = 'tabbar-overflow-item';
item.setAttribute('role', 'menuitem');
// Copy the tab label text.
const labelEl = tab.querySelector('.tab-label');
item.textContent = labelEl ? labelEl.textContent : (tab.textContent || '').trim();
// Active state: hash tabs match current route.
if (tab.dataset.tab && tab.classList.contains('active')) {
item.classList.add('tabbar-overflow-item-active');
}
// Count badge: copy from the tab's count pill if non-zero.
const countEl = tab.querySelector('.tab-count');
if (countEl && !countEl.hidden && countEl.textContent) {
const badge = document.createElement('span');
badge.className = 'tabbar-overflow-badge' + (countEl.classList.contains('tab-count-attn') ? ' tab-count-attn' : '');
badge.textContent = countEl.textContent;
item.append(badge);
}
if (href) {
item.addEventListener('click', closeOverflowMenu);
}
li.append(item);
overflowDrop.append(li);
}
// Step 5: show/hide the overflow button.
const hasItems = overflowedTabs.length > 0;
overflowBtn.hidden = !hasItems;
if (!hasItems) closeOverflowMenu();
// Step 6: mark the button active when the current tab is overflowed.
const activeTab = document.body.dataset.activeTab;
const activeIsOverflowed = overflowedTabs.some(t => t.dataset.tab === activeTab);
overflowBtn.classList.toggle('tabbar-overflow-active', activeIsOverflowed);
}
// Call on init and whenever the bar width changes.
updateTabbarOverflow();
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => updateTabbarOverflow());
const tabbar = $('tabbar');
if (tabbar) ro.observe(tabbar);
}
// Also refresh after P33RS/M4TR1X visibility changes (they're toggled by
// refreshState). A MutationObserver on the tabbar catches attribute changes
// (hidden attr) on child tabs without coupling to specific render paths.
const tabbarEl = $('tabbar');
if (tabbarEl && typeof MutationObserver !== 'undefined') {
new MutationObserver(() => updateTabbarOverflow()).observe(tabbarEl, {
attributes: true, subtree: true, attributeFilter: ['hidden'],
});
}
// Tab count pills — pure derived data from the existing state
// stores so SSE-driven updates flow through without extra plumbing.
// Set `hidden` when the count is zero so the pill doesn't draw
@ -3437,6 +3582,9 @@ window.marked = marked;
// stays hidden by default; future plumbing could broadcast the
// count via localStorage / BroadcastChannel if both pages are
// open.
// Re-render overflow dropdown so badges stay in sync.
updateTabbarOverflow();
}
// Poll the state stores on a 1s tick to keep the pill counts in
// sync. The state stores are mutated synchronously by every SSE