"use client"
import * as React from "react"
import { AngleDial } from "@/components/ui/angle-dial/angle-dial"
/** 拉直范围:±45° 够修正几乎所有的歪地平线 */
const RANGE = 45
/** 画框是 16:10,照片按同一比例画,旋转后要放大到这个倍数才能盖住四角 */
const ASPECT = 0.625
function coverScale(angle: number) {
const radians = (Math.abs(angle) * Math.PI) / 180
const cos = Math.cos(radians)
const sin = Math.sin(radians)
return Math.max(cos + ASPECT * sin, (sin + ASPECT * cos) / ASPECT)
}
/** 带符号的读数:正数补 `+`,0 就是水平 */
const signedDegrees = (value: number) =>
value > 0 ? `+${value}°` : `${value}°`
/**
* 默认示例:把一张歪掉的风景照拉直。照片按当前角度旋转并自动放大到刚好盖住画框,
* 裁切框与三分线不动,地平线是否水平一眼就能看出来;双击刻度尺回到 0° 与原始构图。
*/
export default function AngleDialDemo() {
const [angle, setAngle] = React.useState(-13)
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-medium">裁剪 · 拉直</p>
<p className="shrink-0 text-[11px] text-muted-foreground">
{angle === 0 ? "已水平" : "地平线倾斜"}
</p>
</div>
<div className="relative mt-3 aspect-[16/10] overflow-hidden rounded-lg border border-edge bg-muted">
<div
className="absolute inset-0 transition-transform duration-150 ease-out motion-reduce:transition-none"
style={{
transform: `rotate(${angle}deg) scale(${coverScale(angle).toFixed(4)})`,
}}
>
{/* 线稿风景:点阵天空、描边山脊、横线水面,只有太阳与倒影用色调,和站内其他示例同一种单色 + 一个强调色 */}
<svg viewBox="0 0 400 250" aria-hidden="true" className="size-full">
<defs>
<pattern
id="angle-dial-demo-sky"
width="10"
height="10"
patternUnits="userSpaceOnUse"
>
<circle
cx="5"
cy="5"
r="0.8"
fill="var(--foreground)"
opacity="0.16"
/>
</pattern>
<pattern
id="angle-dial-demo-water"
width="400"
height="9"
patternUnits="userSpaceOnUse"
>
<rect
y="4"
width="400"
height="1"
fill="var(--foreground)"
opacity="0.12"
/>
</pattern>
</defs>
<rect width="400" height="250" fill="var(--muted)" />
<rect width="400" height="125" fill="url(#angle-dial-demo-sky)" />
{/* 太阳:实心圆 + 一圈细环 */}
<circle
cx="296"
cy="70"
r="19"
fill="none"
stroke="var(--flow-amber)"
strokeOpacity="0.45"
/>
<circle cx="296" cy="70" r="11" fill="var(--flow-amber)" />
{/* 远山淡、近山深,都只用前景色的透明度 */}
<path
d="M0 125 L52 82 L96 112 L148 72 L204 125 Z"
fill="var(--foreground)"
fillOpacity="0.06"
stroke="var(--foreground)"
strokeOpacity="0.3"
strokeLinejoin="round"
/>
<path
d="M132 125 L188 92 L232 114 L286 96 L340 110 L400 100 L400 125 Z"
fill="var(--foreground)"
fillOpacity="0.12"
stroke="var(--foreground)"
strokeOpacity="0.45"
strokeLinejoin="round"
/>
<rect
y="125"
width="400"
height="125"
fill="url(#angle-dial-demo-water)"
/>
{/* 太阳在水里的倒影:几段越来越短的色调横线 */}
<rect
x="272"
y="140"
width="48"
height="2"
rx="1"
fill="var(--flow-amber)"
opacity="0.7"
/>
<rect
x="280"
y="158"
width="32"
height="2"
rx="1"
fill="var(--flow-amber)"
opacity="0.5"
/>
<rect
x="288"
y="176"
width="16"
height="2"
rx="1"
fill="var(--flow-amber)"
opacity="0.35"
/>
{/* 地平线:拉直时对照它看是否水平 */}
<rect
y="124"
width="400"
height="1.5"
fill="var(--foreground)"
opacity="0.7"
/>
</svg>
</div>
{/* 裁切框与三分线不跟着转,用来判断地平线 */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-3 overflow-hidden rounded-sm border border-foreground/40"
>
<span className="absolute inset-y-0 left-1/3 w-px bg-foreground/25" />
<span className="absolute inset-y-0 left-2/3 w-px bg-foreground/25" />
<span className="absolute inset-x-0 top-1/3 h-px bg-foreground/25" />
<span className="absolute inset-x-0 top-2/3 h-px bg-foreground/25" />
</div>
</div>
<AngleDial
className="mt-3"
label="拉直"
min={-RANGE}
max={RANGE}
largeStep={5}
pixelsPerDegree={2}
tone="amber"
value={angle}
onValueChange={setAngle}
formatValue={signedDegrees}
modified={angle !== 0}
/>
<p className="mt-2 text-[11px] text-muted-foreground">
拖动刻度尺,或双击回到 0°
</p>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/angle-dial.json用法
import { useState } from "react"
import { AngleDial } from "@/components/ui/angle-dial/angle-dial"
export function StraightenDial() {
const [angle, setAngle] = useState(0)
return (
<AngleDial
label="拉直"
min={-45}
max={45}
value={angle}
onValueChange={setAngle}
/>
)
}value / defaultValue / onValueChange 与站内其他方条控件同一套约定:给 value 就是受控,不给就由组件自己管,defaultValue 只决定起点与复位目标。外框、圆角与高度跟数值滑块完全一致,同一个面板里并排摆不会错位。
拖动刻度,读正中指针
刻度尺里躺着的是一段转盘,固定在正中的竖线是读数指针。按住刻度往左拖,刻度跟着手走,指针就读到更大的角度——每移动 pixelsPerDegree 像素走一度,默认是 3;调大更细,调小更灵敏。刻度每 5° 一根短线、每 15° 一根长线,两端渐隐,像从窗口里露出的一段转盘。
右侧是格式化后的读数,以及一个跟着转的小表盘,扫一眼就知道现在偏向哪边。
键盘与复位
刻度尺自己可以聚焦,键盘操作与数值滑块一致:
| 按键 | 变化 |
|---|---|
← ↓ | 减一个 step |
→ ↑ | 加一个 step |
Shift + 方向键 | 按 largeStep(默认 15) |
PageUp / PageDown | 按 largeStep |
Home / End | 到两端 |
双击刻度尺回到 defaultValue;不传 defaultValue 时回到 0,0 不在范围内时取 min。拖动松手、每次键盘变更、双击复位之后都会触发 onValueCommitted,需要「松手才算一次」时用它。
首尾相接
max - min 正好是 360 时自动首尾相接:范围是一整圈,越过 max 会从 min 接着走,360° 与 0° 是同一个角度,所以读数显示成 min。首尾相接时按 End 会停在 max - step(即离一圈终点最近的那一档),不会停在和起点重合的位置。
给别的范围也可以手动打开:传 wrap 即可,wrap={false} 则关掉自动判断。
<AngleDial label="朝向" min={0} max={360} defaultValue={90} />高度、色调与「已改动」
variant 与 size 的组合高度和数值滑块同一张表:
variant | sm | default | lg |
|---|---|---|---|
bar | 28px | 32px | 36px |
compact | 18px | 22px | 24px |
tone 只给指针与小表盘上色,取 --flow-tone;不传时用前景色。modified 把标签与读数从次要色提到前景色,并在根元素上留一个 data-modified,通常写成 modified={value !== defaultValue},让控制面板里改过的项一眼可辨。
<AngleDial
label="旋转"
variant="compact"
tone="amber"
modified={rotation !== 0}
value={rotation}
onValueChange={setRotation}
/>示例
罗盘方位
范围 0–360 正好一圈,wrap 自动打开:拖过 360° 会从 0° 接着走。罗盘指针与刻度尺右侧的小表盘同步转动,读数旁标出八方位名。largeStep={45} 让 Shift + 方向键一次跳一个方位。
"use client"
import * as React from "react"
import { AngleDial } from "@/components/ui/angle-dial/angle-dial"
/** 八个方位:0° 是北,顺时针每 45° 一个 */
const DIRECTIONS = ["北", "东北", "东", "东南", "南", "西南", "西", "西北"]
const directionOf = (value: number) =>
DIRECTIONS[Math.round(value / 45) % DIRECTIONS.length]
/** 罗盘刻度:每 15° 一根,四个正方向画长一格 */
const TICKS = Array.from({ length: 24 }, (_, index) => index * 15)
/** 四个方位名,沿半径 38 摆在圆周内侧 */
const CARDINALS = [
{ label: "北", x: 60, y: 22 },
{ label: "东", x: 98, y: 60 },
{ label: "南", x: 60, y: 98 },
{ label: "西", x: 22, y: 60 },
]
/**
* 示例:风向罗盘。范围 0–360 正好一圈,刻度尺自动首尾相接——往一端拖过头会从另一端接着走,
* 罗盘指针与刻度尺右侧的小表盘一起转,读数旁标出八方位名。
*/
export default function AngleDialCompassDemo() {
const [wind, setWind] = React.useState(300)
return (
<div className="w-full max-w-xs rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-medium">风向</p>
<p className="shrink-0 text-xs text-muted-foreground">
{directionOf(wind)}风 · {wind}°
</p>
</div>
<div className="mx-auto mt-3 size-40">
<svg viewBox="0 0 120 120" aria-hidden="true" className="size-full">
<circle
cx="60"
cy="60"
r="56"
fill="none"
stroke="var(--edge)"
strokeWidth="1"
/>
{TICKS.map((degree) => (
<line
key={degree}
x1="60"
y1="4"
x2="60"
y2={degree % 90 === 0 ? 13 : 9}
stroke="var(--foreground)"
strokeOpacity={degree % 90 === 0 ? 0.55 : 0.22}
strokeWidth="1"
transform={`rotate(${degree} 60 60)`}
/>
))}
{CARDINALS.map(({ label, x, y }) => (
<text
key={label}
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
fontSize="9"
fill="var(--muted-foreground)"
>
{label}
</text>
))}
{/* 指针:0° 朝上,与罗盘上的「北」对齐 */}
<g transform={`rotate(${wind} 60 60)`}>
<path d="M60 34 L66 62 L60 58 L54 62 Z" fill="var(--flow-cyan)" />
<path
d="M60 86 L64 58 L60 62 L56 58 Z"
fill="var(--foreground)"
opacity="0.28"
/>
</g>
<circle
cx="60"
cy="60"
r="2.6"
fill="var(--background)"
stroke="var(--edge)"
strokeWidth="1"
/>
</svg>
</div>
<AngleDial
className="mt-3"
label="风向"
min={0}
max={360}
largeStep={45}
tone="cyan"
value={wind}
onValueChange={setWind}
/>
<p className="mt-2 text-[11px] text-muted-foreground">
拖过 360° 会从 0° 接着走
</p>
</div>
)
}
渐变角度
色块的 linear-gradient 角度直接来自刻度,下面一行是这段 CSS 的简写。渐变色只用主题提供的 --flow-* 变量,亮暗与颜色主题切换时一起变。
"use client"
import * as React from "react"
import { AngleDial } from "@/components/ui/angle-dial/angle-dial"
/** 渐变取三个内置色调,全是主题变量,亮暗与颜色主题切换时跟着变 */
const STOPS = "var(--flow-violet), var(--flow-cyan) 58%, var(--flow-amber)"
const DEFAULT_ANGLE = 135
/**
* 示例:渐变角度。色块的 `linear-gradient` 角度直接来自滑过的刻度,
* 下面一行是同一段 CSS;360° 一整圈,刻度尺首尾相接。
*/
export default function AngleDialGradientDemo() {
const [angle, setAngle] = React.useState(DEFAULT_ANGLE)
const gradient = `linear-gradient(${angle}deg, ${STOPS})`
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<p className="text-sm font-medium">渐变角度</p>
<div
className="mt-3 aspect-video rounded-lg border border-edge"
style={{ backgroundImage: gradient }}
/>
<AngleDial
className="mt-3"
label="角度"
min={0}
max={360}
largeStep={15}
tone="violet"
value={angle}
onValueChange={setAngle}
modified={angle !== DEFAULT_ANGLE}
/>
<p className="mt-2 truncate font-mono text-[11px] text-muted-foreground">
linear-gradient({angle}deg, …)
</p>
</div>
)
}
API
AngleDial
受控值(度)
类型number | undefined非受控时的初始值;双击刻度尺时也回到它。不传时取 0(不在范围内则取 min)
number | undefined((value: number) => void) | undefined拖动松手、键盘每次变更、双击复位后触发
类型((value: number) => void) | undefined最小值
类型number | undefined-180最大值
类型number | undefined180步长
类型number | undefined1PageUp / PageDown 与 Shift + 方向键的步长
类型number | undefined15首尾相接:越过一端从另一端接着走。不传时,范围正好一整圈(360°)就自动开启
类型boolean | undefined拖动灵敏度:每度对应的像素数,越大越细
类型number | undefined3左侧标签,同时作为可访问名称
类型React.ReactNode读数格式;默认 数值°,读屏朗读的也是它
((value: number) => string) | undefined高度档:bar 与数值滑块默认同高,compact 是面板里用的中等高度
AngleDialVariant | undefined"bar"尺寸档
类型AngleDialSize | undefined"default"色调:指针与小表盘取 --flow-tone;不传时用前景色
FlowTone | undefined标记为「已改动」:标签与读数改用前景色,根元素带 data-modified
boolean | undefinedfalseboolean | undefined默认 min={-180}、max={180}、step={1}、largeStep={15}、pixelsPerDegree={3}、variant="bar"、size="default";wrap 不传时按「范围是否正好 360°」自动决定,formatValue 默认是 数值°,modified、disabled 默认关闭,tone 不传时用前景色。除上表外,其余属性直接落在根元素 <div> 上。
angleDialVariants
cva 生成的根元素样式函数,有 size 与 variant 两个变体。高度通过根元素的 --angle-dial-height 变量传给内部,自己写包装层时可以复用它保持同一套高度。
const angleDialVariants: (props?: {
size?: AngleDialSize
variant?: AngleDialVariant
}) => string类型
type AngleDialSize = "sm" | "default" | "lg"
type AngleDialVariant = "bar" | "compact"
type AngleDialProps = Omit<
React.ComponentProps<"div">,
"defaultValue" | "onChange" | "children"
> & {
value?: number
defaultValue?: number
onValueChange?: (value: number) => void
onValueCommitted?: (value: number) => void
min?: number
max?: number
step?: number
largeStep?: number
wrap?: boolean
pixelsPerDegree?: number
label?: React.ReactNode
formatValue?: (value: number) => string
variant?: AngleDialVariant
size?: AngleDialSize
tone?: FlowTone
modified?: boolean
disabled?: boolean
}data-slot
| 元素 | data-slot |
|---|---|
| 根元素 | angle-dial |
| 左侧标签 | angle-dial-label |
| 刻度尺 | angle-dial-ruler |
| 刻度 | angle-dial-ticks |
| 指针 | angle-dial-needle |
| 读数与表盘 | angle-dial-value |
| 小表盘 | angle-dial-glyph |
根元素始终带 data-variant(bar / compact);拖动中带 data-dragging,modified 为真时带 data-modified,禁用时带 data-disabled。
可访问性
- 刻度尺是
role="slider",带aria-valuemin/aria-valuemax/aria-valuenow与aria-valuetext。aria-valuetext用的就是formatValue的结果,所以把读数换成「东北」「+12°」这类写法后,读屏念的也是它。 - 名称优先取
label(aria-labelledby),没有label时用aria-label,两者都没有就退回「角度」——同一个表单里有多个刻度尺时务必给名称。 - 刻度、指针、读数与表盘都是
aria-hidden的装饰,读屏只念上面那几个值。 - 焦点环只在键盘聚焦时出现(
focus-visible)。鼠标按下刻度尺不会留下焦点框,但焦点会落到刻度尺上,可以用方向键接着微调。 disabled时去掉 Tab 可达性(tabIndex={-1})、指针交互与键盘响应,并降到半透明。- 系统开启「减少动态效果」时指针不再在拖动中轻微放大。
注意事项
- 组件在服务端就输出完整结构(刻度按当前值平移、表盘指针带着
rotate()),不需要预水合脚本,禁用 JS 时也能看到当前值。 formatValue、defaultValue建议定义在组件外,避免每次渲染都换一个新函数或新对象。- 拖动期间的读数变化会正常触发 React 渲染;一次拖动里读数会连续刷新,这是有意的。
- 用作表单字段时外面自己套
input type="hidden",组件只提交到onValueChange/onValueCommitted。 - 受控模式下记得同时处理
onValueChange,否则拖动后值会弹回。 tone的颜色变量由flow-diagram条目提供,安装时会一并拉取;手动安装需要自己补上--flow-*变量。
源码
"use client"
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"
/**
* 角度刻度尺:方条中间是一段可以左右拖动的刻度,固定在正中的竖线指针读出当前角度。
* 外框与高度表与数值滑块一致(bar 28 / 32 / 36px,compact 18 / 22 / 24px),放进同一个面板能对齐。
*/
const angleDialVariants = cva(
"group/angle-dial relative flex h-(--angle-dial-height) w-full min-w-0 items-center gap-3 overflow-hidden rounded-md border border-edge bg-muted/60 px-3 select-none",
{
variants: {
size: {
sm: "text-xs",
default: "text-sm",
lg: "text-sm",
},
variant: {
bar: "",
compact: "gap-2 px-2.5 text-xs",
},
},
compoundVariants: [
{ variant: "bar", size: "sm", class: "[--angle-dial-height:1.75rem]" },
{ variant: "bar", size: "default", class: "[--angle-dial-height:2rem]" },
{ variant: "bar", size: "lg", class: "[--angle-dial-height:2.25rem]" },
{
variant: "compact",
size: "sm",
class: "[--angle-dial-height:1.125rem]",
},
{
variant: "compact",
size: "default",
class: "[--angle-dial-height:1.375rem]",
},
{ variant: "compact", size: "lg", class: "[--angle-dial-height:1.5rem]" },
],
defaultVariants: {
size: "default",
variant: "bar",
},
},
)
type AngleDialSize = "sm" | "default" | "lg"
type AngleDialVariant = "bar" | "compact"
type AngleDialProps = Omit<
React.ComponentProps<"div">,
"defaultValue" | "onChange" | "children"
> & {
/** 受控值(度) */
value?: number
/** 非受控时的初始值;双击刻度尺时也回到它。不传时取 0(不在范围内则取 `min`) */
defaultValue?: number
onValueChange?: (value: number) => void
/** 拖动松手、键盘每次变更、双击复位后触发 */
onValueCommitted?: (value: number) => void
/**
* 最小值
* @default -180
*/
min?: number
/**
* 最大值
* @default 180
*/
max?: number
/**
* 步长
* @default 1
*/
step?: number
/**
* PageUp / PageDown 与 Shift + 方向键的步长
* @default 15
*/
largeStep?: number
/**
* 首尾相接:越过一端从另一端接着走。不传时,范围正好一整圈(360°)就自动开启
*/
wrap?: boolean
/**
* 拖动灵敏度:每度对应的像素数,越大越细
* @default 3
*/
pixelsPerDegree?: number
/** 左侧标签,同时作为可访问名称 */
label?: React.ReactNode
/** 读数格式;默认 `数值°`,读屏朗读的也是它 */
formatValue?: (value: number) => string
/**
* 高度档:`bar` 与数值滑块默认同高,`compact` 是面板里用的中等高度
* @default "bar"
*/
variant?: AngleDialVariant
/**
* 尺寸档
* @default "default"
*/
size?: AngleDialSize
/** 色调:指针与小表盘取 `--flow-tone`;不传时用前景色 */
tone?: FlowTone
/**
* 标记为「已改动」:标签与读数改用前景色,根元素带 `data-modified`
* @default false
*/
modified?: boolean
disabled?: boolean
}
/** 小刻度的间隔(度)与每隔几个小刻度画一根大刻度 */
const MINOR = 5
const MAJOR_EVERY = 3
const defaultFormat = (value: number) => `${value}°`
/** 把值约束进范围:首尾相接时取模,否则夹住;再对齐到步长 */
function normalize(
value: number,
{
min,
max,
step,
wrap,
}: { min: number; max: number; step: number; wrap: boolean },
) {
let next = value
if (wrap) {
const span = max - min
next = ((((next - min) % span) + span) % span) + min
} else {
next = Math.min(max, Math.max(min, next))
}
const decimals = (String(step).split(".")[1] ?? "").length
const stepped = min + Math.round((next - min) / step) * step
// 首尾相接时 max 与 min 是同一个角度,统一显示成 min
const result = wrap && stepped >= max ? min : stepped
return Number(result.toFixed(decimals))
}
/**
* 角度刻度尺。拖动刻度(不是拖指针)改变角度:手指往左带走刻度,指针就读到更大的角度。
* 键盘:方向键按 `step`、Shift / PageUp / PageDown 按 `largeStep`、Home / End 到两端;双击复位。
*/
function AngleDial({
value: valueProp,
defaultValue,
onValueChange,
onValueCommitted,
min = -180,
max = 180,
step = 1,
largeStep = 15,
wrap: wrapProp,
pixelsPerDegree = 3,
label,
formatValue = defaultFormat,
variant = "bar",
size = "default",
tone,
modified = false,
disabled = false,
className,
style,
"aria-label": ariaLabel,
...props
}: AngleDialProps) {
const wrap = wrapProp ?? max - min === 360
const options = { min, max, step, wrap }
const resetValue = defaultValue ?? (0 >= min && 0 <= max ? 0 : min)
const [value, setValue] = useControllableState<number>({
prop: valueProp,
defaultProp: resetValue,
onChange: onValueChange,
})
const labelId = React.useId()
const [dragging, setDragging] = React.useState(false)
const dragRef = React.useRef<{ x: number; from: number } | null>(null)
const commit = (next: number) => {
const normalized = normalize(next, options)
if (normalized !== value) setValue(normalized)
return normalized
}
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (disabled || event.button !== 0) return
// 鼠标按下不抢走文字选区之外的焦点样式;触摸与笔照常
if (event.pointerType === "mouse") event.preventDefault()
event.currentTarget.focus()
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = { x: event.clientX, from: value }
setDragging(true)
}
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
if (!drag) return
commit(drag.from - (event.clientX - drag.x) / pixelsPerDegree)
}
const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return
dragRef.current = null
setDragging(false)
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
onValueCommitted?.(value)
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return
const big = event.shiftKey ? largeStep : step
let next: number | undefined
if (event.key === "ArrowRight" || event.key === "ArrowUp")
next = value + big
else if (event.key === "ArrowLeft" || event.key === "ArrowDown")
next = value - big
else if (event.key === "PageUp") next = value + largeStep
else if (event.key === "PageDown") next = value - largeStep
else if (event.key === "Home") next = min
else if (event.key === "End") next = wrap ? max - step : max
if (next === undefined) return
event.preventDefault()
// 先算再回调:写成 onValueCommitted?.(commit(next)) 时,没传回调会连 commit 一起短路掉
const committed = commit(next)
onValueCommitted?.(committed)
}
const handleDoubleClick = () => {
if (disabled) return
const committed = commit(resetValue)
onValueCommitted?.(committed)
}
const readout = formatValue(value)
// 刻度跟着值平移:值每增加 1°,刻度往左移 pixelsPerDegree 像素
const minorGap = MINOR * pixelsPerDegree
const offset = `calc(50% - ${(value * pixelsPerDegree).toFixed(2)}px)`
return (
<div
{...props}
data-slot="angle-dial"
data-variant={variant}
data-dragging={dragging ? "" : undefined}
data-modified={modified ? "" : undefined}
data-disabled={disabled ? "" : undefined}
className={cn(
angleDialVariants({ size, variant }),
"data-[disabled]:opacity-50",
className,
)}
style={{ ...flowToneStyle(tone), ...style }}
>
{label !== undefined ? (
<span
id={labelId}
data-slot="angle-dial-label"
className="shrink-0 truncate font-medium text-muted-foreground transition-colors group-data-[modified]/angle-dial:text-foreground"
>
{label}
</span>
) : null}
<div
role="slider"
tabIndex={disabled ? -1 : 0}
aria-labelledby={label !== undefined ? labelId : undefined}
aria-label={label === undefined ? (ariaLabel ?? "角度") : undefined}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={readout}
aria-disabled={disabled || undefined}
data-slot="angle-dial-ruler"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={handleKeyDown}
onDoubleClick={handleDoubleClick}
className="relative h-full min-w-0 flex-1 cursor-ew-resize touch-none rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50 in-data-[disabled]:cursor-not-allowed"
>
{/* 刻度:两层重复渐变,小刻度每 5°、大刻度每 15°;两端渐隐,像一段从窗口里露出来的转盘 */}
<span
aria-hidden="true"
data-slot="angle-dial-ticks"
className="pointer-events-none absolute inset-0 [mask-image:linear-gradient(to_right,transparent,var(--foreground)_22%,var(--foreground)_78%,transparent)]"
style={{
backgroundImage: [
"linear-gradient(to right, color-mix(in oklab, var(--foreground) 45%, transparent) 1px, transparent 1px)",
"linear-gradient(to right, color-mix(in oklab, var(--foreground) 22%, transparent) 1px, transparent 1px)",
].join(", "),
backgroundSize: `${minorGap * MAJOR_EVERY}px 60%, ${minorGap}px 34%`,
backgroundPosition: `${offset} center, ${offset} center`,
backgroundRepeat: "repeat-x",
}}
/>
{/* 指针:固定在正中的竖线,与数值滑块的手柄同形 */}
<span
aria-hidden="true"
data-slot="angle-dial-needle"
className={cn(
"pointer-events-none absolute inset-y-[18%] left-1/2 w-1 -translate-x-1/2 rounded-full transition-transform group-data-[dragging]/angle-dial:scale-y-110 motion-reduce:transition-none",
tone !== undefined ? "bg-(--flow-tone)" : "bg-foreground",
)}
/>
</div>
<span
aria-hidden="true"
data-slot="angle-dial-value"
className="flex shrink-0 items-center gap-1.5 font-mono text-muted-foreground tabular-nums transition-colors group-hover/angle-dial:text-foreground group-data-[dragging]/angle-dial:text-foreground group-data-[modified]/angle-dial:text-foreground"
>
{readout}
{/* 小表盘:指针随角度转,帮助读出方向 */}
<svg
viewBox="0 0 16 16"
data-slot="angle-dial-glyph"
className="size-3.5 shrink-0"
>
<circle
cx="8"
cy="8"
r="6.5"
fill="none"
stroke="currentColor"
strokeOpacity="0.4"
/>
<line
x1="8"
y1="8"
x2="8"
y2="2.5"
strokeWidth="1.6"
strokeLinecap="round"
className={
tone !== undefined ? "stroke-(--flow-tone)" : "stroke-current"
}
transform={`rotate(${value.toFixed(2)} 8 8)`}
/>
</svg>
</span>
</div>
)
}
export { AngleDial, angleDialVariants }
export type { AngleDialProps, AngleDialSize, AngleDialVariant }
"use client"
import * as React from "react"
type UseControllableStateParams<T> = {
/** 受控值;传入后组件不再维护内部状态 */
prop?: T
/** 非受控时的初始值 */
defaultProp: T
/** 值变化时回调(受控与非受控都会触发) */
onChange?: (value: T) => void
}
/**
* 同时支持受控与非受控的状态 hook。
* 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
*/
function useControllableState<T>({
prop,
defaultProp,
onChange,
}: UseControllableStateParams<T>) {
const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
const isControlled = prop !== undefined
const value = isControlled ? prop : uncontrolled
// 用 ref 保存最新的回调和值,保证 setValue 引用稳定
const onChangeRef = React.useRef(onChange)
const valueRef = React.useRef(value)
React.useEffect(() => {
onChangeRef.current = onChange
valueRef.current = value
})
const setValue = React.useCallback(
(next: React.SetStateAction<T>) => {
const resolved =
typeof next === "function"
? (next as (prev: T) => T)(valueRef.current)
: next
if (Object.is(resolved, valueRef.current)) return
valueRef.current = resolved
if (!isControlled) setUncontrolled(resolved)
onChangeRef.current?.(resolved)
},
[isControlled],
)
return [value, setValue] as const
}
export { useControllableState }