Blocks 高阶组件

贡献 Blocks 高阶组件的详细指南

概述

Blocks 是高阶组件,包含复合组件、特效组件、业务组件等。它们通常由多个基础组件组合而成,提供完整的功能模块。

创建 Block 组件

1. 生成组件模板

npm run registry:create <block-name> --type block

生成的目录结构:

registry/ssp/block/<block-name>/
├── index.tsx          # 组件源代码
├── index.json         # 组件注册信息
├── example.tsx        # 组件示例
└── README.mdx         # 组件文档

2. 配置依赖

Blocks 通常依赖多个基础组件和外部库:

registry/ssp/block/<block-name>/index.json
{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "block-name",
  "type": "registry:block",
  "dependencies": [
    "framer-motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "card",
    "input"
  ]
}

3. 实现组件

复合组件示例

registry/ssp/block/search-dialog/index.tsx
import * as React from "react"
import { Search, X } from "lucide-react"
import { motion, AnimatePresence } from "framer-motion"
import { Button } from "@/registry/ssp/ui/button"
import { Input } from "@/registry/ssp/ui/input"
import { Card } from "@/registry/ssp/ui/card"
import { cn } from "@/lib/utils"

export interface SearchDialogProps {
  isOpen: boolean
  onClose: () => void
  onSearch: (query: string) => void
  placeholder?: string
  results?: Array<{
    id: string
    title: string
    description?: string
  }>
}

export function SearchDialog({
  isOpen,
  onClose,
  onSearch,
  placeholder = "Search...",
  results = []
}: SearchDialogProps) {
  const [query, setQuery] = React.useState("")

  const handleSearch = (value: string) => {
    setQuery(value)
    onSearch(value)
  }

  return (
    <AnimatePresence>
      {isOpen && (
        <>
          {/* Backdrop */}
          <motion.div
            className="fixed inset-0 bg-black/50 z-50"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
          />
          
          {/* Dialog */}
          <motion.div
            className="fixed top-20 left-1/2 w-full max-w-2xl z-50"
            initial={{ opacity: 0, y: -20, x: "-50%" }}
            animate={{ opacity: 1, y: 0, x: "-50%" }}
            exit={{ opacity: 0, y: -20, x: "-50%" }}
          >
            <Card className="mx-4 p-4">
              <div className="flex items-center gap-2 mb-4">
                <Search className="h-4 w-4 text-muted-foreground" />
                <Input
                  value={query}
                  onChange={(e) => handleSearch(e.target.value)}
                  placeholder={placeholder}
                  className="border-0 focus-visible:ring-0"
                  autoFocus
                />
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={onClose}
                >
                  <X className="h-4 w-4" />
                </Button>
              </div>
              
              {results.length > 0 && (
                <div className="space-y-2">
                  {results.map((result) => (
                    <div
                      key={result.id}
                      className="p-2 rounded hover:bg-muted cursor-pointer"
                    >
                      <div className="font-medium">{result.title}</div>
                      {result.description && (
                        <div className="text-sm text-muted-foreground">
                          {result.description}
                        </div>
                      )}
                    </div>
                  ))}
                </div>
              )}
            </Card>
          </motion.div>
        </>
      )}
    </AnimatePresence>
  )
}

特效组件示例

registry/ssp/block/glow-card/index.tsx
import * as React from "react"
import { motion } from "framer-motion"
import { cn } from "@/lib/utils"

export interface GlowCardProps 
  extends React.HTMLAttributes<HTMLDivElement> {
  glowColor?: string
  intensity?: "low" | "medium" | "high"
}

export function GlowCard({
  className,
  glowColor = "blue",
  intensity = "medium",
  children,
  ...props
}: GlowCardProps) {
  const intensityMap = {
    low: "0.3",
    medium: "0.6", 
    high: "0.9"
  }

  return (
    <motion.div
      className={cn(
        "relative rounded-lg border bg-card p-6",
        "before:absolute before:inset-0 before:rounded-lg before:p-[1px]",
        "before:bg-gradient-to-r before:from-transparent before:via-current before:to-transparent",
        "before:opacity-0 hover:before:opacity-100",
        "before:transition-opacity before:duration-500",
        className
      )}
      style={{
        "--glow-color": `hsl(var(--${glowColor}))`,
        filter: `drop-shadow(0 0 20px hsla(var(--${glowColor}) / ${intensityMap[intensity]}))`,
      } as React.CSSProperties}
      whileHover={{ scale: 1.02 }}
      transition={{ duration: 0.2 }}
      {...props}
    >
      {children}
    </motion.div>
  )
}

4. 创建示例

registry/ssp/block/search-dialog/example.tsx
import * as React from "react"
import { SearchDialog } from "./index"
import { Button } from "@/registry/ssp/ui/button"

const mockResults = [
  { id: "1", title: "React Hooks", description: "Learn about React Hooks" },
  { id: "2", title: "TypeScript", description: "TypeScript fundamentals" },
  { id: "3", title: "Next.js", description: "Next.js framework guide" },
]

export default function Example() {
  const [isOpen, setIsOpen] = React.useState(false)
  const [results, setResults] = React.useState([])

  const handleSearch = (query: string) => {
    if (query.trim()) {
      const filtered = mockResults.filter(item =>
        item.title.toLowerCase().includes(query.toLowerCase())
      )
      setResults(filtered)
    } else {
      setResults([])
    }
  }

  return (
    <div>
      <Button onClick={() => setIsOpen(true)}>
        Open Search Dialog
      </Button>
      
      <SearchDialog
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        onSearch={handleSearch}
        results={results}
        placeholder="Search documentation..."
      />
    </div>
  )
}

5. 编写文档

registry/ssp/block/<block-name>/README.mdx
---
title: Block Name
---

## 安装

<RegistryDownload filename="block-name.json" />

## 用法

import Example from "@/registry/ssp/block/block-name/example";

<ExampleCode content={`_code_autogenerated_`}>
  <Example />
</ExampleCode>

## 特性

- 功能特性 1
- 功能特性 2
- 功能特性 3

## API

### Props

| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| prop1 | string | - | 属性描述 |
| prop2 | boolean | false | 属性描述 |

## 样式定制

### CSS 变量

```css
.block-name {
  --block-color: hsl(var(--primary));
  --block-radius: var(--radius);
}

主题扩展

如需自定义主题,可以扩展以下 CSS 变量:

{
  "cssVars": {
    "--block-custom": "value"
  }
}

## Block 类型

### 1. 复合组件
多个基础组件的组合,提供完整的功能模块:
- 搜索对话框
- 数据表格
- 表单组合
- 导航菜单

### 2. 特效组件
带有动画和特效的组件:
- 发光卡片
- 粒子背景
- 渐变边框
- 动态图标

### 3. 业务组件
特定业务场景的组件:
- 用户头像
- 状态指示器
- 进度追踪
- 评分组件

### 4. 布局组件
页面布局相关的组件:
- 网格布局
- 瀑布流
- 侧边栏
- 面包屑

## 最佳实践

### 1. 组件设计原则
- **单一职责**:每个 Block 专注解决一个特定问题
- **可组合性**:与其他组件良好配合
- **可定制性**:提供足够的配置选项
- **性能优化**:避免不必要的重渲染

### 2. API 设计
```typescript
// 好的 API 设计
interface GoodBlockProps {
  // 核心功能属性
  data: DataType[]
  onAction: (item: DataType) => void
  
  // 可选配置
  variant?: "default" | "compact"
  size?: "sm" | "md" | "lg"
  
  // 样式定制
  className?: string
  style?: CSSProperties
  
  // 行为配置
  disabled?: boolean
  loading?: boolean
}

3. 性能优化

// 使用 memo 避免不必要的渲染
export const ExpensiveBlock = React.memo(({ data, ...props }) => {
  // 组件实现
})

// 使用 useMemo 缓存计算结果
const processedData = React.useMemo(() => {
  return data.map(processItem)
}, [data])

// 使用 useCallback 稳定函数引用
const handleClick = React.useCallback((id: string) => {
  onItemClick(id)
}, [onItemClick])

4. 可访问性

// 提供适当的 ARIA 属性
<div
  role="dialog"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-description"
>
  <h2 id="dialog-title">Dialog Title</h2>
  <p id="dialog-description">Dialog description</p>
</div>

// 支持键盘导航
const handleKeyDown = (event: KeyboardEvent) => {
  if (event.key === 'Escape') {
    onClose()
  }
}

5. 错误边界

// 为复杂组件提供错误边界
export function BlockWithErrorBoundary(props: BlockProps) {
  return (
    <ErrorBoundary fallback={<BlockErrorFallback />}>
      <Block {...props} />
    </ErrorBoundary>
  )
}

测试

为 Block 组件编写全面的测试:

import { render, screen, fireEvent } from '@testing-library/react'
import { SearchDialog } from './search-dialog'

describe('SearchDialog', () => {
  it('renders when open', () => {
    render(
      <SearchDialog
        isOpen={true}
        onClose={jest.fn()}
        onSearch={jest.fn()}
      />
    )
    
    expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
  })
  
  it('calls onSearch when typing', () => {
    const onSearch = jest.fn()
    render(
      <SearchDialog
        isOpen={true}
        onClose={jest.fn()}
        onSearch={onSearch}
      />
    )
    
    fireEvent.change(screen.getByPlaceholderText('Search...'), {
      target: { value: 'test query' }
    })
    
    expect(onSearch).toHaveBeenCalledWith('test query')
  })
})

下一步

完成 Block 开发后,查看 文档生成与同步 了解如何发布组件。