Widgets
Dial
Ein breiter Bogen aus runden Segmenten, die bis zu einem Wert aufleuchten, mit Markierung in der Signalfarbe, oder eine Anzeige mit drei Zonen und Zeiger; nur zur Anzeige oder als Regler.
Vorschau
Verwendung
dial-demo.tsx
import { useState } from 'react'
import { DataTile } from '@/components/data-tile'
import { Dial, type DialZone } from '@/components/dial'
// Example figures for the demo, not real data.
const ZONES: DialZone[] = [
{ label: 'Under', to: 0.85, color: 'var(--chart-1)' },
{ label: 'On track', to: 1.1, color: 'var(--success)' },
{ label: 'Over', to: 1.6, color: 'var(--destructive)' },
]
const PACE = { decimals: 2, suffix: '×' }
export default function DialDemo() {
const [used, setUsed] = useState(77)
const [pace, setPace] = useState(0.92)
return (
<div className="grid w-full gap-4">
<DataTile
title="Monthly budget"
inverted
footer={<span>Focus a dial and use the arrow keys, or drag along it. Example data.</span>}
>
<Dial
value={used}
onValueChange={setUsed}
label="Monthly budget used"
caption="of €1,800 budget"
valueText={(v) => `${v} percent of the monthly budget`}
/>
<div className="mt-5 border-t border-border pt-[18px]">
<Dial
value={pace}
onValueChange={setPace}
zones={ZONES}
step={0.05}
format={PACE}
label="Spending pace"
caption="your daily budget so far"
valueText={(v) => `${v.toFixed(2)} times the daily budget`}
/>
</div>
</DataTile>
<div className="grid gap-4 sm:grid-cols-2">
<DataTile title="Wallet" density="compact" footer={<span>Example data.</span>}>
<Dial value={77} segments={20} label="Monthly budget used" caption="used" />
<p className="mt-3 text-center text-[13.5px] text-muted-foreground tabular-nums">€1,383 of €1,800</p>
</DataTile>
<DataTile title="Pace" density="compact" footer={<span>Read-only. Example data.</span>}>
<Dial value={1.24} zones={ZONES} format={PACE} label="Spending pace" caption="your daily budget" />
</DataTile>
</div>
</div>
)
}Quellcode
dial.tsx
// Dial: a fat arc of rounded segments that light up to the value, with a small marker in the signal colour riding
// the outside and the figure in the middle. Give it `zones` and it becomes the smaller three-state gauge instead: the
// arc is coloured by zone, a needle points at the value, and a status line and zone chips sit beside it.
//
// How it works: the arc runs from -120° to +120° and is cut into `segments` pills. Segments up to the value light
// one after another (14 ms apart, never longer than 300 ms in all), and the marker or needle turns to the value. It
// is read-only by default. Pass `onValueChange` (or `adjustable`) and it becomes a slider: arrow keys step, Page Up
// and Page Down step ten times, Home and End jump to the ends, and dragging along the arc sets the value.
//
// Screen readers: read-only it is a meter, "Monthly budget used, 77 percent of the monthly budget"; adjustable it is
// a slider with the same value text, so each key press is read by the slider itself and nothing else is announced.
// The figure, the ends and the zone chips are hidden because the value text already says them.
//
// Motion: segments only change opacity and the marker only turns (transform). Under reduced motion the value is
// drawn at once.
import {
useEffect,
useRef,
useState,
type CSSProperties,
type HTMLAttributes,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from 'react'
import { cn } from '@/lib/utils'
import { BigNumber } from '@/registry/manniche/chart-kit/chart-kit'
import { EASE_CSS, formatValue, seriesColor, type ValueFormat } from '@/registry/manniche/chart-kit/chart-utils'
import { useChartFrame } from '@/registry/manniche/chart-kit/use-chart'
export type DialZone = {
/** The zone's name, shown on its chip and read with the value: "On track". */
label: string
/** Where the zone ends, in the dial's units. The first zone starts at `min`; the last should end at `max`. */
to: number
/** Any CSS colour. Defaults to the series colours in order; use --success and --destructive for good and bad. */
color?: string
}
export type DialLabels = {
/** The status line of a zoned dial. Default `${label}: ${zone}` with the zone in lower case. */
status?: (zone: string, label: string) => string
}
export type DialProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'defaultValue' | 'onChange'> & {
/** The value. Pass it with `onValueChange` to control an adjustable dial, or alone for a read-only gauge. */
value?: number
/** The starting value when uncontrolled. Default `min`. */
defaultValue?: number
/** Called with the new value when a key or a drag changes it. Passing it makes the dial adjustable. */
onValueChange?: (value: number) => void
/** Make the dial a slider even without `onValueChange` (uncontrolled). Default: true when `onValueChange` is set. */
adjustable?: boolean
/** What the dial measures, such as "Monthly budget used". Its accessible name. */
label: string
/** The value at the left end. Default 0. */
min?: number
/** The value at the right end. Default 100, or the end of the last zone. */
max?: number
/** How far one arrow key moves an adjustable dial. Default 1. */
step?: number
/** How the value is written. Default `{ suffix: '%' }`. */
format?: ValueFormat
/**
* The small line under the figure ("of €1 800 budget"). On a zoned dial it follows the value on the second line
* ("0.92× your daily budget so far").
*/
caption?: ReactNode
/** The labels under the two ends of the arc. Default `min` and `max` in `format`; false hides them. */
ends?: [ReactNode, ReactNode] | false
/** How many segments make the arc. Default 24. */
segments?: number
/** Turns the dial into the zoned gauge: arc coloured by zone, a needle, a status line and zone chips. */
zones?: DialZone[]
/** The value as words for screen readers. Default: the value in `format`, plus the zone when there is one. */
valueText?: (value: number) => string
/** Words for other languages. */
labels?: DialLabels
/** "compact" draws a smaller dial. Inside a compact DataTile this happens on its own. */
density?: 'comfortable' | 'compact'
}
// The arc lives in a 220-wide box around a centre at (110, 112).
const CX = 110
const CY = 112
const A0 = -120
const A1 = 120
const f2 = (v: number) => Number(v.toFixed(2))
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
export function Dial({
value,
defaultValue,
onValueChange,
adjustable,
label,
min = 0,
max: maxProp,
step = 1,
format = { suffix: '%' },
caption,
ends,
segments = 24,
zones,
valueText,
labels,
density,
className,
...rest
}: DialProps) {
const max = maxProp ?? (zones?.length ? zones[zones.length - 1].to : 100)
const span = max - min || 1
const { ref, drawn, reduced } = useChartFrame<HTMLDivElement>()
const [own, setOwn] = useState(() => clamp(defaultValue ?? min, min, max))
const v = clamp(value ?? own, min, max)
const canAdjust = adjustable ?? Boolean(onValueChange)
const dragging = useRef(false)
// Round to the step, counted from min, so 0.05 steps never drift to 0.9500000001.
const decimals = (String(step).split('.')[1] ?? '').length
const snap = (x: number) => clamp(Number((min + Math.round((x - min) / step) * step).toFixed(decimals)), min, max)
const commit = (x: number) => {
const next = snap(x)
if (next === v) return
if (value === undefined) setOwn(next)
onValueChange?.(next)
}
const zoneOf = (x: number) => {
if (!zones?.length) return -1
const i = zones.findIndex((z) => x <= z.to + 1e-9)
return i < 0 ? zones.length - 1 : i
}
const zi = zoneOf(v)
const zone = zi >= 0 ? zones?.[zi] : undefined
const text = valueText ? valueText(v) : `${formatValue(v, format)}${zone ? `, ${zone.label}` : ''}`
// Lit segments, and where the last change started, so they light in order away from it.
const shown = drawn ? v : min
const lit = Math.round(((shown - min) / span) * segments)
const [run, setRun] = useState({ lit, from: lit })
if (run.lit !== lit) setRun({ lit, from: run.lit })
const gap = Math.min(14, 300 / Math.max(1, Math.abs(run.lit - run.from)))
const delayOf = (k: number) => {
if (reduced) return 0
if (run.lit > run.from && k >= run.from && k < run.lit) return Math.round((k - run.from) * gap)
if (run.lit < run.from && k >= run.lit && k < run.from) return Math.round((run.from - 1 - k) * gap)
return 0
}
const angle = A0 + ((A1 - A0) * (shown - min)) / span
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
const by: Record<string, number> = {
ArrowRight: step,
ArrowUp: step,
ArrowLeft: -step,
ArrowDown: -step,
PageUp: step * 10,
PageDown: -step * 10,
}
if (e.key === 'Home') commit(min)
else if (e.key === 'End') commit(max)
else if (e.key in by) commit(v + by[e.key])
else return
e.preventDefault()
}
const fromPointer = (e: PointerEvent<SVGSVGElement>) => {
const box = e.currentTarget.getBoundingClientRect()
const vb = e.currentTarget.viewBox.baseVal
if (!box.width || !vb) return
const x = ((e.clientX - box.left) / box.width) * vb.width - CX
const y = ((e.clientY - box.top) / box.height) * vb.height - CY
// 0° at the top, clockwise; below the centre it snaps to the nearer end.
let a = (Math.atan2(x, -y) * 180) / Math.PI
if (a > A1) a = a > 180 - (180 - A1) / 2 ? A0 : A1
if (a < A0) a = a < -180 + (180 + A0) / 2 ? A1 : A0
commit(min + ((a - A0) / (A1 - A0)) * span)
}
const pointer = canAdjust
? {
onPointerDown: (e: PointerEvent<SVGSVGElement>) => {
if (e.button !== 0) return
dragging.current = true
e.currentTarget.setPointerCapture(e.pointerId)
;(e.currentTarget.parentElement as HTMLElement | null)?.focus({ preventScroll: true })
fromPointer(e)
},
onPointerMove: (e: PointerEvent<SVGSVGElement>) => {
if (dragging.current) fromPointer(e)
},
onPointerUp: () => {
dragging.current = false
},
onPointerCancel: () => {
dragging.current = false
},
}
: {}
// A drag that ends outside the window still lets go.
useEffect(() => {
const up = () => {
dragging.current = false
}
window.addEventListener('pointerup', up)
return () => window.removeEventListener('pointerup', up)
}, [])
const role = canAdjust
? {
role: 'slider',
tabIndex: 0,
'aria-orientation': 'horizontal' as const,
onKeyDown: onKey,
}
: { role: 'meter' }
const aria = {
'aria-label': label,
'aria-valuemin': min,
'aria-valuemax': max,
'aria-valuenow': f2(v),
'aria-valuetext': text,
}
const turn = (deg: number): CSSProperties => ({
transformBox: 'view-box',
transformOrigin: `${CX}px ${CY}px`,
transform: `rotate(${f2(deg)}deg)`,
transition: reduced ? 'none' : `transform 300ms ${EASE_CSS}`,
})
const arc = (n: number, r0: number, len: number, w: number, fill: (k: number) => CSSProperties) =>
Array.from({ length: n }, (_, k) => {
const a = A0 + ((A1 - A0) * (k + 0.5)) / n
return (
<rect
key={k}
x={CX - w / 2}
y={CY - r0 - len}
width={w}
height={len}
rx={w / 2}
transform={`rotate(${f2(a)} ${CX} ${CY})`}
style={fill(k)}
/>
)
})
if (zones?.length) {
const status = labels?.status ?? ((z: string, l: string) => `${l}: ${z.toLowerCase()}`)
return (
<div ref={ref} className={cn('@container w-full min-w-0', className)} {...rest}>
<div className="grid grid-cols-[96px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 [grid-template-areas:'fig_text'_'chips_chips'] @[22rem]:grid-cols-[132px_minmax(0,1fr)] @[22rem]:gap-x-[18px] @[22rem]:gap-y-2.5 @[22rem]:[grid-template-areas:'fig_text'_'fig_chips']">
<div
{...role}
{...aria}
className={cn('relative self-center rounded-2xl [grid-area:fig]', canAdjust && 'cursor-pointer touch-none select-none')}
>
<svg viewBox="0 0 220 150" aria-hidden className="block h-auto w-full overflow-visible" {...pointer}>
{arc(segments, 66, 30, 13, (k) => {
const z = zoneOf(min + ((k + 0.5) / segments) * span)
return {
fill: zones[z].color ?? seriesColor(z),
opacity: drawn && z === zi ? 1 : 0.24,
transition: reduced ? 'none' : `opacity 200ms ${EASE_CSS}`,
}
})}
<g style={turn(angle)}>
<line x1={CX} y1={CY} x2={CX} y2={54} stroke="var(--foreground)" strokeWidth={7} strokeLinecap="round" />
<circle cx={CX} cy={CY} r={11} fill="var(--foreground)" />
</g>
</svg>
</div>
<div className="min-w-0 self-center [grid-area:text] @[22rem]:self-end">
<p className="text-[15px] leading-[1.35] font-medium">{zone ? status(zone.label, label) : label}</p>
<p className="mt-0.5 text-[13px] leading-normal text-muted-foreground">
<span className="tabular-nums">{formatValue(v, format)}</span>
{caption ? <> {caption}</> : null}
</p>
</div>
<div aria-hidden className="flex min-w-0 flex-wrap gap-1.5 self-start [grid-area:chips]">
{zones.map((z, i) => (
<span
key={z.label}
className="relative isolate inline-flex h-[26px] items-center gap-1.5 rounded-full pr-2.5 pl-2 text-[12.5px] leading-none font-medium text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]"
>
<span
className="absolute inset-0 -z-10 rounded-full bg-foreground transition-opacity duration-200 ease-out-quint motion-reduce:transition-none"
style={{ opacity: i === zi ? 1 : 0 }}
/>
<i className="size-2 rounded-full" style={{ background: z.color ?? seriesColor(i) }} />
<span className={cn(i === zi && 'text-card')}>{z.label}</span>
</span>
))}
</div>
</div>
</div>
)
}
const endLabels = ends === false ? null : (ends ?? [formatValue(min, format), formatValue(max, format)])
return (
<div ref={ref} className={cn('@container flex w-full min-w-0 flex-col items-center', className)} {...rest}>
<div
{...role}
{...aria}
className={cn(
'relative @container w-[min(100%,330px)] rounded-3xl',
'group-data-[density=compact]/tile:w-[min(100%,260px)]',
density === 'compact' && 'w-[min(100%,260px)]',
canAdjust && 'cursor-pointer touch-none select-none',
)}
>
<svg viewBox="0 0 220 186" aria-hidden className="block h-auto w-full overflow-visible" {...pointer}>
{arc(segments, 70, 30, 12, (k) => ({
fill: 'var(--foreground)',
opacity: k < lit ? 1 : 0.14,
transition: reduced ? 'none' : `opacity 200ms ${EASE_CSS} ${delayOf(k)}ms`,
}))}
<g style={turn(angle)}>
<path d="M110 2l7 -0.5 -7 9 -7 -9z" fill="var(--primary)" />
</g>
</svg>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-[44%] grid justify-items-center text-center">
<span style={{ ['--dl-num' as string]: 'clamp(34px, 19.4cqi, 64px)' }}>
<BigNumber value={shown} format={format} size="xl" roll={drawn} className="text-[length:var(--dl-num)]" />
</span>
{caption && (
<span className="mt-1.5 max-w-[80%] text-[13px] leading-[1.3] font-medium text-balance text-muted-foreground">
{caption}
</span>
)}
</div>
{endLabels && (
<div aria-hidden className="font-mono text-[11px] leading-none text-muted-foreground tabular-nums">
<span className="absolute bottom-0 left-[15%] -translate-x-1/2">{endLabels[0]}</span>
<span className="absolute bottom-0 left-[85%] -translate-x-1/2">{endLabels[1]}</span>
</div>
)}
</div>
</div>
)
}
export default DialInstallieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/dial.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/dial