diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index e41c2f5..1429df2 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -461,7 +461,8 @@ const menu = moduleApi.header.addMenu({ build(menuEl) { // called on every open with an emptied menu element — fill it with // buttons (give them your scoped classes; the container is styled and - // fixed-positioned by the shell, so the tile's overflow can't clip it) + // positioned by the shell, hosted on the body under an element carrying + // your data-module, so your scoped CSS applies and nothing clips it) }, }); menu.setLabel('Channels (2/4) ▾'); // update the button text any time diff --git a/modules/prodcom-transcript/client.js b/modules/prodcom-transcript/client.js index 87a1980..95dadf8 100644 --- a/modules/prodcom-transcript/client.js +++ b/modules/prodcom-transcript/client.js @@ -81,8 +81,8 @@ export default function create({ root, moduleApi }) { moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) }); applyInstanceSettings(); } - moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) }); - moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) }); + const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) }); + const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) }); // icon and tooltip track the current direction — applyInstanceSettings sets both const flowBtn = moduleApi.header.addButton({ @@ -95,7 +95,7 @@ export default function create({ root, moduleApi }) { }, }); - moduleApi.header.addButton({ + const latestBtn = moduleApi.header.addButton({ icon: JUMP_SVG, title: 'Latest', onClick() { @@ -112,6 +112,82 @@ export default function create({ root, moduleApi }) { }); clearBtn.classList.add('pt-clear'); + /* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ── + Seven controls need ~175 px, and the shell clips whatever the bar + cannot fit — on a 3-column tile among many, or one squeezed by the + fullscreen fit, that used to take the channel filter with it. Below + COMPACT_PX the six secondary controls hide and a single ⋯ menu offers + the same actions, so the filter button is the last thing to go. */ + const MORE_SVG = ''; + const COMPACT_PX = 380; // tile width below which the full bar no longer fits + + let moreMenuEl = null; + const moreMenu = moduleApi.header.addMenu({ + icon: MORE_SVG, + title: 'More', + build(menu) { + menu.classList.add('pt-menu'); + moreMenuEl = menu; + rebuildMoreMenu(); + }, + }); + const moreWrap = moreMenu.button.parentElement; // the shell's .tile-dd + const secondaryBtns = [timeBtn, minusBtn, plusBtn, flowBtn, latestBtn, clearBtn]; + + function addMoreItem(label, { on = null, onClick } = {}) { + const item = document.createElement('button'); + item.className = 'pt-channel-item' + (on ? ' on' : ''); + if (on !== null) { + item.setAttribute('role', 'menuitemcheckbox'); + item.setAttribute('aria-checked', String(on)); + } else { + item.setAttribute('role', 'menuitem'); + } + const check = document.createElement('span'); + check.className = 'check'; + check.textContent = '✓'; + const name = document.createElement('span'); + name.className = 'name'; + name.textContent = label; + item.append(check, name); + item.addEventListener('click', (e) => { + e.stopPropagation(); // the rebuild detaches this item — keep the menu open + onClick(); + }); + moreMenuEl.appendChild(item); + } + + function rebuildMoreMenu() { + if (!moreMenuEl) return; + moreMenuEl.innerHTML = ''; + const s = moduleApi.instanceSettings; + addMoreItem('Timestamps', { on: Boolean(s.showTimes), onClick: () => timeBtn.click() }); + addMoreItem('Newest first', { on: newestFirst(), onClick: () => flowBtn.click() }); + const divider = document.createElement('div'); + divider.className = 'pt-menu-divider'; + moreMenuEl.appendChild(divider); + addMoreItem('Smaller text', { onClick: () => bumpTextSize(-2) }); + addMoreItem('Larger text', { onClick: () => bumpTextSize(2) }); + addMoreItem('Latest', { onClick: () => { moreMenu.close(); latestBtn.click(); } }); + addMoreItem('Clear', { onClick: () => { moreMenu.close(); clearView(); } }); + } + + let compact = null; // unknown until the first measurement + function applyCompact(width) { + const next = width > 0 && width < COMPACT_PX; + if (next === compact) return; + compact = next; + for (const btn of secondaryBtns) btn.hidden = compact; + moreWrap.hidden = !compact; + if (!compact) moreMenu.close(); + } + // the tile's own body tells the width; onResize only knows grid cells + const sizeWatch = new ResizeObserver((entries) => { + applyCompact(entries[0]?.contentRect.width || root.clientWidth); + }); + sizeWatch.observe(root); + applyCompact(root.clientWidth); + function applyInstanceSettings() { const s = moduleApi.instanceSettings; const size = Math.min(48, Math.max(10, Number(s.textSize) || 15)); @@ -126,6 +202,7 @@ export default function create({ root, moduleApi }) { flowBtn.classList.toggle('active', up); flowBtn.title = up ? 'Newest first' : 'Oldest first'; jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages'; + rebuildMoreMenu(); // its checkmarks mirror the buttons } applyInstanceSettings(); @@ -415,6 +492,7 @@ export default function create({ root, moduleApi }) { }, stop() { stopped = true; + sizeWatch.disconnect(); stream?.close(); root.innerHTML = ''; // header controls are removed by the shell }, diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json index 170610a..d5ee214 100644 --- a/modules/prodcom-transcript/module.json +++ b/modules/prodcom-transcript/module.json @@ -1,7 +1,7 @@ { "id": "prodcom-transcript", "name": "ProdCom", - "version": "1.3.2", + "version": "1.3.3", "proddash": ">=1.1.0", "description": "Live ProdCom transcript", "permissions": [ diff --git a/modules/prodcom-transcript/style.css b/modules/prodcom-transcript/style.css index 0153e8d..0867c40 100644 --- a/modules/prodcom-transcript/style.css +++ b/modules/prodcom-transcript/style.css @@ -20,9 +20,13 @@ } [data-module="prodcom-transcript"] .pt-count[hidden] { display: none; } +/* narrow tile: the secondary controls fold into the ⋯ menu (client.js) */ +[data-module="prodcom-transcript"] .tile-btn[hidden], +[data-module="prodcom-transcript"] .tile-dd[hidden] { display: none; } + /* ── channels menu ── The menu container is created and positioned by the shell (.tile-menu); - it lives inside the tile, so [data-module] scoping still applies. */ + it hangs off the body under a [data-module] host, so scoping still applies. */ [data-module="prodcom-transcript"] .pt-channel-item { display: flex; diff --git a/package.json b/package.json index d70640d..a303f58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.10.0", + "version": "1.10.1", "private": true, "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/public/shell.js b/public/shell.js index 607e335..5adb804 100644 --- a/public/shell.js +++ b/public/shell.js @@ -640,8 +640,10 @@ function buildTile(tile) { el.addEventListener('pointermove', (ev) => { if (!headIsOff(tile)) return; // Mid-drag the tile moves under the pointer, and mid-resize the edges do - // — neither is a moment to take the bar away. + // — neither is a moment to take the bar away. Nor is an open header + // menu, which hangs below the bar and is about to be pointed at. if (el.classList.contains('dragging') || el.classList.contains('resizing')) return; + if (el.classList.contains('menu-open')) return; const y = ev.clientY - el.getBoundingClientRect().top; const shown = el.classList.contains('head-peek'); // It takes less to hold the bar open than to open it, so a bar that @@ -652,6 +654,7 @@ function buildTile(tile) { el.addEventListener('pointerleave', () => { if (el.classList.contains('dragging') || el.classList.contains('resizing')) return; + if (el.classList.contains('menu-open')) return; // the pointer is on its way to the menu el.classList.remove('head-peek'); }); @@ -659,7 +662,7 @@ function buildTile(tile) { applyHeadState(tile, el); grid.appendChild(el); - const entry = { el, body, dot, titleEl: title, controls, instance: null }; + const entry = { el, body, dot, titleEl: title, controls, instance: null, openMenus: new Set() }; tileEls.set(tile.id, entry); applyRect(tile); @@ -1426,34 +1429,60 @@ function buildModuleApi(tile, man, entry) { menu.className = 'tile-menu'; menu.hidden = true; + /* The menu lives on the body, not in the bar. A folded-away bar + (auto-hide, fullscreen) carries a backdrop-filter and a transform, + and either one makes a fixed-positioned descendant position + itself against the bar instead of the viewport — the menu landed + a screen-height below its button, inside the tile's overflow, and + never showed. Its host carries the tile's data-module so the + module's scoped CSS still reaches the items. */ + const host = document.createElement('div'); + host.className = 'tile-menu-host'; + if (man) host.dataset.module = man.id; + host.appendChild(menu); + + const setOpen = (on) => { + if (on === !menu.hidden) return; + menu.hidden = !on; + btn.classList.toggle('is-open', on); + if (on) entry.openMenus.add(menu); + else entry.openMenus.delete(menu); + // an open menu holds a folded-away bar out; when the last one + // closes with the pointer elsewhere, the bar goes back + entry.el.classList.toggle('menu-open', entry.openMenus.size > 0); + if (!on && !entry.openMenus.size && !entry.el.matches(':hover')) entry.el.classList.remove('head-peek'); + }; const open = () => { if (typeof build === 'function') { menu.innerHTML = ''; build(menu); } - menu.hidden = false; - // fixed positioning (escapes the tile's overflow): under the - // button, right-aligned, clamped to the viewport + setOpen(true); + // under the button, right-aligned, kept whole inside the viewport const r = btn.getBoundingClientRect(); - menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px'; menu.style.left = '0px'; + menu.style.top = '0px'; const w = menu.offsetWidth; + const h = menu.offsetHeight; + menu.style.top = Math.max(8, Math.min(r.bottom + 6, window.innerHeight - h - 8)) + 'px'; menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px'; }; btn.addEventListener('click', (e) => { e.stopPropagation(); if (menu.hidden) open(); - else menu.hidden = true; + else setOpen(false); }); const onDocClick = (e) => { - if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true; + if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false); }; document.addEventListener('click', onDocClick); docListeners.add(onDocClick); - wrap.append(btn, menu); + wrap.append(btn); entry.controls.appendChild(wrap); + document.body.appendChild(host); headerEls.add(wrap); + headerEls.add(host); return { button: btn, menu, @@ -1461,7 +1490,7 @@ function buildModuleApi(tile, man, entry) { btn.textContent = text; }, close() { - menu.hidden = true; + setOpen(false); }, }; }, @@ -1473,6 +1502,8 @@ function buildModuleApi(tile, man, entry) { for (const handle of [...sseHandles]) handle.close(); for (const el of headerEls) el.remove(); headerEls.clear(); + entry.openMenus.clear(); + entry.el.classList.remove('menu-open'); for (const fn of docListeners) document.removeEventListener('click', fn); docListeners.clear(); }, diff --git a/public/style.css b/public/style.css index 922fc62..383d864 100644 --- a/public/style.css +++ b/public/style.css @@ -445,8 +445,9 @@ body.menu-hidden #topbar { } .tile-dd { position: relative; display: inline-flex; } -/* a module header menu — fixed-positioned by the shell so the tile's - overflow:hidden can't clip it */ +/* a module header menu — hosted on the body (shell.js addMenu) so neither + the tile's overflow nor a folded-away bar's backdrop-filter can touch it */ +.tile-menu-host { display: contents; } .tile-menu { position: fixed; z-index: 70;