{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-search-highlighter",
  "type": "registry:ui",
  "title": "Text Search Highlighter",
  "description": "Find-in-text for any content block: every match highlighted, a \"3 of 12\" counter, and Enter / Shift+Enter to step through them.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/text-search-highlighter.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type TextSearchEngine = \"auto\" | \"marks\";\n\nexport interface TextSearchHighlighterProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** The content to search. Any React tree; only its rendered text is searched. */\n  children: React.ReactNode;\n  /** Accessible name of the search field. */\n  label?: string;\n  placeholder?: string;\n  /** Initial query. */\n  defaultQuery?: string;\n  /** Initial \"match case\" option (off = case-insensitive). */\n  matchCase?: boolean;\n  /** Initial \"match diacritics\" option (off = \"cafe\" finds \"café\"). */\n  matchDiacritics?: boolean;\n  /** Initial \"whole word\" option. */\n  wholeWord?: boolean;\n  /** Show the option toggles in the toolbar. */\n  showOptions?: boolean;\n  /** \"auto\" uses the CSS Custom Highlight API when supported and <mark> wrapping otherwise; \"marks\" always wraps. */\n  engine?: TextSearchEngine;\n  /** Maximum height of the scrolling content area, in px. */\n  maxHeight?: number;\n  /** Highlight colour. */\n  accent?: string;\n  /** Called with the number of matches whenever it changes. */\n  onMatchesChange?: (count: number) => void;\n}\n\n/* --------------------------------- search --------------------------------- */\n\nconst MAX_MATCHES = 1000;\nconst SKIP = new Set([\"SCRIPT\", \"STYLE\", \"NOSCRIPT\", \"TEXTAREA\", \"INPUT\", \"SELECT\", \"OPTION\", \"TEMPLATE\"]);\nconst INLINE = new Set([\n  \"A\", \"ABBR\", \"B\", \"BDI\", \"BDO\", \"CITE\", \"CODE\", \"DATA\", \"DEL\", \"DFN\", \"EM\", \"I\", \"INS\", \"KBD\", \"LABEL\",\n  \"MARK\", \"Q\", \"S\", \"SAMP\", \"SMALL\", \"SPAN\", \"STRONG\", \"SUB\", \"SUP\", \"TIME\", \"U\", \"VAR\",\n]);\nconst WORD_CHAR = /[\\p{L}\\p{N}_]/u;\nconst COMBINING = /\\p{M}/u;\n\ninterface Options {\n  matchCase: boolean;\n  matchDiacritics: boolean;\n  wholeWord: boolean;\n}\n/** One stretch of a match inside a single text node. */\ninterface Piece {\n  node: Text;\n  start: number;\n  end: number;\n}\n\n/** Case / diacritic folding that remembers where every folded character came from. */\nfunction fold(text: string, { matchCase, matchDiacritics }: Options) {\n  let out = \"\";\n  const from: number[] = [];\n  const to: number[] = [];\n  for (let i = 0; i < text.length; ) {\n    const ch = String.fromCodePoint(text.codePointAt(i) ?? 0);\n    let folded = matchDiacritics ? ch : ch.normalize(\"NFD\").replace(/\\p{M}/gu, \"\");\n    if (!matchCase) folded = folded.toLowerCase();\n    for (let k = 0; k < folded.length; k++) {\n      from.push(i);\n      to.push(i + ch.length);\n    }\n    out += folded;\n    i += ch.length;\n  }\n  return { out, from, to };\n}\n\n/** Finds every match of `query` in the rendered text under `root`, as per-text-node pieces. */\nfunction findMatches(root: HTMLElement, query: string, options: Options): Piece[][] {\n  if (!query.trim()) return [];\n  // Flatten the visible text; a separator between blocks stops matches running across paragraphs.\n  const nodes: { node: Text; at: number }[] = [];\n  let text = \"\";\n  let lastBlock: Element | null = null;\n  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {\n    acceptNode(node) {\n      const parent = node.parentElement;\n      if (!parent || !node.nodeValue) return NodeFilter.FILTER_REJECT;\n      if (SKIP.has(parent.tagName) || parent.closest(\"[data-tsh-clone]\")) return NodeFilter.FILTER_REJECT;\n      if (typeof parent.checkVisibility === \"function\" && !parent.checkVisibility()) return NodeFilter.FILTER_REJECT;\n      return NodeFilter.FILTER_ACCEPT;\n    },\n  });\n  for (let n = walker.nextNode(); n; n = walker.nextNode()) {\n    const node = n as Text;\n    let block: Element | null = node.parentElement;\n    while (block && block !== root && INLINE.has(block.tagName)) block = block.parentElement;\n    if (lastBlock && block !== lastBlock) text += \"\\u0000\";\n    lastBlock = block;\n    nodes.push({ node, at: text.length });\n    text += node.nodeValue ?? \"\";\n  }\n\n  const hay = fold(text, options);\n  const needle = fold(query, options).out;\n  const ranges: [number, number][] = [];\n  for (let from = 0; ranges.length < MAX_MATCHES; ) {\n    const index = hay.out.indexOf(needle, from);\n    if (index === -1) break;\n    const endIndex = index + needle.length;\n    if (options.wholeWord && (WORD_CHAR.test(hay.out[index - 1] ?? \"\") || WORD_CHAR.test(hay.out[endIndex] ?? \"\"))) {\n      from = index + 1;\n      continue;\n    }\n    let end = hay.to[endIndex - 1];\n    // Ignoring diacritics: include combining marks that trail the match (\"é\").\n    if (!options.matchDiacritics) while (end < text.length && COMBINING.test(text[end])) end++;\n    ranges.push([hay.from[index], end]);\n    from = endIndex;\n  }\n\n  // Split each flat range into pieces per text node (both lists are sorted, so one sweep is enough).\n  const matches: Piece[][] = [];\n  let k = 0;\n  for (const [start, end] of ranges) {\n    while (k < nodes.length - 1 && nodes[k].at + (nodes[k].node.nodeValue?.length ?? 0) <= start) k++;\n    const pieces: Piece[] = [];\n    for (let j = k; j < nodes.length && nodes[j].at < end; j++) {\n      const { node, at } = nodes[j];\n      const length = node.nodeValue?.length ?? 0;\n      const s = Math.max(start, at) - at;\n      const e = Math.min(end, at + length) - at;\n      if (e > s) pieces.push({ node, start: s, end: e });\n    }\n    if (pieces.length) matches.push(pieces);\n  }\n  return matches;\n}\n\nfunction supportsHighlights() {\n  return typeof CSS !== \"undefined\" && \"highlights\" in CSS && typeof Highlight !== \"undefined\";\n}\n\n/* ------------------------------- component -------------------------------- */\n\ninterface MatchHandle {\n  range: Range | null;\n  marks: HTMLElement[];\n}\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst MARK_STYLE = \"background-color:color-mix(in srgb,var(--tsh-accent) 32%,transparent);color:inherit;border-radius:2px;\";\nconst MARK_CURRENT = \"background-color:var(--tsh-accent);color:#0b0b0c;border-radius:2px;\";\n\n/**\n * Find-in-text for a block of content: highlights every match of the query (case-, diacritic- and\n * whole-word options), counts them (\"3 of 12\"), and steps through them with Enter / Shift+Enter or\n * the arrow buttons, scrolling the current match into view inside its own container. Uses the CSS\n * Custom Highlight API when available (no DOM changes at all) and otherwise <mark> clones that leave\n * React's own text nodes in place and restore them exactly.\n */\nexport function TextSearchHighlighter({\n  children,\n  label = \"Find in text\",\n  placeholder = \"Find in text\",\n  defaultQuery = \"\",\n  matchCase = false,\n  matchDiacritics = false,\n  wholeWord = false,\n  showOptions = true,\n  engine = \"auto\",\n  maxHeight = 280,\n  accent = \"#FF7448\",\n  onMatchesChange,\n  className = \"\",\n  style,\n  ...rest\n}: TextSearchHighlighterProps) {\n  const uid = React.useId();\n  const name = `tsh-${uid.replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const contentId = `${uid}-content`;\n  const reduced = usePrefersReducedMotion();\n  const [query, setQuery] = React.useState(defaultQuery);\n  const [options, setOptions] = React.useState<Options>({ matchCase, matchDiacritics, wholeWord });\n  // Follow option props when they change (they are initial values for the toggles).\n  const [prevProps, setPrevProps] = React.useState<Options>({ matchCase, matchDiacritics, wholeWord });\n  if (prevProps.matchCase !== matchCase || prevProps.matchDiacritics !== matchDiacritics || prevProps.wholeWord !== wholeWord) {\n    setPrevProps({ matchCase, matchDiacritics, wholeWord });\n    setOptions({ matchCase, matchDiacritics, wholeWord });\n  }\n  const [result, setResult] = React.useState({ count: 0, version: 0 });\n  const [current, setCurrent] = React.useState(0);\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const matchesRef = React.useRef<MatchHandle[]>([]);\n  const currentMarks = React.useRef<HTMLElement[]>([]);\n  const onMatchesChangeRef = React.useRef(onMatchesChange);\n  React.useEffect(() => {\n    onMatchesChangeRef.current = onMatchesChange;\n  });\n\n  const safeAccent = /^[#a-zA-Z0-9(),.%\\s-]+$/.test(accent) ? accent : \"#FF7448\";\n  const { count } = result;\n\n  // Search and paint. Re-runs when the query or options change and when the content mutates.\n  React.useEffect(() => {\n    const content = contentRef.current;\n    if (!content) return;\n    const useApi = engine === \"auto\" && supportsHighlights();\n    let undo = () => {};\n    let frame = 0;\n    const observer = new MutationObserver(() => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(run);\n    });\n    const watch = () => observer.observe(content, { childList: true, subtree: true, characterData: true });\n\n    function run() {\n      observer.disconnect();\n      undo();\n      undo = () => {};\n      const found = query ? findMatches(content!, query, options) : [];\n      let handles: MatchHandle[];\n\n      if (useApi) {\n        handles = found.map((pieces) => {\n          const range = document.createRange();\n          range.setStart(pieces[0].node, pieces[0].start);\n          const last = pieces[pieces.length - 1];\n          range.setEnd(last.node, last.end);\n          return { range, marks: [] };\n        });\n        const all = new Highlight(...handles.map((h) => h.range as Range));\n        CSS.highlights.set(name, all);\n        undo = () => {\n          CSS.highlights.delete(name);\n          CSS.highlights.delete(`${name}-current`);\n        };\n      } else {\n        // <mark> fallback that never edits React's nodes structurally: each affected text node is\n        // blanked and a sibling clone carries the marked-up text. Undo removes the clone and puts the\n        // original value back (unless React has since written a new value into the node).\n        handles = found.map(() => ({ range: null, marks: [] }));\n        const byNode = new Map<Text, { start: number; end: number; match: number }[]>();\n        found.forEach((pieces, match) => {\n          for (const p of pieces) {\n            const list = byNode.get(p.node) ?? [];\n            list.push({ start: p.start, end: p.end, match });\n            byNode.set(p.node, list);\n          }\n        });\n        const restore: { node: Text; value: string; clone: HTMLElement }[] = [];\n        byNode.forEach((list, node) => {\n          const value = node.nodeValue ?? \"\";\n          const clone = document.createElement(\"span\");\n          clone.dataset.tshClone = \"\";\n          let cursor = 0;\n          for (const { start, end, match } of list) {\n            if (start > cursor) clone.append(value.slice(cursor, start));\n            const mark = document.createElement(\"mark\");\n            mark.setAttribute(\"style\", MARK_STYLE);\n            mark.textContent = value.slice(start, end);\n            handles[match].marks.push(mark);\n            clone.append(mark);\n            cursor = end;\n          }\n          if (cursor < value.length) clone.append(value.slice(cursor));\n          node.parentNode?.insertBefore(clone, node.nextSibling);\n          node.nodeValue = \"\";\n          restore.push({ node, value, clone });\n        });\n        undo = () => {\n          for (const r of restore) {\n            r.clone.remove();\n            if (r.node.nodeValue === \"\") r.node.nodeValue = r.value;\n          }\n        };\n      }\n\n      matchesRef.current = handles;\n      currentMarks.current = [];\n      observer.takeRecords();\n      watch();\n      setResult((r) => ({ count: handles.length, version: r.version + 1 }));\n      setCurrent((c) => Math.min(c, Math.max(0, handles.length - 1)));\n      onMatchesChangeRef.current?.(handles.length);\n    }\n\n    frame = requestAnimationFrame(run);\n    return () => {\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n      undo();\n      matchesRef.current = [];\n    };\n  }, [query, options, engine, name]);\n\n  // Paint the current match and scroll it into view inside the content container (not the page).\n  React.useEffect(() => {\n    const content = contentRef.current;\n    const match = matchesRef.current[current];\n    for (const mark of currentMarks.current) mark.setAttribute(\"style\", MARK_STYLE);\n    currentMarks.current = [];\n    if (!content || !match) return;\n    let rect: DOMRect;\n    if (match.range) {\n      const highlight = new Highlight(match.range);\n      highlight.priority = 1;\n      CSS.highlights.set(`${name}-current`, highlight);\n      rect = match.range.getBoundingClientRect();\n    } else {\n      for (const mark of match.marks) mark.setAttribute(\"style\", MARK_CURRENT);\n      currentMarks.current = match.marks;\n      rect = match.marks[0].getBoundingClientRect();\n    }\n    const box = content.getBoundingClientRect();\n    const pad = 24;\n    if (rect.top < box.top + pad || rect.bottom > box.bottom - pad) {\n      const top = content.scrollTop + (rect.top - box.top) - (box.height - rect.height) / 2;\n      content.scrollTo({ top: Math.max(0, Math.round(top)), behavior: reduced ? \"auto\" : \"smooth\" });\n    }\n  }, [current, result, name, reduced]);\n\n  const step = (delta: number) => {\n    if (!count) return;\n    setCurrent((c) => (c + delta + count) % count);\n  };\n  const updateQuery = (value: string) => {\n    setQuery(value);\n    setCurrent(0);\n  };\n  const toggle = (key: keyof Options) => {\n    setOptions((o) => ({ ...o, [key]: !o[key] }));\n    setCurrent(0);\n  };\n\n  const capped = count >= MAX_MATCHES;\n  const status = !query.trim() ? \"\" : count ? `${current + 1} of ${count}${capped ? \"+\" : \"\"}` : \"No results\";\n  const iconButton =\n    \"grid size-7 shrink-0 place-items-center rounded-md text-zinc-500 outline-hidden transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--tsh-accent)] disabled:pointer-events-none disabled:opacity-35 dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white\";\n  const toggleButton =\n    \"grid h-7 min-w-7 shrink-0 place-items-center rounded-md px-1 font-mono text-[11px] text-zinc-500 outline-hidden transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--tsh-accent)] aria-pressed:bg-[color-mix(in_srgb,var(--tsh-accent)_18%,transparent)] aria-pressed:text-zinc-950 dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white dark:aria-pressed:text-white\";\n\n  return (\n    <div\n      {...rest}\n      className={`flex w-full min-w-0 flex-col overflow-hidden rounded-2xl border border-zinc-900/10 bg-white text-zinc-800 shadow-[0_1px_2px_rgb(0_0_0/0.04)] dark:border-white/10 dark:bg-[#17191c] dark:text-zinc-200 ${className}`}\n      style={{ ...style, [\"--tsh-accent\" as string]: safeAccent }}\n    >\n      {/* Highlight API colours (names are unique per instance). */}\n      <style>{`::highlight(${name}){background-color:color-mix(in srgb,${safeAccent} 32%,transparent)}::highlight(${name}-current){background-color:${safeAccent};color:#0b0b0c}`}</style>\n\n      <div role=\"search\" className=\"flex min-w-0 flex-wrap items-center gap-1 border-b border-zinc-900/10 px-2 py-1.5 dark:border-white/10\">\n        <div className=\"flex min-w-[13rem] flex-1 items-center gap-1\">\n          <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"ml-1.5 size-3.5 shrink-0 text-zinc-400\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\">\n            <circle cx=\"7\" cy=\"7\" r=\"4.5\" />\n            <path d=\"m10.5 10.5 3 3\" strokeLinecap=\"round\" />\n          </svg>\n          <input\n            type=\"search\"\n            value={query}\n            placeholder={placeholder}\n            aria-label={label}\n            aria-controls={contentId}\n            autoComplete=\"off\"\n            spellCheck={false}\n            onChange={(e) => updateQuery(e.target.value)}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") {\n                e.preventDefault();\n                step(e.shiftKey ? -1 : 1);\n              } else if (e.key === \"Escape\" && query) {\n                // Escape clears the query first; with an empty field it passes through to outer layers.\n                e.preventDefault();\n                e.stopPropagation();\n                updateQuery(\"\");\n              }\n            }}\n            className=\"h-8 w-full min-w-[4rem] flex-1 bg-transparent px-1.5 text-[13.5px] text-zinc-900 outline-hidden placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500 [&::-webkit-search-cancel-button]:appearance-none\"\n          />\n          <span aria-hidden=\"true\" className=\"shrink-0 px-1 font-mono text-[11px] tabular-nums tracking-[0.02em] text-zinc-500 dark:text-zinc-400\">\n            {status}\n          </span>\n          {query ? (\n            <button type=\"button\" aria-label=\"Clear search\" className={iconButton} onClick={() => updateQuery(\"\")}>\n              <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\">\n                <path d=\"m4.5 4.5 7 7m0-7-7 7\" />\n              </svg>\n            </button>\n          ) : null}\n          <span aria-hidden=\"true\" className=\"mx-0.5 h-4 w-px shrink-0 bg-zinc-900/10 dark:bg-white/10\" />\n          <button type=\"button\" aria-label=\"Previous match\" title=\"Previous match (Shift+Enter)\" disabled={!count} className={iconButton} onClick={() => step(-1)}>\n            <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n              <path d=\"m4 10 4-4 4 4\" />\n            </svg>\n          </button>\n          <button type=\"button\" aria-label=\"Next match\" title=\"Next match (Enter)\" disabled={!count} className={iconButton} onClick={() => step(1)}>\n            <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n              <path d=\"m4 6 4 4 4-4\" />\n            </svg>\n          </button>\n        </div>\n        {showOptions ? (\n          <div role=\"group\" aria-label=\"Search options\" className=\"ml-auto flex shrink-0 items-center gap-0.5\">\n            <button type=\"button\" aria-pressed={options.matchCase} aria-label=\"Match case\" title=\"Match case\" className={toggleButton} onClick={() => toggle(\"matchCase\")}>\n              Aa\n            </button>\n            <button type=\"button\" aria-pressed={options.matchDiacritics} aria-label=\"Match diacritics\" title=\"Match diacritics\" className={toggleButton} onClick={() => toggle(\"matchDiacritics\")}>\n              é\n            </button>\n            <button type=\"button\" aria-pressed={options.wholeWord} aria-label=\"Whole word\" title=\"Whole word\" className={toggleButton} onClick={() => toggle(\"wholeWord\")}>\n              <span className=\"border-x border-b border-current px-0.5 leading-[1.1]\">ab</span>\n            </button>\n          </div>\n        ) : null}\n      </div>\n\n      <div\n        ref={contentRef}\n        id={contentId}\n        role=\"region\"\n        aria-label={`${label}: content`}\n        tabIndex={0}\n        className=\"relative min-w-0 overflow-y-auto overscroll-contain px-5 py-4 outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--tsh-accent)]\"\n        style={{ maxHeight }}\n      >\n        {children}\n      </div>\n\n      <p aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {!query.trim() ? \"\" : count ? `Match ${current + 1} of ${count}` : \"No matches\"}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/text-search-highlighter"
}
