"use client"
import * as React from "react"
import {
ControlPanel,
ControlPanelActions,
ControlPanelCopy,
ControlPanelGroup,
ControlPanelHeader,
ControlPanelReset,
ControlPanelTitle,
formatJsx,
} from "@/components/ui/control-panel/control-panel"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import { TonePicker, toneOptions } from "@/components/ui/tone-picker/tone-picker"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
/** 名片卡的可调参数;默认值同时用来判断哪几项改过(复制的代码只写改动过的属性) */
type CardConfig = {
radius: number
padding: number
elevation: number
layout: "row" | "column"
tone: FlowTone
border: boolean
}
const DEFAULTS: CardConfig = {
radius: 14,
padding: 20,
elevation: 28,
layout: "row",
tone: "violet",
border: true,
}
const KEYS = Object.keys(DEFAULTS) as (keyof CardConfig)[]
const LAYOUT_OPTIONS = [
{ value: "row", label: "横排" },
{ value: "column", label: "竖排" },
] as const
// 名片卡只用五个色调,色点少一些更好点
const TONE_OPTIONS = toneOptions([
"primary",
"blue",
"violet",
"amber",
"green",
])
const formatPx = (value: number) => `${value}px`
const formatPercent = (value: number) => `${value}%`
/** 阴影深度换算成 `box-shadow`:越深抬得越高、散得越开、也越浓 */
function elevationShadow(elevation: number) {
if (elevation <= 0) return "none"
const y = Math.round(elevation / 9)
const blur = Math.round(elevation / 1.6)
const spread = Math.round(elevation / 14)
const alpha = Math.round(elevation / 3.2)
return `0 ${y}px ${blur}px ${spread}px color-mix(in oklab, var(--foreground) ${alpha}%, transparent)`
}
/** 只把改过的属性写进代码;一个都没改时是一句自闭合标签 */
function buildCode(config: CardConfig) {
const props: Record<string, unknown> = {}
for (const key of KEYS) {
if (config[key] !== DEFAULTS[key]) props[key] = config[key]
}
return formatJsx("ProfileCard", props)
}
/** 被调的那张名片:圆角、内边距、阴影、色调、朝向、边框全部来自面板 */
function ProfileCard({ config }: { config: CardConfig }) {
const row = config.layout === "row"
return (
<div
className="w-full max-w-60"
style={{
...flowToneStyle(config.tone),
borderRadius: config.radius,
padding: config.padding,
boxShadow: elevationShadow(config.elevation),
borderStyle: "solid",
borderWidth: 1,
borderColor: config.border
? "color-mix(in oklab, var(--flow-tone) 34%, var(--edge))"
: "transparent",
backgroundColor:
"color-mix(in oklab, var(--flow-tone) 6%, var(--background))",
}}
>
<div
className={
row
? "flex items-center gap-3"
: "flex flex-col items-center gap-2 text-center"
}
>
<span
aria-hidden="true"
className="grid size-10 shrink-0 place-items-center rounded-full text-sm font-medium text-(--flow-tone)"
style={{
backgroundColor:
"color-mix(in oklab, var(--flow-tone) 16%, var(--background))",
boxShadow:
"inset 0 0 0 1px color-mix(in oklab, var(--flow-tone) 34%, transparent)",
}}
>
易
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium">林知远</p>
<p className="truncate text-xs text-muted-foreground">
产品设计师 · 杭州
</p>
</div>
</div>
<div
className={
row
? "mt-3 flex flex-wrap items-center gap-1.5"
: "mt-3 flex flex-wrap items-center justify-center gap-1.5"
}
>
<span className="inline-flex items-center gap-1 rounded-full border border-edge bg-background/60 px-2 py-0.5 text-[11px] text-muted-foreground">
<span
aria-hidden="true"
className="size-1.5 rounded-full bg-(--flow-tone)"
/>
可接新项目
</span>
<span className="rounded-full border border-edge bg-background/60 px-2 py-0.5 text-[11px] text-muted-foreground">
作品 24
</span>
</div>
</div>
)
}
/**
* 默认示例:左边一张实时预览的名片卡,右边是控制面板本体——三个可输入的数值滑块、
* 布局与色调的选择、一个开关,全部用默认的 `bar` 高度(32px)排进一列,方便点按。
* 面板里的控件也跟着当前色调着色,换一个色点,预览和面板一起变;
* 改过的项标上 `modified`,标题栏的复制按钮把当前参数写成 JSX。
*/
export default function ControlPanelDemo() {
const [config, setConfig] = React.useState<CardConfig>(DEFAULTS)
const set = (patch: Partial<CardConfig>) =>
setConfig((current) => ({ ...current, ...patch }))
const isModified = (key: keyof CardConfig) => config[key] !== DEFAULTS[key]
return (
// 用容器查询而不是视口断点:列表缩略图里这块只有 300 多像素宽,不能跟着视口分成两栏
<div className="@container w-full max-w-2xl">
<div className="grid overflow-hidden rounded-xl border border-edge bg-background @min-[30rem]:grid-cols-[minmax(0,1fr)_17rem]">
<div
className="relative flex min-h-44 items-center justify-center border-b border-edge bg-muted/20 p-6 @min-[30rem]:border-e @min-[30rem]:border-b-0"
// 预览区底下一层当前色调的光晕,换色调时整块跟着变
style={{
...flowToneStyle(config.tone),
backgroundImage:
"radial-gradient(circle at 50% 45%, color-mix(in oklab, var(--flow-tone) 16%, transparent), transparent 70%)",
}}
>
<ProfileCard config={config} />
</div>
<ControlPanel className="rounded-none border-0">
<ControlPanelHeader>
<ControlPanelTitle>名片卡</ControlPanelTitle>
<ControlPanelActions>
<ControlPanelCopy
label="复制名片卡参数"
code={() => buildCode(config)}
/>
<ControlPanelReset onClick={() => setConfig(DEFAULTS)} />
</ControlPanelActions>
</ControlPanelHeader>
<ControlPanelGroup>
<ValueSlider
className="text-xs"
editable
modified={isModified("radius")}
tone={config.tone}
label="圆角"
min={0}
max={28}
value={config.radius}
onValueChange={(radius) => set({ radius })}
formatValue={formatPx}
/>
<ValueSlider
className="text-xs"
editable
modified={isModified("padding")}
tone={config.tone}
label="内边距"
min={8}
max={32}
value={config.padding}
onValueChange={(padding) => set({ padding })}
formatValue={formatPx}
/>
<ValueSlider
className="text-xs"
editable
modified={isModified("elevation")}
tone={config.tone}
label="阴影"
min={0}
max={100}
value={config.elevation}
onValueChange={(elevation) => set({ elevation })}
formatValue={formatPercent}
/>
<SegmentedControl
className="text-xs"
label="布局"
options={LAYOUT_OPTIONS}
value={config.layout}
modified={isModified("layout")}
tone={config.tone}
onValueChange={(layout) =>
set({ layout: layout as CardConfig["layout"] })
}
/>
<TonePicker
className="text-xs"
label="色调"
options={TONE_OPTIONS}
value={config.tone}
modified={isModified("tone")}
onValueChange={(tone) => set({ tone: tone as FlowTone })}
/>
<ToggleSwitch
className="text-xs"
label="边框"
checked={config.border}
modified={isModified("border")}
tone={config.tone}
onCheckedChange={(border) => set({ border })}
/>
</ControlPanelGroup>
</ControlPanel>
</div>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/control-panel.json用法
面板只负责摆放,不持有任何状态:值由你管理,onValueChange 里改你自己的 state,面板跟着重渲染。
import { useState } 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"
const DEFAULTS = { radius: 12 }
export function CardPanel() {
const [radius, setRadius] = useState(DEFAULTS.radius)
return (
<ControlPanel className="max-w-sm">
<ControlPanelHeader>
<ControlPanelTitle>卡片</ControlPanelTitle>
<ControlPanelActions>
<ControlPanelCopy
code={() =>
formatJsx("Card", radius === DEFAULTS.radius ? {} : { radius })
}
/>
<ControlPanelReset onClick={() => setRadius(DEFAULTS.radius)} />
</ControlPanelActions>
</ControlPanelHeader>
<ControlPanelGroup>
<ValueSlider
label="圆角"
min={0}
max={32}
value={radius}
onValueChange={setRadius}
/>
</ControlPanelGroup>
</ControlPanel>
)
}三个部分
ControlPanel 是带细边框的竖向容器(rounded-lg border border-edge bg-background)。里面通常放两样东西:
ControlPanelHeader:高 40px 的标题栏,底部一条分隔线。左侧放ControlPanelTitle,右侧用ControlPanelActions收拢按钮——它有ms-auto,自动推到末端。ControlPanelGroup:一组控件。title是小标题,columns决定宽屏下的列数,相邻分组之间由组件自己画一条上边框,不用手写<hr>。
ControlPanelCopy 与 ControlPanelReset 都是 icon-sm(28px)的幽灵按钮,直接塞进 ControlPanelActions 即可。前者把 code 写进剪贴板,成功后图标换成对勾、aria-live 播报「已复制」,1.5 秒后复原;后者只是一个按钮,恢复逻辑由你的 onClick 决定。
ControlPanelActions 就是普通容器,可以往里放自己的按钮(文字按钮用 size="sm",与图标按钮同一档高度)。
复制为代码
formatJsx 把组件名与一组属性写成 JSX 字符串,专为「复制当前参数」这一步准备:
import { formatJsx } from "@/components/ui/control-panel/control-panel"
formatJsx("Card", { radius: 12, shadow: "soft" })
// <Card radius={12} shadow="soft" />
formatJsx("Card", { radius: 12 }, { width: 16 })
// <Card
// radius={12}
// />规则:
undefined与null的属性直接省略,所以只传「与默认值不同的项」就得到一句干净的标签;true写成裸属性名(<Card bordered />),false写成{false};- 字符串优先用双引号,含双引号或换行时改成
{"..."};数字最多保留四位小数(去掉浮点尾差); - 数组与对象用
JSON.stringify; - 一行放得下(默认不超过 72 个字符)就写一行,否则一个属性一行;能放下的空属性列表输出自闭合标签。
code 也可以传函数,点击那一刻才求值——面板上的值变化很频繁,传函数能保证复制到的是最新值,也省掉每次渲染重新拼字符串。
示例
分组
三个 ControlPanelGroup,各自带 title,中间的分隔线由组件画。columns={2} 让两个窄控件并排;只放一个控件时用 sm:col-span-2 跨满整行。文件里的 SegmentedControl、ToggleSwitch、ValueSlider 都是同一套方条的默认 bar 档(32px),排在一起高度一致,也足够好点。
"use client"
import * as React from "react"
import {
ControlPanel,
ControlPanelActions,
ControlPanelCopy,
ControlPanelGroup,
ControlPanelHeader,
ControlPanelReset,
ControlPanelTitle,
formatJsx,
} from "@/components/ui/control-panel/control-panel"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
type Settings = {
width: number
height: number
format: string
quality: number
lossless: boolean
position: string
opacity: number
}
const DEFAULTS: Settings = {
width: 1600,
height: 900,
format: "webp",
quality: 82,
lossless: false,
position: "center",
opacity: 60,
}
const FORMAT_OPTIONS = [
{ value: "png", label: "PNG" },
{ value: "webp", label: "WebP" },
{ value: "avif", label: "AVIF" },
] as const
const POSITION_OPTIONS = [
{ value: "corner", label: "右下角" },
{ value: "center", label: "居中" },
{ value: "tile", label: "平铺" },
] as const
const formatPx = (value: number) => `${value}px`
const formatPercent = (value: number) => `${value}%`
/** 只把改过的项写进代码 */
function buildCode(settings: Settings) {
const props: Record<string, unknown> = {}
for (const key of Object.keys(DEFAULTS) as (keyof Settings)[]) {
if (settings[key] !== DEFAULTS[key]) props[key] = settings[key]
}
return formatJsx("ImageExport", props)
}
/**
* 分组示例:三个 `ControlPanelGroup`,各自带小标题,相邻分组之间自动画一条分隔线。
* `columns={2}` 让两个窄控件并排;一行只放一个控件时用 `sm:col-span-2` 跨满两列。
*/
export default function ControlPanelGroupsDemo() {
const [settings, setSettings] = React.useState<Settings>(DEFAULTS)
const set = (patch: Partial<Settings>) =>
setSettings((current) => ({ ...current, ...patch }))
const isModified = (key: keyof Settings) => settings[key] !== DEFAULTS[key]
return (
<ControlPanel className="w-full max-w-md">
<ControlPanelHeader>
<ControlPanelTitle>图片导出</ControlPanelTitle>
<ControlPanelActions>
<ControlPanelCopy
label="复制导出参数"
code={() => buildCode(settings)}
/>
<ControlPanelReset onClick={() => setSettings(DEFAULTS)} />
</ControlPanelActions>
</ControlPanelHeader>
<ControlPanelGroup title="画布" columns={2}>
<ValueSlider
className="text-xs"
editable
label="宽"
min={320}
max={2560}
step={16}
largeStep={160}
value={settings.width}
modified={isModified("width")}
onValueChange={(width) => set({ width })}
formatValue={formatPx}
/>
<ValueSlider
className="text-xs"
editable
label="高"
min={320}
max={1440}
step={16}
largeStep={160}
value={settings.height}
modified={isModified("height")}
onValueChange={(height) => set({ height })}
formatValue={formatPx}
/>
</ControlPanelGroup>
<ControlPanelGroup title="输出" columns={2}>
<SegmentedControl
className="text-xs sm:col-span-2"
label="格式"
options={FORMAT_OPTIONS}
value={settings.format}
modified={isModified("format")}
onValueChange={(format) => set({ format })}
/>
<ValueSlider
className="text-xs"
editable
label="质量"
min={40}
max={100}
value={settings.quality}
modified={isModified("quality")}
onValueChange={(quality) => set({ quality })}
formatValue={formatPercent}
/>
<ToggleSwitch
className="text-xs"
label="无损"
checked={settings.lossless}
modified={isModified("lossless")}
onCheckedChange={(lossless) => set({ lossless })}
/>
</ControlPanelGroup>
<ControlPanelGroup title="水印" columns={2}>
<SegmentedControl
className="text-xs sm:col-span-2"
label="位置"
options={POSITION_OPTIONS}
value={settings.position}
modified={isModified("position")}
onValueChange={(position) => set({ position })}
/>
<ValueSlider
className="text-xs sm:col-span-2"
editable
label="不透明度"
min={0}
max={100}
value={settings.opacity}
modified={isModified("opacity")}
onValueChange={(opacity) => set({ opacity })}
formatValue={formatPercent}
/>
</ControlPanelGroup>
</ControlPanel>
)
}
标题栏操作
ControlPanelActions 里除了复制与恢复,还能放自己的按钮:这里是一个「应用」文字按钮,面板改的是草稿,按下才写进左边的预览,没改动时禁用。面板下面把 formatJsx 的输出直接摊开,属性多的时候会按 width 自动换行。
"use client"
import * as React from "react"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
ControlPanel,
ControlPanelActions,
ControlPanelCopy,
ControlPanelGroup,
ControlPanelHeader,
ControlPanelReset,
ControlPanelTitle,
formatJsx,
} from "@/components/ui/control-panel/control-panel"
import { flowToneStyle } from "@/components/ui/flow-diagram/tone"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
type CoverSettings = { width: number; height: number; radius: number }
const DEFAULTS: CoverSettings = { width: 720, height: 480, radius: 12 }
// 一进来就有两项改过但还没应用:左边的预览仍是默认尺寸,下面代码区已经是改过的属性
const INITIAL: CoverSettings = { width: 1080, height: 480, radius: 20 }
const formatPx = (value: number) => `${value}px`
/** 只把改过的项写进代码;`width` 收窄到 24 个字符,演示超出时自动换行 */
function buildCode(settings: CoverSettings) {
const props: Record<string, unknown> = {}
for (const key of Object.keys(DEFAULTS) as (keyof CoverSettings)[]) {
if (settings[key] !== DEFAULTS[key]) props[key] = settings[key]
}
return formatJsx("CoverCard", props, { width: 24 })
}
/**
* 标题栏示例:`ControlPanelActions` 就是一个普通容器,除了复制与恢复,还能放自己的按钮——
* 这里是「应用」:面板改的是草稿,按下才写进左边的预览,没改动时按钮禁用。
* 面板下面直接把 `formatJsx` 的输出摊开,能看到多行缩进的样子。
*/
export default function ControlPanelActionsDemo() {
const [draft, setDraft] = React.useState<CoverSettings>(INITIAL)
const [applied, setApplied] = React.useState<CoverSettings>(DEFAULTS)
const dirty =
draft.width !== applied.width ||
draft.height !== applied.height ||
draft.radius !== applied.radius
const set = (patch: Partial<CoverSettings>) =>
setDraft((current) => ({ ...current, ...patch }))
return (
<div className="w-full max-w-md">
<div className="flex h-36 items-center justify-center rounded-xl border border-edge bg-muted/20 p-3">
<div
className="grid h-full place-items-center bg-(--flow-tone)/12 ring-1 ring-(--flow-tone)/30 ring-inset"
style={{
...flowToneStyle("violet"),
aspectRatio: `${applied.width} / ${applied.height}`,
borderRadius: applied.radius,
}}
>
<span className="font-mono text-[11px] text-muted-foreground">
{applied.width} × {applied.height}
</span>
</div>
</div>
<ControlPanel className="mt-3">
<ControlPanelHeader>
<ControlPanelTitle>封面尺寸</ControlPanelTitle>
<ControlPanelActions>
<button
type="button"
disabled={!dirty}
onClick={() => setApplied(draft)}
className={cn(
buttonVariants({ variant: "ghost", size: "sm" }),
"text-xs disabled:opacity-40",
)}
>
应用
</button>
<ControlPanelCopy
label="复制封面参数"
code={() => buildCode(draft)}
/>
<ControlPanelReset onClick={() => setDraft(DEFAULTS)} />
</ControlPanelActions>
</ControlPanelHeader>
<ControlPanelGroup>
<ValueSlider
className="text-xs"
editable
label="宽度"
min={320}
max={1440}
step={8}
largeStep={80}
value={draft.width}
modified={draft.width !== DEFAULTS.width}
onValueChange={(width) => set({ width })}
formatValue={formatPx}
/>
<ValueSlider
className="text-xs"
editable
label="高度"
min={240}
max={1080}
step={8}
largeStep={80}
value={draft.height}
modified={draft.height !== DEFAULTS.height}
onValueChange={(height) => set({ height })}
formatValue={formatPx}
/>
<ValueSlider
className="text-xs"
editable
label="圆角"
min={0}
max={48}
value={draft.radius}
modified={draft.radius !== DEFAULTS.radius}
onValueChange={(radius) => set({ radius })}
formatValue={formatPx}
/>
</ControlPanelGroup>
</ControlPanel>
<div className="mt-2 overflow-x-auto rounded-lg border border-edge bg-muted/30 p-3 font-mono text-[11px] leading-5 whitespace-pre text-muted-foreground">
{buildCode(draft)}
</div>
</div>
)
}
API
ControlPanel
ControlPanel 没有自定义 Props;该组件继承原生元素的全部属性。
ControlPanelHeader
ControlPanelHeader 没有自定义 Props;该组件继承原生元素的全部属性。
ControlPanelTitle
<span>,默认 text-xs 的次要色文字,过长时省略。需要标题语义时在外层自己加 <h2>,或给 ControlPanel 加 aria-label。
ControlPanelTitle 没有自定义 Props;该组件继承原生元素的全部属性。
ControlPanelActions
ControlPanelActions 没有自定义 Props;该组件继承原生元素的全部属性。
ControlPanelGroup
分组小标题;不传时不显示
类型string | (string & Iterable<React.ReactNode>) | (string & Promise<AwaitedReactNode>) | (string & React.ReactElement<unknown, string | React.JSXElementConstructor<any>>) | (string & React.ReactPortal) | undefined宽屏下的列数,窄屏(< 640px)一律单列。子控件用 sm:col-span-2 之类跨列
1 | 2 | 3 | undefined1columns 只在 640px 以上生效,窄屏一律单列;组内的子元素是网格项,用 sm:col-span-2 / sm:col-span-3 跨列。子级容器需要 min-w-0,否则里面的 truncate 撑不开。
ControlPanelCopy
要复制的文本;传函数时在点击那一刻才求值
类型string | (() => string)按钮的可访问名称
类型string | undefined"复制代码"复制成功后的提示(读屏会朗读)
类型string | undefined"已复制"ControlPanelReset
按钮的可访问名称
类型string | undefined"恢复默认"formatJsx
function formatJsx(
component: string,
props: Record<string, unknown>,
options?: { width?: number; children?: string },
): stringwidth 默认 72。传 children 时输出带子内容的完整标签(内容缩进两格),不传时是自闭合标签。
类型
type ControlPanelGroupProps = React.ComponentProps<"div"> & {
title?: React.ReactNode
/** 宽屏下的列数,窄屏一律单列
* @default 1 */
columns?: 1 | 2 | 3
}
type ControlPanelCopyProps = Omit<
React.ComponentProps<"button">,
"onClick" | "children"
> & {
/** 要复制的文本;传函数时在点击那一刻才求值 */
code: string | (() => string)
/** 按钮的可访问名称
* @default "复制代码" */
label?: string
/** 复制成功后的提示,读屏会朗读
* @default "已复制" */
copiedLabel?: string
}
type ControlPanelResetProps = React.ComponentProps<"button"> & {
/** 按钮的可访问名称
* @default "恢复默认" */
label?: string
}
type FormatJsxOptions = {
/** 单行的最大宽度
* @default 72 */
width?: number
/** 写在标签里的子内容;不传时输出自闭合标签 */
children?: string
}data-slot
| 元素 | data-slot |
|---|---|
| 根元素 | control-panel |
| 标题栏 | control-panel-header |
| 标题 | control-panel-title |
| 标题栏操作区 | control-panel-actions |
| 分组 | control-panel-group |
| 分组小标题 | control-panel-group-title |
| 分组网格 | control-panel-grid |
| 复制按钮 | control-panel-copy |
| 恢复按钮 | control-panel-reset |
复制按钮在复制成功后的 1.5 秒里带 data-copied,可以据此加样式。
可访问性
ControlPanelTitle是<span>:它只是视觉上的标题,不进文档大纲。需要让读屏知道这块面板是什么,给ControlPanel传aria-label或aria-labelledby。ControlPanelGroup渲染role="group",有小标题时用aria-labelledby关联到标题,读屏在组内切换时会念出组名。ControlPanelCopy与ControlPanelReset是纯图标按钮,必须有名称:label同时写进aria-label与title(悬停提示)。- 复制结果放在
aria-live="polite"的sr-only元素里播报,视觉上只有图标变化。 - 两个按钮都是
icon-sm(28px),与ControlPanelActions里自己加的文字按钮保持同一档高度。
注意事项
- 面板不持有状态,也不提供
onReset。恢复默认由ControlPanelReset的onClick交回给你,因为默认值只有你知道。 navigator.clipboard只在安全上下文(https 或 localhost)可用,不可用时复制静默失败,不弹错、不改图标。需要兼容时自己判断navigator.clipboard再决定是否渲染复制按钮。- 复制按钮不依赖
Button组件,只用它的buttonVariants;安装本条目时会一并拉取button。 - 相邻分组之间的分隔线由
[[data-slot=control-panel-group]+&]这条相邻选择器画出。自己往面板里插别的元素时,分隔线不会出现在那个元素与分组之间。 - 面板本身没有内边距,分组自己带
p-3。把面板嵌进别的卡片时,用className="rounded-none border-0"去掉自身的边框与圆角。
源码
"use client"
import * as React from "react"
import { Check, Copy, RotateCcw } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
/**
* 控制面板:承载数值滑块、分段选择、开关、色调选择这一套方条控件的容器。
* 由标题栏(标题 + 操作)和若干分组组成,分组内是等宽网格;复制按钮把当前参数写成 JSX。
*/
function ControlPanel({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="control-panel"
className={cn(
"flex w-full min-w-0 flex-col rounded-lg border border-edge bg-background text-sm",
className,
)}
{...props}
/>
)
}
function ControlPanelHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="control-panel-header"
className={cn(
"flex h-10 shrink-0 items-center gap-2 border-b border-edge ps-3 pe-1.5",
className,
)}
{...props}
/>
)
}
function ControlPanelTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="control-panel-title"
className={cn(
"truncate text-xs font-medium text-muted-foreground",
className,
)}
{...props}
/>
)
}
/** 标题栏右侧的操作区,放复制、恢复等图标按钮 */
function ControlPanelActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="control-panel-actions"
className={cn("ms-auto flex shrink-0 items-center gap-0.5", className)}
{...props}
/>
)
}
type ControlPanelGroupProps = React.ComponentProps<"div"> & {
/** 分组小标题;不传时不显示 */
title?: React.ReactNode
/**
* 宽屏下的列数,窄屏(< 640px)一律单列。子控件用 `sm:col-span-2` 之类跨列
* @default 1
*/
columns?: 1 | 2 | 3
}
const COLUMNS = {
1: "",
2: "sm:grid-cols-2",
3: "sm:grid-cols-3",
} as const
/** 一组控件:可选的小标题 + 网格。相邻分组之间自动画一条分隔线。 */
function ControlPanelGroup({
title,
columns = 1,
className,
children,
...props
}: ControlPanelGroupProps) {
const titleId = React.useId()
return (
<div
role="group"
aria-labelledby={title !== undefined ? titleId : undefined}
data-slot="control-panel-group"
className={cn(
"flex flex-col gap-2.5 px-4 pt-3 pb-4 [[data-slot=control-panel-group]+&]:border-t [[data-slot=control-panel-group]+&]:border-edge",
className,
)}
{...props}
>
{title !== undefined ? (
<span
id={titleId}
data-slot="control-panel-group-title"
className="text-xs text-muted-foreground"
>
{title}
</span>
) : null}
<div
data-slot="control-panel-grid"
className={cn("grid gap-x-4 gap-y-3", COLUMNS[columns])}
>
{children}
</div>
</div>
)
}
type ControlPanelCopyProps = Omit<
React.ComponentProps<"button">,
"onClick" | "children"
> & {
/** 要复制的文本;传函数时在点击那一刻才求值 */
code: string | (() => string)
/**
* 按钮的可访问名称
* @default "复制代码"
*/
label?: string
/**
* 复制成功后的提示(读屏会朗读)
* @default "已复制"
*/
copiedLabel?: string
}
/** 复制按钮:成功后图标换成对勾 1.5 秒。剪贴板不可用(非安全上下文)时静默失败。 */
function ControlPanelCopy({
code,
label = "复制代码",
copiedLabel = "已复制",
className,
...props
}: ControlPanelCopyProps) {
const [copied, setCopied] = React.useState(false)
React.useEffect(() => {
if (!copied) return
const timer = window.setTimeout(() => setCopied(false), 1500)
return () => window.clearTimeout(timer)
}, [copied])
const copy = async () => {
try {
await navigator.clipboard.writeText(
typeof code === "function" ? code() : code,
)
setCopied(true)
} catch {
// 非 https 或用户拒绝授权时拿不到剪贴板,不打断操作
}
}
return (
<button
type="button"
aria-label={label}
title={label}
data-slot="control-panel-copy"
data-copied={copied ? "" : undefined}
onClick={copy}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"text-muted-foreground hover:text-foreground",
className,
)}
{...props}
>
{copied ? <Check /> : <Copy />}
<span aria-live="polite" className="sr-only">
{copied ? copiedLabel : ""}
</span>
</button>
)
}
type ControlPanelResetProps = React.ComponentProps<"button"> & {
/**
* 按钮的可访问名称
* @default "恢复默认"
*/
label?: string
}
function ControlPanelReset({
label = "恢复默认",
className,
...props
}: ControlPanelResetProps) {
return (
<button
type="button"
aria-label={label}
title={label}
data-slot="control-panel-reset"
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"text-muted-foreground hover:text-foreground",
className,
)}
{...props}
>
<RotateCcw />
</button>
)
}
export {
ControlPanel,
ControlPanelActions,
ControlPanelCopy,
ControlPanelGroup,
ControlPanelHeader,
ControlPanelReset,
ControlPanelTitle,
}
export type {
ControlPanelCopyProps,
ControlPanelGroupProps,
ControlPanelResetProps,
}
export { formatJsx } from "./format-jsx"
export type { FormatJsxOptions } from "./format-jsx"
/**
* 把组件名和一组属性写成 JSX 字符串,用于控制面板的「复制为代码」。
* - `undefined` / `null` 的属性省略;`true` 写成裸属性名,`false` 写成 `{false}`
* - 字符串优先用双引号,含双引号或换行时改成 `{"..."}`
* - 数字去掉浮点尾差(最多 4 位小数);数组与对象用 JSON
* - 一行放得下(不超过 `width` 个字符)就写一行,否则每个属性一行
*/
type FormatJsxOptions = {
/**
* 单行的最大宽度
* @default 72
*/
width?: number
/** 写在标签里的子内容;不传时输出自闭合标签 */
children?: string
}
function formatProp(name: string, value: unknown): string | null {
if (value === undefined || value === null) return null
if (value === true) return name
if (typeof value === "string") {
return /["\n]/.test(value)
? `${name}={${JSON.stringify(value)}}`
: `${name}="${value}"`
}
if (typeof value === "number") {
return `${name}={${Number(value.toFixed(4))}}`
}
return `${name}={${JSON.stringify(value)}}`
}
function formatJsx(
component: string,
props: Record<string, unknown>,
{ width = 72, children }: FormatJsxOptions = {},
) {
const parts = Object.entries(props)
.map(([name, value]) => formatProp(name, value))
.filter((part): part is string => part !== null)
const close = children === undefined ? " />" : `>${children}</${component}>`
const inline = `<${component}${parts.map((part) => ` ${part}`).join("")}${close}`
if (inline.length <= width || parts.length === 0) return inline
const lines = parts.map((part) => ` ${part}`).join("\n")
return children === undefined
? `<${component}\n${lines}\n/>`
: `<${component}\n${lines}\n>\n ${children}\n</${component}>`
}
export { formatJsx }
export type { FormatJsxOptions }