Chapter 09

进阶定制

Command 命令面板、Combobox、DatePicker、Chart,以及把这些原语封装成复合组件、自建 Registry 分发你的组件库。

9.1 关键概念

Command(cmdk)
基于 cmdk 库的命令面板/搜索列表,带即时过滤、键盘导航、分组。是 ⌘K 全局搜索、Combobox 的底座。
CommandDialog
Command 套一层 Dialog,配合快捷键(如 ⌘K)唤起的全局命令面板,Vercel/Linear 同款交互。
Combobox
shadcn 没有单独的 Combobox 组件,而是用 Popover + Command 组合出"可搜索的下拉选择器",是一种模式而非文件。
DatePicker
同样是组合模式:Popover + Calendar(Calendar 基于 react-day-picker),点触发按钮弹出日历选日期。
Chart
shadcn 对 Recharts 的一层薄封装:ChartContainer + ChartConfig 把图表色接入 CSS 变量令牌,配 ChartTooltip
复合组件封装
把常用组合(如带图标+校验的输入)二次封装成项目内组件,减少重复、统一风格,仍建立在 shadcn 原语上。
Registry(注册表)
一个符合 shadcn schema 的 JSON 端点,描述组件的文件、依赖、注册依赖。他人可用 npx shadcn add <你的url> 拉取你的组件。
registry:item schema
单个组件的清单格式:name / type / files / dependencies / registryDependencies,CLI 据此把文件写进使用者项目。

9.2 安装

$ npx shadcn@latest add command popover calendar chart
$ npm i cmdk react-day-picker date-fns recharts

9.3 Command 面板(⌘K)

"use client";
import { useEffect, useState } from "react";
import {
  CommandDialog, CommandEmpty, CommandGroup,
  CommandInput, CommandItem, CommandList,
} from "@/components/ui/command";

export function CommandMenu() {
  const [open, setOpen] = useState(false);
  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault(); setOpen((o) => !o);
      }
    };
    document.addEventListener("keydown", down);
    return () => document.removeEventListener("keydown", down);
  }, []);

  return (
    <CommandDialog open={open} onOpenChange={setOpen}>
      <CommandInput placeholder="输入命令或搜索…" />
      <CommandList>
        <CommandEmpty>无结果。</CommandEmpty>
        <CommandGroup heading="建议">
          <CommandItem>打开仪表盘</CommandItem>
          <CommandItem>新建项目</CommandItem>
        </CommandGroup>
      </CommandList>
    </CommandDialog>
  );
}

9.4 Combobox = Popover + Command

const [open, setOpen] = useState(false);
const [value, setValue] = useState("");

<Popover open={open} onOpenChange={setOpen}>
  <PopoverTrigger asChild>
    <Button variant="outline" role="combobox" aria-expanded={open}
      className="w-[200px] justify-between">
      {value || "选择框架…"}
      <ChevronsUpDown className="opacity-50" />
    </Button>
  </PopoverTrigger>
  <PopoverContent className="w-[200px] p-0">
    <Command>
      <CommandInput placeholder="搜索…" />
      <CommandList>
        <CommandEmpty>无结果</CommandEmpty>
        <CommandGroup>
          {frameworks.map((f) => (
            <CommandItem key={f.value}
              onSelect={() => { setValue(f.value); setOpen(false); }}>
              {f.label}
            </CommandItem>
          ))}
        </CommandGroup>
      </CommandList>
    </Command>
  </PopoverContent>
</Popover>
"模式"不是"组件"

Combobox 和 DatePicker 在 shadcn 里没有独立文件,而是把已有原语组合出来的配方。官网给示例代码,你抄进项目后就是自己的复合组件——可任意改造。

9.5 DatePicker = Popover + Calendar

import { Calendar } from "@/components/ui/calendar";
import { format } from "date-fns";

const [date, setDate] = useState<Date>();

<Popover>
  <PopoverTrigger asChild>
    <Button variant="outline">
      <CalendarIcon className="mr-2 h-4 w-4" />
      {date ? format(date, "yyyy-MM-dd") : "选择日期"}
    </Button>
  </PopoverTrigger>
  <PopoverContent className="w-auto p-0">
    <Calendar mode="single" selected={date} onSelect={setDate} />
  </PopoverContent>
</Popover>

9.6 Chart(Recharts + 令牌)

import {
  ChartContainer, ChartTooltip, ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart";
import { Bar, BarChart, XAxis } from "recharts";

const config = {
  desktop: { label: "桌面", color: "var(--chart-1)" },
} satisfies ChartConfig;

<ChartContainer config={config} className="h-[200px] w-full">
  <BarChart data={data}>
    <XAxis dataKey="month" />
    <ChartTooltip content={<ChartTooltipContent />} />
    <Bar dataKey="desktop" fill="var(--color-desktop)" radius={4} />
  </BarChart>
</ChartContainer>

图表颜色引用 --chart-1..5 令牌,明暗主题自动跟随——与第 3 章的设计系统打通。

9.7 二次封装复合组件

// components/text-field.tsx —— 把 Label+Input+错误 封成一个件
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";

export function TextField({ label, id, error, className, ...props }) {
  return (
    <div className={cn("grid gap-1.5", className)}>
      <Label htmlFor={id}>{label}</Label>
      <Input id={id} aria-invalid={!!error} {...props} />
      {error && <p className="text-sm text-destructive">{error}</p>}
    </div>
  );
}

9.8 自建 Registry 分发组件

// registry.json —— 描述你要分发的组件
{
  "$schema": "https://ui.shadcn.com/schema/registry.json",
  "name": "acme-ui",
  "homepage": "https://acme.com",
  "items": [
    {
      "name": "text-field",
      "type": "registry:component",
      "registryDependencies": ["input", "label"],
      "files": [
        { "path": "registry/text-field.tsx", "type": "registry:component" }
      ]
    }
  ]
}
# 构建并托管后,别人这样安装你的组件
$ npx shadcn@latest build          # 生成 public/r/*.json
$ npx shadcn@latest add https://acme.com/r/text-field.json
企业内共享组件的正解

团队想统一组件又要各项目"拥有代码"?自建 Registry:把公共组件发布成 JSON 端点,各项目用 CLI 拉取。既复用又保留可改的开放代码优势。

9.9 小结与下一步