Dashboard chrome: an icon picker, one segmented shape, a rail without bars
- IconPicker replaces the three icon selects (rail icon, icon-widget rule,
"Otherwise"): the glyphs in a grid, and a button that clears back to none —
which a Radix SelectItem could never offer.
- ModePicker/StylePicker drop out in favour of a shared ui/Segmented, the same
sliding-thumb shape RangePicker and the widget-side control already wear.
- PanelRail draws no scrollbars at all: hiding them also takes back the gutter
a vertical bar claimed from a column exactly as wide as its buttons, which is
what pushed a horizontal bar under them.
- The panels dialog can re-pair one screen (POST /panels/{id}/unpair) without
deleting the panel it hangs on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Plus, X } from "lucide-react"
|
||||
import { Ban, ChevronDown, Plus, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import type { MessageInfo, WidgetDef } from "@/client"
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Segmented } from "@/components/ui/segmented"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -37,7 +43,7 @@ import {
|
||||
columnsOf,
|
||||
type Dashboard,
|
||||
} from "./DashboardView"
|
||||
import { ICON_COLORS, ICON_NAMES } from "./icons"
|
||||
import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
|
||||
import { messageCatalogQueryOptions } from "./queries"
|
||||
import {
|
||||
acceptsDtype,
|
||||
@@ -114,83 +120,104 @@ function MessagePicker({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a chart's lines come from: what the engine kept, or what it asks for.
|
||||
*
|
||||
* The one segmented shape — a single border pill, transparent segments,
|
||||
* bg-accent on the selected one (root DESIGN-GUIDELINES.md).
|
||||
*/
|
||||
function ModePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: "live" | "query"
|
||||
onChange: (mode: "live" | "query") => void
|
||||
}) {
|
||||
return (
|
||||
<fieldset
|
||||
data-testid="chart-source"
|
||||
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
|
||||
>
|
||||
<legend className="sr-only">Where the chart's data comes from</legend>
|
||||
{(
|
||||
[
|
||||
["live", "Live"],
|
||||
["query", "Query"],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
aria-pressed={value === mode}
|
||||
onClick={() => onChange(mode)}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-xs transition-colors",
|
||||
value === mode
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
/** Where a chart's lines come from: what the engine kept, or what it asks for. */
|
||||
const CHART_SOURCES = [
|
||||
["live", "Live"],
|
||||
["query", "Query"],
|
||||
] as const
|
||||
|
||||
/** Which chrome an input wears, in the same segmented shape as the mode. */
|
||||
function StylePicker({
|
||||
/**
|
||||
* Pick one of the tile glyphs, or none.
|
||||
*
|
||||
* A grid of the glyphs themselves rather than a list of their names: an icon is
|
||||
* chosen by how it looks. Clearing is a button rather than an option, because
|
||||
* Radix forbids an empty `SelectItem` value — which is why the selects this
|
||||
* replaces could set an icon but never take one back.
|
||||
*
|
||||
* ponytail: no filter field. `ICONS` is a few dozen and the grid shows all of
|
||||
* it without scrolling; add one when the map outgrows a popover.
|
||||
*/
|
||||
function IconPicker({
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
label,
|
||||
testId,
|
||||
className,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
options: readonly (readonly [string, string])[]
|
||||
onChange: (style: string) => void
|
||||
/** What no icon gets you, on the trigger and on the clearing button. */
|
||||
placeholder: string
|
||||
/** Names the trigger for screen readers. */
|
||||
label: string
|
||||
testId?: string
|
||||
className?: string
|
||||
onChange: (icon: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const Current = ICONS[value]
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
data-testid="widget-style"
|
||||
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
|
||||
>
|
||||
<legend className="sr-only">How this control is drawn</legend>
|
||||
{options.map(([style, label]) => (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
aria-pressed={value === style}
|
||||
onClick={() => onChange(style)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
// Sits in a row of fields, so it wears their border and surface
|
||||
// rather than a button's.
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-xs transition-colors",
|
||||
value === style
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50",
|
||||
"justify-start border-input bg-transparent font-normal",
|
||||
className,
|
||||
)}
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
{Current ? <Current /> : null}
|
||||
<span className={cn("truncate", !value && "text-muted-foreground")}>
|
||||
{value || placeholder}
|
||||
</span>
|
||||
<ChevronDown className="ml-auto opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-2">
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
{ICON_NAMES.map((name) => {
|
||||
const Glyph = ICONS[name]
|
||||
return (
|
||||
<Button
|
||||
key={name}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title={name}
|
||||
aria-label={name}
|
||||
aria-pressed={name === value}
|
||||
className={cn(
|
||||
name === value && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(name)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Glyph />
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1 w-full justify-start text-muted-foreground"
|
||||
data-testid={testId ? `${testId}-clear` : undefined}
|
||||
onClick={() => {
|
||||
onChange("")
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Ban />
|
||||
{placeholder}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -303,8 +330,11 @@ export function WidgetPanel({
|
||||
</div>
|
||||
) : widget.type === "chart" ? (
|
||||
<div className="grid gap-3">
|
||||
<ModePicker
|
||||
<Segmented
|
||||
value={querying ? "query" : "live"}
|
||||
options={CHART_SOURCES}
|
||||
label="Where the chart's data comes from"
|
||||
testId="chart-source"
|
||||
onChange={(source) => set({ source })}
|
||||
/>
|
||||
{querying ? (
|
||||
@@ -747,30 +777,19 @@ export function WidgetPanel({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
<IconPicker
|
||||
value={rule.icon ?? ""}
|
||||
onValueChange={(icon) =>
|
||||
placeholder="No icon"
|
||||
label="Rule icon"
|
||||
className="min-w-0 flex-1"
|
||||
onChange={(icon) =>
|
||||
setRules(
|
||||
rules.map((other, at) =>
|
||||
at === index ? { ...other, icon } : other,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="min-w-0 flex-1"
|
||||
aria-label="Rule icon"
|
||||
>
|
||||
<SelectValue placeholder="Icon" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ICON_NAMES.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
<Select
|
||||
value={rule.color ?? "default"}
|
||||
onValueChange={(color) =>
|
||||
@@ -837,21 +856,13 @@ export function WidgetPanel({
|
||||
</p>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Otherwise</Label>
|
||||
<Select
|
||||
<IconPicker
|
||||
value={str(cfg.icon)}
|
||||
onValueChange={(icon) => set({ icon })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Nothing" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ICON_NAMES.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
placeholder="Nothing"
|
||||
label="Icon when no rule matches"
|
||||
className="w-full"
|
||||
onChange={(icon) => set({ icon })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -860,21 +871,25 @@ export function WidgetPanel({
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Style</Label>
|
||||
{widget.type === "switch" ? (
|
||||
<StylePicker
|
||||
<Segmented
|
||||
value={str(cfg.style) || "track"}
|
||||
options={[
|
||||
["track", "Track"],
|
||||
["button", "Button"],
|
||||
]}
|
||||
label="How this control is drawn"
|
||||
testId="widget-style"
|
||||
onChange={(style) => set({ style })}
|
||||
/>
|
||||
) : (
|
||||
<StylePicker
|
||||
<Segmented
|
||||
value={str(cfg.style) || "list"}
|
||||
options={[
|
||||
["list", "List"],
|
||||
["segmented", "Segmented"],
|
||||
]}
|
||||
label="How this control is drawn"
|
||||
testId="widget-style"
|
||||
onChange={(style) => set({ style })}
|
||||
/>
|
||||
)}
|
||||
@@ -945,21 +960,14 @@ export function DashboardPanel({
|
||||
<div className="grid gap-5 p-4">
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Rail icon</span>
|
||||
<Select
|
||||
<IconPicker
|
||||
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>
|
||||
placeholder="Two letters of the title"
|
||||
label="Rail icon"
|
||||
testId="dashboard-icon"
|
||||
className="w-full"
|
||||
onChange={(icon) => onChange({ icon })}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Drawn on the rail when a panel carries more than one dashboard.
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user