本站首页首屏右边有一块“调一调”面板:一块二维板调视角,两组分段选择换材质和地面,一排色点换色调,一个开关管音效。组件文档里每个示例下面也能展开一块调整面板,改完可以直接复制成 JSX。
这几块面板里的控件是同一套,外观、高度和行为一致。这篇讲它们怎么从一个数值滑块长出来,中间哪些设计被我推翻了,以及面板怎么把“当前参数”变成一段代码。
从一个滑块开始
起点是 value-slider,一个基于 Base UI Slider 的数值滑块:标签和读数放在轨道里面,拖出两端时轨道有橡皮筋式的过冲,松手后弹簧回弹。
做它的时候定下了一套外观,后来整个系列都沿用:
- 细边框
border-edge,圆角rounded-md,底色bg-muted/60; - 填充用色调变量
--flow-tone铺一层淡色,不指定色调时用前景色的 10%; - 手柄是一道竖线,不是圆点。
这个造型在视觉上更像仪表上的一格,而不是通用的圆形滑块。我给它起名“方条”。
一张高度表
方条做出来之后,下一步是让它们能放在同一个面板里。控件各自的高度不一样,一排下来参差不齐,所以先定一张高度表:
| 档位 | sm | default | lg |
|---|---|---|---|
bar | 28px | 32px | 36px |
compact | 18px | 22px | 24px |
bar 是默认高度,文字放在条里;compact 文字仍在条里,字号收到 xs。数值滑块另外有一档 slim(6 / 8 / 10px),轨道太矮放不下文字,标签挪到上方,竖线手柄比轨道高出一截。
实现上,每个控件用 cva 声明一组 compoundVariants,把“形态 × 尺寸”映射成一个 CSS 变量,样式只读这个变量:
compoundVariants: [
{
variant: "bar",
size: "default",
class: "[--toggle-switch-height:2rem] [--toggle-switch-knob:0.875rem]",
},
{
variant: "compact",
size: "default",
class: "[--toggle-switch-height:1.375rem] [--toggle-switch-knob:0.625rem]",
},
// …其余档位同理
],根元素写成 h-(--toggle-switch-height),手柄大小跟着 --toggle-switch-knob 走。每个控件的变量名不同,数字是同一张表。value-slider、segmented-control、toggle-switch、tone-picker、angle-dial 用的都是这套值,所以并排放在一个面板里自然对齐。
下面这个示例把三档高度并排放在一起:
"use client"
import * as React from "react"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
/** 像素单位与百分比的读数格式,定义在组件外,避免每次渲染重建 */
const formatPixels = (value: number) => `${value}px`
const formatPercent = (value: number) => `${value}%`
/** 画笔轨迹的采样点(viewBox 300 × 80)。固定值,每次构建都一样 */
const POINTS = [
[20, 50],
[65, 26],
[110, 54],
[155, 24],
[200, 54],
[245, 26],
[285, 48],
] as const
/**
* 把采样点连成三次贝塞尔:`tension` 取 0 时控制点落在端点上,画出来是折线;
* 取 1 时是 Catmull-Rom 的标准切线,拐角最软。平滑度滑块改的就是这个系数。
*/
function brushPath(tension: number) {
const step = (Math.min(100, Math.max(0, tension)) / 100) * (1 / 6)
let d = `M${POINTS[0][0]} ${POINTS[0][1]}`
for (let index = 0; index < POINTS.length - 1; index += 1) {
const previous = POINTS[index - 1] ?? POINTS[index]
const start = POINTS[index]
const end = POINTS[index + 1]
const next = POINTS[index + 2] ?? end
// 固定一位小数:坐标在服务端与客户端算出的字符串完全一致
const c1x = (start[0] + (end[0] - previous[0]) * step).toFixed(1)
const c1y = (start[1] + (end[1] - previous[1]) * step).toFixed(1)
const c2x = (end[0] - (next[0] - start[0]) * step).toFixed(1)
const c2y = (end[1] - (next[1] - start[1]) * step).toFixed(1)
d += ` C${c1x} ${c1y} ${c2x} ${c2y} ${end[0]} ${end[1]}`
}
return d
}
/**
* 轨道高度示例:一张画笔设置卡片,笔刷大小与不透明度用 `compact`(中等高度,文字仍在轨道里),
* 平滑度用 `slim`(矮轨 + 刻度,文字在上方)。三条滑块都带 `tone`,填充与手柄跟着色调走。
*/
export default function ValueSliderVariantDemo() {
const [brush, setBrush] = React.useState(12)
const [opacity, setOpacity] = React.useState(85)
const [smoothness, setSmoothness] = React.useState(68)
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-5 shadow-sm">
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-medium">画笔设置</p>
<p className="text-xs text-muted-foreground">铅笔 · 软头</p>
</div>
<div className="mt-3 grid h-20 place-items-center overflow-hidden rounded-lg border border-edge bg-muted/30">
<svg viewBox="0 0 300 80" aria-hidden="true" className="size-full">
<path
d={brushPath(smoothness)}
fill="none"
stroke="currentColor"
strokeWidth={brush}
strokeLinecap="round"
strokeLinejoin="round"
strokeOpacity={opacity / 100}
className="text-foreground"
/>
</svg>
</div>
{/* 每条滑块占固定高度:调整面板换尺寸时卡片高度不变(换到 slim 时文字移到上方,会多占一行) */}
<div className="mt-4 flex flex-col gap-2">
<div className="flex h-8 items-center">
<ValueSlider
label="笔刷大小"
variant="compact"
tone="amber"
min={1}
max={24}
step={1}
value={brush}
onValueChange={setBrush}
formatValue={formatPixels}
/>
</div>
<div className="flex h-8 items-center">
<ValueSlider
label="不透明度"
variant="compact"
tone="violet"
value={opacity}
onValueChange={setOpacity}
formatValue={formatPercent}
/>
</div>
<div className="flex h-12 items-center">
<ValueSlider
label="平滑度"
variant="slim"
tone="cyan"
value={smoothness}
onValueChange={setSmoothness}
formatValue={formatPercent}
ticks={4}
/>
</div>
</div>
</div>
)
}
被我否决的设计
pill 和 line
高度表的第一版并不是现在这样。数值滑块的 size 三档只差 28 / 32 / 36px,看起来几乎一样高,我觉得不够有区分度,于是加了一个 variant:
pill:8 / 10 / 12px 的圆角粗轨,实色填充,圆形手柄;line:2 / 4 / 4px 的细线,同样是圆形手柄。
做出来才发现,这两种和通用的滑块没有区别,方条的辨识度丢了,也和后面要补的开关、分段选择对不上。这两个变体在同一天里被我撤掉,换成了现在的 bar / compact / slim:三档都是方条,细边框、淡色分层填充、竖线手柄都不变,只是高度不同。
分段选择的标题
segmented-control 的第一版,标签是方条左边一个带竖线分隔的独立格子,后面才是选项。用起来发现标题容易被当成第一个选项。
现在的结构是:左侧是纯文字的字段名,和滑块、开关的标签同一个位置同一个样式;选项放在右侧一道下凹的槽里,选中的那一项是槽里凸起的一块。不写 label 时,整条都是选项。
后来我又把槽和选中块上的描边去掉了:外框、凹槽、选中块三层线叠在一起太杂。现在凹槽只用浅底色,选中块用背景色加一点轻阴影浮起来。
"use client"
import * as React from "react"
import { LayoutGrid, List, SquareKanban } from "lucide-react"
import { flowToneStyle } from "@/components/ui/flow-diagram/tone"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
const VIEW_OPTIONS = [
{ value: "list", label: "列表", icon: <List /> },
{ value: "grid", label: "网格", icon: <LayoutGrid /> },
{ value: "board", label: "看板", icon: <SquareKanban /> },
] as const
/** 固定的素材数据,六条刚好排满三种视图 */
const ASSETS = [
{ name: "品牌手册", meta: "12 页", tone: "blue" },
{ name: "首页插画", meta: "8 层", tone: "violet" },
{ name: "图标集", meta: "48 个", tone: "amber" },
{ name: "字体样本", meta: "6 字重", tone: "green" },
{ name: "动效片段", meta: "2.4 秒", tone: "rose" },
{ name: "素材打包", meta: "320 MB", tone: "cyan" },
] as const
// 看板三栏:每栏两条,素材按下标分配
const COLUMNS = [
{ title: "草稿", tint: "bg-foreground/15" },
{ title: "审核中", tint: "bg-(--flow-amber)" },
{ title: "已发布", tint: "bg-(--flow-green)" },
] as const
function AssetList() {
return (
<ul className="flex h-full flex-col justify-between gap-1.5">
{ASSETS.map((asset) => (
<li key={asset.name} className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="size-5 shrink-0 rounded-sm bg-(--flow-tone)/15 ring-1 ring-(--flow-tone)/30 ring-inset"
style={flowToneStyle(asset.tone)}
/>
<span className="min-w-0 flex-1 truncate text-xs">{asset.name}</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{asset.meta}
</span>
</li>
))}
</ul>
)
}
function AssetGrid() {
return (
<ul className="grid h-full grid-cols-3 grid-rows-2 gap-1.5">
{ASSETS.map((asset) => (
<li key={asset.name} className="flex min-w-0 flex-col gap-1">
<span
aria-hidden="true"
className="min-h-0 flex-1 rounded-sm bg-(--flow-tone)/15 ring-1 ring-(--flow-tone)/30 ring-inset"
style={flowToneStyle(asset.tone)}
/>
<span className="truncate text-[11px] text-muted-foreground">
{asset.name}
</span>
</li>
))}
</ul>
)
}
function AssetBoard() {
return (
<ul className="grid h-full grid-cols-3 gap-1.5">
{COLUMNS.map((column, columnIndex) => (
<li
key={column.title}
className="flex min-w-0 flex-col gap-1.5 rounded-sm bg-muted/40 p-1.5"
>
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span
aria-hidden="true"
className={`size-1.5 shrink-0 rounded-full ${column.tint}`}
/>
<span className="truncate">{column.title}</span>
</span>
{[0, 1].map((row) => {
const asset = ASSETS[columnIndex * 2 + row]
return (
<span
key={asset.name}
className="truncate rounded-sm border border-edge bg-background px-1.5 py-1 text-[10px]"
>
{asset.name}
</span>
)
})}
</li>
))}
</ul>
)
}
/**
* 默认示例:素材库的视图切换。三个带图标的分段驱动下方的预览区,
* 列表、网格、看板三套排布用的是同一份固定数据。
*/
export default function SegmentedControlDemo() {
const [view, setView] = React.useState<string>("list")
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<p className="text-sm font-medium">素材库</p>
<p className="font-mono text-[11px] text-muted-foreground">06 / 128</p>
</div>
<SegmentedControl
aria-label="视图"
options={VIEW_OPTIONS}
value={view}
onValueChange={setView}
/>
<div className="mt-3 h-44 overflow-hidden rounded-lg border border-edge bg-muted/20 p-2.5">
{view === "grid" ? (
<AssetGrid />
) : view === "board" ? (
<AssetBoard />
) : (
<AssetList />
)}
</div>
</div>
)
}
开关的手柄
开关最初沿用了竖线手柄,和滑块同形。但开关表达的是“开 / 关”两个状态,竖线停在左边还是右边,没有圆形手柄那么直观。我把它改成了胶囊轨道加圆形手柄,打开时轨道变成实色,整条也从左往右铺上淡色。滑块表达的是连续的值,用竖线;开关表达的是两个状态,用圆点。
"use client"
import * as React from "react"
import { Bell } from "lucide-react"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
/** 四个通知渠道:名称、右侧的送达方式、色调与初始状态都固定 */
const CHANNELS = [
{ id: "reply", label: "评论回复", description: "邮件", tone: "blue" },
{ id: "mention", label: "被提及", description: "桌面", tone: "violet" },
{ id: "digest", label: "每周摘要", description: "邮件", tone: "amber" },
{ id: "push", label: "移动端推送", description: "手机", tone: "rose" },
] as const
type ChannelId = (typeof CHANNELS)[number]["id"]
const INITIAL: Record<ChannelId, boolean> = {
reply: true,
mention: true,
digest: false,
push: false,
}
/**
* 默认示例:通知设置卡。每条是一个整条可点的开关,打开时整条铺上淡色、竖线手柄滑到右端,
* 四个渠道各用一种色调;`description` 在标签右侧标出送达方式。
*/
export default function ToggleSwitchDemo() {
const [enabled, setEnabled] = React.useState(INITIAL)
const count = CHANNELS.filter((channel) => enabled[channel.id]).length
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<div className="min-w-0">
<p className="text-sm font-medium">通知</p>
<p className="truncate text-xs text-muted-foreground">
选择想收到提醒的渠道
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground">
{count} / {CHANNELS.length}
</p>
</div>
<div className="flex flex-col gap-2">
{CHANNELS.map((channel) => (
<ToggleSwitch
key={channel.id}
label={channel.label}
description={channel.description}
tone={channel.tone}
checked={enabled[channel.id]}
onCheckedChange={(checked) =>
setEnabled((current) => ({ ...current, [channel.id]: checked }))
}
/>
))}
</div>
<p className="mt-3 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Bell className="size-3" aria-hidden="true" />
关闭的渠道只会写进站内消息
</p>
</div>
)
}
读数可以直接点
滑块拖得再顺,想设一个精确的数还是要输入。所以读数可以点:点读数或者在手柄上按回车,读数变成一个输入块,进入编辑时整段选中,直接打字替换,回车确认。
第一版的输入框是带边框的,聚焦时和滑块整体的焦点环叠在一起,一圈套一圈。后来我把滑块整条的焦点环限制成只在手柄被键盘聚焦时出现,输入框改成背景色小块加一圈色调细描边。
"use client"
import * as React from "react"
import { Info } from "lucide-react"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
const DEFAULTS = { width: 1920, height: 1080, quality: 85 }
const formatPx = (value: number) => `${value}px`
const formatPercent = (value: number) => `${value}%`
/** 估算体积:像素数 × 质量系数,固定一位小数,服务端与客户端算出来一样 */
function estimateSize(width: number, height: number, quality: number) {
return ((width * height * (quality / 100)) / 1_000_000).toFixed(1)
}
/**
* 直接输入示例:导出尺寸卡片。三条滑块都开了 `editable`,点读数(或在手柄上按回车)
* 就能敲数字;改过的项用 `modified` 把标签挑到前景色,一眼看出哪几项此时不是默认值。
*/
export default function ValueSliderEditableDemo() {
const [width, setWidth] = React.useState(DEFAULTS.width)
const [height, setHeight] = React.useState(DEFAULTS.height)
const [quality, setQuality] = React.useState(DEFAULTS.quality)
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-medium">导出图片</p>
<p className="font-mono text-[11px] text-muted-foreground">PNG</p>
</div>
<div className="mt-3 rounded-lg border border-edge bg-muted/20 p-3 text-center">
<p className="font-mono text-base tabular-nums">
{width} × {height}
</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">
约 {estimateSize(width, height, quality)} MB · 质量 {quality}%
</p>
</div>
<div className="mt-3 flex flex-col gap-2">
<ValueSlider
label="宽度"
min={480}
max={3840}
step={40}
largeStep={400}
editable
modified={width !== DEFAULTS.width}
value={width}
onValueChange={setWidth}
formatValue={formatPx}
/>
<ValueSlider
label="高度"
min={480}
max={2160}
step={40}
largeStep={400}
editable
modified={height !== DEFAULTS.height}
value={height}
onValueChange={setHeight}
formatValue={formatPx}
/>
<ValueSlider
label="质量"
min={40}
max={100}
step={1}
editable
modified={quality !== DEFAULTS.quality}
value={quality}
onValueChange={setQuality}
formatValue={formatPercent}
/>
</div>
<p className="mt-3 flex items-start gap-1.5 text-[11px] text-muted-foreground">
<Info className="mt-px size-3 shrink-0" aria-hidden="true" />
点右侧读数可以直接敲数字,回车确认、Esc
放弃;数字会夹在范围内并对齐到步长。
</p>
</div>
)
}
控制面板和“复制为 JSX”
控件有了,需要一个放它们的容器。control-panel 由三部分组成:标题栏(标题加右侧的操作,比如复制和恢复),若干个分组,每个分组里是等宽网格。网格可以设成 1 到 3 列,窄屏(小于 640px)一律单列,相邻的分组之间自动画一条分隔线。
面板上最常用的是两个按钮:
- 恢复:回到默认值;
- 复制:把当前参数写成一段 JSX 放进剪贴板。
复制靠一个很小的函数 formatJsx(组件名, 属性)。它的规则很少:值为 undefined 的属性省略;true 写成裸属性名;字符串优先用双引号,含引号或换行时改成 {"..."};数字去掉浮点尾差,最多保留 4 位小数;一行放得下(默认 72 个字符)就写一行,放不下每个属性一行。
“省略等于默认值的属性”这一步不在 formatJsx 里,而是在调用的地方。拿文档站的做法来说,是把面板当前的所有值先换成组件属性,再把默认值也换一遍,两者用 JSON.stringify 对比,一样的就丢掉:
const props = config.toTuning(all) // 面板当前值 → 组件属性
const defaults = config.toTuning(config.defaults)
const kept: Record<string, unknown> = {}
for (const [key, value] of Object.entries(props)) {
if (value === undefined) continue
if (JSON.stringify(value) === JSON.stringify(defaults[key])) continue
kept[key] = value
}
return formatJsx(config.component ?? "Component", kept)所以复制出来的永远是你相对默认值改过的那几项,比如调了速度和倾斜,得到的是 <Marquee speed={80} rotate={-6} />(示意),而不是把二十个属性全贴一遍。一个都没改时,得到的是一句自闭合标签。
门面示例 control-panel-demo 就是这么做的:左边是一张实时的名片卡,右边是控制面板,圆角、内边距、阴影、色调、朝向、边框都由面板控制,面板里的控件跟着所选的色调变色,点复制得到这张名片的 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 { 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>
)
}
文档里的调整面板
组件文档的每个示例下面,有一个滑块图标的按钮,点开是一块调整面板。它也是用这套控件拼的,位于 src/components/docs/tuner/。
每个组件声明一份配置,说明有哪些控件、面板的值怎么变成组件的覆盖项、示例自己的取值怎么回报给面板。组件那边只需要导出一个覆盖用的 Context,面板打开时由框架把覆盖项挂进去,示例本身不用改。面板的控件类型也是这个系列:滑块、分段、开关、色调、角度(angle-dial)、二维板(xy-pad)。
最早所有示例共用同一份面板。后来发现这样不行:面板的覆盖作用于示例里的所有实例,而很多示例里的几个实例是刻意取了不同值的。比如走马灯的一个示例里有两条彩带,一条正倾斜、一条负倾斜,方向也相反;如果面板里有“旋转”和“反向”两个控件,一拖就把这个差异抹平了,示例的意思也就没了。
所以配置里加了按示例收窄的能力:
examples: {
// 两条彩带一正一负倾斜、方向相反
"marquee-demo": { omit: ["rotate", "reverse"] },
// 竖向滚动不适合倾斜;示例自带暂停按钮
"marquee-vertical-demo": { omit: ["rotate", "paused"] },
},键是示例名,值是 false(这个示例不出面板)或者 { omit: [...] }(去掉指定的控件)。复制代码时被去掉的项也同步排除。已经登记的有走马灯、数值滑块、文字动效、轨道、流程图、字符矩阵等。验收时要逐个示例检查面板有没有不合适的控件,这一条也写进了验收技能。
后来又补了两个控件。走马灯的旋转改用角度刻度尺 angle-dial:方条中间是一段可以左右拖动的刻度,正中的竖线指针读数。等轴测标志的朝向和俯仰合并成了一块二维板 xy-pad,拖一下同时调两个值。
首页的“调一调”
首页首屏的面板就是把这些拼起来:
XyPad:视角,朝向和俯仰在一块板上;SegmentedControl:材质、地面,各一条;TonePicker:色调;ToggleSwitch:音效;- 标题栏的
ControlPanelReset:恢复默认。
面板里的控件全是系列里的组件。窄屏放不下时,它收成一个“调一调”按钮。
同一套控件还拼出了一个设置页区块 settings-01:外观、通知、声音与触感、编辑器、隐私五个分区,滑块、分段选择、开关、色调选择和控制面板全部来自这个系列,分区还会显示“已修改”的计数。