S SmartDocs
系列: React.js javascript 305 行 · 更新於 2026-04-02

Project_CompoundComponents.jsx

React.js/lessons/20_advanced_patterns/projects/Project_CompoundComponents.jsx

/**
 * Project: Tabs + Accordion 複合元件 — 進階設計模式練習
 *
 * 學習重點:
 * - Compound Components 模式
 * - 透過 Context 在子元件間共享狀態
 * - 將子元件掛載到主元件(Tabs.List, Tabs.Content)
 * - 靈活的 API 設計
 *
 * 使用方式:將此檔案內容複製到你的 App.jsx 中
 */

import { createContext, useContext, useState } from 'react';

// ============================================================
// Tabs 複合元件
// ============================================================
const TabsContext = createContext();

function Tabs({ children, defaultValue, onChange }) {
  const [activeTab, setActiveTab] = useState(defaultValue);

  const handleChange = (value) => {
    setActiveTab(value);
    onChange?.(value);
  };

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab: handleChange }}>
      <div style={{ marginBottom: '24px' }}>{children}</div>
    </TabsContext.Provider>
  );
}

function TabsList({ children }) {
  return (
    <div style={{
      display: 'flex', gap: '4px', padding: '4px',
      backgroundColor: '#f1f5f9', borderRadius: '10px', marginBottom: '16px',
    }}>
      {children}
    </div>
  );
}

function TabsTrigger({ value, children }) {
  const { activeTab, setActiveTab } = useContext(TabsContext);
  const isActive = activeTab === value;

  return (
    <button
      onClick={() => setActiveTab(value)}
      style={{
        flex: 1, padding: '10px 16px', borderRadius: '8px', border: 'none',
        backgroundColor: isActive ? 'white' : 'transparent',
        color: isActive ? '#0f172a' : '#64748b',
        fontWeight: isActive ? '600' : '400',
        cursor: 'pointer', fontSize: '0.9rem',
        boxShadow: isActive ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
        transition: 'all 0.2s',
      }}
    >
      {children}
    </button>
  );
}

function TabsContent({ value, children }) {
  const { activeTab } = useContext(TabsContext);
  if (activeTab !== value) return null;
  return <div>{children}</div>;
}

Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Content = TabsContent;

// ============================================================
// Accordion 複合元件
// ============================================================
const AccordionContext = createContext();
const AccordionItemContext = createContext();

function Accordion({ children, multiple = false }) {
  const [openItems, setOpenItems] = useState(new Set());

  const toggle = (id) => {
    setOpenItems((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        if (!multiple) next.clear();
        next.add(id);
      }
      return next;
    });
  };

  return (
    <AccordionContext.Provider value={{ openItems, toggle }}>
      <div style={{
        borderRadius: '12px', border: '1px solid #e2e8f0', overflow: 'hidden',
      }}>
        {children}
      </div>
    </AccordionContext.Provider>
  );
}

function AccordionItem({ value, children }) {
  return (
    <AccordionItemContext.Provider value={{ value }}>
      <div style={{ borderBottom: '1px solid #e2e8f0' }}>{children}</div>
    </AccordionItemContext.Provider>
  );
}

function AccordionTrigger({ children }) {
  const { openItems, toggle } = useContext(AccordionContext);
  const { value } = useContext(AccordionItemContext);
  const isOpen = openItems.has(value);

  return (
    <button
      onClick={() => toggle(value)}
      style={{
        width: '100%', padding: '16px 20px', border: 'none',
        backgroundColor: isOpen ? '#f8fafc' : 'white',
        cursor: 'pointer', display: 'flex', justifyContent: 'space-between',
        alignItems: 'center', fontSize: '0.95rem', fontWeight: '500',
        color: '#0f172a', textAlign: 'left',
      }}
    >
      {children}
      <span style={{
        transform: isOpen ? 'rotate(180deg)' : 'rotate(0)',
        transition: 'transform 0.2s', color: '#94a3b8',
      }}>
        ▼
      </span>
    </button>
  );
}

function AccordionContent({ children }) {
  const { openItems } = useContext(AccordionContext);
  const { value } = useContext(AccordionItemContext);
  if (!openItems.has(value)) return null;

  return (
    <div style={{
      padding: '0 20px 16px', color: '#475569', fontSize: '0.9rem', lineHeight: 1.6,
      backgroundColor: '#f8fafc',
    }}>
      {children}
    </div>
  );
}

Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;

// ============================================================
// App — 展示這些複合元件
// ============================================================
function App() {
  return (
    <div style={{
      maxWidth: '700px', margin: '32px auto', padding: '0 16px',
      fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif",
    }}>
      <h1 style={{ color: '#0f172a', marginBottom: '4px' }}>Compound Components</h1>
      <p style={{ color: '#94a3b8', marginTop: 0, marginBottom: '32px' }}>
        Tabs 和 Accordion 複合元件設計模式展示
      </p>

      {/* Tabs 範例 */}
      <h2 style={{ color: '#475569', fontSize: '1rem', marginBottom: '12px' }}>Tabs 元件</h2>
      <Tabs defaultValue="overview" onChange={(v) => console.log('Tab changed:', v)}>
        <Tabs.List>
          <Tabs.Trigger value="overview">總覽</Tabs.Trigger>
          <Tabs.Trigger value="features">功能</Tabs.Trigger>
          <Tabs.Trigger value="pricing">價格</Tabs.Trigger>
          <Tabs.Trigger value="faq">FAQ</Tabs.Trigger>
        </Tabs.List>

        <Tabs.Content value="overview">
          <div style={{ padding: '20px', borderRadius: '12px', backgroundColor: '#f8fafc' }}>
            <h3 style={{ margin: '0 0 8px', color: '#0f172a' }}>產品總覽</h3>
            <p style={{ margin: 0, color: '#64748b', lineHeight: 1.6 }}>
              這是一個使用 Compound Components 模式建構的 Tabs 元件。
              父元件(Tabs)透過 Context 與子元件(TabsList、TabsTrigger、TabsContent)共享狀態,
              使用者不需要手動傳遞 props,API 設計非常直覺。
            </p>
          </div>
        </Tabs.Content>

        <Tabs.Content value="features">
          <div style={{ padding: '20px', borderRadius: '12px', backgroundColor: '#f8fafc' }}>
            <h3 style={{ margin: '0 0 12px', color: '#0f172a' }}>功能列表</h3>
            {['Context 共享狀態', '靈活的子元件組合', '受控 / 非受控模式', 'onChange 回呼'].map((f) => (
              <div key={f} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 0' }}>
                <span style={{ color: '#22c55e' }}>✓</span>
                <span style={{ color: '#475569' }}>{f}</span>
              </div>
            ))}
          </div>
        </Tabs.Content>

        <Tabs.Content value="pricing">
          <div style={{ padding: '20px', borderRadius: '12px', backgroundColor: '#f8fafc', textAlign: 'center' }}>
            <h3 style={{ margin: '0 0 4px', color: '#0f172a' }}>免費開源</h3>
            <p style={{ margin: 0, color: '#64748b' }}>這是教學範例,永久免費!</p>
          </div>
        </Tabs.Content>

        <Tabs.Content value="faq">
          <div style={{ padding: '20px', borderRadius: '12px', backgroundColor: '#f8fafc' }}>
            <p style={{ margin: 0, color: '#64748b' }}>請參考下方的 Accordion FAQ 元件。</p>
          </div>
        </Tabs.Content>
      </Tabs>

      {/* Accordion 範例 */}
      <h2 style={{ color: '#475569', fontSize: '1rem', margin: '32px 0 12px' }}>Accordion 元件(單選)</h2>
      <Accordion>
        <Accordion.Item value="q1">
          <Accordion.Trigger>什麼是 Compound Components?</Accordion.Trigger>
          <Accordion.Content>
            <p>Compound Components 是一種 React 設計模式,多個元件透過 Context 隱式共享狀態。
            類似 HTML 的 {'<select>'} 和 {'<option>'} 的關係。</p>
          </Accordion.Content>
        </Accordion.Item>
        <Accordion.Item value="q2">
          <Accordion.Trigger>為什麼要使用這種模式?</Accordion.Trigger>
          <Accordion.Content>
            <p>它讓元件的 API 更加直覺和靈活。使用者可以自由組合子元件,
            而不需要傳遞大量的 props。非常適合 UI 元件庫的設計。</p>
          </Accordion.Content>
        </Accordion.Item>
        <Accordion.Item value="q3">
          <Accordion.Trigger>什麼時候該用自訂 Hook 而不是 Compound Components?</Accordion.Trigger>
          <Accordion.Content>
            <p>自訂 Hook 適合共享邏輯(不含 UI),Compound Components 適合共享帶有 UI 結構的元件。
            如果你需要多個視覺元件協同工作,Compound Components 是更好的選擇。</p>
          </Accordion.Content>
        </Accordion.Item>
      </Accordion>

      {/* 多選 Accordion */}
      <h2 style={{ color: '#475569', fontSize: '1rem', margin: '32px 0 12px' }}>Accordion 元件(多選)</h2>
      <Accordion multiple>
        <Accordion.Item value="step1">
          <Accordion.Trigger>步驟 1:建立 Context</Accordion.Trigger>
          <Accordion.Content>
            <code style={{ display: 'block', padding: '12px', borderRadius: '8px', backgroundColor: '#0f172a', color: '#a5b4fc', fontSize: '0.8rem' }}>
              {'const TabsContext = createContext();'}
            </code>
          </Accordion.Content>
        </Accordion.Item>
        <Accordion.Item value="step2">
          <Accordion.Trigger>步驟 2:建立 Provider(父元件)</Accordion.Trigger>
          <Accordion.Content>
            <p>父元件管理共享狀態,透過 Provider 傳遞給所有子元件。</p>
          </Accordion.Content>
        </Accordion.Item>
        <Accordion.Item value="step3">
          <Accordion.Trigger>步驟 3:建立子元件</Accordion.Trigger>
          <Accordion.Content>
            <p>子元件用 useContext 取得共享狀態,實現各自的功能。</p>
          </Accordion.Content>
        </Accordion.Item>
        <Accordion.Item value="step4">
          <Accordion.Trigger>步驟 4:掛載到主元件</Accordion.Trigger>
          <Accordion.Content>
            <code style={{ display: 'block', padding: '12px', borderRadius: '8px', backgroundColor: '#0f172a', color: '#a5b4fc', fontSize: '0.8rem' }}>
              {'Tabs.List = TabsList;\nTabs.Trigger = TabsTrigger;\nTabs.Content = TabsContent;'}
            </code>
          </Accordion.Content>
        </Accordion.Item>
      </Accordion>

      {/* API 展示 */}
      <div style={{
        marginTop: '32px', padding: '20px', borderRadius: '12px',
        backgroundColor: '#0f172a', color: '#94a3b8', fontFamily: 'monospace',
        fontSize: '0.8rem', lineHeight: 1.8,
      }}>
        <p style={{ color: '#818cf8', margin: '0 0 8px' }}>{'// 使用方式如此直覺:'}</p>
        <p style={{ color: '#f1f5f9', margin: 0 }}>{'<Tabs defaultValue="tab1">'}</p>
        <p style={{ color: '#f1f5f9', margin: 0 }}>{'  <Tabs.List>'}</p>
        <p style={{ color: '#f1f5f9', margin: 0 }}>{'    <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>'}</p>
        <p style={{ color: '#f1f5f9', margin: 0 }}>{'    <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>'}</p>
        <p style={{ color: '#f1f5f9', margin: 0 }}>{'  </Tabs.List>'}</p>
        <p style={{ color: '#f1f5f9', margin: 0 }}>{'  <Tabs.Content value="tab1">內容 1</Tabs.Content>'}</p>
        <p style={{ color: '#f1f5f9', margin: 0 }}>{'  <Tabs.Content value="tab2">內容 2</Tabs.Content>'}</p>
        <p style={{ color: '#f1f5f9', margin: 0 }}>{'</Tabs>'}</p>
      </div>
    </div>
  );
}

export default App;

相關文章