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"
|
aria-label="Dashboards on this panel"
|
||||||
data-testid="panel-rail"
|
data-testid="panel-rail"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
dashboardsQueryOptions,
|
dashboardsQueryOptions,
|
||||||
panelsQueryOptions,
|
panelsQueryOptions,
|
||||||
useSavePanels,
|
useSavePanels,
|
||||||
|
useUnpairPanel,
|
||||||
} from "@/components/Dashboard/queries"
|
} from "@/components/Dashboard/queries"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
@@ -201,6 +202,12 @@ function PanelRow({
|
|||||||
}) {
|
}) {
|
||||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||||
const [code, setCode] = useState("")
|
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 assigned = panel.dashboards ?? []
|
||||||
const typed = code.trim().toUpperCase()
|
const typed = code.trim().toUpperCase()
|
||||||
|
|
||||||
@@ -364,6 +371,41 @@ function PanelRow({
|
|||||||
: "No device is waiting on that code."}
|
: "No device is waiting on that code."}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
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 { useState } from "react"
|
||||||
|
|
||||||
import type { MessageInfo, WidgetDef } from "@/client"
|
import type { MessageInfo, WidgetDef } from "@/client"
|
||||||
@@ -20,6 +20,12 @@ import {
|
|||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover"
|
||||||
|
import { Segmented } from "@/components/ui/segmented"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -37,7 +43,7 @@ import {
|
|||||||
columnsOf,
|
columnsOf,
|
||||||
type Dashboard,
|
type Dashboard,
|
||||||
} from "./DashboardView"
|
} from "./DashboardView"
|
||||||
import { ICON_COLORS, ICON_NAMES } from "./icons"
|
import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
|
||||||
import { messageCatalogQueryOptions } from "./queries"
|
import { messageCatalogQueryOptions } from "./queries"
|
||||||
import {
|
import {
|
||||||
acceptsDtype,
|
acceptsDtype,
|
||||||
@@ -114,83 +120,104 @@ function MessagePicker({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Where a chart's lines come from: what the engine kept, or what it asks for. */
|
||||||
* Where a chart's lines come from: what the engine kept, or what it asks for.
|
const CHART_SOURCES = [
|
||||||
*
|
|
||||||
* 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"],
|
["live", "Live"],
|
||||||
["query", "Query"],
|
["query", "Query"],
|
||||||
] as const
|
] 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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,
|
value,
|
||||||
options,
|
placeholder,
|
||||||
|
label,
|
||||||
|
testId,
|
||||||
|
className,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string
|
||||||
options: readonly (readonly [string, string])[]
|
/** What no icon gets you, on the trigger and on the clearing button. */
|
||||||
onChange: (style: string) => void
|
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 (
|
return (
|
||||||
<fieldset
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
data-testid="widget-style"
|
<PopoverTrigger asChild>
|
||||||
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
<legend className="sr-only">How this control is drawn</legend>
|
// Sits in a row of fields, so it wears their border and surface
|
||||||
{options.map(([style, label]) => (
|
// rather than a button's.
|
||||||
<button
|
|
||||||
key={style}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={value === style}
|
|
||||||
onClick={() => onChange(style)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-2.5 py-1 text-xs transition-colors",
|
"justify-start border-input bg-transparent font-normal",
|
||||||
value === style
|
className,
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:bg-accent/50",
|
|
||||||
)}
|
)}
|
||||||
|
aria-label={label}
|
||||||
|
data-testid={testId}
|
||||||
>
|
>
|
||||||
{label}
|
{Current ? <Current /> : null}
|
||||||
</button>
|
<span className={cn("truncate", !value && "text-muted-foreground")}>
|
||||||
))}
|
{value || placeholder}
|
||||||
</fieldset>
|
</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>
|
</div>
|
||||||
) : widget.type === "chart" ? (
|
) : widget.type === "chart" ? (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<ModePicker
|
<Segmented
|
||||||
value={querying ? "query" : "live"}
|
value={querying ? "query" : "live"}
|
||||||
|
options={CHART_SOURCES}
|
||||||
|
label="Where the chart's data comes from"
|
||||||
|
testId="chart-source"
|
||||||
onChange={(source) => set({ source })}
|
onChange={(source) => set({ source })}
|
||||||
/>
|
/>
|
||||||
{querying ? (
|
{querying ? (
|
||||||
@@ -747,30 +777,19 @@ export function WidgetPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Select
|
<IconPicker
|
||||||
value={rule.icon ?? ""}
|
value={rule.icon ?? ""}
|
||||||
onValueChange={(icon) =>
|
placeholder="No icon"
|
||||||
|
label="Rule icon"
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
onChange={(icon) =>
|
||||||
setRules(
|
setRules(
|
||||||
rules.map((other, at) =>
|
rules.map((other, at) =>
|
||||||
at === index ? { ...other, icon } : other,
|
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
|
<Select
|
||||||
value={rule.color ?? "default"}
|
value={rule.color ?? "default"}
|
||||||
onValueChange={(color) =>
|
onValueChange={(color) =>
|
||||||
@@ -837,21 +856,13 @@ export function WidgetPanel({
|
|||||||
</p>
|
</p>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label className="text-sm font-normal">Otherwise</Label>
|
<Label className="text-sm font-normal">Otherwise</Label>
|
||||||
<Select
|
<IconPicker
|
||||||
value={str(cfg.icon)}
|
value={str(cfg.icon)}
|
||||||
onValueChange={(icon) => set({ icon })}
|
placeholder="Nothing"
|
||||||
>
|
label="Icon when no rule matches"
|
||||||
<SelectTrigger>
|
className="w-full"
|
||||||
<SelectValue placeholder="Nothing" />
|
onChange={(icon) => set({ icon })}
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
|
||||||
{ICON_NAMES.map((name) => (
|
|
||||||
<SelectItem key={name} value={name}>
|
|
||||||
{name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -860,21 +871,25 @@ export function WidgetPanel({
|
|||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label className="text-sm font-normal">Style</Label>
|
<Label className="text-sm font-normal">Style</Label>
|
||||||
{widget.type === "switch" ? (
|
{widget.type === "switch" ? (
|
||||||
<StylePicker
|
<Segmented
|
||||||
value={str(cfg.style) || "track"}
|
value={str(cfg.style) || "track"}
|
||||||
options={[
|
options={[
|
||||||
["track", "Track"],
|
["track", "Track"],
|
||||||
["button", "Button"],
|
["button", "Button"],
|
||||||
]}
|
]}
|
||||||
|
label="How this control is drawn"
|
||||||
|
testId="widget-style"
|
||||||
onChange={(style) => set({ style })}
|
onChange={(style) => set({ style })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StylePicker
|
<Segmented
|
||||||
value={str(cfg.style) || "list"}
|
value={str(cfg.style) || "list"}
|
||||||
options={[
|
options={[
|
||||||
["list", "List"],
|
["list", "List"],
|
||||||
["segmented", "Segmented"],
|
["segmented", "Segmented"],
|
||||||
]}
|
]}
|
||||||
|
label="How this control is drawn"
|
||||||
|
testId="widget-style"
|
||||||
onChange={(style) => set({ style })}
|
onChange={(style) => set({ style })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -945,21 +960,14 @@ export function DashboardPanel({
|
|||||||
<div className="grid gap-5 p-4">
|
<div className="grid gap-5 p-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<span className={PANEL_SECTION}>Rail icon</span>
|
<span className={PANEL_SECTION}>Rail icon</span>
|
||||||
<Select
|
<IconPicker
|
||||||
value={str(dashboard.icon)}
|
value={str(dashboard.icon)}
|
||||||
onValueChange={(icon) => onChange({ icon })}
|
placeholder="Two letters of the title"
|
||||||
>
|
label="Rail icon"
|
||||||
<SelectTrigger data-testid="dashboard-icon">
|
testId="dashboard-icon"
|
||||||
<SelectValue placeholder="Two letters of the title" />
|
className="w-full"
|
||||||
</SelectTrigger>
|
onChange={(icon) => onChange({ icon })}
|
||||||
<SelectContent>
|
/>
|
||||||
{ICON_NAMES.map((name) => (
|
|
||||||
<SelectItem key={name} value={name}>
|
|
||||||
{name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Drawn on the rail when a panel carries more than one dashboard.
|
Drawn on the rail when a panel carries more than one dashboard.
|
||||||
</p>
|
</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. */
|
/** Every message any flow declares — what a widget can be pointed at. */
|
||||||
export const messageCatalogQueryOptions = () => ({
|
export const messageCatalogQueryOptions = () => ({
|
||||||
queryKey: dashboardKeys.messages,
|
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