{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "xy-pad",
  "title": "二维拖拽板",
  "description": "一块带网格的区域，拖动手柄同时调两个值，十字线与淡色填充标出位置，键盘左右 / 上下分别调两条轴。",
  "dependencies": [],
  "registryDependencies": [
    "https://yiui.dev/r/flow-tone.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/xy-pad/xy-pad.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\nimport { flowToneStyle, type FlowTone } from \"@/lib/flow-tone\"\n\ntype XyPadValue = { x: number; y: number }\n\ntype XyPadAxis = {\n  /** @default 0 */\n  min?: number\n  /** @default 100 */\n  max?: number\n  /** @default 1 */\n  step?: number\n  /** 轴名：读屏朗读与读数里用，如「朝向」；不传时是 X / Y */\n  label?: string\n  /** 读数格式；不传时直接显示数字 */\n  format?: (value: number) => string\n}\n\ntype XyPadProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\" | \"children\"\n> & {\n  /** 受控值 */\n  value?: XyPadValue\n  /** 非受控时的初始值；不传时取两轴的中点 */\n  defaultValue?: XyPadValue\n  onValueChange?: (value: XyPadValue) => void\n  /** 拖动松手、键盘每次变更后触发 */\n  onValueCommitted?: (value: XyPadValue) => void\n  /** 横轴：往右变大 */\n  x?: XyPadAxis\n  /** 纵轴：往上变大（与屏幕坐标相反，符合「抬高」的直觉） */\n  y?: XyPadAxis\n  /** 左上角标签，同时作为整组的可访问名称 */\n  label?: React.ReactNode\n  /**\n   * 背景网格每个方向分几格\n   * @default 4\n   */\n  grid?: number\n  /** 色调：十字线、手柄与填充取 `--flow-tone`；不传时用前景色 */\n  tone?: FlowTone\n  /**\n   * 标记为「已改动」：标签与读数改用前景色，根元素带 `data-modified`\n   * @default false\n   */\n  modified?: boolean\n  disabled?: boolean\n  /**\n   * 表单字段名：设了就随表单提交两个值。传字符串 `\"pos\"` 时字段是 `pos.x` 与 `pos.y`，\n   * 也可以分别指定 `{ x: \"pan\", y: \"tilt\" }`。表单重置时回到 `defaultValue`\n   */\n  name?: string | { x: string; y: string }\n  /** 关联到另一个表单的 id，同 `<input form>` */\n  form?: string\n}\n\ntype ResolvedAxis = Required<Omit<XyPadAxis, \"format\">> &\n  Pick<XyPadAxis, \"format\">\n\nfunction resolveAxis(axis: XyPadAxis | undefined, name: string): ResolvedAxis {\n  return {\n    min: axis?.min ?? 0,\n    max: axis?.max ?? 100,\n    step: axis?.step ?? 1,\n    label: axis?.label ?? name,\n    format: axis?.format,\n  }\n}\n\nfunction clampStep(value: number, axis: ResolvedAxis) {\n  const decimals = (String(axis.step).split(\".\")[1] ?? \"\").length\n  const clamped = Math.min(axis.max, Math.max(axis.min, value))\n  const stepped =\n    axis.min + Math.round((clamped - axis.min) / axis.step) * axis.step\n  return Number(Math.min(axis.max, stepped).toFixed(decimals))\n}\n\n/** 触摸时移动超过这么多像素就不再算「点按」，交给页面滚动 */\nconst TOUCH_SLOP = 6\n\nconst percentOf = (value: number, axis: ResolvedAxis) =>\n  ((value - axis.min) / (axis.max - axis.min || 1)) * 100\n\nconst show = (value: number, axis: ResolvedAxis) =>\n  axis.format ? axis.format(value) : String(value)\n\n/**\n * 二维拖拽板：一块带网格的区域，拖动手柄同时改两个值。\n * 十字线标出当前位置，从左下角到手柄铺一层淡色（与数值滑块的已选填充同一种做法）。\n *\n * 键盘：板内有两个视觉隐藏的原生滑块（横轴、纵轴），聚焦任一个时左右键改横轴、上下键改纵轴，\n * Shift 加大步长十倍；读屏分别朗读两个轴的名称与读数。\n */\nfunction XyPad({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommitted,\n  x: xProp,\n  y: yProp,\n  label,\n  grid = 4,\n  tone,\n  modified = false,\n  disabled = false,\n  name,\n  form,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n  ...props\n}: XyPadProps) {\n  const xAxis = resolveAxis(xProp, \"X\")\n  const yAxis = resolveAxis(yProp, \"Y\")\n  const [value, setValue] = useControllableState<XyPadValue>({\n    prop: valueProp,\n    defaultProp: defaultValue ?? {\n      x: clampStep((xAxis.min + xAxis.max) / 2, xAxis),\n      y: clampStep((yAxis.min + yAxis.max) / 2, yAxis),\n    },\n    onChange: onValueChange,\n  })\n\n  // 非受控时表单重置回到初始值；reset 事件在表单清空之前触发，等一帧再覆盖\n  const initialRef = React.useRef(value)\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(initialRef.current))\n    element.addEventListener(\"reset\", reset)\n    return () => element.removeEventListener(\"reset\", reset)\n  }, [controlled, setValue])\n  const fieldNames =\n    name === undefined\n      ? null\n      : typeof name === \"string\"\n        ? { x: `${name}.x`, y: `${name}.y` }\n        : name\n  const labelId = React.useId()\n  const areaRef = React.useRef<HTMLDivElement>(null)\n  const [dragging, setDragging] = React.useState(false)\n  // 按在手柄上时记下手指与手柄中心的偏移，拖动时手柄不会先跳到指尖下\n  const offsetRef = React.useRef({ x: 0, y: 0 })\n  // 触摸按在空白处：先不拖，松手时没怎么动才算点按\n  const tapRef = React.useRef<{ id: number; x: number; y: number } | null>(null)\n  const hasTone = tone !== undefined\n\n  const update = (next: XyPadValue) => {\n    const resolved = {\n      x: clampStep(next.x, xAxis),\n      y: clampStep(next.y, yAxis),\n    }\n    if (resolved.x !== value.x || resolved.y !== value.y) setValue(resolved)\n    return resolved\n  }\n\n  const fromPointer = (event: React.PointerEvent) => {\n    const rect = areaRef.current?.getBoundingClientRect()\n    if (!rect) return value\n    const px = (event.clientX - offsetRef.current.x - rect.left) / rect.width\n    const py =\n      1 - (event.clientY - offsetRef.current.y - rect.top) / rect.height\n    return update({\n      x: xAxis.min + px * (xAxis.max - xAxis.min),\n      y: yAxis.min + py * (yAxis.max - yAxis.min),\n    })\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    const thumb = (event.target as Element).closest(\"[data-slot=xy-pad-handle]\")\n    // 触摸按在空白处：板子是 touch-action: pan-x pan-y，手指一动就交给页面滚动，\n    // 只有原地点一下才把手柄移过去；要拖动得按住手柄（手柄外围有 44px 的触摸区，且 touch-action: none）\n    if (event.pointerType === \"touch\" && !thumb) {\n      tapRef.current = {\n        id: event.pointerId,\n        x: event.clientX,\n        y: event.clientY,\n      }\n      return\n    }\n    if (thumb) {\n      const box = thumb.getBoundingClientRect()\n      offsetRef.current = {\n        x: event.clientX - (box.left + box.width / 2),\n        y: event.clientY - (box.top + box.height / 2),\n      }\n    } else offsetRef.current = { x: 0, y: 0 }\n    event.currentTarget.setPointerCapture(event.pointerId)\n    setDragging(true)\n    fromPointer(event)\n    // 把焦点交给横轴的隐藏滑块，接下来可以直接用方向键微调\n    areaRef.current?.querySelector<HTMLInputElement>(\"input\")?.focus()\n  }\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const tap = tapRef.current\n    if (\n      tap?.id === event.pointerId &&\n      Math.hypot(event.clientX - tap.x, event.clientY - tap.y) > TOUCH_SLOP\n    )\n      tapRef.current = null\n    if (dragging) fromPointer(event)\n  }\n\n  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {\n    const tap = tapRef.current\n    if (tap?.id === event.pointerId) {\n      tapRef.current = null\n      if (event.type === \"pointerup\") {\n        offsetRef.current = { x: 0, y: 0 }\n        // 先算再回调，不能写进可选调用里：没传回调时会连 fromPointer 一起短路掉\n        const committed = fromPointer(event)\n        onValueCommitted?.(committed)\n      }\n      return\n    }\n    if (!dragging) return\n    setDragging(false)\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId)\n    }\n    onValueCommitted?.(value)\n  }\n\n  /** 两个隐藏滑块共用：左右改横轴、上下改纵轴，原生的「上下也改本轴」被接管 */\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    const factor = event.shiftKey ? 10 : 1\n    let next: XyPadValue | undefined\n    if (event.key === \"ArrowRight\")\n      next = { ...value, x: value.x + xAxis.step * factor }\n    else if (event.key === \"ArrowLeft\")\n      next = { ...value, x: value.x - xAxis.step * factor }\n    else if (event.key === \"ArrowUp\")\n      next = { ...value, y: value.y + yAxis.step * factor }\n    else if (event.key === \"ArrowDown\")\n      next = { ...value, y: value.y - yAxis.step * factor }\n    if (!next) return\n    event.preventDefault()\n    // 先算再回调：写成 onValueCommitted?.(update(next)) 时，没传回调会连 update 一起短路掉\n    const committed = update(next)\n    onValueCommitted?.(committed)\n  }\n\n  const left = percentOf(value.x, xAxis)\n  const bottom = percentOf(value.y, yAxis)\n  const gridSize = `${100 / Math.max(1, grid)}%`\n\n  return (\n    <div\n      {...props}\n      role=\"group\"\n      aria-labelledby={label !== undefined ? labelId : undefined}\n      aria-label={label === undefined ? ariaLabel : undefined}\n      data-slot=\"xy-pad\"\n      data-dragging={dragging ? \"\" : undefined}\n      data-modified={modified ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\n        \"group/xy-pad relative aspect-square w-full min-w-0 overflow-hidden rounded-md border border-edge bg-muted/60 text-xs select-none has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[disabled]:opacity-50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40\",\n        className,\n      )}\n      style={{ ...flowToneStyle(tone), ...style }}\n    >\n      <div\n        ref={areaRef}\n        data-slot=\"xy-pad-area\"\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        className=\"absolute inset-0 cursor-crosshair touch-pan-x touch-pan-y in-data-[disabled]:cursor-not-allowed\"\n      >\n        {/*\n          背景网格：等分的细线，与站点的线条网格同一种颜色。\n          平铺时最右一列、最下一行的线正好压在外框上，会让这两条边看起来粗一倍，用 clip-path 裁掉。\n        */}\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"xy-pad-grid\"\n          className=\"pointer-events-none absolute inset-0 [clip-path:inset(0_1px_1px_0)]\"\n          style={{\n            backgroundImage:\n              \"linear-gradient(to right, var(--edge) 1px, transparent 1px), linear-gradient(to top, var(--edge) 1px, transparent 1px)\",\n            backgroundSize: `${gridSize} ${gridSize}`,\n            backgroundPosition: \"-1px 100%\",\n          }}\n        />\n        {/* 从左下角到手柄的淡色填充 */}\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"xy-pad-fill\"\n          className={cn(\n            \"pointer-events-none absolute bottom-0 left-0\",\n            hasTone\n              ? \"bg-(--flow-tone)/12 dark:bg-(--flow-tone)/20\"\n              : \"bg-foreground/5 dark:bg-foreground/10\",\n          )}\n          style={{ width: `${left}%`, height: `${bottom}%` }}\n        />\n        {/* 十字线 */}\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"xy-pad-crosshair\"\n          className={cn(\n            \"pointer-events-none absolute inset-y-0 w-px -translate-x-1/2\",\n            hasTone ? \"bg-(--flow-tone)/50\" : \"bg-foreground/30\",\n          )}\n          style={{ left: `${left}%` }}\n        />\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"pointer-events-none absolute inset-x-0 h-px translate-y-1/2\",\n            hasTone ? \"bg-(--flow-tone)/50\" : \"bg-foreground/30\",\n          )}\n          style={{ bottom: `${bottom}%` }}\n        />\n        {/* 手柄：与开关同一种圆形手柄，拖动时轻微放大。外面套一层 44px 的透明触摸区，按住它才能拖 */}\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"xy-pad-handle\"\n          className=\"absolute flex size-11 -translate-x-1/2 translate-y-1/2 cursor-grab touch-none items-center justify-center group-data-[dragging]/xy-pad:cursor-grabbing in-data-[disabled]:cursor-not-allowed\"\n          style={{ left: `${left}%`, bottom: `${bottom}%` }}\n        >\n          <span\n            data-slot=\"xy-pad-thumb\"\n            className={cn(\n              \"size-3.5 rounded-full border-2 bg-background shadow-sm transition-transform group-data-[dragging]/xy-pad:scale-125 motion-reduce:transition-none pointer-coarse:size-5\",\n              hasTone ? \"border-(--flow-tone)\" : \"border-foreground\",\n            )}\n          />\n        </span>\n        {([\"x\", \"y\"] as const).map((key) => {\n          const axis = key === \"x\" ? xAxis : yAxis\n          return (\n            <input\n              key={key}\n              type=\"range\"\n              data-slot={`xy-pad-input-${key}`}\n              aria-label={axis.label}\n              aria-valuetext={show(value[key], axis)}\n              min={axis.min}\n              max={axis.max}\n              step={axis.step}\n              value={value[key]}\n              disabled={disabled}\n              onKeyDown={handleKeyDown}\n              onChange={(event) =>\n                update({ ...value, [key]: Number(event.target.value) })\n              }\n              className=\"sr-only\"\n            />\n          )\n        })}\n      </div>\n      {label !== undefined ? (\n        <span\n          id={labelId}\n          data-slot=\"xy-pad-label\"\n          className=\"pointer-events-none absolute top-2 left-2.5 font-medium text-muted-foreground transition-colors group-data-[modified]/xy-pad:text-foreground\"\n        >\n          {label}\n        </span>\n      ) : null}\n      <span\n        aria-hidden=\"true\"\n        data-slot=\"xy-pad-value\"\n        className=\"pointer-events-none absolute right-2.5 bottom-2 font-mono text-muted-foreground tabular-nums transition-colors group-hover/xy-pad:text-foreground group-data-[dragging]/xy-pad:text-foreground group-data-[modified]/xy-pad:text-foreground\"\n      >\n        {show(value.x, xAxis)} · {show(value.y, yAxis)}\n      </span>\n      {fieldNames &&\n        ([\"x\", \"y\"] as const).map((key) => (\n          <input\n            key={key}\n            ref={key === \"x\" ? hiddenRef : undefined}\n            type=\"hidden\"\n            data-slot={`xy-pad-field-${key}`}\n            name={fieldNames[key]}\n            form={form}\n            value={value[key]}\n            disabled={disabled}\n          />\n        ))}\n    </div>\n  )\n}\n\nexport { XyPad }\nexport type { XyPadAxis, XyPadProps, XyPadValue }\n",
      "type": "registry:ui",
      "target": "@ui/xy-pad/xy-pad.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"
}
