本系列文章會在筆者的部落格繼續連載!Design System 101 感謝大家的閱讀!
在上一篇 FocusScope 中,我們介紹了 FocusScope 的概念以及架構,這篇將介紹如何實作 FocusScope。
本文同步上傳到筆者的個人部落格,裡面透過 Sandpack 直接編輯程式碼!
FocusScope 最重要的核心就是將其範圍內 (Scope) 找出所有 focusable
的元素,並且將其儲存起來。再來透過 focusManager
來控制 focusable
的元素,例如:focusManager.focusNext()
、focusManager.focusPrevious()
。
在這裡,範圍 (Scope) 指的是 FocusScope 組件中的 children。
<FocusScope>{children}</FocusScope>
首先,先建立 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
,它會回傳一個物件,其包含了四種方法:
focusNext
: 將 focus 移至下一個 focusable
元素focusPrevious
: 將 focus 移至上一個 focusable
元素focusFirst
: 將 focus 移至第一個 focusable
元素focusLast
: 將 focus 移至最後一個 focusable
元素這四種方法可以讓開發者根據不同的鍵盤事件來控制 focus
的行為。
在實作 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>
介紹完 TreeWalker
之後,就可以來實作 createFocusManager
了!
這邊當 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;
}
接著,建立 FocusManager,這邊我們只實作 focusNext
,其餘的 focusPrevious
、focusFirst
、focusLast
皆是類似的實作方式 (完整實作可以點這裡)!
還記得我們一開始在 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
元素。
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);
},
};
};
完成了 FocusScope 的基本核心之後,就可以實作一開始提到的 API 了!
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
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
則是會監聽 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)
詳細的程式碼都可以點擊這個Github 連結來參考。
下一章將介紹 Slot 這個概念!See ya!