"use client"
import * as React from "react"
import { XyPad } from "@/components/ui/xy-pad/xy-pad"
/** 初始光源:偏左上,投影落向右下 */
const DEFAULT_LIGHT = { x: 30, y: 74 }
const percent = (value: number) => `${value}%`
/**
* 默认示例:拖动光点给一张卡片打光。十字线位置同时决定投影方向与高光落点——
* 光源在左上时高光在左上、投影落向右下;阴影与光晕都用 `color-mix` 配主题变量。
*/
export default function XyPadDemo() {
const [light, setLight] = React.useState(DEFAULT_LIGHT)
// 光源相对中心的方向:投影永远落在光源的反面
const dx = (light.x - 50) / 50
const dy = (light.y - 50) / 50
const shadowX = Math.round(-dx * 18)
const shadowY = Math.round(dy * 18)
const lightPosition = `${light.x}% ${100 - light.y}%`
return (
// 用容器查询而不是视口断点:列表缩略图里这块只有 300 多像素宽,不能跟着视口分栏
<div className="@container w-full max-w-lg">
<div className="grid overflow-hidden rounded-xl border border-edge bg-background shadow-xs @min-[26rem]:grid-cols-[minmax(0,1fr)_15rem]">
<div className="relative flex min-h-44 items-center justify-center overflow-hidden border-b border-edge bg-muted/20 p-6 @min-[26rem]:border-e @min-[26rem]:border-b-0">
{/* 环境光:从光源位置往外晕开的一层暖色 */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at ${lightPosition}, color-mix(in oklab, var(--flow-amber) 28%, transparent), transparent 72%)`,
}}
/>
<div
className="relative w-40 rounded-lg border border-edge bg-background p-3 transition-shadow duration-150 ease-out motion-reduce:transition-none"
style={{
boxShadow: `${shadowX}px ${shadowY}px 24px -6px color-mix(in oklab, var(--foreground) 42%, transparent), ${shadowX / 2}px ${shadowY / 2}px 8px -3px color-mix(in oklab, var(--foreground) 26%, transparent), ${-shadowX * 0.6}px ${-shadowY * 0.6}px 26px color-mix(in oklab, var(--flow-amber) 28%, transparent)`,
}}
>
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className="size-7 shrink-0 rounded-md bg-(--flow-amber)/20 ring-1 ring-(--flow-amber)/45 ring-inset"
/>
<div className="min-w-0 flex-1">
<span
aria-hidden="true"
className="block h-1.5 w-11 rounded-full bg-foreground/25"
/>
<span
aria-hidden="true"
className="mt-1.5 block h-1.5 w-16 rounded-full bg-foreground/12"
/>
</div>
</div>
<span
aria-hidden="true"
className="mt-3 block h-1.5 w-full rounded-full bg-foreground/10"
/>
<span
aria-hidden="true"
className="mt-1.5 block h-1.5 w-2/3 rounded-full bg-foreground/10"
/>
{/* 打在卡片表面的高光,跟着光源一起走 */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-lg"
style={{
backgroundImage: `radial-gradient(circle at ${lightPosition}, color-mix(in oklab, var(--flow-amber) 24%, transparent), transparent 64%)`,
}}
/>
</div>
</div>
<div className="flex items-center justify-center p-3">
<XyPad
label="光源"
tone="amber"
value={light}
onValueChange={setLight}
x={{ min: 0, max: 100, step: 1, label: "水平", format: percent }}
y={{ min: 0, max: 100, step: 1, label: "高度", format: percent }}
className="mx-auto w-full max-w-56 @min-[26rem]:max-w-none"
/>
</div>
</div>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/xy-pad.json用法
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 各是一个轴对象,字段都一样:
| 字段 | 默认 | 说明 |
|---|---|---|
min | 0 | 最小值 |
max | 100 | 最大值 |
step | 1 | 步长,落点按它对齐 |
label | X/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 按积极 / 消极换成绿 / 玫红,颜色只是加强,名称本身已经把状态说清楚。
"use client"
import * as React from "react"
import { flowToneStyle } from "@/components/ui/flow-diagram/tone"
import { XyPad } from "@/components/ui/xy-pad/xy-pad"
type Mood = { x: number; y: number }
const DEFAULT_MOOD: Mood = { x: 38, y: 72 }
/** 四象限的中文名:横轴平静 → 激动,纵轴消极 → 积极 */
function describe({ x, y }: Mood) {
if (x < 50)
return y >= 50
? { name: "从容", note: "平静 · 积极" }
: { name: "低落", note: "平静 · 消极" }
return y >= 50
? { name: "雀跃", note: "激动 · 积极" }
: { name: "烦躁", note: "激动 · 消极" }
}
/**
* 示例:情绪坐标。两条轴各带自己的 `label` 与取值范围,纵轴往上变大;
* 板子右上角的中文象限名随位置变化,色调也按积极 / 消极换成绿 / 玫红。
*/
export default function XyPadMoodDemo() {
const [mood, setMood] = React.useState(DEFAULT_MOOD)
const quadrant = describe(mood)
const tone = mood.y >= 50 ? "green" : "rose"
return (
<div
className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs"
style={flowToneStyle(tone)}
>
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-medium">今天的情绪</p>
<p className="shrink-0 text-xs font-medium text-(--flow-tone)">
{quadrant.name}
</p>
</div>
<div className="mt-3 grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1.5">
{/* 纵轴标签:上积极、下消极,与拖拽板上下对齐 */}
<div className="flex flex-col justify-between py-1 text-[11px] text-muted-foreground">
<span>积极</span>
<span>消极</span>
</div>
<XyPad
label="记录一下"
tone={tone}
value={mood}
onValueChange={setMood}
x={{ min: 0, max: 100, step: 1, label: "激动程度" }}
y={{ min: 0, max: 100, step: 1, label: "积极程度" }}
/>
{/* 横轴标签:左平静、右激动 */}
<span aria-hidden="true" />
<div className="flex justify-between text-[11px] text-muted-foreground">
<span>平静</span>
<span>激动</span>
</div>
</div>
<p className="mt-3 border-t border-edge pt-3 text-[11px] text-muted-foreground">
{quadrant.note} · 方向键也能调
</p>
</div>
)
}
视角面板
二维拖拽板放进控制面板当「视角」:横轴转朝向、纵轴转俯仰,一次拖出任意机位;下面一条数值滑块单独控制透视距离。板子保持默认的正方形,两条轴的 format 补上度数,modified 标出改过的项,标题栏的复制按钮把当前视角写成 JSX。
"use client"
import * as React from "react"
import {
ControlPanel,
ControlPanelActions,
ControlPanelCopy,
ControlPanelGroup,
ControlPanelHeader,
ControlPanelReset,
ControlPanelTitle,
formatJsx,
} from "@/components/ui/control-panel/control-panel"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
import { XyPad } from "@/components/ui/xy-pad/xy-pad"
type View = { yaw: number; pitch: number; perspective: number }
const DEFAULTS: View = { yaw: -32, pitch: 18, perspective: 900 }
/** 立方体的六个面:同一色调配不同深浅,转到任何角度都能分清前后 */
const FACES = [
{ name: "front", tint: 20, transform: "translateZ(4rem)" },
{ name: "back", tint: 5, transform: "rotateY(180deg) translateZ(4rem)" },
{ name: "right", tint: 34, transform: "rotateY(90deg) translateZ(4rem)" },
{ name: "left", tint: 26, transform: "rotateY(-90deg) translateZ(4rem)" },
{ name: "top", tint: 9, transform: "rotateX(90deg) translateZ(4rem)" },
{ name: "bottom", tint: 5, transform: "rotateX(-90deg) translateZ(4rem)" },
] as const
const degrees = (value: number) => `${value}°`
const pixels = (value: number) => `${value}px`
/**
* 示例:把二维拖拽板放进控制面板当「视角」——横轴转朝向、纵轴转俯仰,两者一起调出任意机位;
* 下面一条数值滑块单独控制透视距离。面板标题栏可以把当前视角复制成一段 JSX。
*/
export default function XyPadViewDemo() {
const [view, setView] = React.useState<View>(DEFAULTS)
const set = (patch: Partial<View>) =>
setView((current) => ({ ...current, ...patch }))
/** 只把改过的项写进代码 */
const code = () => {
const props: Record<string, unknown> = {}
if (view.yaw !== DEFAULTS.yaw) props.yaw = view.yaw
if (view.pitch !== DEFAULTS.pitch) props.pitch = view.pitch
if (view.perspective !== DEFAULTS.perspective)
props.perspective = view.perspective
return formatJsx("Cube", props)
}
return (
<div className="@container w-full max-w-xl">
<div className="grid overflow-hidden rounded-xl border border-edge bg-background shadow-xs @min-[32rem]:grid-cols-[minmax(0,1fr)_15rem]">
<div className="relative flex min-h-56 items-center justify-center border-b border-edge bg-muted/20 p-6 @min-[32rem]:border-e @min-[32rem]:border-b-0">
<div
role="img"
aria-label={`立方体,朝向 ${view.yaw}°,俯仰 ${view.pitch}°`}
className="relative size-32"
style={{ perspective: `${view.perspective}px` }}
>
<div
className="size-full transition-transform duration-150 ease-out [transform-style:preserve-3d] motion-reduce:transition-none"
style={{
transform: `rotateX(${-view.pitch}deg) rotateY(${view.yaw}deg)`,
}}
>
{FACES.map((face) => (
<span
key={face.name}
className="absolute inset-0 border border-edge"
style={{
transform: face.transform,
backgroundColor: `color-mix(in oklab, var(--flow-violet) ${face.tint}%, var(--background))`,
}}
/>
))}
</div>
</div>
</div>
<ControlPanel className="rounded-none border-0">
<ControlPanelHeader>
<ControlPanelTitle>视角</ControlPanelTitle>
<ControlPanelActions>
<ControlPanelCopy label="复制视角参数" code={code} />
<ControlPanelReset onClick={() => setView(DEFAULTS)} />
</ControlPanelActions>
</ControlPanelHeader>
<ControlPanelGroup>
<XyPad
label="机位"
tone="violet"
className="mx-auto w-full max-w-60 @min-[32rem]:max-w-none"
grid={4}
value={{ x: view.yaw, y: view.pitch }}
onValueChange={({ x, y }) => set({ yaw: x, pitch: y })}
x={{
min: -60,
max: 60,
step: 1,
label: "朝向",
format: degrees,
}}
y={{
min: -45,
max: 45,
step: 1,
label: "俯仰",
format: degrees,
}}
modified={
view.yaw !== DEFAULTS.yaw || view.pitch !== DEFAULTS.pitch
}
/>
<ValueSlider
label="透视"
tone="violet"
min={400}
max={1600}
step={50}
largeStep={200}
value={view.perspective}
onValueChange={(perspective) => set({ perspective })}
formatValue={pixels}
modified={view.perspective !== DEFAULTS.perspective}
/>
</ControlPanelGroup>
</ControlPanel>
</div>
</div>
)
}
API
XyPad
受控值
类型XyPadValue | undefined非受控时的初始值;不传时取两轴的中点
类型XyPadValue | undefined((value: XyPadValue) => void) | undefined拖动松手、键盘每次变更后触发
类型((value: XyPadValue) => void) | undefined横轴:往右变大
类型XyPadAxis | undefined纵轴:往上变大(与屏幕坐标相反,符合「抬高」的直觉)
类型XyPadAxis | undefined左上角标签,同时作为整组的可访问名称
类型React.ReactNode背景网格每个方向分几格
类型number | undefined4色调:十字线、手柄与填充取 --flow-tone;不传时用前景色
FlowTone | undefined标记为「已改动」:标签与读数改用前景色,根元素带 data-modified
boolean | undefinedfalseboolean | undefined默认两条轴都是 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-*变量。
源码
"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 }
"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 }