diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 3124816..8775fc3 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -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 ? ( + + + {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.`} + + + + ) : null} + diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index f751ee1..c24f322 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -730,7 +730,7 @@ export function WidgetPanel({
+ + setRules( + rules.map((other, at) => + at === index + ? { ...other, label: event.target.value } + : other, + ), + ) + } + />
))}