Hold what a control sent until the engine confirms it
An input widget published over HTTP and read the result back over the socket, so between the two it drew the pre-publish value — a slider handle let go of visibly snapped back. usePublish now holds the sent value until the echo matches, the publish is refused, or 3 s pass, and every input widget (button, switch, slider, input, dropdown) draws that instead of the live value. A publish in flight pulses a primary ring over the tile: an absolutely positioned, inert overlay, so nothing resizes or shifts. A refusal drops the hold and toasts, naming the message. Success stays silent. The slider also draws its own scale — min, max and a few labelled stops that land on steps — replacing the unlabelled datalist marks that dropped out past fifty steps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
@@ -72,12 +72,41 @@
|
|||||||
height: calc(var(--h) * 5rem + (var(--h) - 1) * 0.75rem);
|
height: calc(var(--h) * 5rem + (var(--h) - 1) * 0.75rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A publish in flight, drawn as a ring just inside the tile's own edge.
|
||||||
|
*
|
||||||
|
* An overlay rather than anything the widget owns: it takes no layout box and
|
||||||
|
* no clicks, so a control being used never resizes its tile or moves what sits
|
||||||
|
* around it. Full opacity at rest, so a panel that asks for no motion still
|
||||||
|
* gets the ring — the animation only breathes it.
|
||||||
|
*/
|
||||||
|
.widget-transmit {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: inset 0 0 0 2px var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Motion. A value settling is a neutral state change; a selection indicator
|
* Motion. A value settling is a neutral state change; a selection indicator
|
||||||
* moving is emphasized (Material). `<MotionConfig reducedMotion="user">` only
|
* moving is emphasized (Material). `<MotionConfig reducedMotion="user">` only
|
||||||
* covers `motion/react`, so CSS asks for itself.
|
* covers `motion/react`, so CSS asks for itself.
|
||||||
*/
|
*/
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
/* One beat per second, which reads as "on its way" from across a room
|
||||||
|
without becoming the loudest thing in a browser tab. */
|
||||||
|
.widget-transmit {
|
||||||
|
animation: widget-transmit var(--duration-pulse) var(--ease-standard)
|
||||||
|
infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes widget-transmit {
|
||||||
|
from {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* The arc is the full 240 degrees and the dash hides the rest of it, so the
|
/* The arc is the full 240 degrees and the dash hides the rest of it, so the
|
||||||
reading changes by animating one number rather than re-pathing. */
|
reading changes by animating one number rather than re-pathing. */
|
||||||
.widget-gauge-arc {
|
.widget-gauge-arc {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
import type { WidgetDef } from "@/client"
|
import type { ApiError, WidgetDef } from "@/client"
|
||||||
import { useLiveValue } from "@/components/Flow/liveStore"
|
import { useLiveValue } from "@/components/Flow/liveStore"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -18,10 +18,15 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { handleError } from "@/utils"
|
||||||
import { BarWidget, segmentsOf } from "./BarWidget"
|
import { BarWidget, segmentsOf } from "./BarWidget"
|
||||||
import { ChartWidget } from "./ChartWidget"
|
import { ChartWidget } from "./ChartWidget"
|
||||||
import { ClockWidget } from "./ClockWidget"
|
import { ClockWidget } from "./ClockWidget"
|
||||||
|
// The transmit overlay's rule lives beside the other widget CSS; a widget is
|
||||||
|
// drawn by the editor as well as by the view, so the sheet is pulled in here.
|
||||||
|
import "./dashboard.css"
|
||||||
import { ForecastWidget } from "./ForecastWidget"
|
import { ForecastWidget } from "./ForecastWidget"
|
||||||
import { IconWidget } from "./IconWidget"
|
import { IconWidget } from "./IconWidget"
|
||||||
import { usePublishMessage } from "./queries"
|
import { usePublishMessage } from "./queries"
|
||||||
@@ -231,7 +236,9 @@ export function WidgetFrame({
|
|||||||
<div
|
<div
|
||||||
data-testid="widget-frame"
|
data-testid="widget-frame"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
|
// `relative` is what the transmit overlay hangs off: a control's pulse
|
||||||
|
// is drawn over the whole tile and must take no layout box at all.
|
||||||
|
"relative flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -549,45 +556,113 @@ function NotificationWidget({ widget }: WidgetProps) {
|
|||||||
// Input
|
// Input
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Publishing, with the value shown as sent until the engine confirms it. */
|
/**
|
||||||
|
* How long a control shows what it sent before falling back to the engine.
|
||||||
|
*
|
||||||
|
* ponytail: a flat 3 s rather than anything the engine tells us. A publish the
|
||||||
|
* server takes but nothing ever echoes — a message no flow consumes — would
|
||||||
|
* otherwise leave the tile holding a value that is not the truth, forever.
|
||||||
|
*/
|
||||||
|
const HOLD_MS = 3000
|
||||||
|
|
||||||
|
/** Whether what came back over the socket is what this control sent. */
|
||||||
|
function confirms(live: unknown, sent: unknown): boolean {
|
||||||
|
// Readings are scalars nearly always; a dropdown may carry a record, and
|
||||||
|
// comparing those as text is cheaper than a deep walk for the same answer.
|
||||||
|
return live === sent || JSON.stringify(live) === JSON.stringify(sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishing, with the value shown as sent until the engine confirms it.
|
||||||
|
*
|
||||||
|
* A control publishes over HTTP and reads the result back over the socket, so
|
||||||
|
* between the two the live value is still the old one — a handle let go of
|
||||||
|
* would snap back to it. The hold ends when the echo matches, when the publish
|
||||||
|
* is refused, or on `HOLD_MS`; success is silent, because the echo is the
|
||||||
|
* confirmation.
|
||||||
|
*/
|
||||||
function usePublish(widget: WidgetDef, dashboard: string) {
|
function usePublish(widget: WidgetDef, dashboard: string) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const target = text(cfg.target)
|
const target = text(cfg.target)
|
||||||
const publish = usePublishMessage()
|
const publish = usePublishMessage()
|
||||||
const live = useLiveValue(target || undefined)
|
const live = useLiveValue(target || undefined)
|
||||||
|
const { showErrorToast } = useCustomToast()
|
||||||
|
// Boxed: holding `false` or `null` is not the same as holding nothing.
|
||||||
|
const [held, setHeld] = useState<{ value: unknown } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!held) return
|
||||||
|
const timer = setTimeout(() => setHeld(null), HOLD_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [held])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (held && confirms(live?.value, held.value)) setHeld(null)
|
||||||
|
}, [held, live])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
target,
|
target,
|
||||||
live,
|
/** What the control draws: what it sent, until the engine answers. */
|
||||||
|
value: held ? held.value : live?.value,
|
||||||
send: (value: unknown) => {
|
send: (value: unknown) => {
|
||||||
if (!target) return
|
if (!target) return
|
||||||
publish.mutate({
|
setHeld({ value })
|
||||||
name: target,
|
publish.mutate(
|
||||||
value,
|
{
|
||||||
dashboard,
|
name: target,
|
||||||
widget: widget.id,
|
value,
|
||||||
label: widget.title || widget.id,
|
dashboard,
|
||||||
kind: widget.type,
|
widget: widget.id,
|
||||||
})
|
label: widget.title || widget.id,
|
||||||
|
kind: widget.type,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
// Back to the engine's own value, and say which message refused it
|
||||||
|
// — a panel showing several controls cannot tell them apart.
|
||||||
|
setHeld(null)
|
||||||
|
handleError.call(
|
||||||
|
(detail: string) => showErrorToast(`${target}: ${detail}`),
|
||||||
|
error as ApiError,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* The in-flight pulse, drawn over the whole tile.
|
||||||
|
*
|
||||||
|
* Absolutely positioned and inert, so it neither resizes the widget nor
|
||||||
|
* moves anything around it. Every control renders it; the frame is what it
|
||||||
|
* hangs off.
|
||||||
|
*/
|
||||||
|
pulse: publish.isPending ? (
|
||||||
|
<span aria-hidden className="widget-transmit" />
|
||||||
|
) : null,
|
||||||
pending: publish.isPending,
|
pending: publish.isPending,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const { target, send, pending } = usePublish(widget, dashboard)
|
const { target, send, pending, pulse } = usePublish(widget, dashboard)
|
||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
|
// Nothing to hold: a button carries no reading, so the pulse and a refusal
|
||||||
|
// are the whole of its feedback.
|
||||||
return (
|
return (
|
||||||
<Button
|
<>
|
||||||
variant="secondary"
|
{pulse}
|
||||||
className="w-full min-w-0"
|
<Button
|
||||||
disabled={pending}
|
variant="secondary"
|
||||||
onClick={() => send(cfg.value ?? true)}
|
className="w-full min-w-0"
|
||||||
>
|
disabled={pending}
|
||||||
<span className="truncate">
|
onClick={() => send(cfg.value ?? true)}
|
||||||
{text(cfg.label, widget.title || "Send")}
|
>
|
||||||
</span>
|
<span className="truncate">
|
||||||
</Button>
|
{text(cfg.label, widget.title || "Send")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,22 +674,26 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
*/
|
*/
|
||||||
function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const { target, live, send } = usePublish(widget, dashboard)
|
const { target, value, send, pulse } = usePublish(widget, dashboard)
|
||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
|
|
||||||
const on = live?.value === true
|
const on = value === true
|
||||||
return cfg.style === "button" ? (
|
return cfg.style === "button" ? (
|
||||||
<Button
|
<>
|
||||||
variant={on ? "default" : "secondary"}
|
{pulse}
|
||||||
className="w-full min-w-0"
|
<Button
|
||||||
aria-pressed={on}
|
variant={on ? "default" : "secondary"}
|
||||||
aria-label={widget.title || target}
|
className="w-full min-w-0"
|
||||||
onClick={() => send(!on)}
|
aria-pressed={on}
|
||||||
>
|
aria-label={widget.title || target}
|
||||||
<span className="truncate">{on ? "On" : "Off"}</span>
|
onClick={() => send(!on)}
|
||||||
</Button>
|
>
|
||||||
|
<span className="truncate">{on ? "On" : "Off"}</span>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{pulse}
|
||||||
<span className="text-sm">{on ? "On" : "Off"}</span>
|
<span className="text-sm">{on ? "On" : "Off"}</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={on}
|
checked={on}
|
||||||
@@ -625,9 +704,72 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many intervals the scale under a slider is cut into.
|
||||||
|
*
|
||||||
|
* A mark lands on a step wherever the range divides evenly, so a value aimed
|
||||||
|
* at is one the slider can stop on. Five labels is what stays readable across a
|
||||||
|
* room; a range of five steps or fewer is simply labelled in full.
|
||||||
|
*/
|
||||||
|
function tickIntervals(steps: number): number {
|
||||||
|
if (!Number.isFinite(steps) || steps <= 0) return 4
|
||||||
|
if (steps <= 5) return Math.max(1, Math.round(steps))
|
||||||
|
return [4, 3, 2].find((count) => Number.isInteger(steps / count)) ?? 4
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scale under the track, drawn rather than declared.
|
||||||
|
*
|
||||||
|
* A `datalist` gives unlabelled marks at best — no browser renders
|
||||||
|
* `<option label>` for a range — and a 20–22 °C setpoint is unusable without
|
||||||
|
* numbers to aim at.
|
||||||
|
*/
|
||||||
|
function SliderTicks({
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
}: {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
step: number
|
||||||
|
}) {
|
||||||
|
const span = max - min
|
||||||
|
if (!(span > 0)) return null
|
||||||
|
const intervals = tickIntervals(step > 0 ? span / step : 0)
|
||||||
|
// Taken off the step, so 0–1 at 0.01 reads "0.25" and 0–100 at 1 reads "25"
|
||||||
|
// without a precision setting of its own.
|
||||||
|
const digits = (String(step).split(".")[1] ?? "").length
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Decoration: the input itself announces min, max and where it stands.
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="relative h-4 text-xs text-muted-foreground tabular-nums"
|
||||||
|
>
|
||||||
|
{Array.from({ length: intervals + 1 }, (_, index) => {
|
||||||
|
const percent = (index / intervals) * 100
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="absolute top-0"
|
||||||
|
// Shifted by its own share of itself: the first label sits flush
|
||||||
|
// left and the last flush right, so neither hangs off the tile.
|
||||||
|
style={{
|
||||||
|
left: `${percent}%`,
|
||||||
|
transform: `translateX(-${percent}%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Number((min + (span * index) / intervals).toFixed(digits))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SliderWidget({ widget, dashboard }: WidgetProps) {
|
function SliderWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const { target, live, send } = usePublish(widget, dashboard)
|
const { target, value, send, pulse } = usePublish(widget, dashboard)
|
||||||
const min = num(cfg.min, 0)
|
const min = num(cfg.min, 0)
|
||||||
const max = num(cfg.max, 100)
|
const max = num(cfg.max, 100)
|
||||||
const step = num(cfg.step, 1)
|
const step = num(cfg.step, 1)
|
||||||
@@ -635,17 +777,11 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
const [dragging, setDragging] = useState<number | null>(null)
|
const [dragging, setDragging] = useState<number | null>(null)
|
||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
|
|
||||||
const current =
|
const current = dragging ?? (typeof value === "number" ? value : min)
|
||||||
dragging ?? (typeof live?.value === "number" ? live.value : min)
|
|
||||||
|
|
||||||
// A 20–22 °C setpoint at 0.1 is unusable without marks to aim at. Past
|
|
||||||
// fifty of them the ticks are a smear, so the browser gets none.
|
|
||||||
const steps = step > 0 ? (max - min) / step : 0
|
|
||||||
const ticks = Number.isFinite(steps) && steps > 0 && steps <= 50 ? steps : 0
|
|
||||||
const ticksId = `ticks-${widget.id}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
|
{pulse}
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<span className="text-2xl tabular-nums">{current}</span>
|
<span className="text-2xl tabular-nums">{current}</span>
|
||||||
{cfg.unit ? (
|
{cfg.unit ? (
|
||||||
@@ -654,25 +790,18 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{ticks ? (
|
|
||||||
<datalist id={ticksId}>
|
|
||||||
{Array.from({ length: Math.floor(ticks) + 1 }, (_, index) => (
|
|
||||||
<option key={index} value={min + index * step} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
) : null}
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
value={current}
|
value={current}
|
||||||
list={ticks ? ticksId : undefined}
|
|
||||||
aria-label={widget.title || target}
|
aria-label={widget.title || target}
|
||||||
className="h-11 w-full accent-[var(--primary)] md:h-8"
|
className="h-11 w-full accent-[var(--primary)] md:h-8"
|
||||||
onChange={(event) => setDragging(Number(event.target.value))}
|
onChange={(event) => setDragging(Number(event.target.value))}
|
||||||
// Only the release publishes: dragging would otherwise send a value
|
// Only the release publishes: dragging would otherwise send a value
|
||||||
// per pixel and flood whatever is listening.
|
// per pixel and flood whatever is listening. Letting go hands the
|
||||||
|
// value to the hold, so the handle stays where it was put.
|
||||||
onPointerUp={() => {
|
onPointerUp={() => {
|
||||||
if (dragging !== null) send(dragging)
|
if (dragging !== null) send(dragging)
|
||||||
setDragging(null)
|
setDragging(null)
|
||||||
@@ -682,13 +811,14 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
setDragging(null)
|
setDragging(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<SliderTicks min={min} max={max} step={step} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputWidget({ widget, dashboard }: WidgetProps) {
|
function InputWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const { target, live, send } = usePublish(widget, dashboard)
|
const { target, value, send, pulse } = usePublish(widget, dashboard)
|
||||||
const [draft, setDraft] = useState<string | null>(null)
|
const [draft, setDraft] = useState<string | null>(null)
|
||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
|
|
||||||
@@ -700,16 +830,19 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<>
|
||||||
value={draft ?? text(live?.value)}
|
{pulse}
|
||||||
type={asNumber ? "number" : "text"}
|
<Input
|
||||||
aria-label={widget.title || target}
|
value={draft ?? text(value)}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
type={asNumber ? "number" : "text"}
|
||||||
onBlur={commit}
|
aria-label={widget.title || target}
|
||||||
onKeyDown={(event) => {
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
if (event.key === "Enter") commit()
|
onBlur={commit}
|
||||||
}}
|
onKeyDown={(event) => {
|
||||||
/>
|
if (event.key === "Enter") commit()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,65 +854,71 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
*/
|
*/
|
||||||
function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const { target, live, send } = usePublish(widget, dashboard)
|
const { target, value, send, pulse } = usePublish(widget, dashboard)
|
||||||
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
|
|
||||||
if (cfg.style === "segmented") {
|
if (cfg.style === "segmented") {
|
||||||
const chosen = options.findIndex(
|
const chosen = options.findIndex(
|
||||||
(option) => text(option.value) === text(live?.value),
|
(option) => text(option.value) === text(value),
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
// The one segmented shape: a single border pill, no dividers,
|
// The pulse stays outside the fieldset: that one is positioned itself,
|
||||||
// transparent segments, bg-accent on the selected one — held by a thumb
|
// and the overlay belongs to the tile rather than to the control.
|
||||||
// that slides rather than a fill that jumps from cell to cell. A
|
<>
|
||||||
// `fieldset` carries `min-inline-size: min-content` from the UA sheet,
|
{pulse}
|
||||||
// which `w-full` does not override.
|
{/* The one segmented shape: a single border pill, no dividers,
|
||||||
<fieldset
|
transparent segments, bg-accent on the selected one — held by a
|
||||||
className="relative grid w-full min-w-0 items-center rounded-full border border-border p-1"
|
thumb that slides rather than a fill that jumps from cell to cell.
|
||||||
style={{
|
A `fieldset` carries `min-inline-size: min-content` from the UA
|
||||||
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
|
sheet, which `w-full` does not override. */}
|
||||||
}}
|
<fieldset
|
||||||
>
|
className="relative grid w-full min-w-0 items-center rounded-full border border-border p-1"
|
||||||
<legend className="sr-only">{widget.title || target}</legend>
|
style={{
|
||||||
{chosen >= 0 ? (
|
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
|
||||||
// Equal tracks and no gap, so a segment is exactly its share of the
|
}}
|
||||||
// padded box and the thumb needs no measuring.
|
>
|
||||||
<span
|
<legend className="sr-only">{widget.title || target}</legend>
|
||||||
aria-hidden
|
{chosen >= 0 ? (
|
||||||
className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
|
// Equal tracks and no gap, so a segment is exactly its share of the
|
||||||
style={{
|
// padded box and the thumb needs no measuring.
|
||||||
left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${options.length})`,
|
<span
|
||||||
width: `calc((100% - 0.5rem) / ${options.length})`,
|
aria-hidden
|
||||||
}}
|
className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
|
||||||
/>
|
style={{
|
||||||
) : null}
|
left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${options.length})`,
|
||||||
{options.map((option, index) => (
|
width: `calc((100% - 0.5rem) / ${options.length})`,
|
||||||
<button
|
}}
|
||||||
key={text(option.value)}
|
/>
|
||||||
type="button"
|
) : null}
|
||||||
aria-pressed={index === chosen}
|
{options.map((option, index) => (
|
||||||
onClick={() => send(option.value)}
|
<button
|
||||||
className={cn(
|
key={text(option.value)}
|
||||||
"relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors md:h-8",
|
type="button"
|
||||||
index === chosen
|
aria-pressed={index === chosen}
|
||||||
? "text-accent-foreground"
|
onClick={() => send(option.value)}
|
||||||
: "text-muted-foreground hover:bg-accent/50",
|
className={cn(
|
||||||
)}
|
"relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors md:h-8",
|
||||||
>
|
index === chosen
|
||||||
{option.label ?? text(option.value)}
|
? "text-accent-foreground"
|
||||||
</button>
|
: "text-muted-foreground hover:bg-accent/50",
|
||||||
))}
|
)}
|
||||||
</fieldset>
|
>
|
||||||
|
{option.label ?? text(option.value)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
|
{pulse}
|
||||||
<Label className="sr-only">{widget.title || target}</Label>
|
<Label className="sr-only">{widget.title || target}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={text(live?.value)}
|
value={text(value)}
|
||||||
onValueChange={(value) => send(asOriginal(value, options))}
|
onValueChange={(selected) => send(asOriginal(selected, options))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full" aria-label={widget.title || target}>
|
<SelectTrigger className="w-full" aria-label={widget.title || target}>
|
||||||
<SelectValue placeholder="Choose" />
|
<SelectValue placeholder="Choose" />
|
||||||
|
|||||||
Reference in New Issue
Block a user