HumansXドキュメントセンター 公式サイトへ ↗

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 を組み合わせることで、モダンなデザインシステムと優れたユーザー体験を実現しています。