
前陣子碰到一個需求,希望使用者能夠一次閱覽多筆的訂單(比對資料、快速查看、或複製等操作),但又不想離開頁面,彈出視窗也不適合,於是逛來逛去就發現 Tab 這個打造好的 component:

然後又看到瀏覽器上面一排的頁籤:

心想:「嗯?好像可以...不如做一個可新增刪除的 Tab 吧!」
類似瀏覽器頁籤的做法,實際上要:
(還有一些留到下篇 (。・`ω´・。))

既然是瀏覽訂單,通常都會有List,而點擊 List 其中一個項目會新增一個 Tab 到 Tabs,Panel 則依照當下的選擇顯示對應的資料。
若是使用現有的元件要做出動態的 Tabs 時,要留意該元件有無相關的 prop 可以進行處理,例如 Chakra-UI 的 Tab 是複合型的(compound components)且設計上偏 static,因此在實現這個需求時 custom 就會比較麻煩一點,比對下來反而 MUI 的彈性就高很多,使用既有的元件要留意一下,或者也可以自己從頭建立就不用擔心這些問題 XD。
呈現 List 的部分就是一坨假資料,每一筆大致會有訂單編號、日期、客人、商品等資訊,格式大概這樣:
{
  id: "6e2e49b7-1c7e-4d29-b7d9-ab4e95fd1bd7",
  no: "OR20220619",
  date: "2022-06-19T09:58:52+08:00",
  customer: {
    name: "Julian",
    phone: "1-466-316-1793 x50734",
  },
  products: [
    {
      id: "869f01e6-faba-4eb8-99fe-05b0f491ee0a",
      no: "PR6146",
      name: "Fish",
      price: 145,
      qty: 24,
      subTotal: 1510,
    },
  ],
  total: 1510,
  createAt: "2022-06-19T09:58:52+08:00",
}

透過點擊,我們會新增一個Tab,這樣的動作無疑就是需要一個 array,因此會有一組專門記錄「開啟」的 Tabs:
const storedTabs = [{...}, {...}]
不過 {...} 實際上會儲存甚麼呢?
依照當下需求,來決定 Tab 要用到什麼資料,而按照畫面來看就是訂單ID以及訂單編號,而我們也需要知道現在是哪一個 Tab 正在瀏覽,因此多一個 currentTab :
const currentTab = id
const storedTabs = [{id, no}, ...]
訂單ID指的是獨一無二的ID,通常都長得跟亂碼一樣(UUID);訂單編號雖然也是獨一無二,但文字上容易閱讀,通常會有明顯的格式(或邏輯),以本篇的資料格式就是指 id 與 no 。
雖然是可以,但實際上資料不一定是最新或是正確的,打API時,顯示列表你會用 GET /order (or GET /order/list),但若是單一筆資料你會用 GET /order/:id,有可能在 GET /order 情況下就獲取了 99% 的所有內容,但使用上,不同使用者之間有編輯有資料操作的情況,所以仍要分開處理,才能確保使用者瀏覽時才能獲取較正確的資訊。 (不過這一塊就要看後端怎麼給惹)
這樣大致上就知道需要怎麼建立這個 Hook 了!由於有不同的操作情境,我們來用 useReducer 來建立吧!
const initValue = {
  currentTab: null,
  storedTabs: [],
}
function useTabs() {
  const [state, dispatch] = useReducer(tabsReducer, initValue)
  const tabDispatch = useCallback(
    (type, payload) => dispatch({ type, payload }),
    []
  )
  return [state, tabDispatch]
}
initValue 定義了一開始狀態dispatch 採用了 {type, payload} 格式,為避免手殘,另外包裝成 tabDispatch(type, payload)
而重點的 reudcer 在這裡:
const tabsReducer = (state, action) => {
  const type = action.type
  const payload = action.payload
  switch (type.toUpperCase()) {
    case "ADD": {
      if (state.storedTabs.some((tab) => tab.id === payload.id)) {
        return {
          ...state,
          currentTab: payload.id,
        }
      }
      return {
        ...state,
        currentTab: payload.id,
        storedTabs: [...state.storedTabs, payload],
      }
    }
    case "REMOVE": {
      const newStoredTabs = state.storedTabs.filter((tab) => tab.id !== payload)
      return {
        ...state,
        currentTab: newStoredTabs?.at(-1)?.id || null,
        storedTabs: newStoredTabs,
      }
    }
    case "NAVIGATE": {
      return {
        ...state,
        currentTab: payload,
      }
    }
    default: {
      throw new Error("[useTabs] dispatch has received non exist type")
    }
  }
}
很單純,就是針對 array 進行新增與刪除的動作,我們有:
ADD 新增一個 Tab 並同時進行閱讀,若 storedTabs 已經有了,則將 currentTab 改為剛剛點擊的 idREMOVE 移除 Tab,並顯示最後一個 Tab,若已經是最後一個則為 nullNAVIGATE 點擊 Tab 時,切換到該顯示的頁面function Example() {
  const [tabs, tabDispatch] = useTabs()
  const isEmpty = tabs.storedTabs.length === 0
  return (
    <OrderBoard>
      {/* 列表在這邊~~~ */}
      <OrderList>
        {data.map((order) => (
          <OrderItem
            key={order.id}
            onClick={() => tabDispatch("ADD", { id: order.id, no: order.no })}
          >
            <Text>{order.no}</Text>
            <Text>$ {order.total}</Text>
          </OrderItem>
        ))}
      </OrderList>
      {/* tab 在這邊~~~ */}
      <OrderViewer>
        {isEmpty ? (
          <Alert>
            <AlertIcon />
            Select a order from list to view.
          </Alert>
        ) : (
          <>
            <Tabs >
              <TabList>
                {tabs.storedTabs.map((tab) => (
                  <OrderTab
                    key={tab.id}
                    isSelected={tab.id === tabs.currentTab}
                    onChoose={() => tabDispatch("NAVIGATE", tab.id)}
                    onClose={() => tabDispatch("REMOVE", tab.id)}
                  >
                    <Text>{tab.id}</Text>
                  </OrderTab>
                ))}
              </TabList>
            </Tabs>
          </>
        )}
      </OrderViewer>
    </OrderBoard>
  )
}
太多了,挑重點看XD
左側的 List 點擊時是新增的行為,因此放上 ADD,一併存入 {id, no} 方便等等給 tab 顯示用:
<OrderItem
  key={order.id}
  onClick={() => tabDispatch("ADD", { id: order.id, no: order.no })}
>
  <Text>{order.no}</Text>
  <Text>$ {order.total}</Text>
</OrderItem>
上排的 Tabs 則有幾個內容:
<OrderTab
  key={tab.id}
  isSelected={tab.id === tabs.currentTab}
  onChoose={() => tabDispatch("NAVIGATE", tab.id)}
  onClose={() => tabDispatch("REMOVE", tab.id)}
>
  <Text>{tab.id}</Text>
</OrderTab>
NAVIAGTE
REMOVE
到目前為止會長這樣:

我們存了 id & no,用 id 不好閱讀,於是改用 no:
<OrderTab {...省略的props}>
  <Text>{tab.no}</Text> //id 改 no
</OrderTab>

請忽略有一點 UX 不友善的地方 XD
這樣一來 tabs 部分就完成了!下一篇來弄弄 panel,看看實際如何把資料帶入 panel:
