{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "angle-dial",
  "title": "角度刻度尺",
  "description": "方条样式的角度输入：左右拖动刻度、中间竖线指针读数，满一圈首尾相接，与数值滑块同一套高度。",
  "dependencies": [
    "class-variance-authority"
  ],
  "registryDependencies": [
    "https://yiui.dev/r/flow-tone.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/angle-dial/angle-dial.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cva } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\nimport { flowToneStyle, type FlowTone } from \"@/lib/flow-tone\"\n\n/**\n * 角度刻度尺：方条中间是一段可以左右拖动的刻度，固定在正中的竖线指针读出当前角度。\n * 外框与高度表与数值滑块一致（bar 28 / 32 / 36px，compact 18 / 22 / 24px），放进同一个面板能对齐。\n */\n/** 触摸时横向移动超过这么多像素才算拖动，先往上下走的交给页面滚动 */\nconst TOUCH_SLOP = 6\n\nconst angleDialVariants = cva(\n  \"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 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40\",\n  {\n    variants: {\n      size: {\n        sm: \"text-xs\",\n        default: \"text-sm\",\n        lg: \"text-sm\",\n      },\n      variant: {\n        bar: \"\",\n        compact: \"gap-2 px-2.5 text-xs\",\n      },\n    },\n    compoundVariants: [\n      {\n        variant: \"bar\",\n        size: \"sm\",\n        class:\n          \"[--angle-dial-height:1.75rem] pointer-coarse:[--angle-dial-height:2.25rem]\",\n      },\n      {\n        variant: \"bar\",\n        size: \"default\",\n        class:\n          \"[--angle-dial-height:2rem] pointer-coarse:[--angle-dial-height:2.5rem]\",\n      },\n      {\n        variant: \"bar\",\n        size: \"lg\",\n        class:\n          \"[--angle-dial-height:2.25rem] pointer-coarse:[--angle-dial-height:2.75rem]\",\n      },\n      {\n        variant: \"compact\",\n        size: \"sm\",\n        class: \"[--angle-dial-height:1.125rem]\",\n      },\n      {\n        variant: \"compact\",\n        size: \"default\",\n        class: \"[--angle-dial-height:1.375rem]\",\n      },\n      { variant: \"compact\", size: \"lg\", class: \"[--angle-dial-height:1.5rem]\" },\n    ],\n    defaultVariants: {\n      size: \"default\",\n      variant: \"bar\",\n    },\n  },\n)\n\ntype AngleDialSize = \"sm\" | \"default\" | \"lg\"\ntype AngleDialVariant = \"bar\" | \"compact\"\n\ntype AngleDialProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\" | \"children\"\n> & {\n  /** 受控值（度） */\n  value?: number\n  /** 非受控时的初始值；双击刻度尺时也回到它。不传时取 0（不在范围内则取 `min`） */\n  defaultValue?: number\n  onValueChange?: (value: number) => void\n  /** 拖动松手、键盘每次变更、双击复位后触发 */\n  onValueCommitted?: (value: number) => void\n  /**\n   * 最小值\n   * @default -180\n   */\n  min?: number\n  /**\n   * 最大值\n   * @default 180\n   */\n  max?: number\n  /**\n   * 步长\n   * @default 1\n   */\n  step?: number\n  /**\n   * PageUp / PageDown 与 Shift + 方向键的步长\n   * @default 15\n   */\n  largeStep?: number\n  /**\n   * 首尾相接：越过一端从另一端接着走。不传时，范围正好一整圈（360°）就自动开启\n   */\n  wrap?: boolean\n  /**\n   * 拖动灵敏度：每度对应的像素数，越大越细\n   * @default 3\n   */\n  pixelsPerDegree?: number\n  /** 左侧标签，同时作为可访问名称 */\n  label?: React.ReactNode\n  /** 读数格式；默认 `数值°`，读屏朗读的也是它 */\n  formatValue?: (value: number) => string\n  /**\n   * 高度档：`bar` 与数值滑块默认同高，`compact` 是面板里用的中等高度\n   * @default \"bar\"\n   */\n  variant?: AngleDialVariant\n  /**\n   * 尺寸档\n   * @default \"default\"\n   */\n  size?: AngleDialSize\n  /** 色调：指针与小表盘取 `--flow-tone`；不传时用前景色 */\n  tone?: FlowTone\n  /**\n   * 标记为「已改动」：标签与读数改用前景色，根元素带 `data-modified`\n   * @default false\n   */\n  modified?: boolean\n  disabled?: boolean\n  /** 表单字段名：设了就随表单提交当前角度（数字字符串），表单重置时回到 `defaultValue` */\n  name?: string\n  /** 关联到另一个表单的 id，同 `<input form>` */\n  form?: string\n}\n\n/** 小刻度的间隔（度）与每隔几个小刻度画一根大刻度 */\nconst MINOR = 5\nconst MAJOR_EVERY = 3\n\nconst defaultFormat = (value: number) => `${value}°`\n\n/** 把值约束进范围：首尾相接时取模，否则夹住；再对齐到步长 */\nfunction normalize(\n  value: number,\n  {\n    min,\n    max,\n    step,\n    wrap,\n  }: { min: number; max: number; step: number; wrap: boolean },\n) {\n  let next = value\n  if (wrap) {\n    const span = max - min\n    next = ((((next - min) % span) + span) % span) + min\n  } else {\n    next = Math.min(max, Math.max(min, next))\n  }\n  const decimals = (String(step).split(\".\")[1] ?? \"\").length\n  const stepped = min + Math.round((next - min) / step) * step\n  // 首尾相接时 max 与 min 是同一个角度，统一显示成 min\n  const result = wrap && stepped >= max ? min : stepped\n  return Number(result.toFixed(decimals))\n}\n\n/**\n * 角度刻度尺。拖动刻度（不是拖指针）改变角度：手指往左带走刻度，指针就读到更大的角度。\n * 键盘：方向键按 `step`、Shift / PageUp / PageDown 按 `largeStep`、Home / End 到两端；双击复位。\n */\nfunction AngleDial({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommitted,\n  min = -180,\n  max = 180,\n  step = 1,\n  largeStep = 15,\n  wrap: wrapProp,\n  pixelsPerDegree = 3,\n  label,\n  formatValue = defaultFormat,\n  variant = \"bar\",\n  size = \"default\",\n  tone,\n  modified = false,\n  disabled = false,\n  name,\n  form,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n  ...props\n}: AngleDialProps) {\n  const wrap = wrapProp ?? max - min === 360\n  const options = { min, max, step, wrap }\n  const resetValue = defaultValue ?? (0 >= min && 0 <= max ? 0 : min)\n  const [value, setValue] = useControllableState<number>({\n    prop: valueProp,\n    defaultProp: resetValue,\n    onChange: onValueChange,\n  })\n\n  // 非受控时表单重置回到初始值；reset 事件在表单清空之前触发，等一帧再覆盖\n  const hiddenRef = React.useRef<HTMLInputElement>(null)\n  const controlled = valueProp !== undefined\n  React.useEffect(() => {\n    const element = hiddenRef.current?.form\n    if (!element || controlled) return\n    const reset = () => window.setTimeout(() => setValue(resetValue))\n    element.addEventListener(\"reset\", reset)\n    return () => element.removeEventListener(\"reset\", reset)\n  }, [controlled, resetValue, setValue])\n  const labelId = React.useId()\n  const [dragging, setDragging] = React.useState(false)\n  // 按下刻度时会主动把焦点给刻度尺（之后可以直接用方向键微调），但程序聚焦会让浏览器当成键盘聚焦、\n  // 显示焦点环。记下「这次焦点是指针带来的」，按任意键或失焦后恢复\n  const [pointerFocus, setPointerFocus] = React.useState(false)\n  // pending：触摸刚按下、还没看出是横向拖动还是上下滚动页面\n  const dragRef = React.useRef<{\n    x: number\n    y: number\n    from: number\n    pending: boolean\n  } | null>(null)\n\n  const commit = (next: number) => {\n    const normalized = normalize(next, options)\n    if (normalized !== value) setValue(normalized)\n    return normalized\n  }\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (disabled || event.button !== 0) return\n    // 鼠标按下不抢走文字选区之外的焦点样式；触摸与笔照常\n    if (event.pointerType === \"mouse\") event.preventDefault()\n    setPointerFocus(true)\n    event.currentTarget.focus({ preventScroll: true })\n    const touch = event.pointerType === \"touch\"\n    dragRef.current = {\n      x: event.clientX,\n      y: event.clientY,\n      from: value,\n      pending: touch,\n    }\n    // 触摸先不接管：轨道是 touch-action: pan-y，上下滑由浏览器滚动页面，横向移动过阈值才开始拖\n    if (touch) return\n    event.currentTarget.setPointerCapture(event.pointerId)\n    setDragging(true)\n  }\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const drag = dragRef.current\n    if (!drag) return\n    if (drag.pending) {\n      const dx = Math.abs(event.clientX - drag.x)\n      const dy = Math.abs(event.clientY - drag.y)\n      if (dx < TOUCH_SLOP && dy < TOUCH_SLOP) return\n      if (dy >= dx) {\n        dragRef.current = null\n        return\n      }\n      // 从这里起算，过阈值的那几像素不计入，转盘不会突然跳一下\n      drag.pending = false\n      drag.x = event.clientX\n      event.currentTarget.setPointerCapture(event.pointerId)\n      setDragging(true)\n      return\n    }\n    commit(drag.from - (event.clientX - drag.x) / pixelsPerDegree)\n  }\n\n  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {\n    const drag = dragRef.current\n    if (!drag) return\n    dragRef.current = null\n    // 只是点了一下，或者被浏览器拿去滚动页面了：值没动，不算一次提交\n    if (drag.pending) return\n    setDragging(false)\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId)\n    }\n    onValueCommitted?.(value)\n  }\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (disabled) return\n    const big = event.shiftKey ? largeStep : step\n    let next: number | undefined\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowUp\")\n      next = value + big\n    else if (event.key === \"ArrowLeft\" || event.key === \"ArrowDown\")\n      next = value - big\n    else if (event.key === \"PageUp\") next = value + largeStep\n    else if (event.key === \"PageDown\") next = value - largeStep\n    else if (event.key === \"Home\") next = min\n    else if (event.key === \"End\") next = wrap ? max - step : max\n    if (next === undefined) return\n    event.preventDefault()\n    // 先算再回调：写成 onValueCommitted?.(commit(next)) 时，没传回调会连 commit 一起短路掉\n    const committed = commit(next)\n    onValueCommitted?.(committed)\n  }\n\n  const handleDoubleClick = () => {\n    if (disabled) return\n    const committed = commit(resetValue)\n    onValueCommitted?.(committed)\n  }\n\n  const readout = formatValue(value)\n  // 刻度跟着值平移：值每增加 1°，刻度往左移 pixelsPerDegree 像素\n  const minorGap = MINOR * pixelsPerDegree\n  const offset = `calc(50% - ${(value * pixelsPerDegree).toFixed(2)}px)`\n\n  return (\n    <div\n      {...props}\n      data-slot=\"angle-dial\"\n      data-variant={variant}\n      data-dragging={dragging ? \"\" : undefined}\n      data-modified={modified ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\n        angleDialVariants({ size, variant }),\n        \"data-[disabled]:opacity-50\",\n        className,\n      )}\n      style={{ ...flowToneStyle(tone), ...style }}\n    >\n      {label !== undefined ? (\n        <span\n          id={labelId}\n          data-slot=\"angle-dial-label\"\n          className=\"shrink-0 truncate font-medium text-muted-foreground transition-colors group-data-[modified]/angle-dial:text-foreground\"\n        >\n          {label}\n        </span>\n      ) : null}\n      <div\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-labelledby={label !== undefined ? labelId : undefined}\n        aria-label={label === undefined ? (ariaLabel ?? \"角度\") : undefined}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuenow={value}\n        aria-valuetext={readout}\n        aria-disabled={disabled || undefined}\n        data-slot=\"angle-dial-ruler\"\n        data-pointer-focus={pointerFocus ? \"\" : undefined}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        onKeyDownCapture={() => setPointerFocus(false)}\n        onBlur={() => setPointerFocus(false)}\n        onKeyDown={handleKeyDown}\n        onDoubleClick={handleDoubleClick}\n        className=\"relative h-full min-w-0 flex-1 cursor-ew-resize touch-pan-y rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset in-data-[disabled]:cursor-not-allowed data-pointer-focus:focus-visible:ring-0\"\n      >\n        {/* 刻度：两层重复渐变，小刻度每 5°、大刻度每 15°；两端渐隐，像一段从窗口里露出来的转盘 */}\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"angle-dial-ticks\"\n          className=\"pointer-events-none absolute inset-0 [mask-image:linear-gradient(to_right,transparent,var(--foreground)_22%,var(--foreground)_78%,transparent)]\"\n          style={{\n            backgroundImage: [\n              \"linear-gradient(to right, color-mix(in oklab, var(--foreground) 45%, transparent) 1px, transparent 1px)\",\n              \"linear-gradient(to right, color-mix(in oklab, var(--foreground) 22%, transparent) 1px, transparent 1px)\",\n            ].join(\", \"),\n            backgroundSize: `${minorGap * MAJOR_EVERY}px 60%, ${minorGap}px 34%`,\n            backgroundPosition: `${offset} center, ${offset} center`,\n            backgroundRepeat: \"repeat-x\",\n          }}\n        />\n        {/* 指针：固定在正中的竖线，与数值滑块的手柄同形 */}\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"angle-dial-needle\"\n          className={cn(\n            \"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\",\n            tone !== undefined ? \"bg-(--flow-tone)\" : \"bg-foreground\",\n          )}\n        />\n      </div>\n      <span\n        aria-hidden=\"true\"\n        data-slot=\"angle-dial-value\"\n        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\"\n      >\n        {readout}\n        {/* 小表盘：指针随角度转，帮助读出方向 */}\n        <svg\n          viewBox=\"0 0 16 16\"\n          data-slot=\"angle-dial-glyph\"\n          className=\"size-3.5 shrink-0\"\n        >\n          <circle\n            cx=\"8\"\n            cy=\"8\"\n            r=\"6.5\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeOpacity=\"0.4\"\n          />\n          <line\n            x1=\"8\"\n            y1=\"8\"\n            x2=\"8\"\n            y2=\"2.5\"\n            strokeWidth=\"1.6\"\n            strokeLinecap=\"round\"\n            className={\n              tone !== undefined ? \"stroke-(--flow-tone)\" : \"stroke-current\"\n            }\n            transform={`rotate(${value.toFixed(2)} 8 8)`}\n          />\n        </svg>\n      </span>\n      {name !== undefined && (\n        <input\n          ref={hiddenRef}\n          type=\"hidden\"\n          data-slot=\"angle-dial-field\"\n          name={name}\n          form={form}\n          value={value}\n          disabled={disabled}\n        />\n      )}\n    </div>\n  )\n}\n\nexport { AngleDial, angleDialVariants }\nexport type { AngleDialProps, AngleDialSize, AngleDialVariant }\n",
      "type": "registry:ui",
      "target": "@ui/angle-dial/angle-dial.tsx"
    },
    {
      "path": "src/registry/hooks/use-controllable-state.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype UseControllableStateParams<T> = {\n  /** 受控值；传入后组件不再维护内部状态 */\n  prop?: T\n  /** 非受控时的初始值 */\n  defaultProp: T\n  /** 值变化时回调（受控与非受控都会触发） */\n  onChange?: (value: T) => void\n}\n\n/**\n * 同时支持受控与非受控的状态 hook。\n * 约定见 component-conventions 技能第 4 节：open/defaultOpen/onOpenChange 等成对 props 都用它实现。\n */\nfunction useControllableState<T>({\n  prop,\n  defaultProp,\n  onChange,\n}: UseControllableStateParams<T>) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)\n  const isControlled = prop !== undefined\n  const value = isControlled ? prop : uncontrolled\n\n  // 用 ref 保存最新的回调和值，保证 setValue 引用稳定\n  const onChangeRef = React.useRef(onChange)\n  const valueRef = React.useRef(value)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n    valueRef.current = value\n  })\n\n  const setValue = React.useCallback(\n    (next: React.SetStateAction<T>) => {\n      const resolved =\n        typeof next === \"function\"\n          ? (next as (prev: T) => T)(valueRef.current)\n          : next\n      if (Object.is(resolved, valueRef.current)) return\n      valueRef.current = resolved\n      if (!isControlled) setUncontrolled(resolved)\n      onChangeRef.current?.(resolved)\n    },\n    [isControlled],\n  )\n\n  return [value, setValue] as const\n}\n\nexport { useControllableState }\n",
      "type": "registry:ui",
      "target": "hooks/use-controllable-state.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:ui"
}
