(오픈소스 분석) Radix FocusScope

·

6 min read

@radix-ui/react-focus-scope 라이브러리 코드와 TreeWalker Wep API를 살펴본다.

용도

  1. FocusScope이 mount될 때, 내부의 첫번째 Focusable한 엘리먼트에 focus된다.

  2. FosucScope이 unmount될 때, mount되기 직전에 focus를 받았던 엘리먼트에 focus된다.

  3. 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;

focusScopesStackfocusScopeadd, remove하는 기능을 가진 객체이고, add할 때 현재 활성화된 focusScopepause후, 추가된 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을 담당하고, 하나는 onMountAutoFocusonUnmountAutoFocusfocusScopesStack의 관리를 담당한다.

우선 두번째 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의 관리가 이루어진다. 또한 onMountAutoFocusonUnmountAutoFocus는 커스텀 이벤트에 대한 핸들러로 처리한다. 그리고 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을 사용한다. 전자와 후자의 차이는 이벤트 버블링의 발생 여부인데, focusblur는 이벤트 버블링이 발생하지 않는다.

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

trappedtrue인 경우, 어떤 방식으로든 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 });
  }
}

relatedTargetevent마다 정해져 있다.focusout이벤트의 경우, 포커스를 받는 엘리먼트가 relatedTarget이 된다.

relatedTargetnull이 되는 경우는

  1. 유저가 다른 탭, 혹은 다른 창으로 전환할 때

  2. focus된 엘리먼트가 DOM에서 제거되면서 발생

이 때는 브라우저가 알아서 하도록 핸들러에서 아무 처리도 하지 않는다.

만약 relatedTargetnull이 아닐 경우, 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의 옵션을 먼저 살펴보면, childListtrue인데, 이 경우엔 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핸들러를 살펴봐야 한다. 이 핸들러는 focuslooping시키는 기능을 담당한다. 다른 핸들러와 마찬가지로 현재 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;
}