跳至正文
组件

XY Pad二维拖拽板

一块带网格的区域,拖动手柄同时调两个值,十字线与淡色填充标出位置,键盘左右 / 上下分别调两条轴。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/xy-pad.json
会连带安装 本站条目 grid-layout、flow-diagram。已存在的文件会询问是否覆盖。

用法

components/light-pad.tsx
import { useState } from "react"
import { XyPad } from "@/components/ui/xy-pad/xy-pad"

export function LightPad() {
  const [light, setLight] = useState({ x: 30, y: 70 })

  return (
    <XyPad
      label="光源"
      value={light}
      onValueChange={setLight}
      x={{ min: 0, max: 100, step: 1, label: "水平" }}
      y={{ min: 0, max: 100, step: 1, label: "高度" }}
    />
  )
}

值是一个 { x, y } 对象。value / defaultValue / onValueChange 与其他方条控件同一套约定:给 value 就是受控,不给时由组件自己管,defaultValue 不传就落在两轴的中点。

拖动与十字线

按住任意位置或手柄拖动,两条轴一起变;十字线标出当前位置,一块淡色填充从左下角铺到手柄,与数值滑块的已选填充是同一种做法。松手时触发一次 onValueCommitted,拖动过程中只走 onValueChange。

指针按下时组件会把焦点交给横轴的隐藏滑块,所以拖完可以直接按方向键微调,不用再点一次。

两条轴

x 与 y 各是一个轴对象,字段都一样:

字段默认说明
min0最小值
max100最大值
step1步长,落点按它对齐
labelX/Y轴名,读屏朗读与读数的名称
format无读数格式,返回的字符串同时用于读数与 aria-valuetext

横轴往右变大,纵轴往上变大——与屏幕坐标相反,符合「抬高」的直觉。角度这类有正负的轴直接写 min={-60}、max={60},负数就在轴线左侧 / 下方。

<XyPad
  label="视角"
  x={{ min: -60, max: 60, step: 1, label: "朝向", format: (v) => `${v}°` }}
  y={{ min: -45, max: 45, step: 1, label: "俯仰", format: (v) => `${v}°` }}
/>

grid 只改背景网格的疏密(默认每个方向 4 格),不影响取值与步长。

键盘

板子里有两个视觉隐藏的原生滑块,横轴一个、纵轴一个。聚焦其中任何一个时:

按键变化
← / →横轴减 / 加一个 step
↑ / ↓纵轴加 / 减一个 step
Shift + 方向键步长放大十倍
Home / End交给当前那条轴,跳到两端
PageUp / PageDown交给当前那条轴,按原生序列走

上下键被接管成纵轴,所以两条轴都能用同一组方向键调,不必先在两个滑块之间 Tab 切换。

尺寸与色调

默认是正方形(aspect-square),用 className 改比例或高度:className="aspect-[4/3]"、className="aspect-auto h-40"。面板里需要跟着行高撑满时,用容器查询给不同断点分别设值。

tone 给十字线、手柄与填充上色,取 --flow-tone;不传时用前景色。modified 把标签与读数从次要色提到前景色,并在根元素上留一个 data-modified,写法通常是 modified={value.x !== 50 || value.y !== 50}。

<XyPad
  className="aspect-[4/3]"
  label="白平衡"
  tone="cyan"
  x={{ label: "绿", min: -50, max: 50 }}
  y={{ label: "红", min: -50, max: 50 }}
/>

示例

情绪坐标

两条轴各带自己的中文轴名与取值范围,纵轴往上变大;板子外面标出「积极 / 消极」「平静 / 激动」,右上角的象限名随位置变化,tone 按积极 / 消极换成绿 / 玫红,颜色只是加强,名称本身已经把状态说清楚。

正在加载…

视角面板

二维拖拽板放进控制面板当「视角」:横轴转朝向、纵轴转俯仰,一次拖出任意机位;下面一条数值滑块单独控制透视距离。板子保持默认的正方形,两条轴的 format 补上度数,modified 标出改过的项,标题栏的复制按钮把当前视角写成 JSX。

正在加载…

API

XyPad

默认两条轴都是 min={0}、max={100}、step={1},名称分别是 X 与 Y;grid={4}、modified 与 disabled 默认关闭,tone 不传时用前景色。除上表外,其余属性直接落在根元素 <div> 上(role="group" 与 aria-label 已经在组件里处理)。

类型

type XyPadValue = { x: number; y: number }

type XyPadAxis = {
  min?: number
  max?: number
  step?: number
  /** 轴名:读屏朗读与读数里用,如「朝向」;不传时是 X / Y */
  label?: string
  /** 读数格式;不传时直接显示数字 */
  format?: (value: number) => string
}

type XyPadProps = Omit<
  React.ComponentProps<"div">,
  "defaultValue" | "onChange" | "children"
> & {
  value?: XyPadValue
  defaultValue?: XyPadValue
  onValueChange?: (value: XyPadValue) => void
  onValueCommitted?: (value: XyPadValue) => void
  x?: XyPadAxis
  y?: XyPadAxis
  label?: React.ReactNode
  grid?: number
  tone?: FlowTone
  modified?: boolean
  disabled?: boolean
}

data-slot

元素data-slot
根元素xy-pad
交互区xy-pad-area
背景网格xy-pad-grid
淡色填充xy-pad-fill
竖十字线xy-pad-crosshair
手柄xy-pad-thumb
横轴滑块xy-pad-input-x
纵轴滑块xy-pad-input-y
标签xy-pad-label
读数xy-pad-value

根元素在拖动中带 data-dragging,modified 为真时带 data-modified,禁用时带 data-disabled。

可访问性

  • 根元素是 role="group",名称取 label(aria-labelledby),没有 label 时用 aria-label;两者都没有时这组滑块就没有名字,务必给一个。
  • 语义落在两个视觉隐藏的原生 input[type=range] 上:各自带轴名与 aria-valuetext(format 的结果),读屏能分别念出「朝向 -32°」「俯仰 18°」这样的值。读数是 aria-hidden 的装饰,不重复朗读。
  • 方向键的左右 / 上下分别对应横轴与纵轴,Shift 加大十倍;Home / End / PageUp / PageDown 交给原生滑块,只改当前聚焦的那条轴。
  • 焦点环画在整块板子外面(:has(:focus-visible)),键盘进入时能看出是这一组在响应。
  • disabled 时两个滑块一起禁用、去掉指针交互,并降到半透明。
  • 十字线与填充只是加强,位置信息同时存在于手柄与两个滑块的数值里,颜色不是唯一载体。

注意事项

  • 组件在服务端就输出完整结构(手柄按百分比定位、填充有宽高),禁用 JS 时也能看到当前值。
  • 拖动用 setPointerCapture,指针移出板子也还能继续拖;鼠标按下时会阻止默认行为,不会选中旁边的文字。
  • 落点会先夹进 min–max,再对齐到 step,所以读数不会出现步长之外的数值。
  • 小数的位数按 step 的写法推导:step={0.5} 保留一位,step={0.01} 保留两位。
  • 受控模式下记得同时处理 onValueChange,否则拖动后手柄会弹回。
  • 用作表单字段时外面自己套两个 input type="hidden",组件只提交到回调。
  • tone 的颜色变量由 flow-diagram 条目提供,安装时会一并拉取;手动安装需要自己补上 --flow-* 变量。

源码

components/ui/xy-pad/xy-pad.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"

type XyPadValue = { x: number; y: number }

type XyPadAxis = {
  /** @default 0 */
  min?: number
  /** @default 100 */
  max?: number
  /** @default 1 */
  step?: number
  /** 轴名:读屏朗读与读数里用,如「朝向」;不传时是 X / Y */
  label?: string
  /** 读数格式;不传时直接显示数字 */
  format?: (value: number) => string
}

type XyPadProps = Omit<
  React.ComponentProps<"div">,
  "defaultValue" | "onChange" | "children"
> & {
  /** 受控值 */
  value?: XyPadValue
  /** 非受控时的初始值;不传时取两轴的中点 */
  defaultValue?: XyPadValue
  onValueChange?: (value: XyPadValue) => void
  /** 拖动松手、键盘每次变更后触发 */
  onValueCommitted?: (value: XyPadValue) => void
  /** 横轴:往右变大 */
  x?: XyPadAxis
  /** 纵轴:往上变大(与屏幕坐标相反,符合「抬高」的直觉) */
  y?: XyPadAxis
  /** 左上角标签,同时作为整组的可访问名称 */
  label?: React.ReactNode
  /**
   * 背景网格每个方向分几格
   * @default 4
   */
  grid?: number
  /** 色调:十字线、手柄与填充取 `--flow-tone`;不传时用前景色 */
  tone?: FlowTone
  /**
   * 标记为「已改动」:标签与读数改用前景色,根元素带 `data-modified`
   * @default false
   */
  modified?: boolean
  disabled?: boolean
}

type ResolvedAxis = Required<Omit<XyPadAxis, "format">> &
  Pick<XyPadAxis, "format">

function resolveAxis(axis: XyPadAxis | undefined, name: string): ResolvedAxis {
  return {
    min: axis?.min ?? 0,
    max: axis?.max ?? 100,
    step: axis?.step ?? 1,
    label: axis?.label ?? name,
    format: axis?.format,
  }
}

function clampStep(value: number, axis: ResolvedAxis) {
  const decimals = (String(axis.step).split(".")[1] ?? "").length
  const clamped = Math.min(axis.max, Math.max(axis.min, value))
  const stepped =
    axis.min + Math.round((clamped - axis.min) / axis.step) * axis.step
  return Number(Math.min(axis.max, stepped).toFixed(decimals))
}

const percentOf = (value: number, axis: ResolvedAxis) =>
  ((value - axis.min) / (axis.max - axis.min || 1)) * 100

const show = (value: number, axis: ResolvedAxis) =>
  axis.format ? axis.format(value) : String(value)

/**
 * 二维拖拽板:一块带网格的区域,拖动手柄同时改两个值。
 * 十字线标出当前位置,从左下角到手柄铺一层淡色(与数值滑块的已选填充同一种做法)。
 *
 * 键盘:板内有两个视觉隐藏的原生滑块(横轴、纵轴),聚焦任一个时左右键改横轴、上下键改纵轴,
 * Shift 加大步长十倍;读屏分别朗读两个轴的名称与读数。
 */
function XyPad({
  value: valueProp,
  defaultValue,
  onValueChange,
  onValueCommitted,
  x: xProp,
  y: yProp,
  label,
  grid = 4,
  tone,
  modified = false,
  disabled = false,
  className,
  style,
  "aria-label": ariaLabel,
  ...props
}: XyPadProps) {
  const xAxis = resolveAxis(xProp, "X")
  const yAxis = resolveAxis(yProp, "Y")
  const [value, setValue] = useControllableState<XyPadValue>({
    prop: valueProp,
    defaultProp: defaultValue ?? {
      x: clampStep((xAxis.min + xAxis.max) / 2, xAxis),
      y: clampStep((yAxis.min + yAxis.max) / 2, yAxis),
    },
    onChange: onValueChange,
  })
  const labelId = React.useId()
  const areaRef = React.useRef<HTMLDivElement>(null)
  const [dragging, setDragging] = React.useState(false)
  const hasTone = tone !== undefined

  const update = (next: XyPadValue) => {
    const resolved = {
      x: clampStep(next.x, xAxis),
      y: clampStep(next.y, yAxis),
    }
    if (resolved.x !== value.x || resolved.y !== value.y) setValue(resolved)
    return resolved
  }

  const fromPointer = (event: React.PointerEvent) => {
    const rect = areaRef.current?.getBoundingClientRect()
    if (!rect) return value
    const px = (event.clientX - rect.left) / rect.width
    const py = 1 - (event.clientY - rect.top) / rect.height
    return update({
      x: xAxis.min + px * (xAxis.max - xAxis.min),
      y: yAxis.min + py * (yAxis.max - yAxis.min),
    })
  }

  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (disabled || event.button !== 0) return
    // 鼠标按下时不让浏览器开始选中旁边的文字
    if (event.pointerType === "mouse") event.preventDefault()
    event.currentTarget.setPointerCapture(event.pointerId)
    setDragging(true)
    fromPointer(event)
    // 把焦点交给横轴的隐藏滑块,接下来可以直接用方向键微调
    areaRef.current?.querySelector<HTMLInputElement>("input")?.focus()
  }

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (dragging) fromPointer(event)
  }

  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!dragging) return
    setDragging(false)
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId)
    }
    onValueCommitted?.(value)
  }

  /** 两个隐藏滑块共用:左右改横轴、上下改纵轴,原生的「上下也改本轴」被接管 */
  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    const factor = event.shiftKey ? 10 : 1
    let next: XyPadValue | undefined
    if (event.key === "ArrowRight")
      next = { ...value, x: value.x + xAxis.step * factor }
    else if (event.key === "ArrowLeft")
      next = { ...value, x: value.x - xAxis.step * factor }
    else if (event.key === "ArrowUp")
      next = { ...value, y: value.y + yAxis.step * factor }
    else if (event.key === "ArrowDown")
      next = { ...value, y: value.y - yAxis.step * factor }
    if (!next) return
    event.preventDefault()
    // 先算再回调:写成 onValueCommitted?.(update(next)) 时,没传回调会连 update 一起短路掉
    const committed = update(next)
    onValueCommitted?.(committed)
  }

  const left = percentOf(value.x, xAxis)
  const bottom = percentOf(value.y, yAxis)
  const gridSize = `${100 / Math.max(1, grid)}%`

  return (
    <div
      {...props}
      role="group"
      aria-labelledby={label !== undefined ? labelId : undefined}
      aria-label={label === undefined ? ariaLabel : undefined}
      data-slot="xy-pad"
      data-dragging={dragging ? "" : undefined}
      data-modified={modified ? "" : undefined}
      data-disabled={disabled ? "" : undefined}
      className={cn(
        "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 data-[disabled]:opacity-50",
        className,
      )}
      style={{ ...flowToneStyle(tone), ...style }}
    >
      <div
        ref={areaRef}
        data-slot="xy-pad-area"
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        className="absolute inset-0 cursor-crosshair touch-none in-data-[disabled]:cursor-not-allowed"
      >
        {/*
          背景网格:等分的细线,与站点的线条网格同一种颜色。
          平铺时最右一列、最下一行的线正好压在外框上,会让这两条边看起来粗一倍,用 clip-path 裁掉。
        */}
        <span
          aria-hidden="true"
          data-slot="xy-pad-grid"
          className="pointer-events-none absolute inset-0 [clip-path:inset(0_1px_1px_0)]"
          style={{
            backgroundImage:
              "linear-gradient(to right, var(--edge) 1px, transparent 1px), linear-gradient(to top, var(--edge) 1px, transparent 1px)",
            backgroundSize: `${gridSize} ${gridSize}`,
            backgroundPosition: "-1px 100%",
          }}
        />
        {/* 从左下角到手柄的淡色填充 */}
        <span
          aria-hidden="true"
          data-slot="xy-pad-fill"
          className={cn(
            "pointer-events-none absolute bottom-0 left-0",
            hasTone
              ? "bg-(--flow-tone)/12 dark:bg-(--flow-tone)/20"
              : "bg-foreground/5 dark:bg-foreground/10",
          )}
          style={{ width: `${left}%`, height: `${bottom}%` }}
        />
        {/* 十字线 */}
        <span
          aria-hidden="true"
          data-slot="xy-pad-crosshair"
          className={cn(
            "pointer-events-none absolute inset-y-0 w-px -translate-x-1/2",
            hasTone ? "bg-(--flow-tone)/50" : "bg-foreground/30",
          )}
          style={{ left: `${left}%` }}
        />
        <span
          aria-hidden="true"
          className={cn(
            "pointer-events-none absolute inset-x-0 h-px translate-y-1/2",
            hasTone ? "bg-(--flow-tone)/50" : "bg-foreground/30",
          )}
          style={{ bottom: `${bottom}%` }}
        />
        {/* 手柄:与开关同一种圆形手柄,拖动时轻微放大 */}
        <span
          aria-hidden="true"
          data-slot="xy-pad-thumb"
          className={cn(
            "pointer-events-none absolute size-3.5 -translate-x-1/2 translate-y-1/2 rounded-full border-2 bg-background shadow-sm transition-transform group-data-[dragging]/xy-pad:scale-125 motion-reduce:transition-none",
            hasTone ? "border-(--flow-tone)" : "border-foreground",
          )}
          style={{ left: `${left}%`, bottom: `${bottom}%` }}
        />
        {(["x", "y"] as const).map((key) => {
          const axis = key === "x" ? xAxis : yAxis
          return (
            <input
              key={key}
              type="range"
              data-slot={`xy-pad-input-${key}`}
              aria-label={axis.label}
              aria-valuetext={show(value[key], axis)}
              min={axis.min}
              max={axis.max}
              step={axis.step}
              value={value[key]}
              disabled={disabled}
              onKeyDown={handleKeyDown}
              onChange={(event) =>
                update({ ...value, [key]: Number(event.target.value) })
              }
              className="sr-only"
            />
          )
        })}
      </div>
      {label !== undefined ? (
        <span
          id={labelId}
          data-slot="xy-pad-label"
          className="pointer-events-none absolute top-2 left-2.5 font-medium text-muted-foreground transition-colors group-data-[modified]/xy-pad:text-foreground"
        >
          {label}
        </span>
      ) : null}
      <span
        aria-hidden="true"
        data-slot="xy-pad-value"
        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"
      >
        {show(value.x, xAxis)} · {show(value.y, yAxis)}
      </span>
    </div>
  )
}

export { XyPad }
export type { XyPadAxis, XyPadProps, XyPadValue }
hooks/use-controllable-state.ts
"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 }