Coolify 仓库 shadcn 组件组合规范(composition.md)全解:Group 嵌套、覆盖层选择与"组件替代自定义标记"实践
【免费下载链接】coolifyAn open-source, self-hostable PaaS alternative to Vercel, Heroku & Netlify that lets you easily deploy static sites, databases, full-stack applications and 280+ one-click services on your own servers.项目地址: https://gitcode.com/GitHub_Trending/co/coolify
本文以 Coolify 仓库中 .agents/skills/shadcn/rules/composition.md 为核心,完整拆解其中 14 条组件组合(Composition)规则:Item 必须嵌套在 Group 内、覆盖层组件选型、Dialog/Sheet/Drawer 的 Title 强制要求、Card 完整结构、Button 加载态的正确写法,以及 Separator/Skeleton/Badge 替代自定义标记的模式。读完你可以直接把这些规则作为 shadcn/ui 项目(或 AI Agent 辅助编码场景)的组件组合检查清单,并对照 SKILL.md 中的 Critical Rules 理解每条规则的来龙去脉。
这份文档在 Coolify 仓库中的定位
composition.md位于 .agents/skills/shadcn/rules/ 目录,是 Coolify 仓库为 AI Agent 内置的一套 shadcn/ui 技能(skill)中的"组件结构"规则文件。从 SKILL.md 的 Critical Rules 部分可以看到,它被明确归类为两条规则的详细出处:
- Component Structure → composition.md:覆盖 Group 嵌套、asChild/render 自定义触发器、Title 强制要求、Card 完整组合、Button 无
isPending、TabsTrigger 位置、AvatarFallback 等; - Use Components, Not Custom Markup → composition.md:覆盖 Alert、Empty、sonner Toast、Separator、Skeleton、Badge 等"用现成组件而非手写标记"的规则。
该 skill 的适用前提是"任何带components.json的 shadcn 项目"(SKILL.md 描述中的触发条件),配套文件还有 forms.md(表单布局)、styling.md(样式与 Tailwind)、icons.md(图标规范)和 base-vs-radix.md(base 与 radix 两套底层原语的 API 差异)。composition.md 与它们的关系是:它管"组件之间怎么嵌套",而不是"样式怎么写"或"表单怎么布局"。需要说明的是,Coolify 主站 UI 本身是 Laravel + Blade + Alpine.js 技术栈(见 package.json 与 TECH_STACK.md),本规则文件属于面向 Agent 的通用 shadcn 技能包内容,因此下文的代码示例均为 shadcn/ui 生态下的 React/TSX 用法。
总览:14 条组合规则清单
文档开头给出的 Contents 完整列出了全部规则,可视为检查清单:
- Items always inside their Group component(Item 必须位于 Group 内)
- Callouts use Alert(提示框用 Alert)
- Empty states use Empty component(空状态用 Empty)
- Toast notifications use sonner(Toast 用 sonner)
- Choosing between overlay components(覆盖层组件选型)
- Dialog, Sheet, and Drawer always need a Title(覆盖层必须有 Title)
- Card structure(Card 完整结构)
- Button has no isPending or isLoading prop(Button 加载态的正确写法)
- TabsTrigger must be inside TabsList(TabsTrigger 必须在 TabsList 内)
- Avatar always needs AvatarFallback(Avatar 必须带 Fallback)
- Use Separator instead of raw hr or border divs(分隔线用 Separator)
- Use Skeleton for loading placeholders(加载占位用 Skeleton)
- Use Badge instead of custom styled spans(徽章用 Badge)
- Use existing components instead of custom markup(总原则:优先用组件而非自定义标记)
Item 必须嵌套在 Group 组件内
这是文档的第一条规则,核心要求是:永远不要把 Item 直接渲染在内容容器里。以 Select 为例,文档给出 Incorrect/Correct 对照:
// 错误:SelectItem 直接放在 SelectContent 下 <SelectContent> <SelectItem value="apple">Apple</SelectItem> <SelectItem value="banana">Banana</SelectItem> </SelectContent> // 正确:先包一层 SelectGroup <SelectContent> <SelectGroup> <SelectItem value="apple">Apple</SelectItem> <SelectItem value="banana">Banana</SelectItem> </SelectGroup> </SelectContent>文档随后给出了一张完整的 Item → Group 映射表,说明该规则适用于所有"基于 Group 的组件":
| Item | Group |
|---|---|
SelectItem、SelectLabel | SelectGroup |
DropdownMenuItem、DropdownMenuLabel、DropdownMenuSub | DropdownMenuGroup |
MenubarItem | MenubarGroup |
ContextMenuItem | ContextMenuGroup |
CommandItem | CommandGroup |
这条规则同样出现在 SKILL.md 的 Critical Rules 摘要中("Items always inside their Group"),并且在 SKILL.md 的 Workflow 第 7 步中还被用作审查从社区 registry 添加组件时的检查项——"Check for missing sub-components (e.g.SelectItemwithoutSelectGroup)"。也就是说,从第三方 registry 拉下来的组件若缺失 Group 包裹,属于需要人工/Agent 修复的组合缺陷,而不是可接受的写法。
补充一层背景:为什么 Group 层如此重要?从 base-vs-radix.md 对 Select 的 API 描述可以看出,base 原语要求把数据通过items属性传给根组件、radix 则用内联 JSX,两种底层的SelectContent渲染机制都依赖SelectGroup来组织列表结构(包括键盘导航分组与视觉间距)。因此"漏掉 Group"不仅是不规范的代码风格问题,还可能导致组件在某个底层原语上渲染或行为异常——这也是该规则被放进"always enforced"(始终强制)类别的原因。
提示框(Callout)使用 Alert
第二条规则约定提示类内容一律用Alert组合,文档给出标准结构:
<Alert> <AlertTitle>Warning</AlertTitle> <AlertDescription>Something needs attention.</AlertDescription> </Alert> </parameter>即Alert根组件 +AlertTitle+AlertDescription三段式结构。SKILL.md 的对应摘要为"Callouts useAlert. Don't build custom styled divs."——禁止用自定义样式的div手搓提示框,因为Alert已内置语义化角色、间距与图标位(结合 icons.md 可知 Alert 内的图标由组件 CSS 处理尺寸,不需要size-4之类的类名)。
空状态使用 Empty 组件
空状态不手写,而用Empty组件族完整组合。文档示例展示了四个子组件的层级:Empty→EmptyHeader→(EmptyMedia+EmptyTitle+EmptyDescription)→EmptyContent:
<Empty> <EmptyHeader> <EmptyMedia variant="icon"><FolderIcon /></EmptyMedia> <EmptyTitle>No projects yet</EmptyTitle> <EmptyDescription>Get started by creating a new project.</EmptyDescription> </EmptyHeader> <EmptyContent> <Button>Create Project</Button> </EmptyContent> </Empty> </parameter>要点有两处:EmptyMedia通过variant="icon"声明媒体类型为图标并直接承载图标组件;EmptyContent是放置主操作(如"Create Project"按钮)的位置。SKILL.md 的组件选择表也明确把"Empty states"映射到Empty这一个组件(SKILL.md),空状态没有第二种合规写法。
Toast 通知使用 sonner
文档约定 Toast 一律来自sonner库的toast()函数,而不是 shadcn 自家的组件:
import { toast } from "sonner" toast.success("Changes saved.") toast.error("Something went wrong.") toast("File deleted.", { action: { label: "Undo", onClick: () => undoDelete() }, })三种用法分别覆盖:成功通知(toast.success)、错误通知(toast.error)、带操作按钮的通知(第二个参数传action对象,label+onClick实现"撤销删除"这类可交互 Toast)。这与 SKILL.md 组件选择表"Feedback →sonner(toast)"(SKILL.md)一致:shadcn 生态中 Toast 的职责由 sonner 承担,Alert/Badge/Progress/Skeleton/Spinner 承担其余反馈形态。
覆盖层组件选型:Dialog、Sheet、Drawer 与 HoverCard
选择哪个覆盖层组件,文档给出一张按"使用场景 → 组件"的对照表:
| 使用场景 | 组件 |
|---|---|
| 需要输入的聚焦任务 | Dialog |
| 破坏性操作确认 | AlertDialog |
| 侧边面板(详情或筛选) | Sheet |
| 移动端优先的底部面板 | Drawer |
| 悬停显示快速信息 | HoverCard |
| 点击显示小范围上下文内容 | Popover |
这张表与 SKILL.md 组件选择表中"Overlays"一行(Dialog(modal)、Sheet(side panel)、Drawer(bottom sheet)、AlertDialog(confirmation))互为印证。选型判断可归纳为三个维度:是否需要用户输入(Dialog)、是否不可逆(AlertDialog)、内容在屏幕上的空间形态(居中 / 侧边 / 底部 / 浮动跟随)。
Dialog、Sheet、Drawer 必须提供 Title
这是文档中明确的可访问性(accessibility)强制项:DialogTitle、SheetTitle、DrawerTitle均为必需;若视觉上不需要展示标题,用className="sr-only"隐藏(屏幕阅读器仍可读取):
<DialogContent> <DialogHeader> <DialogTitle>Edit Profile</DialogTitle> <DialogDescription>Update your profile.</DialogDescription> </DialogHeader> ... </DialogContent>结构上遵循DialogContent→DialogHeader→(DialogTitle+DialogDescription)的层级。SKILL.md 将这条与"UseclassName=\"sr-only\"if visually hidden"一并列入 Critical Rules(SKILL.md),可见"缺 Title"会被视为违规而非风格问题。
Card 结构:使用完整组合,而非全部塞进 CardContent
文档要求 Card 使用"full composition",并明确反对把所有内容堆进CardContent:
<Card> <CardHeader> <CardTitle>Team Members</CardTitle> <CardDescription>Manage your team.</CardDescription> </CardHeader> <CardContent>...</CardContent> <CardFooter> <Button>Invite</Button> </CardFooter> </Card>即CardHeader(承载CardTitle、CardDescription)、CardContent(正文)、CardFooter(放操作按钮等收尾内容)各司其职。这与 SKILL.md 的表述一致:"Use full Card composition.CardHeader/CardTitle/CardDescription/CardContent/CardFooter. Don't dump everything inCardContent."。
Button 没有 isPending / isLoading 属性
shadcn 的Button组件不内置加载态属性(没有isPending或isLoadingprop),正确做法是组合Spinner+data-icon+disabled:
<Button disabled> <Spinner><Tabs defaultValue="account"> <TabsList> <TabsTrigger value="account">Account</TabsTrigger> <TabsTrigger value="password">Password</TabsTrigger> </TabsList> <TabsContent value="account">...</TabsContent> </Tabs>即Tabs根组件下分两支:TabsList(承载全部TabsTrigger)与一个或多个TabsContent(通过value与对应的TabsTrigger关联)。defaultValue声明初始选中的 tab。这条规则在 SKILL.md 摘要中被表述为"TabsTriggermust be insideTabsList. Never render triggers directly inTabs.",其重要性等级与 Group 嵌套规则相同,属于结构层面的硬性约束。
Avatar 必须包含 AvatarFallback
图片头像加载失败时必须有兜底,因此AvatarFallback是必备子组件:
<Avatar> <AvatarImage src="/avatar.png" alt="User" /> <AvatarFallback>JD</AvatarFallback> </Avatar>AvatarFallback通常展示用户姓氏首字母(如示例中的 "JD")。SKILL.md 对应规则为"Avataralways needsAvatarFallback. For when the image fails to load."——即使你"确定图片不会失败",该规则也不允许省略,这是以确定性兜底换取列表页、侧边栏等大量头像场景的健壮性。
用组件替代自定义标记:Separator、Skeleton、Badge
文档最后(也是第一条规则"总则")的替换对照表给出了三组高频场景的"不要 → 要"映射:
| Instead of(不要) | Use(要用) |
|---|---|
<hr>或<div className="border-t"> | <Separator /> |
<div className="animate-pulse">加样式 div 拼的骨架 | <Skeleton className="h-4 w-3/4" /> |
<span className="rounded-full bg-green-100 ..."> | <Badge variant="secondary"> |
三条规则的共同逻辑是:shadcn 组件已经封装了语义、深色模式适配与交互行为,手写标记既丢失语义又会在主题切换时失配。其中 Skeleton 通过className控制尺寸形状(h-4 w-3/4表示一行文本的占位),这符合 SKILL.md 中"classNamefor layout, not styling"的原则——className只用于布局维度(宽高、比例),不承担换色换字的样式职责;Badge 则通过variant="secondary"这类内置变体表达状态色,而不是写死bg-green-100之类的原始色值。SKILL.md 的关键模式示例也给出了一组对照(SKILL.md):<Badge variant="secondary">+20.1%</Badge>正确,<span className="text-emerald-600">+20.1%</span>错误。
与 base / radix 双底层的衔接
读完 composition.md 后还有一条必要的延伸:上述组件的嵌套结构与底层原语无关,但属性 API会因components.json中base字段(radix或base)而不同。这一点由姊妹文件 base-vs-radix.md 专门覆盖,与 composition 规则直接交叉的几处包括:
- 触发器自定义元素:radix 用
asChild(如<DialogTrigger asChild>),base 用render(如<DialogTrigger render={<Button />}>),且 base 将render目标改为非 button 元素(<a>、<span>)时需追加nativeButton={false}; - Select:base 要求根组件传
items属性、用{ value: null }项表达占位符,而 radix 直接用<SelectValue placeholder="...">——但无论哪种底层,SelectContent内的SelectGroup包裹结构都保持不变; - ToggleGroup / Accordion:base 用
multiple布尔 + 数组型defaultValue,radix 用type="single" | "multiple"+ 字符串defaultValue; - Slider:base 单滑块接受标量
defaultValue={50},radix 永远是数组defaultValue={[50]}。
因此实际工作流是:先用 composition.md 确定骨架(谁包谁、缺什么子组件),再查npx shadcn@latest info输出的base字段决定属性写法。SKILL.md 的 Workflow(SKILL.md)把这一流程固化为:获取项目上下文 → 检查已安装组件 →search找组件 →docs <component>拉文档 →add安装 → 审阅新增文件是否违反 Critical Rules(其中就包括本文件的 Group 嵌套检查)。
小结:把 composition.md 当作组件组合检查清单
回到文档本身,它的价值在于把 shadcn/ui 中"组件之间如何嵌套"这一最容易出错的层面,压缩成 14 条可逐条勾选的硬规则:
- 结构完整性:Item 在 Group 内、TabsTrigger 在 TabsList 内、覆盖层带 Title、Avatar 带 Fallback、Card 用完整五件套;
- 职责单一化:Callout 归 Alert、空状态归 Empty、Toast 归 sonner、加载占位归 Skeleton、分隔线归 Separator、状态标签归 Badge;
- 组合优先于 API:Button 加载态不是找
isPending属性,而是Spinner+data-icon+disabled的组合。
配合 forms.md(表单用FieldGroup/Field/InputGroup)、styling.md(语义色、gap-*间距、cn()条件类)、icons.md(data-icon与图标对象传递)和 base-vs-radix.md(双层 API 差异),这套 rules 目录构成了完整的 shadcn 代码审查标准,也是 SKILL.md 中"always enforced"规则的落地细则。
【免费下载链接】coolifyAn open-source, self-hostable PaaS alternative to Vercel, Heroku & Netlify that lets you easily deploy static sites, databases, full-stack applications and 280+ one-click services on your own servers.项目地址: https://gitcode.com/GitHub_Trending/co/coolify
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考