UI コンポーネントシステムドキュメント
UI アーキテクチャ概要
フロントエンドの UI システムは shadcn/ui + TailwindCSS をベースに構築され、アトミックデザインの考え方を採用し、完全でカスタマイズ可能かつ型安全なコンポーネントライブラリを提供します。テーマ切り替え、レスポンシブデザイン、アクセシビリティに対応しています。
️ コンポーネントアーキテクチャの階層
デザインシステムの階層
┌─────────────────────────────────────────────────────────────┐
│ 设计系统架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 设计令牌 │ │ 主题系统 │ │ 响应式断点 │ │
│ │ (CSS变量) │ │ (Theme) │ │ (Breakpoints)│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 基础组件层 │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Button │ │ Input │ │ Card │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 复合组件层 │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │MessageBubble│ │ToolCallList │ │LanguageSwitch│ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 业务组件层 │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ChatInterface│ │ Sphere │ │ AppLayout │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
shadcn/ui の統合
コンポーネントのインストールと設定
components.json の設定 :
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
コアユーティリティ関数
lib/utils.ts :
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 响应式类名处理
export function responsive(base: string, sm?: string, md?: string, lg?: string) {
return cn(
base,
sm && `sm:${sm}`,
md && `md:${md}`,
lg && `lg:${lg}`
)
}
// 变体类名生成
export function variant(base: string, variant: string, variants: Record<string, string>) {
return cn(base, variants[variant])
}
基本コンポーネントシステム
Button コンポーネント
特長 :
- 複数のバリアントに対応(default、destructive、outline、secondary、ghost、link)
- サイズを設定可能(default、sm、lg、icon)
- ローディング状態に対応
- 完全なアクセシビリティ対応
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
loading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{children}
</Comp>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
Input コンポーネント
特長 :
- エラー状態の処理
- プレースホルダーとラベルに対応
- 複数の入力タイプに対応
- オートフォーカスとバリデーション
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
error?: boolean
helperText?: string
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, error, helperText, ...props }, ref) => {
return (
<div className="grid w-full items-center gap-1.5">
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
error && "border-destructive focus-visible:ring-destructive",
className
)}
ref={ref}
{...props}
/>
{helperText && (
<p className={cn(
"text-sm",
error ? "text-destructive" : "text-muted-foreground"
)}>
{helperText}
</p>
)}
</div>
)
}
)
Input.displayName = "Input"
export { Input }
Card コンポーネント
特長 :
- 柔軟なレイアウトに対応
- 影と枠線を設定可能
- コンポーネントのネストに対応
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
Card.displayName = "Card"
CardHeader.displayName = "CardHeader"
CardTitle.displayName = "CardTitle"
CardContent.displayName = "CardContent"
export { Card, CardHeader, CardTitle, CardContent }
複合コンポーネント
MessageBubble:メッセージバブルコンポーネント
import React from 'react'
import { cn } from '@/lib/utils'
import { Message, ToolCall } from '@/lib/app-state-manager'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ToolCallList } from './tool-call-display'
import ReasoningDisplay from './reasoning-display'
interface MessageBubbleProps {
message: Message
className?: string
}
export function MessageBubble({ message, className }: MessageBubbleProps) {
const isUser = message.role === 'user'
const isAssistant = message.role === 'assistant'
return (
<div
className={cn(
"flex w-full mb-4",
isUser ? "justify-end" : "justify-start",
className
)}
>
<div className={cn(
"max-w-[85%] sm:max-w-[75%]",
isUser ? "ml-auto" : "mr-auto"
)}>
<Card className={cn(
"overflow-hidden",
isUser ? "bg-primary text-primary-foreground" : "bg-muted"
)}>
<CardContent className="p-3">
{/* 角色标识 */}
<div className="flex items-center gap-2 mb-2">
<Badge variant={isUser ? "secondary" : "default"} className="text-xs">
{isUser ? "用户" : "助手"}
</Badge>
<span className="text-xs opacity-70">
{new Date(message.timestamp).toLocaleTimeString()}
</span>
</div>
{/* 思维链显示 */}
{message.reasoningContent && (
<ReasoningDisplay
content={message.reasoningContent}
isStreaming={message.isReasoningStreaming || false}
className="mb-3"
/>
)}
{/* 消息内容 */}
{message.content && (
<div className="prose prose-sm max-w-none">
<p className="whitespace-pre-wrap leading-relaxed">
{message.content}
</p>
</div>
)}
{/* 工具调用列表 */}
{message.toolCalls && message.toolCalls.length > 0 && (
<ToolCallList
toolCalls={message.toolCalls}
className="mt-3"
/>
)}
</CardContent>
</Card>
</div>
</div>
)
}
LanguageSwitcher:言語切り替えコンポーネント
import React from 'react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useI18n } from '@/lib/i18n/context'
import { SUPPORTED_LOCALES, type SupportedLocale } from '@/lib/i18n/config'
interface LanguageSwitcherProps {
variant?: 'default' | 'compact'
className?: string
}
export function LanguageSwitcher({ variant = 'default', className }: LanguageSwitcherProps) {
const { locale, setLocale } = useI18n()
const handleLocaleChange = (newLocale: SupportedLocale) => {
setLocale(newLocale)
}
if (variant === 'compact') {
return (
<div className="px-2 py-1">
{Object.values(SUPPORTED_LOCALES).map((localeConfig) => (
<button
key={localeConfig.code}
onClick={() => handleLocaleChange(localeConfig.code)}
className={cn(
"block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900",
locale === localeConfig.code && "bg-gray-100 text-gray-900"
)}
>
<div className="flex items-center">
<span className="mr-2">{localeConfig.flag}</span>
{localeConfig.nativeName}
</div>
</button>
))}
</div>
)
}
const currentLocale = SUPPORTED_LOCALES[locale]
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className={className}>
<span className="mr-2">{currentLocale.flag}</span>
{currentLocale.nativeName}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{Object.values(SUPPORTED_LOCALES).map((localeConfig) => (
<DropdownMenuItem
key={localeConfig.code}
onClick={() => handleLocaleChange(localeConfig.code)}
className="cursor-pointer"
>
<span className="mr-2">{localeConfig.flag}</span>
{localeConfig.nativeName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
テーマシステム
CSS 変数の定義
app/globals.css :
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
}
/* 自定义工具类 */
@layer utilities {
.animate-fade-in {
animation: fadeIn 0.2s ease-in-out;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
.animate-pulse-gentle {
animation: pulseGentle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes pulseGentle {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
テーマプロバイダー
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
// 使用示例
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
)
}
レスポンシブデザイン
ブレークポイントシステム
// tailwind.config.ts中的断点配置
const config: Config = {
theme: {
screens: {
'xs': '475px',
'sm': '640px',
'md': '768px',
'lg': '1024px',
'xl': '1280px',
'2xl': '1536px',
},
}
}
// 使用自定义钩子检测移动设备
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(false)
React.useEffect(() => {
const checkDevice = () => {
setIsMobile(window.innerWidth < 768)
}
checkDevice()
window.addEventListener("resize", checkDevice)
return () => {
window.removeEventListener("resize", checkDevice)
}
}, [])
return isMobile
}
レスポンシブコンポーネントの例
import { useIsMobile } from '@/hooks/use-mobile'
export function ResponsiveButton() {
const isMobile = useIsMobile()
return (
<Button
size={isMobile ? "sm" : "default"}
className={cn(
"transition-all duration-200",
isMobile ? "w-full" : "w-auto"
)}
>
{isMobile ? "发送" : "发送消息"}
</Button>
)
}
♿ アクセシビリティ
キーボードナビゲーションへの対応
export function AccessibleButton({
children,
onActivate,
...props
}: {
children: React.ReactNode
onActivate: () => void
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate()
}
}
return (
<button
{...props}
onClick={onActivate}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
aria-pressed="false"
>
{children}
</button>
)
}
ARIA 属性への対応
export function MessageInput() {
const [input, setInput] = useState('')
const { sendUserInput } = useAppState()
return (
<div className="relative">
<label htmlFor="message-input" className="sr-only">
输入消息
</label>
<Textarea
id="message-input"
placeholder="输入你的问题..."
value={input}
onChange={(e) => setInput(e.target.value)}
aria-describedby="message-help"
aria-label="消息输入框"
/>
<p id="message-help" className="sr-only">
输入消息后按Enter键发送,或点击发送按钮
</p>
<Button
onClick={() => sendUserInput(input)}
aria-label="发送消息"
disabled={!input.trim()}
>
发送
</Button>
</div>
)
}
コンポーネント開発のベストプラクティス
1. コンポーネント設計の原則
// ✅ 好的组件设计
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
}
// ❌ 避免的设计
interface BadButtonProps {
isPrimary?: boolean
isSecondary?: boolean
isSmall?: boolean
isMedium?: boolean
isLarge?: boolean
showSpinner?: boolean
}
2. TypeScript の型定義
// 扩展HTML元素属性
interface CustomInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helperText?: string
}
// 使用泛型支持多种元素类型
interface FlexProps<T = HTMLDivElement> extends React.HTMLAttributes<T> {
direction?: 'row' | 'column'
align?: 'start' | 'center' | 'end'
justify?: 'start' | 'center' | 'end' | 'between'
gap?: number
as?: React.ElementType
}
3. コンポーネントのテスト
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from '../button'
describe('Button Component', () => {
it('renders with correct text', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument()
})
it('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('shows loading state correctly', () => {
render(<Button loading>Loading</Button>)
expect(screen.getByRole('button')).toBeDisabled()
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
})
})
UI コンポーネントシステムは、フロントエンドアプリケーションに統一的で柔軟かつ保守しやすいインターフェース基盤を提供します。shadcn/ui と TailwindCSS を組み合わせることで、モダンなデザインシステムと優れたユーザー体験を実現しています。
ドキュメント