Dashboard add widget: searchable command palette with ⌘P, as on flows

This commit is contained in:
2026-10-12 21:59:16 +02:00
parent bb63601fad
commit 59272299f5
3 changed files with 119 additions and 30 deletions
@@ -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>(dashboard)
const [selected, setSelected] = useState<string | null>(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<WidgetDef>) =>
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.
</p>
) : stacked ? (
// One column at the viewport's width. Edit mode still picks a widget and
@@ -622,37 +626,21 @@ export function DashboardEditor({
</motion.div>
) : null}
<Popover>
<PopoverTrigger asChild>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={() => setPaletteOpen(true)}
aria-label="Add widget"
data-testid="add-widget"
>
<Plus />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="w-56 p-2">
<p className="px-2 py-1.5 text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
Add a widget
</p>
<div className="mt-1 grid gap-0.5">
{KINDS.map((kind) => (
<button
key={kind}
type="button"
className="rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50"
data-testid={`add-widget-${kind}`}
onClick={() => addWidget(kind)}
>
{WIDGET_LABELS[kind]}
</button>
))}
</div>
</PopoverContent>
</Popover>
</TooltipTrigger>
<TooltipContent>Add a widget (⌘P)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -785,6 +773,12 @@ export function DashboardEditor({
{edit ? (
<>
<WidgetPalette
open={paletteOpen}
onOpenChange={setPaletteOpen}
onAdd={addWidget}
/>
<WidgetPanel
widget={active}
onChange={(changes) => active && patch(active.id, changes)}
@@ -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 (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Add a widget"
description="Search the widget types"
>
<CommandInput placeholder="Add a widget…" />
<CommandList>
<CommandEmpty>Nothing matches that.</CommandEmpty>
{GROUPS.map(([heading, kinds]) => (
<CommandGroup key={heading} heading={heading}>
{kinds.map((kind) => (
<CommandItem
key={kind}
value={`${WIDGET_LABELS[kind]} ${WIDGET_DESCRIPTIONS[kind]}`}
data-testid={`add-widget-${kind}`}
onSelect={() => {
onOpenChange(false)
onAdd(kind)
}}
>
<span className="flex flex-col">
<span>{WIDGET_LABELS[kind]}</span>
<span className="text-xs text-muted-foreground">
{WIDGET_DESCRIPTIONS[kind]}
</span>
</span>
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
)
}
@@ -97,6 +97,30 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
color: "Colour",
}
/** One line per type for the add palette, which searches these too. */
export const WIDGET_DESCRIPTIONS: Record<WidgetKind, string> = {
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<WidgetKind, { w: number; h: number }> = {
stat: { w: 3, h: 2 },