diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d113f1..897b703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ auto-generated per-PR notes; this file is the curated, human-readable history. reaches incompatible dependencies (#383). ### Changed +- KPI queries now have one frameless Dashboard presentation in Grid Tiles, + Full view, Report, 2-column, and 3-column styles. Flow KPI-band members and + transparent grid wrappers move with Command/Ctrl-drag while plain dragging + continues to select text; movement reuses the shared floating preview, + destination resolution, auto-scroll, cancellation, and exact-once commit + path. Grid KPI title/delete/resize chrome appears transiently on hover or + keyboard focus without shifting content, and resize is keyboard-operable + with arrow keys (#340). - Consolidated the non-Iceberg examples into three responsive flagship dashboards: On-time flights, Shop analytics, and ClickHouse Operations. Each uses an authored `grafana-grid@1` layout with a complete `flow@1` diff --git a/src/styles.css b/src/styles.css index 7328184..8f4cd3e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2720,6 +2720,15 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } /* 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-member { display: contents; } +.dash-grid.modkey .dash-kpi-member:hover > * { cursor: grab; } +.dash-kpi-member.dash-drop-target > * { + outline: 2px dashed var(--accent); outline-offset: -2px; +} +.dash-kpi-member.dash-floating { + display: flex; flex-wrap: wrap; align-items: stretch; gap: 10px; + box-sizing: border-box; background: var(--bg-side); border-radius: 10px; + outline: 2px solid var(--accent); outline-offset: -2px; +} .dash-kpi-state-card { --kpi-accent: var(--accent); min-width: 160px; max-width: 320px; padding: 14px 16px; @@ -2829,7 +2838,8 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .dash-gg-del, .dash-gg-resize { user-select: none; } /* #332: edit chrome stays non-selectable */ .dash-gg-resize { display: none; } .dash-gg-grid .dash-gg-resize { - display: block; position: absolute; right: 0; bottom: 0; width: 14px; height: 14px; cursor: nwse-resize; + display: block; position: absolute; right: 0; bottom: 0; width: 14px; height: 14px; padding: 0; + border: 0; background: transparent; cursor: nwse-resize; } .dash-gg-resize::after { content: ''; position: absolute; right: 3px; bottom: 3px; width: 6px; height: 6px; @@ -2890,13 +2900,8 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } host was forced to `1fr` still tries to occupy N implicit tracks, rendering side-by-side/overflowing instead of the model's actual N-column layout. */ -/* ── #316: view-mode KPI tiles are frameless ─────────────────────────────── - `.is-view` is a static per-load class (ui/dashboard.ts sets it once per - tile card, from the same `readOnly` that already governs drag/remove/ - resize — it never toggles mid-session; `.is-kpi` is likewise fixed per - session today but is still re-toggled defensively on every reconcile, - ui/dashboard.ts `reconcileGridTile`). Scoped to - `.dash-gg-grid .dash-gg-tile.is-kpi` so: +/* ── #340: KPI tiles are frameless in edit and view modes ────────────────── + Scoped to `.dash-gg-grid .dash-gg-tile.is-kpi` so: - a non-KPI grid tile (`.is-view` with no `.is-kpi`) keeps its full frame — only KPI content gets this treatment; - a flow-rendered card is never matched — flow never applies @@ -2907,10 +2912,49 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } "invisible structural wrapper" the issue asks for, not a new DOM node, and never `display: contents` (that would drop it out of grid placement). The accessible name comes from `role="group"`/`aria-label` set directly on this - same element (ui/dashboard.ts) — hiding the header visually here does not - remove it from the accessibility tree. */ -.dash-gg-grid .dash-gg-tile.is-kpi.is-view { + same element. Edit controls are overlay chrome: revealing them never changes + the KPI body's size or the tile's authored height. */ +.dash-gg-grid .dash-gg-tile.is-kpi { background: transparent; border-color: transparent; border-radius: 0; box-shadow: none; } -.dash-gg-grid .dash-gg-tile.is-kpi.is-view > .dash-tile-head { display: none; } -.dash-gg-grid .dash-gg-tile.is-kpi.is-view > .dash-tile-body { padding: 0; } +.dash-gg-grid .dash-gg-tile.is-kpi > .dash-tile-head { + position: absolute; z-index: 2; inset: 0 0 auto 0; min-height: 0; padding: 4px 5px; + border: 0; opacity: 0; pointer-events: none; +} +.dash-gg-grid .dash-gg-tile.is-kpi > .dash-tile-head > .dash-tile-name { + width: fit-content; max-width: calc(100% - 30px); padding: 2px 6px; + border-radius: 4px; background: color-mix(in srgb, var(--bg-modal) 92%, transparent); + box-shadow: 0 1px 4px rgba(0, 0, 0, .18); +} +.dash-gg-grid .dash-gg-tile.is-kpi > .dash-tile-head > .dash-gg-del { pointer-events: auto; } +.dash-gg-grid .dash-gg-tile.is-kpi > .dash-tile-body { padding: 0; } +.dash-gg-grid .dash-gg-tile.is-kpi .dash-gg-grip { display: none; } +.dash-gg-grid .dash-gg-tile.is-kpi::before { + content: ''; position: absolute; z-index: 1; inset: 0; border-radius: 8px; + outline: 1px solid transparent; outline-offset: -1px; pointer-events: none; +} +.dash-gg-grid .dash-gg-tile.is-kpi:not(.is-view):hover::before, +.dash-gg-grid .dash-gg-tile.is-kpi:not(.is-view):focus-within::before, +.dash-gg-grid.modkey .dash-gg-tile.is-kpi:not(.is-view):hover::before { + outline-color: var(--border); +} +.dash-gg-grid .dash-gg-tile.is-kpi:not(.is-view):hover > .dash-tile-head, +.dash-gg-grid .dash-gg-tile.is-kpi:not(.is-view):focus-within > .dash-tile-head, +.dash-gg-grid.modkey .dash-gg-tile.is-kpi:not(.is-view):hover > .dash-tile-head { + opacity: 1; +} +.dash-gg-grid .dash-gg-tile.is-kpi:hover .dash-gg-resize::after, +.dash-gg-grid .dash-gg-tile.is-kpi:focus-within .dash-gg-resize::after, +.dash-gg-grid.modkey .dash-gg-tile.is-kpi:hover .dash-gg-resize::after, +.dash-gg-grid .dash-gg-tile.is-kpi .dash-gg-resize:focus-visible::after { opacity: 1; } +.dash-gg-grid .dash-gg-tile.is-kpi:focus-within .dash-gg-del { opacity: 1; } +.dash-gg-grid .dash-gg-tile.is-kpi.dash-floating, +.dash-gg-grid .dash-gg-tile.is-kpi.dash-gg-resizing { + background: var(--bg-side); border-color: transparent; border-radius: 8px; +} +.dash-gg-grid .dash-gg-tile.is-kpi.dash-floating::before, +.dash-gg-grid .dash-gg-tile.is-kpi.dash-gg-resizing::before { + outline: 2px solid var(--accent); outline-offset: -2px; +} +.dash-gg-grid .dash-gg-tile.is-kpi.dash-floating > .dash-tile-head, +.dash-gg-grid .dash-gg-tile.is-kpi.dash-gg-resizing > .dash-tile-head { opacity: 1; } diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 0662a00..43fc416 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -64,7 +64,8 @@ import type { import { defaultLayoutRegistry, resolveLayoutPluginSync } from '../dashboard/layouts/layout-registry.js'; import type { FlowLayoutModel } from '../dashboard/layouts/flow-layout.js'; import { - DEFAULT_GRID_HEIGHT_UNITS, GRAFANA_GRID_MAX_COLUMNS, GRID_GAP_PX, contentBoxWidth, deriveFlowFallback, + DEFAULT_GRID_HEIGHT_UNITS, GRAFANA_GRID_MAX_COLUMNS, GRID_GAP_PX, GRID_HEIGHT_UNIT_MAX, GRID_HEIGHT_UNIT_MIN, + contentBoxWidth, deriveFlowFallback, gridHeightUnitsToPx, snapGridHeight, snapGridSpan, } from '../dashboard/layouts/grafana-grid-layout.js'; import type { GrafanaGridLayoutModel, GridRenderMode } from '../dashboard/layouts/grafana-grid-layout.js'; @@ -186,6 +187,10 @@ let installedGridResizeListener: { win: Window; handler: () => void } | null = n let installedModifierListeners: | { win: Window; onKeyDown: (e: KeyboardEvent) => void; onKeyUp: (e: KeyboardEvent) => void; onBlur: () => void } | null = null; +// An in-flight move owns window/document listeners and pointer capture. A new +// route render must cancel it before replacing the page so no stale gesture can +// commit into the newly-rendered Dashboard. +let installedGestureCancel: (() => void) | null = null; /** * Build the flow preset switcher as a compact `