Dashboard add widget: searchable command palette with ⌘P, as on flows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user