---
# System prepended metadata

title: 用 React 實作 Tabs 元件
tags: [note]

---

# 用 React 實作 Tabs 元件

Elantris

[TOC]

---

## 元件分析

### 組成結構

- 按鈕列 (Bar)
  - 標籤按鈕 (Item)
- 內容 (Content)
  - 內容框 (Pane)

```html
<div class="tabs">
  <div class="tabs-bar">
    <button class="tabs-item active"></button>
    <button class="tabs-item"></button>
    <button class="tabs-item"></button>
  </div>
  <div class="tabs-content">
    <div class="tabs-pane active"></div>
    <div class="tabs-pane"></div>
    <div class="tabs-pane"></div>
  </div>
</div>
```

### 互動邏輯

1. 只顯示其中一個內容框
2. 點擊按鈕列上的標籤切換要顯示的內容框

## React 元件設計

### 最小可行性元件

最基本的資料所需的參數

- `key` 用來對應每一筆資料
- `label` 顯示於按鈕列上的標籤
- `content` 顯示內容

```typescript
type ItemProps = {
  key: string
  label: string
  content: React.ReactNode
}
```

理論上元件 props 只需要 `Item` 陣列即可，`active` 狀態由內部 state 控制：

```tsx
const Tabs: React.FC<{
  items: ItemProps[]
}> = ({ items }) => {
  const [activeKey, setActiveKey] = React.useState(items[0].key)

  return (
    <div className="tabs">
      <div className="tabs-bar">
        {items.map((item) => {
          const isActive = item.key === activeKey
          return (
            <button
              key={item.key}
              className={`tabs-item ${isActive ? 'active' : ''}`}
              onClick={() => setActiveKey(item.key)}
            >
              {item.label}
            </button>
          )
        })}
      </div>
      <div className="tabs-content">
        {items.map((item) => {
          const isActive = item.key === activeKey
          return (
            <div key={item.key} className={`tabs-pane ${isActive ? 'active' : ''}`}>
              {item.content}
            </div>
          )
        })}
      </div>
    </div>
  )
}
```

### 增加外部控制參數

為了讓這個元件更方便使用加上一些 props，渲染時優先取用外部狀態：

```tsx
Tabs: React.FC<{
  items: ItemProps[]
  defaultActiveKey?: string
  activeKey?: string
  onChange?: (key: string) => void
}> = ({ items, defaultActiveKey, activeKey, onChange }) => {
  const [_activeKey, _setActiveKey] = React.useState(defaultActiveKey ?? items[0].key)
  const __activeKey = activeKey ?? _activeKey
  // ...
}
```

- `defaultActiveKey` 由外部指定預設要顯示的內容，只影響內部狀態的初始值
- `activeKey` 由外部直接指定要顯示的內容，內部狀態加上底線前綴
- `onChange` 點擊標籤按鈕時同步呼叫外部 onChange 將目標資料帶到外部去處理

如此一來在取用這個元件時可以更自由且精準地控制每一個狀態，例如綁定 router 路徑參數或根據情境實現特定操作邏輯。

## CSS 樣式

在切換內容時因為想要有進場動畫的特效，比較合理的作法是透過 class 改變 CSS 屬性、觸發 transition 動畫。

```css
.tabs-content {
  position: relative;
  overflow: hidden;
}

.tabs-pane {
  position: absolute;
  top: 100%;
  width: 100%;
  transform: translateY(-8px);
  opacity: 0;
  
  &.active {
    position: unset;
    top: unset;
    transition: all 0.5s ease-in-out;
    transform: translateY(0);
    opacity: 1;
  }
}
```

1. 預設隱藏狀態
   - 透過絕對定位將整個 `tabs-pane` 移出顯示區域，並且隱藏在外層 overflow hidden 中
   - `tabs-pane` 固定寬度 100% 與 `tabs-content` 相同，維持 box model 尺寸
2. 從隱藏狀態切換為顯示狀態
   - 加上 transition 屬性觸發進場動畫
   - positoin、top 的數值在兩個狀態之間不是線性變化，不屬於 transition 作用的範圍而不受影響
   - transform 與 opacity 製造出由上而下淡入的進場動畫
3. 從顯示狀態切換為隱藏狀態
   - 因為失去了 transition 屬性導致所有屬性變化都是瞬間切換完成，畫面上就會看起來是瞬間消失，並且銜接另一個 `tabs-pane` 的進場動畫
   - 可以透過 transition-delay 實現進場、離場動畫同時存在的效果
