Put the dashboard panels away on a click off the widgets

Mirrors the flow editor's onPaneClick: clicking the canvas margin or the
empty surface closes the widget settings and the dashboard panel. Widget
frames, grid resize handles and menus portalled out of the canvas keep it
open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
This commit is contained in:
2026-08-28 11:19:30 +02:00
co-authored by Claude Opus 5
parent e18a669aa0
commit 7e8fd1182e
2 changed files with 37 additions and 0 deletions
@@ -144,6 +144,14 @@ const AUTOSAVE_MS = 800
const INTERACTIVE = const INTERACTIVE =
"button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle, .widget-grip" "button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle, .widget-grip"
/**
* What a click may land on without counting as a click off the widgets: the
* widget itself, and the handles the grid draws around it — resizing a tile is
* still working on it. The grid item covers both; the frame is what a stacked
* dashboard has instead.
*/
const ON_WIDGET = "[data-testid=widget-frame], .react-grid-item"
function nextId(dashboard: DashboardDef_Output, type: string): string { function nextId(dashboard: DashboardDef_Output, type: string): string {
const taken = new Set(widgetsOf(dashboard).map((widget) => widget.id)) const taken = new Set(widgetsOf(dashboard).map((widget) => widget.id))
let candidate = type let candidate = type
@@ -524,12 +532,26 @@ export function DashboardEditor({
return ( return (
<LookProvider dashboard={draft}> <LookProvider dashboard={draft}>
{/* Clicking off the widgets is how you put a panel away, as it is on the
flow pane — the dashboard is what you went back to look at. A menu a
widget portals to `body` lands outside this element but still bubbles
through React's tree, so only a press that landed in the subtree
counts. */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Escape closes the panel; this is a pointer shortcut, not a control. */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: see above. */}
<div <div
className={cn( className={cn(
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200", "absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
panelOpen && "md:pr-[27rem]", panelOpen && "md:pr-[27rem]",
)} )}
data-testid="dashboard-canvas" data-testid="dashboard-canvas"
onClick={(event) => {
const target = event.target as Element
if (!event.currentTarget.contains(target)) return
if (target.closest(ON_WIDGET)) return
setSelected(null)
setSettingsOpen(false)
}}
> >
<PaletteProvider dashboard={draft}>{body}</PaletteProvider> <PaletteProvider dashboard={draft}>{body}</PaletteProvider>
</div> </div>
+15
View File
@@ -135,6 +135,21 @@ test("the drag handle moves a widget without selecting it", async ({
await expect(settings).toBeVisible() await expect(settings).toBeVisible()
}) })
test("clicking off the widgets puts the settings panel away", async ({
page,
}) => {
await openEditor(page)
const settings = page.getByTestId("widget-settings")
await page.getByTestId("widget-frame").filter({ hasText: "Top" }).click()
await expect(settings).toBeVisible()
// The margin around the canvas is not a widget, the way the flow pane is not
// a node.
await page.getByTestId("dashboard-canvas").click({ position: { x: 8, y: 8 } })
await expect(settings).toBeHidden()
})
test("a widget without its title can still be dragged and picked", async ({ test("a widget without its title can still be dragged and picked", async ({
page, page,
}) => { }) => {