접혀진 아코디언은 검색할 수 없나요?

접혀진 아코디언은 검색할 수 없나요?

접근성과 애니메이션을 포기하지 않는 UI를 만들 수 있을까?

최근에 사내 유저가이드 시스템을 고도화하면서 재밌는 버그 리포트를 받았다. 검색을 했는데 내용이 하이라이팅되지 않는다고 해서, 검색을 위한 Algolia의 인덱싱에 문제가 있다는 말씀이실까 생각했었다. 그런데 직접 만나 이야기를 해보니 전혀 달랐다.

Ctrl + F로 어떤 내용을 검색했을 때, 아코디언(접었다 폈다 할 수 있는 UI) 안에 접혀진 내용 중 일치하는 내용이 있으면 아코디언을 펼쳐달라는 말씀이었다.

아코디언(Accordion) 예시

순간 살짝 당황했다. 브라우저의 '페이지 내에서 찾기(Find in page)'에서 사용자가 입력한 값을 가져올 수는 없고, 그렇다고 Ctrl + F 이후의 모든 키보드 입력을 받아 입력한 단어를 추론하는 것도 CJK(한국어, 중국어, 일본어)에서는 매우 복잡하다. (한글만 해도 입력한 자음이 초성에 올지 종성에 올지 고민해야 한다)

그렇게 여러 자료를 찾아보던 중, [hidden="until-found"] 라는 HTML 속성을 알게 되었다.

[hidden="until-found"] 알아보기

[hidden="until-found"]는 HTML에 추가된 가장 최신 스펙 중 하나로, 숨겨진 영역의 콘텐츠를 검색하고 일치하는 내용이 있으면 beforematch 이벤트를 addEventListener()로 받아 표시할 수 있다. 현재는 Chrome 102 이후의 브라우저에서만 사용 가능하다.

일반적으로 콘텐츠를 가릴 때에는 display: none을 적용하거나, React 같은 뷰 프레임워크를 쓰면 isOpen && ... 같이 아예 DOM을 렌더링하지 않게 만든다.

[hidden="until-found"]display: none 대신 content-visibility:hidden을 적용하여 콘텐츠 자체는 검색이 가능하도록 만든다. 개발자들은 대신 아래와 같이 해당 속성이 적용되어 있을 때에 CSS로 내용을 가려주게끔 작업을 해야한다.

div[hidden="until-found"] {
  height: 0;
  overflow-y: hidden;
}

NextJS로 정적 페이지를 생성하는 형태로 유저 가이드 시스템을 개선하고 있고, 보통 아코디언의 내용이 동적으로 크게 변화하는 경우가 없기에 [hidden="until-found"]를 기존의 아코디언 로직에 같이 적용하기로 했다. (브라우저 지원 버전은 이번에는 크게 고려하지 않기로 했다)

속성에 관한 보다 자세한 내용은 Chrome Developers에서 작성한 내용을 참고하면 좋을 것 같다.

ReactDOM과 모던 웹의 잘못된 만남

모든 것이 잘 풀렸다면 블로그 글로 남기지 않았을 것이다. 속성만 적용하면 잘 될거라는 생각과 함께 아코디언 컴포넌트에 속성을 적용해보았다. 하지만, 렌더링도 제대로 되지 않았고 beforematch 이벤트도 제대로 받지 못했다. 무슨 일이 일어난걸까?

ReactDOM의 강제 보정 로직

[hidden="until-found"]는 추가된지 얼마 안된 HTML 스펙이다. 기존에는 hidden 에 들어갈 수 있는 값은 boolean 밖에 없었기에 JSX의 타이핑에서 문제가 날 수는 있다. 하지만 이를 무시하더라도 실제 렌더링 결과에는 hidden={true}로 보정된다.

문제의 원인은 ReactDOM에 있다. ReactDOM은 렌더링 과정에서 React로 짜여진 컴포넌트가 아닌 일반적인 태그에 정의되지 않은 prop 또는 잘못된 attribute를 넣으려고 할 때 안정성을 위해 강제로 boolean으로 보정하여 렌더링을 한다. 즉, ReactDOM은 hidden에 내가 무슨 값을 넣더라도 boolean으로 변경되기에 이론적으로는 의도대로 렌더링할 수 없다.

ReactDOM을 속여라

이론적으로 해결할 수 없는 문제에도 꼼수가 하나 존재한다. prop의 이름을 hiddenHIDDEN으로. 대문자로 작성해 주입하는 방식이다.

이렇게 HIDDEN="until-found"를 사용하면 강제로 렌더링 결과에 내가 의도한 값을 넣어버릴 수 있다. 비슷하게 스타일을 넣을 때 Object로 넣어주어야 하는 style prop도 STYLE="margin:0; color: red;"와 같이 넣으면 실제 DOM에도 적용이 된다.

하지만, 이렇게 코드를 작성해놓으면 나중에 볼 때 기분도 안좋고 관리에도 불편함이 있다. 정말로 특별한 경우가 아니라면 해당 방법을 추천하지 않는다.

왜 이벤트 콜백을 못받을까요?

의도한대로 [hidden="until-found"]를 렌더링 결과에 넣는 것에 성공했고, Ctrl + F로 검색했을 때 하이라이팅을 하는 것까지는 되었지만, 이를 React에서 제어하기 위한 beforematch 이벤트가 실행되지 않았다. 분명 ref를 제대로 사용하고 있었는데...

이 부분은 명확하게 파악하지는 못했지만(그래서 개선의 여지가 많다), 서버 사이드 렌더링(SSR) 과정에서 ref를 제때에 지정하지 못했거나 React의 렌더링 로직 중 관련 내용이 구현되지 않아 이벤트를 작동시키지 않았다는 생각이 들었다.

이번 작업을 하면서는 전자 대신 후자의 가능성을 높게 판단, document.querySelector()를 사용하여 '브라우저에서 찾은 DOM'에 addEventListener()를 걸도록 수정했다.

useEffect(() => {
    const handleBeforeMatch = (e: Event) => {
        setIsOpen(true);
    };

    // ReactDOM does not support this feature yet,
    // so delegate this logic to browser.
    const container = root.current?.querySelector('#accordion-item-{random-id}');
    container?.addEventListener('beforematch', handleBeforeMatch);

    return () => {
        container?.removeEventListener('beforematch', handleBeforeMatch);
    };
}, []);

애니메이션으로 더욱 미려한 UI 만들기

아코디언을 접고 펼 때 가벼운 애니메이션이 있으면 좋을 것 같은 느낌이 들었다. 기존에 Framer Motion을 사용한 컴포넌트들이 있었기에 <div><motion.div>로 변경하면 될거라고 생각했다. 하지만 이것 역시 잘 되지 않았다. <motion.div>는 아까의 대문자 꼼수가 적용되지 않았으며 펼쳐진 상태를 다시 접을 때 [hidden="until-found"]가 적용되며 애니메이션이 강제로 중지된다.

그래도 위의 ReactDOM보다 간단하게 해결할 수 있었다. 하나는 애니메이션이 진행중이라는 상태를 두는 것. 다른 하나는 <motion.div> 대신 Framer Motion의 useAnimate() Hook을 사용해 로지컬하게 애니메이션을 실행하는 것.

코드의 일부만 가져오면 아래와 같이 될 것이다.

const [scope, animate] = useAnimate();

useEffect(() => {
    void animate(
        scope.current,
        isOpen ? variants.animate : variants.initial,
        {
            onComplete() {
                setIsAnimating(false);
            },
        },
    );
}, [isOpen]);

<div
    ref={scope}
    HIDDEN={!isAnimating && !isOpen ? 'until-found' : undefined}
>

isOpentrue로 바꾸는 곳에서 isAnimatingtrue로 같이 변경하고, animateonComplete() callback으로 isAnimatingfalse로 바꿔주는 것이 주요 로직이다.

마음에 들지는 않지만 일단 이 방법 외에는 좋은 방법이 떠오르지 않았다.

접근성을 준수하는 프론트엔드 개발

유저 가이드를 고도화하면서 기존 애널리틱스 웹앱을 개발할 때와는 또 다른 새로운 경험을 하고 있다. 유저 가이드는 문서의 탐색 편의성과 접근성이 특히나 중요하기에 WAI-ARIA 등 기존에 상대적으로 덜 신경쓰던 부분도 조금 더 가져가보려고 하고 있다.

한편으로는 접근성을 준수하는 프론트엔드 개발에는 생각보다 많은 기술적 장벽 역시 있다고도 느껴졌다. 특히 이번의 ReactDOM 문제를 경험하면서, ReactDOM이 모던 웹과 조금씩 멀어지는 것은 아닌가 생각했다. 프레임워크나 코어 라이브러리가 접근성을 제한한다면 개발자는 어디까지 이를 준수해야하는가?

하지만 정보를 전달하는 프론트엔드의 역할을 생각했을 때 포기할 수는 없다. 접근성을 준수하는 프론트엔드 개발이라는 것은 포기할 수 없고 조금은 비현실적인. 그럼에도 천천히 걸어가야할 머나먼 여정이 아닐까.