(오픈소스 분석) Radix FocusScope
@radix-ui/react-focus-scope
라이브러리 코드와 TreeWalker Wep API를 살펴본다.
용도
FocusScope
이 mount될 때, 내부의 첫번째 Focusable한 엘리먼트에 focus된다.FosucScope
이 unmount될 때, mount되기 직전에 focus를 받았던 엘리먼트에 focus된다.FocusScope
내부에 focus를 가두거나, loop시킬 수 있다.
구현
FocusScope
컴포넌트는 내부적으로 유틸리티 함수를 여러개 사용한다. 대부분 짧은 함수이고, 이름만으로도 역할을 쉽게 유추할 수 있기 때문에 추가적인 코드 분석 없이 넘어간다. (살펴보고 싶다면 여기를 참고)
focusScopesStack, focusScope
파일의 맨 끝 부분에 focusScopesStack
이라는 객체가 하나 있는데, 여러개의 FocusScope
사이에서 공유하는 변수로 사용된다.
FocusScope
컴포넌트는 내부적으로 focusScope
이라는 Mutable ref를 하나 사용한다. 이 변수가 필요한 이유는 여러개의 FocusScope
이 렌더링 되었을 때, 모든 FocusScope
에 대한 포커스 관리는 불가능하기 때문에, 관리 대상이 되는 하나의 FocusScope
을 기억하기 위해서이다.
focusScope
은 다음과 같다.
const focusScope = useRef({
paused: false,
pause() {
this.paused = true;
},
resume() {
this.paused = false;
}
}).current;
focusScopesStack
은 focusScope
을 add
, remove
하는 기능을 가진 객체이고, add
할 때 현재 활성화된 focusScope
를 pause
후, 추가된 focusScope
을 활성화시킨다. remove
할 때는 현재 활성화된 focusScope
을 제거한 후, 다음 focusScope
을 활성화시킨다. (참고)
FocusScope
const FocusScope = forwardRef((props, ref) => {
const {
// true일 때
// 마지막 tabbable 엘리먼트에서 `tab`하면 첫번째 tabbable로,
// 첫번째 tabbable 엘리먼트에서 `shift + tab`하면 마지막 tabbable로 이동한다.
loop = false,
// true일 때
// focus를 FocusScope안에 가둔다.
// 키보드, 포인터, element.focus()와 같은 프로그래밍 방식으로
// 포커스를 바깥으로 이동시킬 수 없다.
trapped = false,
// mount후 autofocus될 때 호출되는 콜백
onMountAutoFocus,
// unmount후 autofocus될 때 호출되는 콜백
onUnmountAutoFocus,
} = props;
const [container, setContainer] = useState(null);
const lastFocusedElementRef = useRef(null);
const composedRefs = useComposedRefs(ref, (node) => setContainer(node));
const focusScope = useRef({
paused: false,
pause() {
this.paused = true;
},
resume() {
this.paused = false;
},
}).current;
useEffect(() => {}, [...]);
useEffect(() => {}, [...]);
const handleKeyDown = () => { ... };
return (
<Primitive.div
tabIndex={-1}
onKeyDown={handleKeyDown}
ref={composedRefs}
>
{props.children}
</div>
)
})
FocusScope
컴포넌트는 두개의 useEffect
로 이루어져 있다. 하나는 trap
을 담당하고, 하나는 onMountAutoFocus
및 onUnmountAutoFocus
및 focusScopesStack
의 관리를 담당한다.
우선 두번째 useEffect
를 먼저 살펴보자.
useEffect(() => {
if (container) {
// FocusScope이 렌더링되면, focusScopes를 추가한다.
focusScopesStack.add(focusScope);
const previouslyFocusedElement = document.activeElement;
const hasFocusedCandidate = container.contains(previouslyFocusedElement);
if (!hasFocusedCandidate) {
const mountEvent = new CustomEvent(AUTOFOCUS_ON_MOUNT, EVENT_OPTIONS);
container.addEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
container.dispatchEvent(mountEvent);
// props.onMountAutoFocus에서 e.preventDefault()를 호출하지 않았다면
// 이 블럭이 실행된다.
if (!mountEvent.defaultPrevented) {
focusFirst(
removeLinks(getTabbableCandidates(container)),
{ select: true }
);
// 만약 내부에 focusable한 엘리먼트가 없는 경우, container에 focus를 준다.
// container는 tabIndex가 -1이므로 focus 가능하다.
if (document.activeElement === previouslyFocusedElement) {
focus(container);
}
}
}
}
return () => {
container.removeEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
// https://github.com/facebook/react/issues/17894
// 리액트 17버전에 unmount시 focus동작에 대한 버그가 있어서
// setTimeout을 통해 약간의 지연을 준다.
setTimeout(() => {
const unmountEvent = new CustomEvent(AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS);
container.addEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);
container.dispatchEvent(unmountEvent);
// onUnmountAutoFocus의 기본동작은 FocusScope가 렌더링 되기 전
// focus되어있던 엘리먼트에 focus를 주는 것이다.
// 만약 그 엘리먼트가 더 이상 DOM에 존재하지 않는다면 body에 포커스를 준다.
if (!unmountEvent.defaultPrevented) {
focus(previouslyFocusedElement ?? document.body, { select: true });
}
// 즉시 핸들러를 제거한다.
container.removeEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);
// unmount시 focusScopesStack에서 이 컴포넌트를 정리한다.
focusScopesStack.remove(focusScope);
}, 0)
}
})
이 useEffect
에서는 Autofocus핸들러와 focusScopesStack
의 관리가 이루어진다. 또한 onMountAutoFocus
와 onUnmountAutoFocus
는 커스텀 이벤트에 대한 핸들러로 처리한다. 그리고 e.preventDefault()
로 기본 동작을 막았을 때 autofocus기능이 제거된다는 점을 알 수 있다.
다음으로 trap
기능을 담당하는 첫번째 useEffect
를 살펴보자.
useEffect(() => {
if (trapped) {
const handleFocusIn = ...;
const handleFocusOut = ...;
const handleMutations = ...;
document.addEventListener('focusin', handleFocusIn);
document.addEventListener('focusout', handleFocusOut);
const mutationObserver = new MutationObserver(handleMutations);
if (container) {
mutationObserver.observe(container, { childList: true, subtree: true });
}
return () => {
document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('focusout', handleFocusOut);
mutationObserver.disconnect();
};
}
}, [trapped, container, focusScope.paused])
focus
, blur
이벤트 대신, focusin
, focusout을
사용한다. 전자와 후자의 차이는 이벤트 버블링의 발생 여부인데, focus
와 blur
는 이벤트 버블링이 발생하지 않는다.
handleFocusIn
function handleFocusIn(event) {
// 현재 focusScope이 paused라면 즉시 종료.
if (focusScope.paused || !container) return;
const target = event.target as HTMLElement | null;
// 포커스 타겟이 컨테이너(=FocusScope)밖에 있으면 select까지 추가로 실행.
if (container.contains(target)) {
lastFocusedElementRef.current = target;
} else {
focus(lastFocusedElementRef.current, { select: true });
}
}
lastFocusedElementRef.current
의 값은 focusin
이벤트에서 container
내부에 focus
가 발생한 경우에만 갱신된다. 예를들어 다음 예제에서 FocusScope내부에 있는 Tabbable로 focus가 이동하는 경우에만, lastFocusedElementRef.current
가 갱신된다.
<>
<Tabbable />
<Tabbable />
<FocusScope>
<Tabbable />
<Tabbable />
</FocusScope>
</>
handleFocusOut
trapped
가 true
인 경우, 어떤 방식으로든 FocusScope
외부로 포커스가 이동되는걸 막는다.
function handleFocusOut(event) {
// 현재 focusScope이 paused라면 즉시 종료.
if (focusScope.paused || !container) return;
const relatedTarget = event.relatedTarget;
if (relatedTarget === null) return;
if (!container.contains(relatedTarget)) {
focus(lastFocusedElementRef.current, { select: true });
}
}
relatedTarget
은 event
마다 정해져 있다.focusout
이벤트의 경우, 포커스를 받는 엘리먼트가 relatedTarget
이 된다.
relatedTarget
이 null
이 되는 경우는
유저가 다른 탭, 혹은 다른 창으로 전환할 때
focus된 엘리먼트가 DOM에서 제거되면서 발생
이 때는 브라우저가 알아서 하도록 핸들러에서 아무 처리도 하지 않는다.
만약 relatedTarget
이 null
이 아닐 경우, container
바깥으로 포커스가 이동했는지 확인한 후, 바깥으로 이동한 경우, lastFocusedElementRef.current
로 포커스를 강제 이동시킨다. 이로써 trap
이 완성된다.
handleMutations
function handleMutations(mutations: MutationRecord[]) {
const focusedElement = document.activeElement as HTMLElement | null;
if (focusedElement !== document.body) return;
for (const mutation of mutations) {
// container는 tabIndex가 -1이기 때문에 focusable하다.
if (mutation.removedNodes.length > 0) focus(container);
}
}
if (container) {
mutationObserver.observe(container, { childList: true, subtree: true });
}
뒷쪽에 등록된 mutationObserver
의 옵션을 먼저 살펴보면, childList
가 true
인데, 이 경우엔 container
에 자식 노드가 추가되거나 제거되는 상황을 감지한다. subtree
또한 true
라서, 이 때는 자손 노드의 자식 노드가 추가되거나 제거되는 상황까지 전부 감지한다. (참고)
다음으로 handleMutations
을 살펴보자. container
내부에 자식노드가 추가되거나 제거되었을 때 호출되는 콜백이다.
첫번째 조건문은 container
내부에 focus
가능한 엘리먼트가 남아있는지를 확인하는 용도이다.
if (focusedElement !== document.body) return;
document.body
가 사용된 이유는 포커스된 엘리먼트가 DOM에서 제거되는 경우 브라우저는 document.body
로 포커스를 옮기기 때문이다. 위 조건문을 만족하는 경우는 예를들어 focus
가능한 엘리먼트가 여러개 있는 상태에서, 현재 포커스중인 엘리먼트 말고 다른 엘리먼트가 제거된 경우가 있을 것이다. 이 경우엔 이미 container
내부에 focus
가 있으니까 아무것도 할 필요가 없다.
위 조건문을 만족하지 않을 때는 focus
중인 엘리먼트가 제거되는 상황으로 인해 container
바깥으로 focus
가 이동한 경우다. 이 경우엔 focus
를 가두기 위해서 container
로 다시 focus
를 옮겨주고 있다.
loop
두가지 useEffect
를 살펴봤는데, 마지막으로 FocusScope
에 등록되는 onKeyDown
핸들러를 살펴봐야 한다. 이 핸들러는 focus
를 looping
시키는 기능을 담당한다. 다른 핸들러와 마찬가지로 현재 focusScope
이 활성화된 focusScope
인 경우에만 동작한다.
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (!loop && !trapped) return;
if (focusScope.paused) return;
// metaKey는 windows 에선 window키, mac에선 command키이다.
const isTabKey = event.key === 'Tab'
&& !event.altKey
&& !event.ctrlKey
&& !event.metaKey;
const focusedElement = document.activeElement;
if (isTabKey && focusedElement) {
const container = event.currentTarget;
const [first, last] = getTabbableEdges(container);
const hasTabbableElementsInside = first && last;
if (!hasTabbableElementsInside) {
if (focusedElement === container) event.preventDefault();
} else {
if (!event.shiftKey && focusedElement === last) {
event.preventDefault();
// loop 동작
if (loop) focus(first, { select: true });
} else if (event.shiftKey && focusedElement === first) {
event.preventDefault();
// loop 동작
if (loop) focus(last, { select: true });
}
}
}
},
[loop, trapped, focusScope.paused]
);
TreeWalker
유틸리티 함수중 container
내부의 tabbable
엘리먼트를 가져와서 반환하는 getTabbableCandidates
함수가 있다. 이 함수는 내부적으로 TreeWalker라는 Web API를 사용한다.
TreeWalker는 DOM Tree를 Traverse하기 위한 객체로, document.createTreeWalker
로 생성할 수 있다.
순회 대상은 DOM Tree에 있는 모든 노드이다. 즉, 엘리먼트 노드 뿐 아니라 TextNode, CommentNode, AttributeNode까지 전부 포함된다.
다음과 같이 TreeWalker
인스턴스를 생성할 수 있는데, whatToShow
로 순회 대상이 되는 노드의 타입을 필터링할 수 있고, filter
를 통해 whatToShow
에 의해 필터링된 노드를 한번 더 세부조건을 통해 필터링할 수 있다.
createTreeWalker(root, whatToShow, filter);
예를들어, whatToShow
를 통해 엘리먼트 노드만 순회 대상으로 하면서, 포커스 가능한 엘리먼트가 아니라면 포함시키지 않고 싶을 때 filter
를 사용하여 disabled
이거나 hidden
엘리먼트를 제거할 수 있다.
이 API를 사용하여 구현된 getTabbableCandidates
는 다음과 같다.
// NodeFilter는 브라우저에서 제공하는 객체이다.
function getTabbableCandidates(container: HTMLElement) {
const nodes: HTMLElement[] = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
acceptNode: (node: any) => {
// FILTER_SKIP인 경우, traverse 대상에 포함되지 않는다.
const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden';
if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP;
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
},
});
// TreeWalker.nextNode API를 통해 다음 노드로 이동할 수 있다.
while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement);
return nodes;
}