import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode, } from 'react'; import { SearchIcon, XIcon } from 'lucide-react'; import { rankMatch, type SettingsEntry } from '../../lib/seo/settings-index'; import { classNames } from '../../lib/utils'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { TabsContent } from '../ui/tabs'; /** One option as the search sees it: where it lives and what it says. */ export interface OptionEntry { id: string; section: string; label: string; description: string; tip: string; } type Register = (entry: OptionEntry) => () => void; const RegisterContext = createContext(null); const HighlightContext = createContext(null); const SectionContext = createContext(''); export const useOptionRegister = (): Register | null => useContext(RegisterContext); export const useOptionHighlight = (): string | null => useContext(HighlightContext); export const useOptionSection = (): string => useContext(SectionContext); /** * One section of a settings page. Its panel stays mounted while hidden, so * every option of the page is in the index whichever section is open. */ export const SettingsSection = ({ value, children, className, }: { value: string; children: ReactNode; className?: string; }): JSX.Element => ( {children} ); /** The index of a page's options, kept outside React state until a batch of rows has registered. */ export const useOptionIndex = (): { register: Register; entries: OptionEntry[]; } => { const rows = useRef(new Map()); const pending = useRef(false); const [entries, setEntries] = useState([]); // Rows register one by one on mount; the index is published once per batch. const publish = useCallback((): void => { if (!pending.current) { pending.current = true; queueMicrotask(() => { pending.current = false; setEntries([...rows.current.values()]); }); } }, []); const register = useCallback( entry => { rows.current.set(entry.id, entry); publish(); return () => { rows.current.delete(entry.id); publish(); }; }, [publish] ); return { register, entries }; }; export const OptionIndexProvider = ({ register, highlighted, children, }: { register: Register; highlighted: string | null; children: ReactNode; }): JSX.Element => ( {children} ); /** Matches listed from the other pages before the list says how many more there are. */ const MAX_ELSEWHERE = 20; /** The entries of this page a query matches, the ones matched by their label first. */ export const searchOptions = ( entries: OptionEntry[], query: string ): OptionEntry[] => entries .map(entry => ({ entry, rank: rankMatch(query, [ entry.label, `${entry.description} ${entry.tip}`, ]), })) .filter(match => match.rank > 0) .sort((a, b) => b.rank - a.rank) .map(match => match.entry); /** The options and sections of the other settings pages a query matches, sections and labels first. */ export const searchElsewhere = ( entries: SettingsEntry[], query: string ): SettingsEntry[] => entries .map(entry => ({ entry, rank: rankMatch(query, [ entry.label, `${entry.detail ?? ''} ${entry.description} ${entry.tip}`, ]), })) .filter(match => match.rank > 0) .sort( (a, b) => b.rank + (b.entry.isSection ? 0.25 : 0) - (a.rank + (a.entry.isSection ? 0.25 : 0)) ) .map(match => match.entry); /** * The search box of a settings page, the way Rank Math offers "Search * options", over every SEO settings page: the options of this page first * (picking one opens its section with it highlighted), then the options * and sections of the others, which open that page there. */ export const OptionSearch = ({ entries, elsewhere = [], sectionLabel, onPick, onPickElsewhere, }: { entries: OptionEntry[]; /** The options and sections of the other settings pages. */ elsewhere?: SettingsEntry[]; sectionLabel: (section: string) => string; onPick: (entry: OptionEntry) => void; onPickElsewhere?: (entry: SettingsEntry) => void; }): JSX.Element => { const [query, setQuery] = useState(''); const results = useMemo( () => searchOptions(entries, query), [entries, query] ); const others = useMemo( () => (onPickElsewhere ? searchElsewhere(elsewhere, query) : []), [elsewhere, query, onPickElsewhere] ); const open = query.trim() !== ''; const shownOthers = others.slice(0, MAX_ELSEWHERE); const pick = (entry: OptionEntry): void => { setQuery(''); onPick(entry); }; const pickElsewhere = (entry: SettingsEntry): void => { setQuery(''); onPickElsewhere?.(entry); }; const row = ( key: string, label: string, where: string, description: string, onClick: () => void ): JSX.Element => (
  • ); return (
    setQuery(event.target.value)} onKeyDown={event => { if (event.key === 'Enter' && results[0]) { event.preventDefault(); pick(results[0]); } else if (event.key === 'Enter' && shownOthers[0]) { event.preventDefault(); pickElsewhere(shownOthers[0]); } else if (event.key === 'Escape') { setQuery(''); } }} placeholder="Search all settings" aria-label="Search all settings" className="pr-8 pl-8" /> {open && ( )} {open && (
      {results.length === 0 && others.length === 0 && (
    • No option matches.
    • )} {results.length > 0 && others.length > 0 && (
    • On this page
    • )} {results.map(entry => row( entry.id, entry.label, sectionLabel(entry.section), entry.description, () => pick(entry) ) )} {shownOthers.length > 0 && (
    • Other settings pages
    • )} {shownOthers.map(entry => row( `${entry.page}|${entry.section}|${entry.label}|${entry.detail ?? ''}`, entry.isSection ? `${entry.label} (section)` : entry.label, [entry.pageLabel, entry.detail].filter(Boolean).join(' ยท '), entry.description, () => pickElsewhere(entry) ) )} {others.length > shownOthers.length && (
    • {others.length - shownOthers.length} more on other pages: add a word to narrow it down.
    • )}
    )}
    ); };