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:
@@ -60,7 +60,14 @@ export function PanelRail({
|
||||
aria-label="Dashboards on this panel"
|
||||
data-testid="panel-rail"
|
||||
className={cn(
|
||||
"pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-12 flex-col items-center gap-1 overflow-y-auto rounded-lg border border-border bg-card/80 p-1 shadow-e2 backdrop-blur-md",
|
||||
"pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-12 flex-col items-center gap-1 rounded-lg border border-border bg-card/80 p-1 shadow-e2 backdrop-blur-md",
|
||||
// More dashboards than the column is tall still scroll, but no bar is
|
||||
// ever drawn: a wall panel is swiped, and there is no room for one
|
||||
// anyway. `w-12` less `p-1` either side is exactly the 40px button, so
|
||||
// a classic vertical bar claiming its gutter is what pushed the buttons
|
||||
// out sideways and put a horizontal bar under them — `overflow-y` alone
|
||||
// computes `overflow-x` to `auto` rather than leaving it visible.
|
||||
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
dashboardsQueryOptions,
|
||||
panelsQueryOptions,
|
||||
useSavePanels,
|
||||
useUnpairPanel,
|
||||
} from "@/components/Dashboard/queries"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
@@ -201,6 +202,12 @@ function PanelRow({
|
||||
}) {
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
const [code, setCode] = useState("")
|
||||
// Unpairing cannot be undone from here — the screen has to be at hand to
|
||||
// read out a new code — so the button asks a second time before it fires.
|
||||
// ponytail: a two-step button rather than a dialog, since this one already
|
||||
// lives inside a dialog.
|
||||
const [confirmUnpair, setConfirmUnpair] = useState(false)
|
||||
const unpair = useUnpairPanel(panel.id)
|
||||
const assigned = panel.dashboards ?? []
|
||||
const typed = code.trim().toUpperCase()
|
||||
|
||||
@@ -364,6 +371,41 @@ function PanelRow({
|
||||
: "No device is waiting on that code."}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={confirmUnpair ? "destructive" : "outline"}
|
||||
disabled={unpair.isPending}
|
||||
data-testid={`unpair-${panel.id}`}
|
||||
onBlur={() => setConfirmUnpair(false)}
|
||||
onClick={() => {
|
||||
if (!confirmUnpair) {
|
||||
setConfirmUnpair(true)
|
||||
return
|
||||
}
|
||||
setConfirmUnpair(false)
|
||||
unpair.mutate(undefined, {
|
||||
onSuccess: () =>
|
||||
showSuccessToast(
|
||||
"Unpaired — the screen asks for a new code.",
|
||||
),
|
||||
onError: (error) =>
|
||||
handleError.call(showErrorToast, error as ApiError),
|
||||
})
|
||||
}}
|
||||
>
|
||||
{confirmUnpair
|
||||
? "Confirm — sign this screen out"
|
||||
: "Unpair screen"}
|
||||
</Button>
|
||||
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
Sends the screen hanging here back to a pairing code and keeps
|
||||
the panel, its dashboards and their arrangement — unlike
|
||||
removing the panel, which throws all three away. A screen paired
|
||||
through the portal holds a credential this does not reach; that
|
||||
one is revoked at the portal.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
{(
|
||||
[
|
||||
/** 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
|
||||
).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>
|
||||
)
|
||||
}
|
||||
] 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>
|
||||
|
||||
@@ -68,6 +68,21 @@ export function useSavePanels() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop honouring one panel's credential, and keep the panel.
|
||||
*
|
||||
* The screen goes back to showing a pairing code while its dashboards and their
|
||||
* arrangement stay — deleting the panel is what throws all three away.
|
||||
* Superuser-only on the server.
|
||||
*/
|
||||
export function useUnpairPanel(id: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => PanelsService.unpairPanel({ panelId: id }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: panelKeys.all }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Every message any flow declares — what a widget can be pointed at. */
|
||||
export const messageCatalogQueryOptions = () => ({
|
||||
queryKey: dashboardKeys.messages,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// The thumb's transition lives beside the dashboard's own widgets, and CSS is
|
||||
// chunked per entry — so the rule is pulled in wherever this control is used,
|
||||
// or two copies of one shape would move differently.
|
||||
import "@/components/Dashboard/dashboard.css"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* One of N, as the one segmented shape: a single border pill, no dividers,
|
||||
* transparent segments, and `bg-accent` held by a thumb that slides rather than
|
||||
* a fill that jumps from cell to cell (root DESIGN-GUIDELINES.md).
|
||||
*
|
||||
* ponytail: sized for editor chrome — `w-fit`, `text-xs`, a mouse-sized target.
|
||||
* The widget-side copy in `Dashboard/widgets.tsx` is a full-width pill with a
|
||||
* 44px touch target, so pointing that one here needs a size prop first.
|
||||
*/
|
||||
export function Segmented({
|
||||
value,
|
||||
options,
|
||||
label,
|
||||
testId,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
/** `[value, label]` pairs, in the order they are drawn. */
|
||||
options: readonly (readonly [string, string])[]
|
||||
/** Names the group for screen readers. */
|
||||
label: string
|
||||
testId?: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
const chosen = options.findIndex(([option]) => option === value)
|
||||
return (
|
||||
// A `fieldset` carries `min-inline-size: min-content` from the UA sheet,
|
||||
// which no width utility overrides. Equal tracks and no gap put the thumb
|
||||
// at its share of the padded box without measuring — a grid rather than a
|
||||
// flex row because `flex-1` under `w-fit` sizes the segments to a share of
|
||||
// the widest label instead of to the label itself.
|
||||
<fieldset
|
||||
data-testid={testId}
|
||||
className="relative grid w-fit min-w-0 items-center rounded-full border border-border p-1"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
<legend className="sr-only">{label}</legend>
|
||||
{chosen >= 0 ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
|
||||
style={{
|
||||
left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${options.length})`,
|
||||
width: `calc((100% - 0.5rem) / ${options.length})`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{options.map(([option, optionLabel], index) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={index === chosen}
|
||||
onClick={() => onChange(option)}
|
||||
className={cn(
|
||||
"relative z-10 min-w-0 truncate rounded-full px-2.5 py-1 text-xs transition-colors",
|
||||
index === chosen
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{optionLabel}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user