Repository Wiki
Naptie/endfield-docmaker

shadcn-svelte UI 组件库

endfield-docmaker 前端基于 shadcn-svelte(Lyra 风格)构建的可复用 UI 原语组件库,位于 src/lib/components/ui/,通过 tailwind-variants 驱动统一的视觉变体系统,并以 barrel index.ts 对外暴露导出。

Purpose and Scope

本页覆盖 src/lib/components/ui/ 目录下的 shadcn-svelte 基础组件库:它的目录组织、components.json 生成配置、变体(Tailwind Variants)驱动的设计规范、cn 工具函数与 data-slot 约定,以及以 Button 为代表的组件实现剖析。

不在本页范围(留给兄弟页面):

  • 上层业务组件(如 DocLibrary、SettingsModal、DynamicForm 等)——见 frontend 业务组件相关页面
  • 路由与页面级布局——见 frontend 路由相关页面
  • 主题/语言切换与全局样式变量(src/routes/layout.css 的 CSS 变量体系)——见主题相关页面

Overview

shadcn-svelte 的核心理念是「复制到你的仓库,而不是安装依赖」:组件源码直接存在于项目内(src/lib/components/ui/),开发者可以自由修改,而非像传统组件库那样从 npm 引入并接受其黑盒行为。这带来三个实际收益:

  1. 零升级摩擦——组件随项目代码演进,不存在依赖版本冲突;
  2. 深度可定制——可以直接改 Tailwind 类、增删变体(本项目已定制了 icon-xs、icon-sm 等 shadcn 原版没有的尺寸);
  3. 类型闭环——变体类型(如 ButtonVariant)由 tailwind-variants 推导,IDE 自动补全与编译期校验一致。

本项目在生成时选择了 style: "lyra"(方角 rounded-none、text-xs 小字号、紧凑高度)与 iconLibrary: "phosphor",契合文档工具的密集信息展示场景。

当前源码中确认存在的组件目录(由 index.ts 导出证实):badge、button、card、dialog、input、label、select、separator、spinner、switch、tabs、tooltip。

Architecture

Loading diagram...

分层要点:

  • components.json 是构建期配置,只在通过 shadcn-svelte CLI 添加新组件时被读取,运行时不参与任何逻辑;
  • 每个组件目录自包含:.svelte 实现文件 + index.ts barrel 导出,业务代码只 import index.ts;
  • cn 是唯一的类合并出口:所有组件的最终 class 都经过它,保证外部传入的类能正确覆盖默认变体;
  • 变体样式依赖主题 CSS 变量(bg-primary、text-muted-foreground 等),主题切换通过替换变量值实现,组件代码无需感知。

目录组织与导出约定

每个组件目录遵循统一的「实现 + barrel」结构:

text
1src/lib/components/ui/ 2├── badge/ 3│ ├── badge.svelte # 单文件实现 4│ └── index.ts # export { default as Badge } 5├── button/ 6│ ├── button.svelte 7│ └── index.ts 8├── card/ 9│ ├── card.svelte # 组合式组件:多个 .svelte 10│ ├── card-action.svelte 11│ ├── card-content.svelte 12│ ├── card-description.svelte 13│ ├── card-footer.svelte 14│ ├── card-header.svelte 15│ ├── card-title.svelte 16│ └── index.ts 17├── dialog/ # dialog-close/content/description/ 18│ ... # footer/header/overlay/portal/title 19├── input/ label/ select/ separator/ spinner/ switch/ 20└── tabs/ tooltip/

index.ts 存在两种导出风格,取决于组件形态:

单文件组件(badge / spinner / switch)直接以组件名导出,并同时转发变体函数与类型:

ts
export { default as Badge } from './badge.svelte'; export { badgeVariants, type BadgeVariant } from './badge.svelte';

Source: index.ts

复合组件(button / card / dialog / input / label / select / separator / tabs / tooltip)采用 Root 别名模式,在 index.ts 中将内部文件重命名为语义化名称后统一导出(例如 card.svelte → Card、card-content.svelte → Card.Content,并以 Root 作为主名保留)。这种模式的好处是:业务侧可以按需引入单个子部件,同时 registry CLI 仍能以稳定的 Root 名定位组件根。

Source: index.ts Source: index.ts

生成配置:components.json

json
1{ 2 "$schema": "https://shadcn-svelte.com/schema.json", 3 "tailwind": { 4 "css": "src/routes/layout.css", 5 "baseColor": "neutral" 6 }, 7 "aliases": { 8 "components": "$lib/components", 9 "utils": "$lib/utils/ui", 10 "ui": "$lib/components/ui", 11 "hooks": "$lib/hooks", 12 "lib": "$lib" 13 }, 14 "typescript": true, 15 "registry": "https://shadcn-svelte.com/registry", 16 "style": "lyra", 17 "iconLibrary": "phosphor" 18}

Source: components.json

这份文件只在 CLI 执行 npx shadcn-svelte@latest add <component> 时被读取。关键决策解读见下文「Configuration Options」。

核心机制一:tailwind-variants 变体系统

每个组件在 <script lang="ts" module> 模块级作用域中用 tv() 定义样式函数。以 Button 为例(完整定义见「Usage Examples」):

ts
1export const buttonVariants = tv({ 2 base: "focus-visible:border-ring ... rounded-none border border-transparent ... text-xs font-medium ...", 3 variants: { 4 variant: { default: '...', outline: '...', secondary: '...', ghost: '...', destructive: '...', link: '...' }, 5 size: { default: '...', xs: '...', sm: '...', lg: '...', icon: '...', 'icon-xs': '...', 'icon-sm': '...', 'icon-lg': '...' } 6 }, 7 defaultVariants: { variant: 'default', size: 'default' } 8}); 9 10export type ButtonVariant = VariantProps<typeof buttonVariants>['variant']; 11export type ButtonSize = VariantProps<typeof buttonVariants>['size'];

设计意图:

  • base 承载通用行为(焦点环、禁用态、SVG 图标尺寸、active:not-aria-[haspopup]:translate-y-px 的按压位移),变体只叠加差异,避免重复;
  • VariantProps<typeof buttonVariants> 从实现反推类型,变体名与 Tailwind 类永远同步——若删掉某个变体,其类型自动消失,不会出现「类型有但样式无」的漂移;
  • defaultVariants 兜底,调用方不传参数也能得到合法样式;
  • 模块级 <script lang="ts" module> 让 buttonVariants 成为可被 barrel 转发的模块导出,而非组件实例内部变量,便于在不渲染组件的场景(如菜单项 className)复用样式。

核心机制二:Button 组件实现剖析

Button 是整个组件库中最能体现 shadcn-svelte 实现范式的组件:单文件、双元素多态、props 透传与类合并。

svelte
1<script lang="ts" module> 2 import { cn, type WithElementRef } from '$lib/utils/ui.js'; 3 import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'; 4 import { type VariantProps, tv } from 'tailwind-variants'; 5 6 export const buttonVariants = tv({ 7 base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", 8 variants: { 9 variant: { 10 default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', 11 outline: 12 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground', 13 secondary: 14 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', 15 ghost: 16 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground', 17 destructive: 18 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30', 19 link: 'text-primary underline-offset-4 hover:underline' 20 }, 21 size: { 22 default: 23 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', 24 xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", 25 sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", 26 lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', 27 icon: 'size-8', 28 'icon-xs': "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3", 29 'icon-sm': 'size-7 rounded-none', 30 'icon-lg': 'size-9' 31 } 32 }, 33 defaultVariants: { 34 variant: 'default', 35 size: 'default' 36 } 37 }); 38</script> 39 40<script lang="ts"> 41 let { 42 class: className, 43 variant = 'default', 44 size = 'default', 45 ref = $bindable(null), 46 href = undefined, 47 type = 'button', 48 disabled, 49 children, 50 ...restProps 51 }: ButtonProps = $props(); 52</script> 53 54{#if href} 55 <a 56 bind:this={ref} 57 data-slot="button" 58 class={cn(buttonVariants({ variant, size }), className)} 59 href={disabled ? undefined : href} 60 aria-disabled={disabled} 61 role={disabled ? 'link' : undefined} 62 tabindex={disabled ? -1 : undefined} 63 {...restProps} 64 > 65 {@render children?.()} 66 </a> 67{:else} 68 <button ... /> 69{/if}

Source: button.svelte

关键实现细节:

1. 双元素多态({#if href} 分支)

同一份变体样式同时服务 <button> 与 <a>。传 href 即渲染链接,否则渲染按钮。重点在链接分支的禁用处理——HTML 的 <a> 没有原生 disabled 属性,因此组件手动实现了一整套语义:

  • href={disabled ? undefined : href}:移除 href,阻止点击跳转;
  • aria-disabled={disabled}:向辅助技术暴露禁用状态;
  • role={disabled ? 'link' : undefined}:保留「链接」角色而非被降级为按钮;
  • tabindex={disabled ? -1 : undefined}:移出 Tab 序列,防止键盘用户进入死链。

这是无障碍设计中「polyfill 语义缺失」的典型做法。

2. data-slot="button" 约定

每个渲染的根元素都打上 data-slot="button"。这不是样式钩子,而是结构标识:允许业务代码通过 CSS 后代选择器(如 [data-slot='card'] [data-slot='button'])或测试选择器精准定位,而不必依赖脆弱的类名匹配。

3. props 解构与透传

ts
1let { 2 class: className, 3 variant = 'default', 4 size = 'default', 5 ref = $bindable(null), 6 href = undefined, 7 type = 'button', 8 disabled, 9 children, 10 ...restProps 11}: ButtonProps = $props();
  • class 重命名为 className 以避开 Svelte 保留字,随后经 cn() 与变体类合并;
  • ref = $bindable(null) 是 Svelte 5 的双向绑定插槽,父组件可 bind:ref 拿到底层 DOM 元素引用;
  • children 是 Svelte 5 片段(Snippet)props,通过 {@render children?.()} 渲染,可选调用(?.)使其兼容无子内容场景;
  • ...restProps 捕获剩余全部属性原样透传到 DOM,保证任何原生事件监听器/属性都不丢失。

4. cn 的覆盖语义

class={cn(buttonVariants({ variant, size }), className)} 中,变体类在前、外部类在后。cn 基于 tailwind-merge 的规则,同名 utility(如外部再传 h-10)会正确覆盖内部默认值——这就是 shadcn 系组件「默认值 + 可覆盖」的基石。

核心机制三:类型契约与 WithElementRef

ts
1export type ButtonProps = WithElementRef<HTMLButtonAttributes> & 2 WithElementRef<HTMLAnchorAttributes> & { 3 variant?: ButtonVariant; 4 size?: ButtonSize; 5 };

Source: button.svelte

ButtonProps 是两种 HTML 属性类型的交集而非并集:HTMLButtonAttributes ∩ HTMLAnchorAttributes。这意味着只有 button 与 anchor 共有的属性(如 onclick、class、aria-*)才直接可用;target、rel 等仅链接独有的属性需通过 WithElementRef 包裹的扩展机制处理。这种设计以轻微的类型限制换取了双元素多态下的编译期安全——给非当前渲染元素的属性传值会被 TypeScript 拒绝。WithElementRef 同时为核心属性扩展了可选的 ref 字段(对应上文 $bindable),它与变体 props 合并后共同构成完整的对外类型面。

核心流程:从业务调用到最终 DOM

Loading diagram...

流程中每一步的设计意图:

  1. barrel 隔离内部路径——业务代码只认 $lib/components/ui/button,将来重命名 button.svelte → 其他文件名不影响调用方;
  2. tv() 在组件实例化前完成纯函数求值,不依赖任何响应式状态,渲染开销极小;
  3. cn 是唯一合并点,保证覆盖语义一致,也让每个组件的样式来源可预测;
  4. 多态分支放在渲染末尾,样式计算与元素选择解耦,新增第三种元素(如 NuxtLink 式路由链接)只需增加分支。

Configuration Options

components.json 中的所有选项均为 CLI 生成期配置,不参与运行时:

OptionTypeDefault(本仓库取值)Description
$schemastringhttps://shadcn-svelte.com/schema.jsonJSON Schema 校验地址,供编辑器提示
tailwind.cssstringsrc/routes/layout.css主题 CSS 变量与 Tailwind 指令所在文件;CLI 新增组件涉及样式变量时写入此处
tailwind.baseColorstringneutral中性色基底,决定 --background/--foreground 等变量初始值
aliases.componentsstring$lib/components业务组件目录别名
aliases.utilsstring$lib/utils/uicn 等工具所在模块,生成代码中的 $lib/utils/ui.js import 即来源于此
aliases.uistring$lib/components/uiUI 原语目录,即本页主题目录
aliases.hooksstring$lib/hooks钩子目录别名
aliases.libstring$lib库根别名
typescriptbooleantrue生成 TypeScript 组件(本项目全部组件均为 .svelte + lang="ts")
registrystringhttps://shadcn-svelte.com/registry组件 registry 源
stylestringlyra视觉风格;lyra 带来方角(rounded-none)、text-xs 小字号与紧凑尺寸体系
iconLibrarystringphosphor图标库,决定示例代码中的图标 import 来源
menuColor / menuAccentstringdefault / subtle菜单配色与强调风格

运行时「配置」实际由变体系统承担:调用方可传入的配置面就是各组件的 props(variant、size、class、href、disabled 等),默认值在 defaultVariants 与 props 解构默认值中定义(如 variant: 'default'、size: 'default'、type: 'button')。

API Reference

以 Button 组件为代表(其余组件遵循相同范式:props → cn(variants(), class) → data-slot → 透传):

ButtonProps

ts
1export type ButtonProps = WithElementRef<HTMLButtonAttributes> & 2 WithElementRef<HTMLAnchorAttributes> & { 3 variant?: ButtonVariant; 4 size?: ButtonSize; 5 };

Parameters:

参数类型默认值说明
variant'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link''default'视觉变体;destructive 为低饱和红调(bg-destructive/10)而非实心红,契合 lyra 风格
size'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg''default'尺寸;icon-* 系列为纯图标按钮(size-* 正方形)
classstring—追加类,经 cn 覆盖同 utility 默认值
hrefstringundefined传入后渲染 <a>;此时 disabled 走链接禁用 polyfill
typestring'button'仅 button 分支生效
disabledboolean—禁用态;button 原生支持,a 分支由组件 polyfill
refHTMLButtonElement | HTMLAnchorElement | nullnull$bindable,父组件 bind:ref 获取元素引用
childrenSnippet—子内容片段,{@render children?.()} 可选渲染
...restProps——其余属性原样透传到 DOM 元素

Returns: 渲染为 <button> 或 <a> DOM 元素,无编程式返回值。

Throws: 实现中未捕获/抛出自定义异常;错误路径主要为编译期类型不匹配。

导出的模块级成员

成员类型说明
buttonVariants(props?: { variant?, size?, class? }) => stringtv 生成的样式函数,可脱离组件用于菜单项等场景
ButtonVariantunion type全部 variant 值
ButtonSizeunion type全部 size 值

badge 同样导出 badgeVariants 与 BadgeVariant,遵循一致的「样式函数 + 类型」范式。

Failure Modes、边界情况与并发

  • <a> 禁用语义缺失:由 href=undefined + aria-disabled + tabindex=-1 显式 polyfill,避免死链可点击/可聚焦(见上文剖析);
  • 类名冲突:cn 基于 tailwind-merge 解决——外部传入与内部默认相同的 utility 时后者胜出,防止「传入无效」的隐性 bug;命名空间不冲突的类则共存;
  • 图标尺寸漂移:[&_svg:not([class*='size-'])]:size-4 仅在子 SVG 未显式指定 size-* 类时才施加默认尺寸,已自定义尺寸的图标不被覆盖;
  • 非交互元素承载交互:disabled:pointer-events-none disabled:opacity-50 同时从事件与视觉两个维度阻断,防止 hover 等伪类在禁用态被触发;
  • children?.() 可选调用:组件允许无子内容(纯图标按钮),片段缺失时不报错;
  • 并发/无状态性:所有组件均为无状态纯展示组件(状态由 bits-ui 类 headless 原语管理,如 Select/Tooltip 的开合状态),因此天然线程安全(浏览器单线程事件循环内无共享可变状态),不存在竞态问题。

Performance 与运维注意

  • tv() 的 base/variants/defaultVariants 是纯函数调用,无响应式依赖,样式串在每次渲染时重建——对 Svelte 5 的细粒度响应式而言,仅在 variant/size/class 变化时才触发 DOM class 更新;
  • transition-all + translate-y-px 按压动效依赖 GPU 合成,避免 layout 抖动;
  • 升级方式:修改 components.json 后重新执行 CLI add,新文件会覆盖旧实现——项目内定制的变体(如 icon-xs/icon-sm)会被覆盖丢失,运维上需以版本控制 diff 审慎合并;
  • 主题切换零成本:组件只引用语义色(bg-primary 等),明暗模式与主题切换由 CSS 变量在 layout.css 层完成,组件代码不感知。

Extension Points

  • 新增视觉变体:在组件 tv() 的 variants.variant 中追加键值(如 success:),类型经 VariantProps 自动扩展,无需改类型定义;
  • 新增尺寸:同上,在 variants.size 追加;本项目已实践(icon-xs、icon-sm、icon-lg 即为定制扩展);
  • 新增组件:执行 npx shadcn-svelte@latest add <name>,CLI 依据 aliases.ui 写入 src/lib/components/ui/<name>/ 并自动生成 index.ts;
  • 替换底层 headless 原语:Select/Dialog/Tooltip 等复合组件基于 bits-ui,可在不动样式层的前提下替换交互原语;
  • 子部件级定制:复合组件(card/dialog 等)的每个子部件都是独立 .svelte 文件,可单独修改而不影响其余部件。

Tests

源码中未发现针对 src/lib/components/ui/ 的单元测试文件。组件正确性依赖:TypeScript 编译期校验(props 类型与变体类型)、shadcn-svelte 上游 registry 的既有测试,以及业务组件层的集成验证。此为该仓库的客观现状,不构成设计保证。

  • button.svelte — 变体系统与多态渲染的参考实现
  • components.json — registry CLI 生成配置
  • badge/index.ts — 单文件组件 barrel 导出范式
  • card/index.ts — 复合组件 Root 别名导出范式
  • dialog/index.ts — 最复杂的复合组件导出结构
  • 姊妹页面:frontend 业务组件(DocLibrary / SettingsModal / DynamicForm 等)、frontend 路由布局、主题与全局样式

Sources

(2 files)
src/lib/components/ui/button