From f1cbdb9234176ed5214fe659157e728f42dc80b9 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Mon, 20 Jul 2026 11:12:26 +0200 Subject: [PATCH] fix(#331): Dashboard KPI flow band, shared File-menu primitive, tile containment Three independent Dashboard UI/UX fixes, each with unit + real-browser tests. 1. KPI bands render as one horizontal wrapping row in flow layouts. The flow renderer emits `.dash-kpi-member` band members, but the `display:contents` flattening rule targeted a never-emitted `.dash-kpi-source` class, so members stayed block-level and KPI cards stacked vertically. Unify the class contract on `.dash-kpi-member` across renderer, CSS, and tests. Report / 2-column / 3-column bands are now one horizontal card stream in canonical tile order. Grid Tiles (#321) and frameless (#316) KPI behavior unchanged. 2. Dashboard File menu shares the Workbench File-menu primitive. New data-driven `src/ui/menu.ts` (`openMenu`) owns the shared dropdown structure + interaction grammar (icon/label/meta columns, section headings, separators, overlay/ outside-click dismiss, Escape + focus-restore, ArrowUp/Down roving focus, aria-haspopup/expanded, anchored placement); both the Workbench (`file-menu.ts`) and Dashboard (`buildDashboardFileMenu`) menus are rebuilt on it, ending the duplicated builders. Dashboard File trigger switches to the shared downward chevron (no more right-arrow that misread as navigation); its menu gains EXPORT/IMPORT/OPEN sections, icons, and `.json` metadata. Read-only Dashboards still expose Export only. Actions + permission rules unchanged. `menu.ts` is fully unit-tested at 100/100/100/100. 3. Tile content stays inside the body, above the footer. `.dash-tile-body` gains `overflow:hidden`, and the tall-panel containment contract (flex:1 1 auto; min-width/min-height:0; max-height:100%; overflow:auto) now covers Markdown/text (`.md-view`) and the `.panel-with-note` wrapper alongside tables and logs, so long content scrolls inside the body instead of painting over the footer or neighbouring tiles (both flow and Grid Tiles engines share the tile shell). A metaless panel (e.g. a Text tile) now hides its footer rather than reserving an empty footer line, reasserted before the unchanged-rows repaint short-circuit so a sibling tile's refresh can't leave it stale. Real-browser verification (agent Chrome, real styles.css): flow KPI members resolve display:contents with all cards on one row in tile order; `.dash-tile-body` is overflow:hidden with table/markdown panels scrolling internally and the footer never overlapped. Unit suite 4268 passing, tsc clean, boundaries OK, build OK. Closes #331. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UDsUDSPoDYa1M1rbpgdG3f --- CHANGELOG.md | 33 ++++ src/styles.css | 25 ++- src/ui/app.types.ts | 2 - src/ui/dashboard.ts | 107 +++++++------ src/ui/file-menu.ts | 117 +++++++------- src/ui/menu.ts | 155 ++++++++++++++++++ tests/e2e/dashboard-grid.html | 75 +++++++++ tests/e2e/dashboard-grid.spec.js | 45 ++++++ tests/unit/dashboard.test.ts | 98 +++++++++++- tests/unit/file-menu.test.ts | 3 + tests/unit/menu.test.ts | 261 +++++++++++++++++++++++++++++++ 11 files changed, 799 insertions(+), 122 deletions(-) create mode 100644 src/ui/menu.ts create mode 100644 tests/unit/menu.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b3a55cc0..2ef71c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,6 +159,39 @@ auto-generated per-PR notes; this file is the curated, human-readable history. issue's ~3 KB estimate, still a low single-digit percent of the bundle and no new network requests). +### Fixed +- **Dashboard KPI bands now render as one horizontal wrapping row in flow + layouts** (#331). The flow renderer emitted each consecutive-KPI band member + as `.dash-kpi-member`, but the `display:contents` flattening rule targeted a + never-emitted `.dash-kpi-source` class, so members stayed block-level and the + KPI cards stacked vertically instead of sharing the band's `flex-wrap` stream. + The class contract is unified on `.dash-kpi-member` across renderer, CSS, and + tests; Report / 2-column / 3-column bands are now a single horizontal card + stream in canonical tile order. Grid Tiles (#321) and frameless (#316) KPI + behavior are unchanged. +- **Dashboard File menu now shares the Workbench File-menu primitive** (#331). + A new data-driven `src/ui/menu.ts` (`openMenu`) owns the shared dropdown + structure and interaction grammar — icon/label/metadata columns, section + headings, separators, overlay/outside-click dismiss, Escape + focus-restore, + ArrowUp/Down roving focus, `aria-haspopup`/`aria-expanded`, and anchored + placement — and both the Workbench (`file-menu.ts`) and Dashboard + (`buildDashboardFileMenu`) menus are rebuilt on it, ending the duplicated + builders. The Dashboard File trigger now uses the same downward-chevron + treatment as Workbench (no more right-arrow that misread as navigation) and + its menu gains EXPORT/IMPORT/OPEN section headings, icons, and `.json` + metadata; read-only Dashboards still expose Export only. Actions and + permission rules are unchanged. +- **Dashboard tile content stays inside the tile body, above the footer** + (#331). `.dash-tile-body` gained `overflow:hidden`, and the tall-panel + containment contract (`flex:1 1 auto; min-width/min-height:0; max-height:100%; + overflow:auto`) now covers Markdown/text (`.md-view`) and the diagnostic + `.panel-with-note` wrapper alongside tables and logs, so long content scrolls + inside the body instead of painting over the footer or neighbouring tiles + (both flow and Grid Tiles engines, which share the tile shell). A metaless + panel (e.g. a Text tile that never runs a query) now hides its footer rather + than reserving an empty footer line, reasserted before the unchanged-rows + repaint short-circuit so a sibling tile's refresh can't leave it stale. + ## [0.6.0] - 2026-07-18 ### Added diff --git a/src/styles.css b/src/styles.css index fe745a22..fa1bce88 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2426,15 +2426,26 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } font-size: 11px; color: var(--fg-mute); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.dash-tile-body { flex: 1; min-height: 0; padding: 6px 8px; display: flex; } +.dash-tile-body { flex: 1; min-height: 0; padding: 6px 8px; display: flex; overflow: hidden; } .dash-tile-body > .chart-view, .dash-tile-body > .kpi-panel { flex: 1; min-width: 0; } -/* Table/logs tiles (#149 D9): explicit flex constraints so content scrolls - inside the tile instead of clipping; the workbench grid's height:100% is - overridden back to auto (flex sizes it here). */ +/* Table/logs/markdown-text tiles (#149 D9, #331): explicit flex + overflow + containment so tall content scrolls inside the tile body instead of + growing past it and painting over the footer/other tiles; the workbench + grid's height:100% is overridden back to auto (flex sizes it here). */ .dash-tile-body > .res-table-wrap, -.dash-tile-body > .dash-logs { flex: 1; min-width: 0; min-height: 0; } +.dash-tile-body > .dash-logs, +.dash-tile-body > .md-view { + flex: 1 1 auto; min-width: 0; min-height: 0; max-height: 100%; overflow: auto; +} .dash-tile-body > .res-table-wrap { height: auto; } +/* #331: a diagnostic wrapper (schema-mismatch/staleness note + the actual + panel root) may be the direct child instead — same containment contract, + plus a column flex so the wrapped panel root can still scroll within it. */ +.dash-tile-body > .panel-with-note { + flex: 1 1 auto; min-width: 0; min-height: 0; max-height: 100%; overflow: hidden; + display: flex; flex-direction: column; +} /* Logs view (#149 D9): a compact, scroll-only reading surface — level colors via the --log-* theme vars, monospace, wrapped messages. */ .dash-logs { @@ -2508,9 +2519,9 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .dash-kpi-stream .kpi-card { flex: 0 1 auto; inline-size: fit-content; min-inline-size: 160px; max-inline-size: 320px; } -/* A source's stable host contributes its children (cards, or a state card) +/* A member's stable host contributes its children (cards, or a state card) directly to the stream's flex-wrap — it is never itself a visible box. */ -.dash-kpi-source { display: contents; } +.dash-kpi-member { display: contents; } .dash-kpi-state-card { --kpi-accent: var(--accent); min-width: 160px; max-width: 320px; padding: 14px 16px; diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 30fbeb15..fdc7f587 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -65,8 +65,6 @@ export interface SchemaFocus { export interface AppDom { fileBtn?: HTMLElement; fileDialog?: HTMLElement; - fileMenu?: HTMLElement; - fileMenuOverlay?: HTMLElement; libraryTitle?: HTMLElement; /** #302 — the Workbench header "Dashboard →" nav control (shown only when the * current workspace has a Dashboard). */ diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index f9f12e42..3a1e61c4 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -29,8 +29,10 @@ // is injected on the `app` controller. import { effect } from '@preact/signals-core'; -import { h, fixedAnchor } from './dom.js'; +import { h } from './dom.js'; import { Icon as IconUntyped } from './icons.js'; +import { openMenu } from './menu.js'; +import type { MenuHandle, MenuRow } from './menu.js'; import { renderResolvedPanel } from './panels.js'; import { resolvePanel } from '../core/panel-cfg.js'; import type { Column } from '../core/panel-cfg.js'; @@ -87,6 +89,10 @@ const Icon: { moon(): SVGElement; arrow(): SVGElement; trash(): SVGElement; + chevDown(): SVGElement; + download(): SVGElement; + upload(): SVGElement; + eye(): SVGElement; } = IconUntyped; const formatRows: (n: number | null | undefined) => string = formatRowsUntyped; @@ -225,65 +231,63 @@ function renderDashboardNotFound(app: DashboardApp): void { back))); } -/** #302 — the standalone Dashboard header's own "File" menu: a keyboard- and - * screen-reader-accessible dropdown owning Dashboard-scoped operations. Edit - * mode offers Export / Import / Open for viewing; a read-only (detached) view - * offers Export only (import + re-preview are edit-context operations). Every - * item delegates to an `app.actions.*` seam (dashboard.ts never reaches into - * app.ts). Esc/outside-click close and restore focus; arrows move between - * items. */ +/** #302/#331 — the standalone Dashboard header's own "File" menu: a keyboard- + * and screen-reader-accessible dropdown owning Dashboard-scoped operations, + * built on the shared `openMenu` primitive (menu.ts) — the same structure + + * interaction grammar (icons, `.fm-section` headings, Esc/outside-click + * close + focus-restore, ArrowUp/ArrowDown roving focus) as the Workbench + * File menu, with Dashboard-specific CONTENTS: + * EXPORT ⭳ Export Dashboard… .json + * IMPORT ⭱ Import Dashboard… + * OPEN ◇ Open for viewing… + * Edit mode offers all three sections; a read-only (detached) view offers + * EXPORT only (import + re-preview are edit-context operations). Every item + * delegates to an `app.actions.*` seam (dashboard.ts never reaches into + * app.ts). The trigger uses the shared downward-chevron treatment + * (`Icon.chevDown()`, matching the Workbench File button) rather than a + * right-pointing arrow, which would misread as navigation. The trigger owns + * its own open/close TOGGLE (unlike the Workbench menu, which only ever + * opens) — clicking it again while open closes the menu and restores focus, + * tracked here via the returned `MenuHandle` rather than a second + * `openMenu` call. */ function buildDashboardFileMenu(app: DashboardApp, readOnly: boolean): HTMLElement { const doc = app.document; const btn = h('button', { class: 'dash-btn dash-file-btn', 'aria-haspopup': 'menu', 'aria-expanded': 'false', title: 'File — dashboard import/export', 'aria-label': 'Dashboard File menu', - }, h('span', null, 'File'), Icon.arrow()) as HTMLButtonElement; - let menu: HTMLElement | null = null; - let overlay: HTMLElement | null = null; + }, h('span', null, 'File'), Icon.chevDown()) as HTMLButtonElement; - const close = (): void => { - doc.removeEventListener('keydown', onKey, true); - menu?.remove(); overlay?.remove(); - menu = null; overlay = null; - btn.setAttribute('aria-expanded', 'false'); - }; - const onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape') { e.preventDefault(); close(); btn.focus(); return; } - if (!menu) return; - const items = Array.from(menu.querySelectorAll('.dash-fm-item')); - const at = items.indexOf(doc.activeElement as HTMLButtonElement); - if (e.key === 'ArrowDown') { e.preventDefault(); items[(at + 1) % items.length]?.focus(); } - else if (e.key === 'ArrowUp') { e.preventDefault(); items[(at - 1 + items.length) % items.length]?.focus(); } - }; - - const item = (label: string, onClick: () => void): HTMLButtonElement => h('button', { - class: 'fm-item dash-fm-item', role: 'menuitem', - onclick: () => { close(); onClick(); }, - }, h('span', { class: 'fm-label' }, label)) as HTMLButtonElement; + let handle: MenuHandle | null = null; const open = (): void => { - overlay = h('div', { class: 'fm-overlay', onclick: close }); - const items = [item('Export Dashboard…', () => app.actions.exportDashboard())]; + const rows: MenuRow[] = [ + { kind: 'section', label: 'Export' }, + { + kind: 'item', icon: Icon.download(), label: 'Export Dashboard…', meta: '.json', extraClass: 'dash-fm-item', + onClick: () => app.actions.exportDashboard(), + }, + ]; if (!readOnly) { - items.push( - item('Import Dashboard…', () => app.actions.importDashboard()), - item('Open for viewing…', () => app.actions.openDashboardForViewing()), + rows.push( + { kind: 'section', label: 'Import' }, + { + kind: 'item', icon: Icon.upload(), label: 'Import Dashboard…', extraClass: 'dash-fm-item', + onClick: () => app.actions.importDashboard(), + }, + { kind: 'section', label: 'Open' }, + { + kind: 'item', icon: Icon.eye(), label: 'Open for viewing…', extraClass: 'dash-fm-item', + onClick: () => app.actions.openDashboardForViewing(), + }, ); } - menu = h('div', { class: 'file-menu dash-file-menu', role: 'menu' }, ...items); - doc.body.appendChild(overlay); - doc.body.appendChild(menu); - const r = btn.getBoundingClientRect(); - const a = fixedAnchor(r) as { top: number; left: number }; - menu.style.position = 'fixed'; - menu.style.top = a.top + 'px'; - menu.style.left = a.left + 'px'; - btn.setAttribute('aria-expanded', 'true'); - doc.addEventListener('keydown', onKey, true); - items[0].focus(); + handle = openMenu({ + document: doc, trigger: btn, rows, menuClass: 'dash-file-menu', + onClose: () => { handle = null; }, + }); }; - btn.onclick = () => { if (menu) { close(); btn.focus(); } else open(); }; + btn.onclick = () => { if (handle) { handle.close(); btn.focus(); } else open(); }; return btn; } @@ -778,6 +782,15 @@ export async function renderDashboard(app: DashboardApp): Promise { // Paint an ordinary (non-KPI) tile's result once per new result. Only ever // called for a 'ready' tile, so columns/rows/meta/panel are all present. function paintPanel(ts: ViewerTileState, tileEl: TileEl): void { + // #331: reasserted BEFORE the unchanged-rows early return below — a + // republish that repaints a DIFFERENT tile (e.g. a sibling's query + // finishing) still runs this function for every ready tile with the + // SAME `ts.rows` reference this tile painted last time, which would + // otherwise skip past the meta check entirely and leave whatever + // `reconcileGridTile`'s unconditional `foot.hidden = ts.isKpi` (#316) + // last wrote in place — stale-visible for a metaless tile once any + // other tile's data arrives. + tileEl.foot.hidden = !ts.meta; if (ts.rows === tileEl.paintedRows) return; destroyChart(tileEl); const panel = (ts.panel || {}) as Record; diff --git a/src/ui/file-menu.ts b/src/ui/file-menu.ts index 1862426a..687e88aa 100644 --- a/src/ui/file-menu.ts +++ b/src/ui/file-menu.ts @@ -15,8 +15,10 @@ // (app.workspace.commit / app.downloadFile / app.FileReader / app.document / // app.genId / app.wallNow), so it is fully testable. -import { h, fixedAnchor, attachBackdropClose } from './dom.js'; +import { h, attachBackdropClose } from './dom.js'; import { Icon } from './icons.js'; +import { openMenu } from './menu.js'; +import type { MenuRow } from './menu.js'; import { flashToast } from './toast.js'; import { renderSavedHistory } from './saved-history.js'; import { buildMarkdownDoc, buildSqlDoc } from '../core/saved-io.js'; @@ -48,6 +50,7 @@ const first = (diagnostics: readonly WorkspaceDiagnostic[], fallback: string): s export function libraryControls(app: App): HTMLElement[] { app.dom.fileBtn = h('button', { class: 'hd-file-btn', title: 'File — workspace and dashboard import/export', + 'aria-haspopup': 'menu', 'aria-expanded': 'false', onclick: () => openFileMenu(app), }, h('span', null, 'File'), Icon.chevDown()); app.dom.libraryTitle = h('div', { class: 'lib-title' }); @@ -113,31 +116,29 @@ export function renderLibraryTitle(app: App): void { state.libraryDirty.value ? h('span', { class: 'lib-dirty', title: 'Changes since the last export or import' }) : null)); } -/** Open the File dropdown anchored under the File button (Esc / outside-click close). */ +/** Open the File dropdown anchored under the File button (Esc / outside-click + * close; #331 area 2 — built on the shared `openMenu` primitive, which is + * itself idempotent per trigger, so re-opening while already open is a + * no-op). */ export function openFileMenu(app: App): void { - if (app.dom.fileMenu) return; const doc = app.document; + // Re-entrancy guard: `openMenu` itself dedups per trigger, but the picker + // setup + `handle.el.appendChild(...)` below run BEFORE that call, so a + // redundant open (e.g. a keyboard shortcut fired while the menu is already + // up) would splice two orphaned hidden inputs into the live menu. The + // trigger's `aria-expanded` — set to 'true' by `openMenu` on open and back + // to 'false' on close — is the authoritative open-state flag to bail on. + if (app.dom.fileBtn!.getAttribute('aria-expanded') === 'true') return; const list = app.state.savedQueries; - const close = (): void => { - doc.removeEventListener('keydown', onKey, true); - if (app.dom.fileMenu) { app.dom.fileMenu.remove(); app.dom.fileMenu = undefined; } - if (app.dom.fileMenuOverlay) { app.dom.fileMenuOverlay.remove(); app.dom.fileMenuOverlay = undefined; } - }; - const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') { e.preventDefault(); close(); } }; - - const importQueriesInput = pickerInput(app, (f) => onImportQueriesFile(app, f)); - const replaceWorkspaceInput = pickerInput(app, (f) => onReplaceWorkspaceFile(app, f)); - const item = (icon: Node, label: string, meta: string | null, onClick: () => void): HTMLButtonElement => h('button', { class: 'fm-item', onclick: onClick }, - h('span', { class: 'fm-icon' }, icon), h('span', { class: 'fm-label' }, label), - meta ? h('span', { class: 'fm-meta' }, meta) : null); - const sep = (): HTMLDivElement => h('div', { class: 'fm-sep' }); const empty = list.length === 0; // #302: the Workbench File menu owns workspace + query-collection operations // ONLY. Dashboard navigation moved to the header "Dashboard →" control // (`libraryControls`), and Dashboard import/export moved to the Dashboard // page's own File menu — none of them appear here anymore. - const newWorkspaceItem = item(Icon.plus(), 'New workspace…', null, () => { close(); newWorkspaceAction(app); }); + const importQueriesInput = pickerInput(app, (f) => onImportQueriesFile(app, f)); + const replaceWorkspaceInput = pickerInput(app, (f) => onReplaceWorkspaceFile(app, f)); + // Variable recent-value history (#171): this is the closest thing the app // has to a "settings" surface today (no dedicated preferences panel exists // yet — rg for one turned up nothing), so it follows the File menu's own @@ -155,47 +156,47 @@ export function openFileMenu(app: App): void { }, }), h('span', { class: 'fm-label' }, 'Remember recent variable values')); - const clearAllRecentItem = item(Icon.trash(), 'Clear all recent values', null, () => { - close(); - app.params.clearAllVarRecent(); - flashToast('Cleared recent variable values', { document: app.document }); - }); - const menu = h('div', { class: 'file-menu' }, - newWorkspaceItem, - sep(), - h('div', { class: 'fm-section' }, 'Variable history'), - historyToggle, - clearAllRecentItem, - sep(), - h('div', { class: 'fm-section' }, 'Import / replace'), - item(Icon.upload(), 'Import queries…', null, () => { importQueriesInput.click(); close(); }), - item(Icon.refresh(), 'Replace workspace…', null, () => { replaceWorkspaceInput.click(); close(); }), - sep(), - h('div', { class: 'fm-section' }, 'Export'), - item(Icon.download(), 'Export workspace…', '.json', () => { close(); exportWorkspaceAction(app); }), - sep(), - h('div', { class: 'fm-section' }, 'Share / publish'), - item(Icon.download(), 'Download Markdown', '.md', () => { close(); downloadAction(app, 'md'); }), - item(Icon.download(), 'Download SQL', '.sql', () => { close(); downloadAction(app, 'sql'); }), - h('div', { class: 'fm-count' }, empty ? 'Workspace is empty' : queries(list.length) + ' in workspace'), - importQueriesInput, replaceWorkspaceInput); - - const overlay = h('div', { class: 'fm-overlay', onclick: close }); - app.dom.fileMenuOverlay = overlay; - app.dom.fileMenu = menu; - doc.body.appendChild(overlay); - const r = app.dom.fileBtn!.getBoundingClientRect(); - // Anchor under the button. fixedAnchor's return type is a `{top,left}` / - // `{top,right}` union (the right-align branch only fires when a - // `viewportW` option is passed); this call site never passes one, so it's - // always the `{top,left}` shape. - const a = fixedAnchor(r) as { top: number; left: number }; - menu.style.position = 'fixed'; - menu.style.top = a.top + 'px'; - menu.style.left = a.left + 'px'; - doc.body.appendChild(menu); - doc.addEventListener('keydown', onKey, true); - setTimeout(() => newWorkspaceItem.focus()); + const countRow = h('div', { class: 'fm-count' }, empty ? 'Workspace is empty' : queries(list.length) + ' in workspace'); + + const rows: MenuRow[] = [ + { kind: 'item', icon: Icon.plus(), label: 'New workspace…', onClick: () => newWorkspaceAction(app) }, + { kind: 'sep' }, + { kind: 'section', label: 'Variable history' }, + { kind: 'custom', node: historyToggle, focusable: true }, + { + kind: 'item', icon: Icon.trash(), label: 'Clear all recent values', + onClick: () => { + app.params.clearAllVarRecent(); + flashToast('Cleared recent variable values', { document: app.document }); + }, + }, + { kind: 'sep' }, + { kind: 'section', label: 'Import / replace' }, + { kind: 'item', icon: Icon.upload(), label: 'Import queries…', onClick: () => importQueriesInput.click() }, + { kind: 'item', icon: Icon.refresh(), label: 'Replace workspace…', onClick: () => replaceWorkspaceInput.click() }, + { kind: 'sep' }, + { kind: 'section', label: 'Export' }, + { kind: 'item', icon: Icon.download(), label: 'Export workspace…', meta: '.json', onClick: () => exportWorkspaceAction(app) }, + { kind: 'sep' }, + { kind: 'section', label: 'Share / publish' }, + { kind: 'item', icon: Icon.download(), label: 'Download Markdown', meta: '.md', onClick: () => downloadAction(app, 'md') }, + { kind: 'item', icon: Icon.download(), label: 'Download SQL', meta: '.sql', onClick: () => downloadAction(app, 'sql') }, + { kind: 'custom', node: countRow }, + ]; + + const handle = openMenu({ document: doc, trigger: app.dom.fileBtn!, rows }); + // The hidden file pickers aren't menu ROWS (no label/click chrome of their + // own) — they're display:none inputs `.click()`-triggered by the Import + // queries / Replace workspace items above. Parent them to the mounted menu + // so they're torn down with it on close (no leak) and `picker(i)`-style + // lookups (`.file-menu input[type=file]`) keep finding them. The item click + // closes the menu (detaching these) BEFORE running its onClick, so the + // `.click()` fires on a now-detached input — which is fine: a programmatic + // `.click()` opens the native file chooser whether or not the input is in + // the document (the standard detached-input pattern), and it still runs + // synchronously inside the original user gesture. + handle.el.appendChild(importQueriesInput); + handle.el.appendChild(replaceWorkspaceInput); } // ── file pickers + bundle decode ──────────────────────────────────────────── diff --git a/src/ui/menu.ts b/src/ui/menu.ts new file mode 100644 index 00000000..6dc69ff8 --- /dev/null +++ b/src/ui/menu.ts @@ -0,0 +1,155 @@ +// A shared, data-driven dropdown-menu primitive extracted from the previously +// duplicated Workbench (file-menu.ts) and Dashboard (dashboard.ts) File-menu +// builders (#331 area 2). `openMenu(opts)` owns the shared STRUCTURE and +// INTERACTION GRAMMAR — `.fm-item`/`.fm-section`/`.fm-sep` rows, `.fm-overlay` +// outside-click dismiss, Escape + focus-restore-to-trigger, ArrowUp/ArrowDown +// roving focus across the focusable rows, autofocus of the first focusable +// row on open, `aria-haspopup`/`aria-expanded` on the trigger, and +// `fixedAnchor` placement under it — while each caller supplies its own +// resource-specific CONTENTS via the `MenuRow[]` data model (icons, labels, +// meta text, sections, separators, and arbitrary `custom` rows for anything +// that doesn't fit the item shape, e.g. the Workbench's variable-history +// toggle row or its `.fm-count` footer). +// +// Re-calling `openMenu` on a trigger that already has an open menu is a +// no-op — it returns the SAME handle rather than stacking a second menu. A +// caller that wants an explicit open/close TOGGLE on its trigger button (the +// Dashboard File button) tracks that itself via the returned handle, calling +// `handle.close()` directly rather than a second `openMenu` — `openMenu` +// itself only ever opens (or returns the existing open handle). +// +// Pure-DOM, no globals: the `Document` and the trigger element are both +// passed in (matching every other render module's injected-`document` +// convention), so this is fully unit-testable under happy-dom. + +import { h, fixedAnchor } from './dom.js'; + +/** One row of a dropdown menu. + * - `item` — an actionable `.fm-item` row: icon + label + optional meta text + * (e.g. a file extension), invoking `onClick` after the menu closes. + * `extraClass` adds a caller-specific marker class alongside `.fm-item` + * (e.g. `dash-fm-item`). + * - `section` — a `.fm-section` heading. + * - `sep` — a `.fm-sep` divider. + * - `custom` — an arbitrary caller-built node spliced in as-is. `focusable: + * true` makes it a stop in the ArrowUp/ArrowDown roving-focus order — the + * focus target is the row's own first focusable descendant (an ``/ + * `