iT邦幫忙

2023 iThome 鐵人賽

DAY 8
0
Modern Web

設計系統 - Design System系列 第 8

[Day 8] Design System - FocusScope 組件 (二)

  • 分享至 

  • xImage
  •  

本系列文章會在筆者的部落格繼續連載!Design System 101 感謝大家的閱讀!

前言

在上一篇 FocusScope 中,我們介紹了 FocusScope 的概念以及架構,這篇將介紹如何實作 FocusScope。

本文同步上傳到筆者的個人部落格,裡面透過 Sandpack 直接編輯程式碼!

FocusScope - 核心

FocusScope 最重要的核心就是將其範圍內 (Scope) 找出所有 focusable 的元素,並且將其儲存起來。再來透過 focusManager 來控制 focusable 的元素,例如:focusManager.focusNext()focusManager.focusPrevious()

在這裡,範圍 (Scope) 指的是 FocusScope 組件中的 children。

<FocusScope>{children}</FocusScope>

FocusScopeContext

首先,先建立 FocusScopeContext 將 focusManager 能夠傳遞給其子組件。而開發者可以在子組件透過 useFocusManager hook 取得 focusManager,進而根據不同的鍵盤事件控制 focus 的行為。

// focus-scope/context
export const FocusScopeContext = React.createContext(null);

export const useFocusManager = () => {
  const context = useContext(FocusScopeContext);

  if (!context) {
    throw new Error('useFocusManager hook must be used within a FocusManagerProvider');
  }

  return context.focusManager;
};

export const FocusScopeProvider = (props) => {
  return (
    <FocusScopeContext.Provider value={{ focusManager: props.focusManager }}>
      {props.children}
    </FocusScopeContext.Provider>
  );
};

Github - FocusScopeContext

取得 focusable 元素

接著,我們需要找出 Scope 裡所有 focusable 的元素,可以用 <span hidden ref={startRef} /><span hidden ref={endRef} /> 先將 Scope 的範圍包起來,再來遍歷 Scope 裡的所有元素,並且將其儲存起來。

export const FocusScope = ({ children, autoFocus = false, contain = false, restoreFocus = false }) => {
  const startRef = useRef(null);
  const endRef = useRef(null);
  const scopeRef = useRef([]);

  useEffect(() => {
    let node = startRef.current?.nextSibling;
    const nodes = [];

    while (node && node !== endRef.current) {
      nodes.push(node);
      node = node.nextSibling;
    }
    scopeRef.current = nodes;
  }, [children]);

  const focusManager = {}; // createFocusManager(scopeRef); Not yet implement

  return (
    <FocusScopeProvider focusManager={focusManager}>
      <span hidden ref={startRef} />
      {children}
      <span hidden ref={endRef} />
    </FocusScopeProvider>
  );
};

createFocusManager

再來,建立一個 createFocusManager,它會回傳一個物件,其包含了四種方法:

  • focusNext: 將 focus 移至下一個 focusable 元素
  • focusPrevious: 將 focus 移至上一個 focusable 元素
  • focusFirst: 將 focus 移至第一個 focusable 元素
  • focusLast: 將 focus 移至最後一個 focusable 元素

這四種方法可以讓開發者根據不同的鍵盤事件來控制 focus 的行為。

TreeWalker

在實作 createFocusManager 之前,我們先來介紹一下 TreeWalker

什麼是 TreeWalker?

TreeWalker 是一個 DOM 的物件,可以用來導航和遍歷 DOM 的結構,也就是可以使用它遍歷 DOM 元素,並可以根據特定的過濾條件查找節點 (node),這讓我們找 DOM 中某些特定的節點變得非常容易。

如何使用 TreeWalker?

假設在一個頁面中,找出 focusable 的元素,並且我們已經將這些元素加入 data-focusable 屬性,這時候我們就可以透過 TreeWalker 來找出這些元素。

可以看到下圖的 console, 我們只有印出有 data-focusable 屬性的元素

<body>
  <div id="root">
    <button data-focusable>I'm focusable No.1</button>
    <br />
    <button data-focusable>I'm focusable No.2</button>
    <br />
    <span>I'm not focusable</span>
    <br />
    <button data-focusable>I'm focusable No.3</button>
  </div>
</body>

<script>
  function focusableFilter(node) {
    return node.hasAttribute('data-focusable') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
  }

  const walker = document.createTreeWalker(
    document.querySelector('#root'),
    NodeFilter.SHOW_ELEMENT,
    { acceptNode: focusableFilter },
    false,
  );

  const focusableElements = [];

  while (walker.nextNode()) {
    focusableElements.push(walker.currentNode);
  }

  console.log(focusableElements); // 這將列印出所有 'data-focusable' 屬性的元素
</script>

Image

CodeSandbox Playground

createFocusManager

介紹完 TreeWalker 之後,就可以來實作 createFocusManager 了!

Step 1, 先用 TreeWalker 找出 Scope 中的所有 focusable 元素

這邊當 TreeWalker 在遍 node 是 focusable 以及該 node 是在 Scope 內,就會將其加入 focusableElements 陣列中。

// 確認元素是否在 Scope 中
export function isElementInScope(el, scope) {
  if (!scope || !el) {
    return false;
  }
  return scope.includes(el) || scope.some((node) => node.contains(el));
}

export function getFocusableTreeWalker(root, opts, scope) {
  // Source: https://github.com/JingHuangSu1996/tocino/blob/main/packages/components/focus-scope/src/utils/index.tsx#L19-L39
  const selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR;

  const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
    acceptNode: (node) => {
      if (opts.from?.contains(node)) {
        return NodeFilter.FILTER_REJECT;
      }

      if (node.matches(selector) && (!scope || isElementInScope(node, scope))) {
        return NodeFilter.FILTER_ACCEPT;
      }

      return NodeFilter.FILTER_SKIP;
    },
  });

  if (opts.from) {
    walker.currentNode = opts.from;
  }

  return walker;
}
Step 2, 建立 FocusManager

接著,建立 FocusManager,這邊我們只實作 focusNext,其餘的 focusPreviousfocusFirstfocusLast 皆是類似的實作方式 (完整實作可以點這裡)!

還記得我們一開始在 Scope 外層包了兩個 <span hidden ref={startRef} /><span hidden ref={endRef} /> 嗎? 這時我們就可以透過這兩個元素來當作 sentinel,並且用 walker 去遍歷 Scope 中的所有元素。

// 建立 FocusManager
export const createFocusManager = (scopeRef) => {
  const getSentinelStart = (scope) => scope[0].previousElementSibling;

  const focusNode = (node) => {
    if (node) {
      focusElement(node);
    }
    return node;
  };

  return {
    focusNext: (opts = {}) => {
      const scope = scopeRef.current;
      const { from, tabbable } = opts;
      const node = from || document.activeElement;
      const sential = getSentinelStart(scope);

      const walker = getFocusableTreeWalker(getScopeRoot(scope), { tabbable }, scope);
      walker.currentNode = isElementInScope(node, scope) ? node : sential;
      let nextNode = walker.nextNode();

      return focusNode(nextNode);
    },
  };
};

在這裡可以透過下面的 CodeSandbox 來玩看看,當我們按下 -> 鍵時,focus 就會移至下一個 focusable 元素。

no wrap

CodeSandbox Playground

Step 3, 處理 wrap 的情況

可以看到上面的動畫,當 -> 按到最後一個元素時,focus 就不會再往下移動了,如果想要讓跳回第一個,我們就需要加入 wrap 的功能。

sentinel 在這裡就扮演重要的角色, 當 focus 移至 Scope 的最後一個元素時,就會移至 sentinel,這時候我們就可以將 walker.currentNode 設定為 sentinel,這樣就可以讓 walker 再次從 Scope 的第一個元素開始遍歷。

// 建立 FocusManager
export const createFocusManager = (scopeRef) => {
  const getSentinelStart = (scope) => scope[0].previousElementSibling;

  const focusNode = (node) => {
    if (node) {
      focusElement(node);
    }
    return node;
  };

  return {
    focusNext: (opts = {}) => {
      const scope = scopeRef.current;
      const { wrap, from, tabbable } = opts;
      const node = from || document.activeElement;
      const sential = getSentinelStart(scope);

      const walker = getFocusableTreeWalker(getScopeRoot(scope), { tabbable }, scope);
      walker.currentNode = isElementInScope(node, scope) ? node : sential;

      // ---- 新增 ----
      let nextNode = walker.nextNode();

      if (!nextNode && wrap) {
        walker.currentNode = sential;
        nextNode = walker.nextNode();
      }
      // -----------

      return focusNode(nextNode);
    },
  };
};

wrap

CodeSandbox Playground

FocusScope - API

完成了 FocusScope 的基本核心之後,就可以實作一開始提到的 API 了!

useAutoFocus

useAutoFocus hook 會在 Scope 渲染時,將 focus 移至第一個 focusable 元素,並且透過 sharedState 來記錄當前的 Scope。

export const useAutoFocus = (scopeRef, autoFocus) => {
  useEffect(() => {
    if (!autoFocus) {
      return;
    }

    sharedState.activeScope = scopeRef.current;

    if (!isElementInScope(document.activeElement, sharedState.activeScope)) {
      focusFirstInScope(scopeRef.current);
    }
  }, [scopeRef, autoFocus]);
};

useRestoreFocus

useRestoreFocus hook 會在 Scope 卸載時,將 focus 移至上一個 Scope 的 focusable 元素。

export const useRestoreFocus = (restoreFocus) => {
  useLayoutEffect(() => {
    const nodeToRestore = document.activeElement;

    return () => {
      if (restoreFocus && nodeToRestore) {
        requestAnimationFrame(() => {
          if (document.body.contains(nodeToRestore)) {
            focusElement(nodeToRestore);
          }
        });
      }
    };
  }, [restoreFocus]);
};

useFocusContainment

useFocusContainment 則是會監聽 keydown 事件,並且將 focus 維持在 Scope 中。

可以在 onKeyDown 的邏輯看見透過鍵盤的 Tab 事件,在 focus 移動時會持續判斷當前的 focus 是否在 Scope 中,如果不在就會將 focus 移至 Scope 中的第一個元素,反之當鍵盤事件是 Shift + Tab 時,就會將 focus 移至 Scope 中的最後一個元素。

export const useFocusContainment = (scopeRef, contain) => {
  const focusNode = useRef();

  useEffect(() => {
    if (!contain) {
      return;
    }

    const onKeyDown = (e) => {
      if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) {
        return;
      }

      const focusedElement = document.activeElement;
      const scope = scopeRef.current;

      if (!scope || !isElementInScope(focusedElement, scope)) {
        return;
      }

      const root = getScopeRoot(scope);

      const walker = getFocusableTreeWalker(root, { tabbable: true }, scope);
      walker.currentNode = focusedElement;

      const lastPosition = scope.length - 1;
      let nextElement = e.shiftKey ? walker.previousNode() : walker.nextNode();

      if (!nextElement) {
        walker.currentNode = e.shiftKey ? scope[lastPosition].nextElementSibling : scope[0].previousElementSibling;
        nextElement = e.shiftKey ? walker.previousNode() : walker.nextNode();
      }

      e.preventDefault();

      if (nextElement) {
        focusElement(nextElement);
      }
    };

    document.addEventListener('keydown', onKeyDown, false);

    return () => {
      document.removeEventListener('keydown', onKeyDown, false);
    };
  }, [scopeRef, contain]);
};

最後將這些 API 加入到 FocusScope 本身的邏輯中,就完成了 FocusScope 的實作!

export const FocusScope = ({ children, autoFocus = false, contain = false, restoreFocus = false }) => {
  const startRef = useRef(null);
  const endRef = useRef(null);
  const scopeRef = useRef([]);

  useEffect(() => {
    let node = startRef.current?.nextSibling;
    const nodes = [];

    while (node && node !== endRef.current) {
      nodes.push(node);
      node = node.nextSibling;
    }
    scopeRef.current = nodes;
  }, [children]);
  
  useAutoFocus(scopeRef, autoFocus);
  useFocusContainment(scopeRef, contain);
  useRestoreFocus(restoreFocus);

  const focusManager = createFocusManager(scopeRef);

  return (
    <FocusScopeProvider focusManager={focusManager}>
      <span hidden ref={startRef} />
      {children}
      <span hidden ref={endRef} />
    </FocusScopeProvider>
  );
};

Demo
(DEMO)

CodeSandbox Playground

詳細的程式碼都可以點擊這個Github 連結來參考。

小結

下一章將介紹 Slot 這個概念!See ya!

Reference

  1. React RFC - FocusScope
  2. React-Specturm - FocusScope

上一篇
[Day 7] Design System - FocusScope 組件
下一篇
[Day 9] Design System - React Slots (插槽)
系列文
設計系統 - Design System30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言