Ghost Shade 设计系统 inputSurface Recipe 详解:表单控件视觉规则的单点定义与三种应用模式
【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost
Ghost 的 Shade 设计系统(apps/shade)中,所有表单类控件共享同一套视觉规则:边框、背景、圆角、过渡、聚焦光环与无效状态。这套规则被收敛到一个名为inputSurface的样式配方(recipe)中,并通过一份 Agent Skill 文档规范其使用方式。读完本文,你将掌握inputSurface('self')与inputSurface('within')两种模式的适用场景、手动组合原子(atoms)的边界情况写法、Tailwind JIT 对类名字面量的约束,以及配方与消费组件之间的职责边界,从而在 Shade 中正确地"复用而非重写"表单控件外观。
背景:配方化(Recipe)与 Agent Skill 机制
在 Shade 的设计系统文档中,inputSurface被定义为"规范示例"(canonical example)的配方:Input、Textarea、InputGroup和Select触发器共用的边框、背景、圆角与聚焦光环,见 recipes-guide.mdx。
本文主体对应的 Skill 文档是 SKILL.md。它不仅面向人类开发者,还是一份面向 AI 编码助手的技能定义,其 frontmatter 中声明了自动触发条件:
name: Shade inputSurface recipe description: Use the inputSurface() recipe for form-control chrome (border, background, radius, focus ring, invalid state) — don't roll your own. autoTrigger: - fileEdit: "apps/shade/src/components/ui/{input,textarea,input-group,select,combobox,multi-select-combobox,dropzone,calendar,command}.tsx"也就是说,当编辑apps/shade/src/components/ui/下任何"表单控件形态"的文件(input、textarea、select、combobox、dropzone 等)时,该技能会被自动加载,提醒开发者:不要自己拼写聚焦光环与无效状态样式,组合 recipe。这是 Ghost 仓库将设计规范"工程化"到开发工具链中的典型做法。
inputSurface 源码:七个原子与两种模式组合
配方的唯一事实来源(source of truth)是 input-surface.ts。文件顶部 JSDoc 即规范说明,核心结构如下:
export const inputSurfaceClasses = { base: 'rounded-md border border-control-border bg-control-surface transition-colors', focusSelf: 'focus-visible:outline-hidden focus-visible:border-focus-ring focus-visible:ring-2 focus-visible:ring-focus-ring/25', focusWithin: 'has-[:focus-visible]:outline-hidden has-[:focus-visible]:border-focus-ring has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-focus-ring/25', invalidSelf: 'aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive/20 dark:aria-[invalid=true]:ring-destructive/40', invalidWithin: 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20 dark:has-[[aria-invalid=true]]:ring-destructive/40', disabledSelf: 'disabled:cursor-not-allowed disabled:opacity-50', disabledFieldSelf: 'disabled:bg-control-disabled-surface disabled:text-muted-foreground disabled:opacity-100 disabled:hover:bg-control-disabled-surface', } as const; export function inputSurface(mode: 'self' | 'within' = 'self') { if (mode === 'self') { return cn( inputSurfaceClasses.base, inputSurfaceClasses.focusSelf, inputSurfaceClasses.invalidSelf, inputSurfaceClasses.disabledSelf, ); } return cn( inputSurfaceClasses.base, inputSurfaceClasses.focusWithin, inputSurfaceClasses.invalidWithin, ); }从源码可以确认几个关键事实:
- 原子共 7 个。Skill 文档列出 6 个常用原子,源码中还有第 7 个
disabledFieldSelf——它不改变透明度,而是把禁用控件换成disabled背景并保留文本可读性,专门给文本输入类控件叠加使用(后文 Input 组件即如此)。 - 模式即原子的组合。
inputSurface('self')组合了base + focusSelf + invalidSelf + disabledSelf;'within'组合了base + focusWithin + invalidWithin,刻意不带 disabled 原子——因为包裹层(wrapper)本身没有 HTMLdisabled属性,禁用态由消费者自行处理。 - 所有颜色都走语义化 token:
border-control-border、bg-control-surface、focus-ring、destructive等,而非具体色值;无效态还针对深色模式单独调整了 ring 透明度(dark:...ring-destructive/40)。
两种标准模式
inputSurface('self'):直接作用于可聚焦元素
适用于<input>、<textarea>或任何自身接收焦点的元素。配方覆盖:基础外观(chrome)+focus-visible:光环 +aria-[invalid=true]无效样式 +disabled:透明度。
实际消费方 Input 组件 的写法:
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>( ({ className, type, ...props }, ref) => { return ( <input ref={ref} className={cn( inputSurface('self'), inputSurfaceClasses.disabledFieldSelf, 'flex h-(--control-height) w-full px-3 py-1 text-control file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground', className, )} type={type} {...props} /> ); }, );注意两个细节:高度使用 CSS 变量h-(--control-height)以便统一调整控件高度;在inputSurface('self')之外再叠加disabledFieldSelf,使禁用的输入框背景变化但文字依然可读(opacity-100覆盖了disabledSelf的opacity-50)。Textarea 组件 采用完全相同的组合模式,只是自身布局类不同(min-h-[80px] px-3 py-2 text-base)。
inputSurface('within'):作用于包含可聚焦子元素的包裹层
当被着色的元素本身不可聚焦、真正接收焦点的是其子元素时(例如InputGroup),使用'within'模式。此时聚焦与无效样式通过:has()选择器从任意可聚焦后代派生:
<div className={cn( inputSurface('within'), 'flex h-9 items-center gap-2 px-3', className, )}> <Icon /> <input className="bg-transparent outline-hidden focus:outline-hidden" /> </div>对应源码中的选择器差异:
| 维度 | 'self'原子 | 'within'原子 |
|---|---|---|
| 聚焦触发 | focus-visible: | has-[:focus-visible]:(任意可聚焦后代) |
| 无效状态 | aria-[invalid=true]:(自身) | has-[[aria-invalid=true]]:(任意后代) |
| 禁用样式 | disabledSelf生效 | 无(包裹层无 disabled 属性) |
包裹层内部的真实输入元素应保持"透明化":bg-transparent outline-hidden,让外层统一呈现边框与光环。
边界情况:手动组合原子 + 字面量类名
当'within'的"任意可聚焦后代"过于宽泛时——例如包裹层内有多个可聚焦元素,但只有某一个应该驱动表面光环——应放弃inputSurface()函数,改为手动组合原子:
import {inputSurfaceClasses} from '@/components/ui/input-surface'; <div className={cn( inputSurfaceClasses.base, inputSurfaceClasses.invalidWithin, // 字面量类名字符串 —— Tailwind 需要在构建期"看到"它 'has-[[data-slot=control]:focus-visible]:border-focus-ring', 'has-[[data-slot=control]:focus-visible]:ring-2', 'has-[[data-slot=control]:focus-visible]:ring-focus-ring/25' )} />Skill 文档强调了一个容易被忽略的工程约束:聚焦选择器必须写成字面量类名字符串,因为 Tailwind 的 JIT 编译器通过静态扫描源码提取类名;模板字符串拼接出来的动态类名(如`has-...:${ringColor}`)无法被检测,会静默丢失样式。
仓库中 InputGroup 组件 正是这个模式的生产级实例。它的源码注释明确解释了为什么不用inputSurface('within'):
className={cn( inputSurfaceClasses.base, inputSurfaceClasses.invalidWithin, 'group/input-group relative flex w-full items-center outline-hidden ...', // 聚焦状态 —— 精确限定到 input-group 的 control, // 这样点击组内的 InputGroupButton 不会触发表面聚焦光环。 // 这就是这里不使用 inputSurface('within') 的原因。 'has-[[data-slot=input-group-control]:focus-visible]:border-focus-ring has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-focus-ring/25 has-[[data-slot=input-group-control]:focus-visible]:outline-hidden', className, )}这里通过data-slot="input-group-control"数据属性把聚焦光环的范围锁定到组内真正输入的那个<input>,而组内的按钮、附加件聚焦时不产生表面光环——这是通用:has()方案无法精确表达的语义。
职责边界:配方管什么,组件管什么
Skill 文档给出的"配方拥有 / 消费者补充"对照表,与源码逐条吻合:
| 配方拥有(recipe owns) | 消费者补充(you add) |
|---|---|
边框(border-control-border) | 高度、内边距 |
背景(bg-control-surface) | 字体排印(text-control、text-sm) |
圆角(rounded-md) | 布局(flex、items-center) |
过渡(transition-colors) | 占位符样式 |
聚焦光环(focus-visible:ring-focus-ring/25) | 图标 / 插槽定位 |
无效状态(aria-[invalid=true]:border-destructive) | 组件特有微调 |
禁用态(disabled:opacity-50)——仅 self 模式 | — |
Storybook 中的配方文档(路径为 Storybook → Recipes / Input Surface)把同样的边界以表格形式可视化,并提供了 Self、Within、States、WithinStates、CustomFocusScope 等可交互故事,用于验证默认、无效、禁用各状态下两种模式的实际表现。由于配方本身没有"组件实体",它在 Storybook 中归在 Recipes 分组而非 Components 分组。
三类典型反模式
Skill 文档明确列出了三种应避免的写法:
// 反模式 1 —— 在表单控件上自己拼一套聚焦样式 <input className=" rounded-md border border-input bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-[invalid=true]:border-red-500 disabled:opacity-50 " /> // 反模式 2 —— 对 self 聚焦的元素误用 inputSurface('within')(范围过宽) <input className={cn(inputSurface('within'), 'h-9')} /> // 反模式 3 —— Tailwind JIT 无法识别的非字面量类名拼接 const dyn = `has-[[data-slot=control]:focus-visible]:${ringColor}`;反模式 1 的问题在于使用了通用 token(ring、red-500、border-input)而非控件专用 token(focus-ring、destructive、control-border),会导致控件外观偏离设计系统并产生重复样式来源;反模式 2 会让输入框上的任何后代聚焦都误触包裹层光环;反模式 3 如前所述会导致样式在构建期被丢弃。
小结与延伸阅读
inputSurface用不到 60 行 TypeScript 把 Ghost Shade 中所有表单控件的外观一致性收敛到单点:新增控件时只需在 input-surface.ts 修改一处即可全局生效;常规场景调用inputSurface('self' | 'within'),聚焦范围需要精确控制时用inputSurfaceClasses原子加字面量has-[[data-slot=...]:focus-visible]:选择器手工组合。相关入口:
- 配方实现与规范 JSDoc:input-surface.ts
- 消费方实例:input.tsx、textarea.tsx、select.tsx、input-group.tsx
- Storybook 文档故事:input-surface.stories.tsx
- 设计系统配方指南:recipes-guide.mdx
- 本文对应 Skill 文档:SKILL.md
【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考