From 9c75d7ae133537a68bb96e05865b43b853bac905 Mon Sep 17 00:00:00 2001
From: stroblme
Date: Mon, 12 Oct 2026 21:59:16 +0200
Subject: [PATCH] =?UTF-8?q?Dashboard=20add=20widget:=20searchable=20comman?=
=?UTF-8?q?d=20palette=20with=20=E2=8C=98P,=20as=20on=20flows?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
.../components/Dashboard/DashboardEditor.tsx | 54 +++++++-------
.../components/Dashboard/WidgetPalette.tsx | 71 +++++++++++++++++++
frontend/src/components/Dashboard/widgets.tsx | 24 +++++++
3 files changed, 119 insertions(+), 30 deletions(-)
create mode 100644 frontend/src/components/Dashboard/WidgetPalette.tsx
diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx
index b5bb194..190daf7 100644
--- a/frontend/src/components/Dashboard/DashboardEditor.tsx
+++ b/frontend/src/components/Dashboard/DashboardEditor.tsx
@@ -29,11 +29,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { FluksioLoader } from "@/components/ui/fluksio-loader"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
import {
Tooltip,
@@ -44,6 +39,7 @@ import useCustomToast from "@/hooks/useCustomToast"
import { useIsMobile } from "@/hooks/useMobile"
import { slideUp, transitions } from "@/lib/motion"
import { appPath } from "@/lib/portal"
+import { useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { handleError } from "@/utils"
import {
@@ -73,6 +69,7 @@ import { PaletteProvider } from "./settings"
import { useUi } from "./ui"
import { showTitle } from "./ui/core/config"
import { LookProvider, useCanvasRoot } from "./ui/core/look"
+import { WidgetPalette } from "./WidgetPalette"
import {
WIDGET_LABELS,
WIDGET_SIZES,
@@ -81,8 +78,6 @@ import {
widgetIssue,
} from "./widgets"
-const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[]
-
/**
* A widget as the editor draws it: the panel's own look, plus a click that
* picks it.
@@ -240,6 +235,7 @@ export function DashboardEditor({
const [draft, setDraft] = useState(dashboard)
const [selected, setSelected] = useState(null)
const [settingsOpen, setSettingsOpen] = useState(false)
+ const [paletteOpen, setPaletteOpen] = useState(false)
// A phone reads the dashboard rather than arranges it, so the grid library
// never mounts there. See DESIGN-GUIDELINES.md → Responsive.
const stacked = useIsMobile()
@@ -342,6 +338,13 @@ export function DashboardEditor({
setSelected(id)
}
+ // ⌘P, as on the flow canvas. Reachable while typing, so a settings field
+ // having focus does not hide it.
+ useShortcuts(
+ { "mod+p": () => (edit ? setPaletteOpen((open) => !open) : false) },
+ ["mod+p"],
+ )
+
const patch = (id: string, changes: Partial) =>
updateWidgets(
widgets.map((widget) =>
@@ -457,7 +460,8 @@ export function DashboardEditor({
className="text-sm text-muted-foreground"
data-testid="dashboard-empty"
>
- Nothing on this dashboard yet. Add a widget from the bar below.
+ Nothing on this dashboard yet. Press ⌘P, or use the plus in the bar
+ below.
) : stacked ? (
// One column at the viewport's width. Edit mode still picks a widget and
@@ -622,37 +626,21 @@ export function DashboardEditor({
) : null}
-
-
+
+
-
-
-
- Add a widget
-
-
- {KINDS.map((kind) => (
-
- ))}
-
-
-
+
+ Add a widget (⌘P)
+
@@ -785,6 +773,12 @@ export function DashboardEditor({
{edit ? (
<>
+
+
active && patch(active.id, changes)}
diff --git a/frontend/src/components/Dashboard/WidgetPalette.tsx b/frontend/src/components/Dashboard/WidgetPalette.tsx
new file mode 100644
index 0000000..f1498b1
--- /dev/null
+++ b/frontend/src/components/Dashboard/WidgetPalette.tsx
@@ -0,0 +1,71 @@
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ INPUT_WIDGETS,
+ WIDGET_DESCRIPTIONS,
+ WIDGET_LABELS,
+ type WidgetKind,
+} from "./widgets"
+
+const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[]
+
+const GROUPS = [
+ ["Show a value", KINDS.filter((kind) => !INPUT_WIDGETS.has(kind))],
+ ["Control something", KINDS.filter((kind) => INPUT_WIDGETS.has(kind))],
+] as const
+
+/** ⌘P on a dashboard being edited: the flow canvas's add-a-node palette, for widgets. */
+export function WidgetPalette({
+ open,
+ onOpenChange,
+ onAdd,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onAdd: (kind: WidgetKind) => void
+}) {
+ // Unmounted rather than faded, for the reason the flow palette gives.
+ if (!open) return null
+
+ return (
+
+
+
+ Nothing matches that.
+ {GROUPS.map(([heading, kinds]) => (
+
+ {kinds.map((kind) => (
+ {
+ onOpenChange(false)
+ onAdd(kind)
+ }}
+ >
+
+ {WIDGET_LABELS[kind]}
+
+ {WIDGET_DESCRIPTIONS[kind]}
+
+
+
+ ))}
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx
index 4968329..03dc8ec 100644
--- a/frontend/src/components/Dashboard/widgets.tsx
+++ b/frontend/src/components/Dashboard/widgets.tsx
@@ -97,6 +97,30 @@ export const WIDGET_LABELS: Record = {
color: "Colour",
}
+/** One line per type for the add palette, which searches these too. */
+export const WIDGET_DESCRIPTIONS: Record = {
+ stat: "A single reading, large",
+ gauge: "A number against its range",
+ chart: "Readings over time",
+ markdown: "Formatted text, headings and links",
+ agenda: "Upcoming events from a calendar list",
+ notification: "The latest published title and message",
+ bar: "Several readings, a bar each",
+ icon: "An icon picked by the value it reads",
+ forecast: "What comes next, as a strip of columns",
+ clock: "Local time and date",
+ media: "A camera image, clip or audio",
+ player: "What a streamer is playing",
+ embed: "Another web page, framed",
+ button: "Sends a value when pressed",
+ buttons: "A grid of buttons, each its own value",
+ switch: "Turns something on or off",
+ slider: "Sets a number by dragging",
+ input: "Sends what is typed",
+ dropdown: "Picks one of a list of options",
+ color: "Picks a colour or brightness",
+}
+
/** Default footprint per type, in grid units. */
export const WIDGET_SIZES: Record = {
stat: { w: 3, h: 2 },