iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Modern Web

再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學系列 第 21 篇

【 Day 20 】方向鍵會動了,焦點該在誰身上?|互動行為(一)

  • 分享至 

  • xImage
  •  

從「遠端世界」到「瀏覽器 API」,這四個領域包起來的,都是畫面上看不見的東西:一份遠端資料、一個 store、表單裡的值、localStorage 的一個 key。元件只負責把它們讀出來、顯示在畫面上。

但有一類程式碼,包的不是值,而是一段互動。例如一個下拉選單:按下按鈕會打開,方向鍵可以上下移動,Enter 選取,Escape 關閉。

這類函式庫常被描述成「把互動行為交給你,卻不決定你的 DOM」。這句話,大概每一個無頭 (headless) UI 函式庫都會同意。

今天進入第五個設計問題:互動行為的複雜度 —— 如果 DOM 由你決定,複雜度會跑去哪裡?

在讀任何一個函式庫之前,先讓我們自己用 useState 寫一個下拉選單,看看它缺了什麼。

遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✓ · 瀏覽器 API ✓ · 互動行為 ✍️ · 真實 DOM

用 useState 寫一個下拉選單

一個下拉選單,至少要記住三件事:選單有沒有打開、方向鍵目前停在哪一項,以及使用者最後選了什麼。我們各用一個 useState 來管理,鍵盤事件掛在按鈕上:

import type { KeyboardEvent } from "react";
import { useState } from "react";

const items = ["蘋果", "香蕉", "櫻桃", "榴槤", "芭樂", "芒果", "番茄"];

export const Select = () => {
  const [isOpen, setIsOpen] = useState(false);
  const [highlightedIndex, setHighlightedIndex] = useState(0);
  const [selectedItem, setSelectedItem] = useState<string | null>(null);

  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
    if (!isOpen) {
      if (event.key === "ArrowDown") {
        event.preventDefault();
        setIsOpen(true);
        setHighlightedIndex(0);
      }
      return;
    }
    switch (event.key) {
      case "ArrowDown":
        event.preventDefault();
        setHighlightedIndex((i) => Math.min(i + 1, items.length - 1));
        break;
      case "ArrowUp":
        event.preventDefault();
        setHighlightedIndex((i) => Math.max(i - 1, 0));
        break;
      case "Enter":
        event.preventDefault();
        setSelectedItem(items[highlightedIndex] ?? null);
        setIsOpen(false);
        break;
      case "Escape":
        setIsOpen(false);
        break;
    }
  };

  return (
    <div>
      <button
        type="button"
        onClick={() => setIsOpen((open) => !open)}
        onKeyDown={onKeyDown}
      >
        {selectedItem ?? "選一個"}
      </button>
      {isOpen && (
        <ul className="menu">
          {items.map((item, index) => (
            <li
              key={item}
              className={index === highlightedIndex ? "highlighted" : undefined}
              onClick={() => {
                setSelectedItem(item);
                setIsOpen(false);
              }}
            >
              {item}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

方向鍵停在哪一項,靠的是 highlighted 這個 class。滑鼠移過去的那一項,我們也希望它亮起來,所以兩者共用同一個樣式:

.menu li.highlighted,
.menu li:hover {
  background: #e8eefc;
}

試著操作一次:按 Tab 讓按鈕拿到焦點,按一下方向鍵往下,選單打開,第 1 項亮起來;再按兩下,亮到第 3 項「櫻桃」;按 Enter,選單關閉,按鈕上寫著「櫻桃」。

打開、移動、選取、關閉都做到了。目前,這個選單似乎能夠正常運作。

焦點現在在哪裡?

那麼,在剛才按方向鍵、第 3 項亮起來的那一刻,焦點在誰身上?

答案是按鈕。

從按下 Tab 開始,到 Enter 關閉選單為止,document.activeElement 一直是那個 <button>。鍵盤事件之所以收得到,正是因為焦點從來沒有離開過它。

那第 3 項的「亮起來」,對瀏覽器來說又是什麼?

只是一個 class。

它讓畫面上的某一列換了背景色,但瀏覽器不知道這一列和按鈕有什麼關係。依賴焦點來判斷「使用者正在操作哪裡」的輔助技術,例如螢幕閱讀器,看到的通常也是那個按鈕。

畫面上的「目前項目」與焦點,在這個版本裡是分開的。只不過,這件事是我們不小心做出來的,不是決定出來的。

再多做一步。按方向鍵到第 3 項之後,把滑鼠移到第 5 項「芭樂」上。這時畫面上會有兩列同時亮著:第 3 項是 class,第 5 項是 :hover。

如果這時再按一次方向鍵,會從哪一項開始往下?第 4 項,還是第 6 項?

在我們的版本裡,答案是第 4 項「榴槤」。因為 highlightedIndex 只有方向鍵會改,滑鼠的移動只改變了樣式,沒有碰到狀態。

這是一個 bug 嗎?

我想不太算。可以補一個 onMouseMove,讓滑鼠也去改 highlightedIndex,那樣答案就會是第 6 項。但也可以主張,滑鼠只是剛好停在那裡,鍵盤的位置不應該被它搶走。兩種寫法都說得通,差別在於「目前項目」到底是什麼。

焦點與目前項目,可以是兩件事嗎?

回頭看這兩個問題:焦點在哪裡,以及方向鍵從哪裡接著走。

一開始,我以為這個選單缺的是幾個鍵盤事件:Home、End、空白鍵,再補一個滑鼠的處理。但補上這些事件之前,有一件事得先決定:

焦點在誰身上?誰是目前項目?這兩件事,可以是兩個不同的東西嗎?

我缺的不是幾個事件,而是一份焦點合約。

那麼,這份合約的第一條,焦點該在誰身上?

這份合約有兩種常見的答案,W3C 的 ARIA 撰寫實務指南 (APG) 把它們並列成管理焦點的兩種方式。

第一種是 roving tabindex:焦點真的跟著目前項目走。按下方向鍵時,把下一項的 tabIndex 設成 0、其他設成 -1,再呼叫它的 focus()。在這個答案裡,焦點就是目前項目,兩者是同一件事。

第二種是 aria-activedescendant:焦點一直留在外層的元素上,例如我們的按鈕;目前項目改由按鈕上的 aria-activedescendant 屬性指出來,屬性的值是那一項的 id。在這個答案裡,焦點與目前項目是兩件事,一個不動,一個在動。

剛才那個「第 4 項還是第 6 項」的問題,就落在這兩個答案的分岔上。

如果目前項目就是焦點,滑鼠移過去時要讓它變成目前項目,就得把焦點搬過去,而搬動焦點不只是改一個值:APG 提到,瀏覽器會把剛拿到焦點的元素捲進視窗裡。所以這個答案往往傾向讓滑鼠只是滑鼠,答案是第 4 項。

如果目前項目只是一個狀態,滑鼠移過去時改掉它,成本跟方向鍵一樣,焦點也不必動。答案就可以是第 6 項。

兩種答案都不是 bug 的修法,在同一份合約裡也很難同時成立。這是一個只能選邊的設計決定。

把選擇寫成一份合約

在接下來的範例裡,我們選了 aria-activedescendant,因為它讓焦點與目前項目分得最開。合約只回答三件事:焦點在誰身上、哪一項是目前項目、鍵盤怎麼移。

先從狀態開始。目前項目在這裡是一個獨立的欄位 highlightedIndex,跟焦點無關:

export interface ListboxState {
  readonly isOpen: boolean;
  readonly highlightedIndex: number | null;
  readonly selectedIndex: number | null;
}

接著是狀態機 (transition)。它是一個純函式,收下目前的狀態、一個事件與項目數,交回下一個狀態。以下節錄其中三種事件:

export function transition(
  state: ListboxState,
  event: ListboxEvent,
  itemCount: number,
): ListboxState {
  if (itemCount === 0) return state;
  switch (event.type) {
    // ...
    case "TriggerKeyDown":
      return state.isOpen
        ? onOpenKey(state, event.key, itemCount)
        : onClosedKey(state, event.key, itemCount);
    case "OptionPointerMove":
      return state.isOpen && !Object.is(state.highlightedIndex, event.index)
        ? opened(state, event.index)
        : state;
    case "OptionClick":
      return selected(state, event.index);
  }
}

TriggerKeyDown 是按鈕收到的按鍵,OptionPointerMove 是滑鼠在某一項上移動。兩種事件改的是同一個 highlightedIndex,所以第 3 項、滑到第 5 項、再按方向鍵,會到第 6 項。這一行,就是我們在上一節選的那一邊。

方向鍵本身的移動寫在 onOpenKey 裡,一次一格,到底不繞回:

case "ArrowDown":
  return opened(
    state,
    current === null ? 0 : clamp(current + 1, itemCount),
  );

最後,合約要把「誰是目前項目」告訴按鈕以外的世界。按鈕上的 aria-activedescendant 指向目前項目的 id,選項則用 tabIndex: -1 讓自己不在 Tab 順序裡:

export const triggerAttributes = (
  state: ListboxState,
  ids: ListboxIds,
): TriggerAttributes => ({
  id: ids.trigger,
  role: "combobox",
  tabIndex: 0,
  "aria-haspopup": "listbox",
  "aria-expanded": state.isOpen,
  "aria-controls": ids.listbox,
  "aria-activedescendant":
    state.isOpen && state.highlightedIndex !== null
      ? ids.option(state.highlightedIndex)
      : undefined,
});

這個檔案裡沒有 React,也沒有任何事件處理器。它只寫合約的內容:狀態怎麼變、每個元素身上該有哪些屬性。

這裡的合約是為了讓設計決定現形的小實驗,並不是 production-ready 的實作。

💡 這份合約刻意沒有處理打字跳到相符的項目 (typeahead)、停用的項目、多選、捲動到目前項目與浮層定位。roving tabindex 的版本也沒有寫,因為這裡要比較的不是這兩個答案,而是同一份合約怎麼交出去。

合約交出去的形狀:一包 props

合約寫好了。但內容不等於交付:狀態要放在哪裡、事件處理器掛在哪個元素上、屬性怎麼交到呼叫端手上,transition 都沒有回答。

而「不決定你的 DOM」這句話,換個方式說,是 DOM 的結構歸你。函式庫只能交出合約,元素得由你來寫。

最直接的交法,是一個 Hook 交回一包可以展開的 props。這是 Downshift 的 useSelect 所採用的形狀,以下便是照這個形狀臨摹的一部分。2026 年談 Downshift,談的是 useSelect、useCombobox 這幾個 Hook,不是 render prop 形式的 <Downshift> 元件。

Hook 用 useReducer 跑上一節的狀態機,交回狀態與三個 prop getter:給按鈕的 getToggleButtonProps、給清單的 getMenuProps,以及給每一項的 getItemProps(index)。以下節錄按鈕與選項的兩個 getter。

它們做的事情都一樣:把合約的屬性展開,再加上把 DOM 事件翻譯成合約事件的處理器。

getToggleButtonProps: (own = {}) => ({
  ...triggerAttributes(state, ids),
  onClick: callAll(own.onClick, () => send({ type: "TriggerClick" })),
  onKeyDown: callAll(own.onKeyDown, (event) => {
    if (contractKeys.has(event.key)) event.preventDefault();
    send({ type: "TriggerKeyDown", key: event.key });
  }),
  onBlur: callAll(own.onBlur, () => send({ type: "TriggerBlur" })),
}),

getItemProps: (index, own = {}) => ({
  ...optionAttributes(state, ids, index),
  onClick: callAll(own.onClick, () => send({ type: "OptionClick", index })),
  onMouseMove: callAll(own.onMouseMove, () =>
    send({ type: "OptionPointerMove", index }),
  ),
  onMouseDown: callAll(own.onMouseDown, (event) => event.preventDefault()),
}),

要注意 onMouseDown 事件裡的 event.preventDefault()。

在瀏覽器裡,按下一個不能拿焦點的元素,原本拿著焦點的按鈕會失去焦點。所以按下選項時要 preventDefault,焦點才會留在按鈕上。這一行屬於合約的第一條,焦點在誰身上。

至於 callAll,它讓呼叫端自己的處理器與合約的處理器並存:

const callAll =
  <E extends SyntheticEvent>(
    own: ((event: E) => void) | undefined,
    contract: (event: E) => void,
  ) =>
  (event: E): void => {
    own?.(event);
    contract(event);
  };

先呼叫呼叫端的,再呼叫合約的,兩個都會跑。

用起來是這樣。按鈕、清單與選項的元素,全部由呼叫端自己寫,再把對應的 getter 展開上去:

import { useSelect } from "../useSelect";

export const Select = ({ items }: { items: readonly string[] }) => {
  const {
    isOpen,
    selectedItem,
    getToggleButtonProps,
    getMenuProps,
    getItemProps,
  } = useSelect(items);

  return (
    <div>
      <button type="button" {...getToggleButtonProps()}>
        {selectedItem ?? "選一個"}
      </button>
      <ul {...getMenuProps()}>
        {isOpen &&
          items.map((item, index) => (
            <li key={item} {...getItemProps(index)}>
              {item}
            </li>
          ))}
      </ul>
    </div>
  );
};

跟第一節的版本相比,呼叫端少了所有的 useState 與 switch,但 JSX 的形狀幾乎沒變。

換一次 DOM,要改幾行

既然 DOM 歸呼叫端,那就實際換一次。把 <ul>/<li> 換成 <div role="listbox">/<div role="option">,看看這個形狀要改多少。

在我的臨摹裡,同一個呼叫端寫了兩份,一份用 <ul>/<li>,一份用 <div>。兩份都跑同一組焦點合約斷言,例如「方向鍵移動時,焦點一直留在按鈕上」「選到第 3 項、滑到第 5 項,再按方向鍵到第 6 項」,每份十條。

兩份都是綠的。

接著用 pnpm test 數兩份呼叫端的差異,只算程式碼行:

形狀 呼叫端程式碼行 拿掉 寫上 形狀層改了幾行
一包 props 30 4 4 0

拿掉的四行是 <ul>、<li> 各自的開標籤與閉標籤,寫上的四行是同樣位置的 <div>。role 不必自己補,因為 getter 交回的 props 裡已經帶著 role="listbox" 與 role="option"。useSelect 本身一行都沒有改。

在這個形狀裡,「不決定你的 DOM」兌現得很直接:元素本來就是呼叫端寫的,換元素也就只是換元素。

這是這個形狀贏的地方。DOM 完全是你的,資料從 Hook 直接流到元素上,中間沒有上下文,也沒有另外的元件。要理解它,讀一個 Hook 的回傳值就夠了。

合約斷掉的時候,誰會知道?

不過,元素歸你,也代表最後一步歸你:getter 要不要呼叫、展開到哪個元素上,都由呼叫端決定。

假使我們想在按鈕上記錄使用者按了 Tab 離開選單,於是在展開 getter 之後,自己寫了一個 onKeyDown:

<button
  type="button"
  {...getToggleButtonProps()}
  onKeyDown={(event) => {
    if (event.key === "Tab") console.log("離開選單");
  }}
>
  {selectedItem ?? "選一個"}
</button>

型別檢查會通過,主控台沒有任何錯誤或警告,按下 Tab 也確實會印出「離開選單」。

但方向鍵不會動了。因為 JSX 的屬性是後面蓋掉前面,合約的 onKeyDown 被我們自己的那一個取代,按下方向鍵,選單不會打開,aria-activedescendant 也不會出現。滑鼠點擊按鈕倒是一切正常,因為 onClick 沒有被蓋掉。

另一種情況,是忘了在選項上展開 getItemProps。這時方向鍵看起來還能動,按鈕上的 aria-activedescendant 也照常更新,可是它指向的 id 不存在於頁面上的任何元素:選項上沒有 id,也沒有 role="option"。

這兩種情況,都沒有任何東西會出聲。

要避免第一種,可以把自己的處理器交給 getter,而不是寫在展開之後:

<button
  type="button"
  {...getToggleButtonProps({
    onKeyDown: (event) => {
      if (event.key === "Tab") console.log("離開選單");
    },
  })}
>

這樣 callAll 會讓兩個處理器都執行,方向鍵也恢復了。但這條路要不要走,仍然取決於呼叫端知不知道它存在。

這是這個形狀輸的地方。合約的內容寫在 Hook 裡,合約是否完整,則取決於呼叫端的紀律:getter 沒貼、貼錯元素,或是自己另外掛一個 onKeyDown 把它蓋掉,合約都會靜默失效。

一包 props 把合約的內容替我們寫好了,卻把「貼上去」這一步,原封不動地交還給呼叫端。

getter 與元素之間,只隔著一個展開運算子,合約在這裡斷掉時,沒有任何東西會出聲。那麼,這個靜默失效,有沒有可能變成一個看得見的接縫?

Downshift README 寫明,useSelect 與 useCombobox 支援最新的 ARIA combobox pattern,render prop 的 <Downshift> 元件則不支援,並將在 Hook 成熟後完全移除,查核於 2026-10-05。roving tabindex 與 aria-activedescendant 兩種方式,依 W3C APG〈Developing a Keyboard Interface〉,查核於 2026-10-05。

本文對照 downshift 9.4.0,查核於 2026-10-05;實驗跑在 react 19.3.0 與 react-dom 19.3.0。


上一篇
【 Day 19 】什麼時候,正確答案是不要寫這個 Hook?|瀏覽器 API(四完)
下一篇
【 Day 21 】分層之後,「要切在哪一層」這個決定歸誰?|互動行為(二)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言