Say when a widget falls off the canvas, and offer to pack it back

Shrinking a dashboard's canvas silently clipped whatever now fell past the
bottom edge: `maxRows` constrains a drag and nothing else, so a stored placement
is corrected against the column count alone. Nothing warned, and nothing offered
a way out.

The remedy is a notice rather than a reflow, because the canvas height is
written on every keystroke — typing 400 passes through 4 and 40, and anything
that moved widgets would flatten the arrangement while the number was still
being typed. The notice carries the reflow as its one button, and packing is
sideways because the grid already compacts vertically: nothing below the canvas
has room above it.

Dropping a widget also selected it, which opened its panel, which rescaled the
canvas under the pointer. The drag handle was simply missing from the selector
that already exempts the resize handle — which is why resizing never had this
problem.

Also: an icon rule's caption could only be set through the API, and the panel
rail drew two letters where a dashboard can now carry a lucide icon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
This commit is contained in:
2026-08-21 14:33:12 +02:00
co-authored by Claude Opus 5
parent c3ea884d72
commit 06f84e18ae
4 changed files with 250 additions and 8 deletions
@@ -95,9 +95,17 @@ const AUTOSAVE_MS = 800
/**
* Controls a widget owns. A press on one of these is the widget's own — a
* slider still slides in edit mode — so it never counts as picking the widget.
*
* The drag handle counts too: a press on the grip is a move, not a pick, and
* the mouseup ending a drag still fires a click on the frame. Selecting there
* would open the settings panel — and the canvas lurches while it animates —
* every time a widget is dropped. The body is what picks it instead, which is
* how the resize handle has always behaved. On a phone the grip class is never
* applied, so the header keeps selecting: there is no dragging to confuse it
* with.
*/
const INTERACTIVE =
"button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle"
"button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle, .widget-grip"
function nextId(dashboard: DashboardDef_Output, type: string): string {
const taken = new Set(
@@ -328,6 +336,11 @@ export function DashboardEditor({
)
const layout = layoutOf(widgets, columns)
const rows = rowsOf(draft)
// maxRows below only constrains a *drag*. A stored placement is corrected
// against `cols` and nothing else, so a canvas that shrank leaves whatever
// now falls past its bottom edge drawn under the clip.
const clipped = layout.filter((item) => item.y + item.h > rows)
/** Store what the grid ended up doing, unless it did nothing. */
const applyLayout = (next: Layout) => {
@@ -351,6 +364,21 @@ export function DashboardEditor({
)
}
/** Shelf-pack in reading order, which is the only reflow that can help:
* the grid already compacts vertically, so nothing below the canvas has
* room above it — it has to move sideways. */
const reflow = () => {
const at = new Map(layout.map((item) => [item.i, item]))
const ordered = [...widgets].sort((a, b) => {
const left = at.get(a.id)
const right = at.get(b.id)
return (
(left?.y ?? 0) - (right?.y ?? 0) || (left?.x ?? 0) - (right?.x ?? 0)
)
})
applyLayout(packed(ordered, columns))
}
const active = widgets.find((widget) => widget.id === selected) ?? null
const panelOpen = Boolean(active) || settingsOpen
@@ -438,8 +466,9 @@ export function DashboardEditor({
margin: [GRID_GAP, GRID_GAP],
containerPadding: [0, 0],
// The canvas is the constraint: nothing may be dragged off the
// panel it is being drawn for.
maxRows: rowsOf(draft),
// panel it is being drawn for. Shared with the warning above, so
// the constraint and what it is warned about cannot disagree.
maxRows: rows,
}}
// Only the header moves a widget, so a slider under the cursor still
// slides and a switch still flips while the dashboard is being edited.
@@ -520,6 +549,44 @@ export function DashboardEditor({
>
{edit ? (
<>
{/* A canvas can be shrunk to less than what is arranged on it,
and the grid says nothing: the placement it corrects is
bounded by the columns alone. Say so, and offer the one
remedy — re-packing across the columns. Not an error, so no
destructive colour and no brand fill either; Done already
owns the one brand affordance here. A phone has no canvas to
fall off, so it never sees this. */}
{clipped.length > 0 && !stacked ? (
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
role="status"
aria-live="polite"
data-testid="canvas-clipped"
// A sibling of the buttons, positioned against the dock, so
// it clears however many rows the dock wrapped into.
className="absolute bottom-full left-1/2 mb-3 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-2 rounded-full border border-border bg-card/80 py-1 pl-4 pr-1 shadow-e2 backdrop-blur-md"
>
<span className="truncate text-sm">
{clipped.length === 1
? "1 widget sits below the canvas — a panel this size will not show it."
: `${clipped.length} widgets sit below the canvas — a panel this size will not show them.`}
</span>
<Button
variant="outline"
size="sm"
className="h-11 shrink-0 md:h-8"
onClick={reflow}
data-testid="reflow-canvas"
>
Reflow
</Button>
</motion.div>
) : null}
<Popover>
<PopoverTrigger asChild>
<Button
@@ -1,6 +1,7 @@
import { useQueries } from "@tanstack/react-query"
import { Link, type LinkProps } from "@tanstack/react-router"
import { ICONS } from "@/components/Dashboard/icons"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { Button } from "@/components/ui/button"
import {
@@ -45,10 +46,13 @@ export function PanelRail({
linkFor: (name: string) => LinkProps
className?: string
}) {
const titles = useQueries({
const entries = useQueries({
queries: dashboards.map((name) => dashboardQueryOptions(name)),
combine: (results) =>
results.map((result, index) => result.data?.title || dashboards[index]),
results.map((result, index) => ({
label: result.data?.title || dashboards[index],
icon: result.data?.icon ?? "",
})),
})
return (
@@ -61,7 +65,8 @@ export function PanelRail({
)}
>
{dashboards.map((name, index) => {
const label = titles[index]
const { label, icon } = entries[index]
const Glyph = ICONS[icon]
const active = name === current
return (
<Tooltip key={name}>
@@ -81,7 +86,7 @@ export function PanelRail({
data-testid={`panel-rail-${name}`}
>
<Link {...linkFor(name)} aria-label={label}>
{initials(label)}
{Glyph ? <Glyph className="size-5" /> : initials(label)}
</Link>
</Button>
</TooltipTrigger>
+38 -1
View File
@@ -730,7 +730,7 @@ export function WidgetPanel({
<div
// Position is the only identity a rule row has.
key={`rule-${index}`}
className="flex items-end gap-1.5"
className="flex flex-wrap items-end gap-1.5"
>
<Input
className="w-20"
@@ -803,6 +803,21 @@ export function WidgetPanel({
>
<X />
</Button>
<Input
className="basis-full"
value={rule.label ?? ""}
placeholder="Caption under the glyph"
aria-label="Rule label"
onChange={(event) =>
setRules(
rules.map((other, at) =>
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
</div>
))}
<Button
@@ -928,6 +943,28 @@ export function DashboardPanel({
}
>
<div className="grid gap-5 p-4">
<div className="grid gap-2">
<span className={PANEL_SECTION}>Rail icon</span>
<Select
value={str(dashboard.icon)}
onValueChange={(icon) => onChange({ icon })}
>
<SelectTrigger data-testid="dashboard-icon">
<SelectValue placeholder="Two letters of the title" />
</SelectTrigger>
<SelectContent>
{ICON_NAMES.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Drawn on the rail when a panel carries more than one dashboard.
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Grid</span>
<Select
+133
View File
@@ -0,0 +1,133 @@
import { expect, type Page, test } from "@playwright/test"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* The dashboard editor's two quiet failures.
*
* Both are about a placement that nothing complains about: the grid library
* corrects a stored layout against the columns alone, so shrinking the canvas
* hides whatever now falls past its bottom edge; and the header that drags a
* widget used to also select it, so every drop opened the settings panel.
*/
const dashboardName = `test_editor_${Date.now().toString(36)}`
/** The layout the editor is opened on. `below` cannot compact above `top`. */
const WIDGETS = [
{
id: "top",
type: "clock",
title: "Top",
layout: { lg: { x: 0, y: 0, w: 8, h: 4 } },
config: {},
},
{
id: "below",
type: "clock",
title: "Below",
layout: { lg: { x: 0, y: 8, w: 4, h: 2 } },
config: {},
},
]
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser)
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
// The working copy, which is what a new dashboard may be: the editor reads
// the draft, so the setup writes one rather than publishing.
const dashboard = await (
await api(page, `/dashboards/${dashboardName}?draft=true`)
).json()
dashboard.columns = 12
dashboard.canvas_width = 1920
dashboard.canvas_height = 1080
dashboard.pages[0].sections[0].widgets = WIDGETS
await api(page, `/dashboards/${dashboardName}`, {
method: "PUT",
data: dashboard,
})
await page.close()
})
test.afterAll(async ({ browser }) => {
await deleteAll(browser, [`/dashboards/${dashboardName}`])
})
/** Where each widget is stored right now, as the editor last saved it. */
async function placements(page: Page) {
const doc = await (
await api(page, `/dashboards/${dashboardName}?draft=true`)
).json()
const widgets = doc.pages[0].sections[0].widgets as {
id: string
layout: { lg: { x: number; y: number } }
}[]
return Object.fromEntries(
widgets.map((widget) => [widget.id, widget.layout.lg]),
)
}
async function openEditor(page: Page) {
await page.goto(`/dashboards/${dashboardName}?edit=true`)
await page
.getByTestId("widget-frame")
.first()
.waitFor({ state: "visible", timeout: 15000 })
}
test("shrinking the canvas warns about what falls off it, and re-packs", async ({
page,
}) => {
await openEditor(page)
const notice = page.getByTestId("canvas-clipped")
await expect(notice).toBeHidden()
// Where `below` sits before the reflow — anywhere but the top shelf.
const before = (await placements(page)).below.y
expect(
before,
"the setup layout was already packed across the columns",
).toBeGreaterThan(0)
await page.getByTestId("edit-dashboard").click()
// 400px is four grid rows, which the four-row `top` fills on its own.
await page.getByTestId("canvas-height").fill("400")
await expect(notice).toBeVisible()
await expect(notice).toContainText("1 widget")
await page.getByTestId("reflow-canvas").click()
await expect(notice).toBeHidden()
// A hidden notice on its own could just be a re-render. The stored draft is
// what a panel will read, so that is what has to have moved.
await expect
.poll(async () => (await placements(page)).below.y, {
message: "the reflow was never saved",
timeout: 10000,
})
.toBeLessThan(before)
})
test("the drag handle moves a widget without selecting it", async ({
page,
}) => {
await openEditor(page)
const frame = page.getByTestId("widget-frame").filter({ hasText: "Top" })
const settings = page.getByTestId("widget-settings")
// A press on the grip is the start of a move; the click ending a drag must
// not open the panel behind it.
await frame.locator(".widget-grip").click()
await expect(settings).toBeHidden()
// The other half: selection still works, it just lives on the body now.
await frame.click()
await expect(settings).toBeVisible()
})