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:
parent
f56b5c5a7b
commit
e9d22ef2d3
3 changed files with 271 additions and 14 deletions
|
|
@ -45,6 +45,7 @@ body.dashboard-shell {
|
||||||
|
|
||||||
.tabbar {
|
.tabbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.2em;
|
gap: 0.2em;
|
||||||
padding: 0.5em 1em 0;
|
padding: 0.5em 1em 0;
|
||||||
|
|
@ -98,6 +99,104 @@ body.dashboard-shell {
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Overflowed tabs are hidden from the bar (moved to the ⋮ dropdown). */
|
||||||
|
.tabbar .tab[data-overflow="default"],
|
||||||
|
.tabbar .tab.tab-overflowed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── overflow ⋮ button + dropdown ────────────────────────── */
|
||||||
|
.tabbar-overflow {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto; /* push to the far right */
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar-overflow-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.2em;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.3em 0.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tabbar-overflow-btn:hover {
|
||||||
|
color: var(--purple);
|
||||||
|
background: rgba(203, 166, 247, 0.06);
|
||||||
|
}
|
||||||
|
.tabbar-overflow-btn.tabbar-overflow-active {
|
||||||
|
color: var(--purple);
|
||||||
|
border-color: var(--purple-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar-overflow-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 2px);
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--surface0);
|
||||||
|
border: 1px solid var(--surface2);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.3em 0;
|
||||||
|
min-width: 12em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar-overflow-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6em;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.88em;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.45em 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tabbar-overflow-item:hover {
|
||||||
|
background: var(--surface1);
|
||||||
|
color: var(--purple);
|
||||||
|
}
|
||||||
|
.tabbar-overflow-item-active {
|
||||||
|
color: var(--purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbar-overflow-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--purple-dim);
|
||||||
|
color: var(--purple);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 0.5em;
|
||||||
|
min-width: 1.4em;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.82em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.tabbar-overflow-badge.tab-count-attn {
|
||||||
|
background: rgba(243, 139, 168, 0.22);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
/* Tab pane visibility — show only the active one. The .tab-pane-active
|
/* Tab pane visibility — show only the active one. The .tab-pane-active
|
||||||
class is set by tabs.js based on the URL hash; default (no hash)
|
class is set by tabs.js based on the URL hash; default (no hash)
|
||||||
resolves to SW4RM. */
|
resolves to SW4RM. */
|
||||||
|
|
|
||||||
|
|
@ -71,14 +71,6 @@
|
||||||
<span class="tab-label">◆ M4TR1X ◆ →</span>
|
<span class="tab-label">◆ M4TR1X ◆ →</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- L0GS is its own page (`/logs.html`). Links to the dedicated
|
|
||||||
log viewer with three sub-tabs (build history, agent journal,
|
|
||||||
system services). Same nav pattern as FL0W. -->
|
|
||||||
<a class="tab tab-link" id="tab-logs" href="/logs.html"
|
|
||||||
title="open the log viewer">
|
|
||||||
<span class="tab-label">◆ L0GS ◆ →</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- FL0W is its own page (`/flow.html`), not a tab. The link
|
<!-- FL0W is its own page (`/flow.html`), not a tab. The link
|
||||||
lives in the tab strip so it reads as a peer surface;
|
lives in the tab strip so it reads as a peer surface;
|
||||||
clicking navigates rather than swapping panes in place.
|
clicking navigates rather than swapping panes in place.
|
||||||
|
|
@ -90,15 +82,33 @@
|
||||||
<span class="tab-count" id="tab-count-flow" hidden></span>
|
<span class="tab-count" id="tab-count-flow" hidden></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- S3TT1NGS: operator-local preferences (browser notification
|
<!-- L0GS and S3TT1NGS: default-overflow — always live in the ⋮
|
||||||
toggle today; future: theme, density). Sits at the end of
|
dropdown, never in the main strip. `data-overflow="default"`
|
||||||
the strip because it's rarely-touched and not a primary
|
marks them so JS keeps them out of the bar regardless of
|
||||||
surface. See docs/web-ui.md::S3TT1NGS tab. -->
|
available width. -->
|
||||||
|
<a class="tab tab-link" id="tab-logs" href="/logs.html"
|
||||||
|
data-overflow="default"
|
||||||
|
title="open the log viewer">
|
||||||
|
<span class="tab-label">◆ L0GS ◆ →</span>
|
||||||
|
</a>
|
||||||
<a class="tab" id="tab-settings" href="#settings" role="tab"
|
<a class="tab" id="tab-settings" href="#settings" role="tab"
|
||||||
aria-controls="tab-pane-settings"
|
aria-controls="tab-pane-settings"
|
||||||
data-tab="settings">
|
data-tab="settings"
|
||||||
|
data-overflow="default">
|
||||||
<span class="tab-label">◆ S3TT1NGS ◆</span>
|
<span class="tab-label">◆ S3TT1NGS ◆</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Overflow ⋮ button + dropdown. JS populates the dropdown with
|
||||||
|
tab clones and keeps the button hidden when the dropdown would
|
||||||
|
be empty. Gets `.tabbar-overflow-active` when the active tab
|
||||||
|
is inside. -->
|
||||||
|
<div class="tabbar-overflow" id="tabbar-overflow">
|
||||||
|
<button type="button" class="tabbar-overflow-btn" id="tabbar-overflow-btn"
|
||||||
|
aria-haspopup="menu" aria-expanded="false"
|
||||||
|
title="more tabs" hidden>⋮</button>
|
||||||
|
<ul class="tabbar-overflow-dropdown" id="tabbar-overflow-dropdown"
|
||||||
|
role="menu" hidden></ul>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3382,6 +3382,8 @@ window.marked = marked;
|
||||||
// on hashchange without waiting for the next SSE update.
|
// on hashchange without waiting for the next SSE update.
|
||||||
document.body.dataset.activeTab = target;
|
document.body.dataset.activeTab = target;
|
||||||
renderSelectionBar(Array.from(containersState.values()));
|
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
|
// Schedules pane has no SSE channel for mutations, so re-fetch
|
||||||
// on activation so the operator never lands on stale data.
|
// on activation so the operator never lands on stale data.
|
||||||
if (target === 'schedules') refreshSchedules();
|
if (target === 'schedules') refreshSchedules();
|
||||||
|
|
@ -3390,9 +3392,152 @@ window.marked = marked;
|
||||||
const h = (window.location.hash || '#swarm').replace(/^#/, '');
|
const h = (window.location.hash || '#swarm').replace(/^#/, '');
|
||||||
activateTab(h);
|
activateTab(h);
|
||||||
}
|
}
|
||||||
window.addEventListener('hashchange', syncTabFromHash);
|
window.addEventListener('hashchange', () => {
|
||||||
|
syncTabFromHash();
|
||||||
|
updateTabbarOverflow();
|
||||||
|
});
|
||||||
syncTabFromHash();
|
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
|
// Tab count pills — pure derived data from the existing state
|
||||||
// stores so SSE-driven updates flow through without extra plumbing.
|
// stores so SSE-driven updates flow through without extra plumbing.
|
||||||
// Set `hidden` when the count is zero so the pill doesn't draw
|
// 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
|
// stays hidden by default; future plumbing could broadcast the
|
||||||
// count via localStorage / BroadcastChannel if both pages are
|
// count via localStorage / BroadcastChannel if both pages are
|
||||||
// open.
|
// 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
|
// Poll the state stores on a 1s tick to keep the pill counts in
|
||||||
// sync. The state stores are mutated synchronously by every SSE
|
// sync. The state stores are mutated synchronously by every SSE
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue