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