텍스트 범위 강조 표시

CSS Custom Highlight API를 사용하면 DOM 구조를 수정하지 않고도 페이지의 임의의 텍스트 범위에 스타일을 지정할 수 있습니다. 이를 통해 텍스트를 추가 요소로 래핑하거나 innerHTML 조작에 의존하지 않고도 검색 결과 강조 표시, 문법 색상 지정, 공동작업 수정 커서, 맞춤법 및 문법 오류 마커를 사용할 수 있습니다.

핵심 구현

텍스트 범위를 강조 표시하려면 타겟 텍스트 노드를 수집하고, Range 및 Highlight 객체를 만들고, HighlightRegistry에 등록한 다음, ::highlight() 가상 요소로 스타일을 지정해야 합니다.

1. 텍스트 노드 수집 및 범위 만들기

TreeWalker를 사용하여 타겟 요소의 모든 텍스트 노드를 수집한 다음 강조 표시하려는 문자 오프셋을 가리키는 Range 객체를 만듭니다.

const article = document.querySelector("article");

// MANDATORY: Use TreeWalker to collect text nodes — do not manipulate innerHTML.
const treeWalker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
const allTextNodes = [];
let currentNode = treeWalker.nextNode();
while (currentNode) {
  allTextNodes.push(currentNode);
  currentNode = treeWalker.nextNode();
}

// MANDATORY: Set range start/end on text nodes, not element nodes.
const range = new Range();
range.setStart(textNode, matchStartIndex);
range.setEnd(textNode, matchEndIndex);

트리를 탐색하는 데는 비용이 많이 들기 때문에 텍스트 노드 목록을 캐시하고 DOM 콘텐츠가 실제로 변경될 때만 다시 빌드합니다.

2. 범위에서 하이라이트 만들기

하나 이상의 Range 객체를 Highlight로 그룹화합니다. 동일한 스타일을 공유하는 여러 범위가 단일 하이라이트에 속합니다.

const searchHighlight = new Highlight(...matchingRanges);

3. 레지스트리에 하이라이트 등록

Map와 유사한 HighlightRegistry인 CSS.highlights를 사용하여 커스텀 이름으로 각 Highlight를 등록합니다.

// MANDATORY: Clear previous highlights before registering new ones
// to avoid stale ranges persisting on the page.
CSS.highlights.clear();

CSS.highlights.set("search-results", searchHighlight);

여러 강조 표시가 겹치는 경우 priority 속성을 사용하여 쌓기 순서를 제어합니다. 우선순위가 높은 강조 표시가 맨 위에 표시됩니다.

const primary = new Highlight(...primaryRanges);
primary.priority = 1;

const secondary = new Highlight(...secondaryRanges);
secondary.priority = 0; // painted first (behind primary)

CSS.highlights.set("primary", primary);
CSS.highlights.set("secondary", secondary);

4. ::highlight() 스타일 지정

CSS에서 ::highlight() 가상 요소를 사용하여 등록된 각 하이라이트의 스타일을 이름별로 지정합니다.

::highlight(search-results) {
  background-color: #ffdd00;
  color: black;
}

::highlight() 내에서는 color, background-color, text-decoration 및 그 긴 형식, text-shadow, -webkit-text-stroke-color, -webkit-text-fill-color, -webkit-text-stroke-width와 같은 제한된 CSS 속성 집합만 작동합니다. background-image, font-size, padding와 같은 속성은 무시됩니다.

접근성

AVOID: 시맨틱 HTML 대신 커스텀 하이라이트를 사용합니다.

커스텀 하이라이트는 순전히 표시용이며 접근성 트리에 노출되지 않습니다. 강조 표시된 텍스트가 문서와 의미상 관련이 있는 경우 (예: 사용자가 선택한 구절) 대신 <mark>을 사용하세요. 검색 결과나 문법 색상 지정과 같은 일시적인 시각적 효과에만 커스텀 하이라이트를 사용하세요.

강조 표시는 의미를 전달하는 데 색상에만 의존해서는 안 됩니다. 강조 표시가 오류를 나타내는 경우 text-decoration: wavy underline 또는 인접한 텍스트 라벨과 같은 다른 시각적 표시기와 함께 사용하세요. WCAG 2.1 요구사항 (일반 텍스트의 경우 최소 4.5:1)을 충족하도록 강조 표시 배경과 텍스트 색상 간의 대비가 충분해야 합니다.

대체 전략

CSS Custom Highlight API를 지원하지 않는 브라우저의 경우 시각적 하이라이트가 없어도 텍스트를 읽을 수 있는 기능적 기본 환경을 제공해야 합니다.

API를 사용하기 전에 지원 여부를 감지할 수 있습니다.

if (CSS.highlights) {
  // CSS Custom Highlight API is supported.
} else {
  // Fallback: wrap matches in <mark> elements.
}

강조표시가 사용자 환경에 중요한 경우 일치하는 텍스트를 <mark> 요소로 래핑하는 것으로 대체합니다. 이렇게 하면 DOM이 수정되므로 이벤트 리스너를 보존하고 문서 구조가 손상되지 않도록 주의하세요.

if (!CSS.highlights) {
  // Walk text nodes and wrap matches in <mark>, preserving structure.
  const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
  const nodes = [];
  for (let n = walker.nextNode(); n; n = walker.nextNode()) nodes.push(n);

  const term = searchTerm.toLowerCase();
  for (const textNode of nodes) {
    const text = textNode.textContent;
    let pos = text.toLowerCase().indexOf(term);
    if (pos === -1) continue;

    const frag = document.createDocumentFragment();
    let last = 0;
    while (pos !== -1) {
      frag.append(text.slice(last, pos));
      const mark = document.createElement("mark");
      // textContent assignment avoids HTML injection.
      mark.textContent = text.slice(pos, pos + term.length);
      frag.append(mark);
      last = pos + term.length;
      pos = text.toLowerCase().indexOf(term, last);
    }
    frag.append(text.slice(last));
    textNode.replaceWith(frag);
  }
}