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