import React, {useCallback, useEffect, useRef, useState} from 'react'; interface RowEntry { /** The entity-anchor id (e.g. "ref-current_interval_price") */ anchorId: string; /** The translation_key / entity ID suffix */ key: string; /** English name (first translated name) */ englishName: string; /** All translated names (for display in results) */ translatedNames: string[]; /** All searchable text from the row (names in all languages, key) */ searchText: string; /** The element */ row: HTMLTableRowElement; /** Platform heading (e.g. "Sensors", "Binary Sensors") */ platform: string; /** Doc page slugs that reference this entity (from data-refs attribute) */ docRefs: string[]; /** Name cells (columns between key and default) for match highlighting */ nameCells: HTMLTableCellElement[]; /** Original innerHTML of name cells (for restoring after highlighting) */ originalNameHTML: string[]; } const MAX_RESULTS = 12; /** Display names for doc page back-links (from data-refs attribute). */ const DOC_NAMES: Record = { 'sensors-overview': 'Sensors Overview', 'sensors-average': 'Average Sensors', 'sensors-ratings-levels': 'Ratings & Levels', 'sensors-volatility': 'Volatility', 'sensors-trends': 'Trends', 'sensors-timing': 'Timing', 'sensors-energy-tax': 'Energy & Tax', configuration: 'Configuration', 'period-calculation': 'Period Calculation', 'period-relaxation': 'Relaxation', 'automation-examples': 'Automation Examples', actions: 'Actions', }; /** Platform filter chips. `match` is tested with startsWith against h2 text. */ const PLATFORM_CHIPS = [ {label: 'Sensors', match: 'Sensors'}, {label: 'Binary Sensors', match: 'Binary Sensors'}, {label: 'Numbers', match: 'Number Entities'}, {label: 'Switches', match: 'Switch Entities'}, ]; /** * Highlight `needle` inside `html` by wrapping matches in tags. * Only replaces inside text nodes (outside HTML tags) to keep markup intact. */ function highlightHTML(html: string, needle: string): string { const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp(`(${escaped})`, 'gi'); return html.replace(/(<[^>]*>)|([^<]+)/g, (_m, tag: string, text: string) => { if (tag) return tag; return text.replace(re, '$1'); }); } /** * Live-filtering search bar for the sensor-reference page. * * Scans all `.entity-anchor` spans on mount to build an index of * entity keys and translated names. Typing filters the tables in * real-time and shows a clickable result list to jump to entries. */ export default function EntitySearch(): React.ReactElement { const [query, setQuery] = useState(''); const [total, setTotal] = useState(0); const [matchCount, setMatchCount] = useState(0); const [matches, setMatches] = useState([]); const [activeIndex, setActiveIndex] = useState(-1); const [activeChip, setActiveChip] = useState(null); const entriesRef = useRef([]); const containerRef = useRef(null); const inputRef = useRef(null); const resultsRef = useRef(null); // ── Build the search index on mount ────────────────────────── useEffect(() => { const anchors = document.querySelectorAll('.entity-anchor'); const entries: RowEntry[] = []; anchors.forEach((anchor) => { const row = anchor.closest('tr'); if (!row) return; const anchorId = anchor.id; const key = anchorId.replace(/^ref-/, ''); // Determine platform from closest h2 above this table let platform = ''; const table = row.closest('table'); if (table) { let el = table.previousElementSibling; while (el) { if (el.tagName === 'H2') { platform = el.textContent?.trim() ?? ''; break; } el = el.previousElementSibling; } } // Doc back-links from data attribute (set by generator) const refsAttr = anchor.getAttribute('data-refs'); const docRefs = refsAttr ? refsAttr.split(',').filter(Boolean) : []; // Collect text from all cells + store name cells for highlighting const cells = row.querySelectorAll('td'); const texts: string[] = [key]; const translatedNames: string[] = []; const nameCells: HTMLTableCellElement[] = []; const originalNameHTML: string[] = []; cells.forEach((cell, i) => { const text = cell.textContent?.trim(); if (text && text !== '✅' && text !== '❌') { texts.push(text); } // Name cells = columns between key (0) and default (last) if (i > 0 && i < cells.length - 1) { nameCells.push(cell); originalNameHTML.push(cell.innerHTML); const t = cell.textContent?.trim(); if (t) translatedNames.push(t); } }); // ── Inject copy-entity-ID button ── const firstCell = cells[0]; if (firstCell && !firstCell.querySelector('.entity-copy-btn')) { const btn = document.createElement('button'); btn.className = 'entity-copy-btn'; btn.title = 'Copy entity ID suffix'; btn.setAttribute('aria-label', `Copy ${key}`); btn.textContent = '\u29C9'; // ⧉ overlapping squares btn.addEventListener('click', (e) => { e.stopPropagation(); navigator.clipboard.writeText(key).then(() => { btn.textContent = '\u2713'; // ✓ btn.classList.add('copied'); setTimeout(() => { btn.textContent = '\u29C9'; btn.classList.remove('copied'); }, 1500); }); }); firstCell.appendChild(btn); } // ── Inject doc back-links ── if (docRefs.length > 0 && firstCell && !firstCell.querySelector('.entity-back-links')) { const span = document.createElement('span'); span.className = 'entity-back-links'; docRefs.forEach((ref) => { const slug = ref.split('#')[0]; const a = document.createElement('a'); a.href = ref; a.className = 'entity-back-link'; a.title = DOC_NAMES[slug] ?? slug; a.setAttribute('aria-label', `View in: ${DOC_NAMES[slug] ?? slug}`); a.textContent = '\uD83D\uDCD6'; // 📖 span.appendChild(a); }); firstCell.appendChild(span); } entries.push({ anchorId, key, englishName: translatedNames[0] ?? key, translatedNames, searchText: texts.join(' ').toLowerCase(), row, platform, docRefs, nameCells, originalNameHTML, }); }); entriesRef.current = entries; setTotal(entries.length); setMatchCount(entries.length); }, []); // ── Jump to #ref-* hash on arrival / hash change ───────────── useEffect(() => { const entries = entriesRef.current; if (entries.length === 0) return; const jumpToHash = () => { const hash = window.location.hash; if (!hash.startsWith('#ref-')) return; const key = hash.slice(5); const entry = entries.find((e) => e.key === key); if (!entry) return; document.querySelectorAll('.entity-search-jump-highlight').forEach((el) => { el.classList.remove('entity-search-jump-highlight'); }); const anchor = document.getElementById(entry.anchorId); if (anchor) { requestAnimationFrame(() => { anchor.scrollIntoView({behavior: 'smooth', block: 'center'}); void entry.row.offsetWidth; entry.row.classList.add('entity-search-jump-highlight'); }); } }; jumpToHash(); window.addEventListener('hashchange', jumpToHash); return () => window.removeEventListener('hashchange', jumpToHash); }, [total]); // ── Global "/" shortcut to focus the search input ──────────── useEffect(() => { const handleSlash = (e: KeyboardEvent) => { if ( e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement) && !(e.target instanceof HTMLSelectElement) ) { e.preventDefault(); inputRef.current?.focus(); inputRef.current?.scrollIntoView({behavior: 'smooth', block: 'nearest'}); } }; document.addEventListener('keydown', handleSlash); return () => document.removeEventListener('keydown', handleSlash); }, []); // ── Core filtering logic ───────────────────────────────────── const applyFilter = useCallback((search: string, chip: string | null) => { const entries = entriesRef.current; const needle = search.toLowerCase().trim(); let matchCountLocal = 0; const matchedEntries: RowEntry[] = []; const sectionsWithMatches = new Set(); // Restore previous match highlights entries.forEach((entry) => { entry.nameCells.forEach((cell, i) => { if (cell.innerHTML !== entry.originalNameHTML[i]) { cell.innerHTML = entry.originalNameHTML[i]; } }); }); entries.forEach((entry) => { // Platform chip filter const chipMatch = !chip || entry.platform.startsWith(chip); // Text search filter const textMatch = !needle || entry.searchText.includes(needle); const isMatch = chipMatch && textMatch; if (isMatch) { matchCountLocal++; matchedEntries.push(entry); entry.row.classList.remove('entity-search-hidden'); entry.row.classList.add('entity-search-match'); // Highlight matched text in name cells if (needle) { entry.nameCells.forEach((cell, i) => { cell.innerHTML = highlightHTML(entry.originalNameHTML[i], needle); }); } const table = entry.row.closest('table'); if (table) { let prev = table.previousElementSibling; while (prev && prev.tagName !== 'H3' && prev.tagName !== 'H2') { prev = prev.previousElementSibling; } if (prev) sectionsWithMatches.add(prev); } } else { entry.row.classList.add('entity-search-hidden'); entry.row.classList.remove('entity-search-match'); } }); const isActive = !!(needle || chip); if (isActive) { document.querySelectorAll('.markdown h3, .markdown h2').forEach((heading) => { if (heading.textContent?.includes('How to Find')) return; const hasMatch = sectionsWithMatches.has(heading); if (heading.tagName === 'H3') { heading.classList.toggle('entity-search-section-hidden', !hasMatch); let el = heading.nextElementSibling; while (el && el.tagName !== 'TABLE' && el.tagName !== 'H3' && el.tagName !== 'H2') { el.classList.toggle('entity-search-section-hidden', !hasMatch); el = el.nextElementSibling; } } }); } else { document.querySelectorAll('.entity-search-section-hidden').forEach((el) => { el.classList.remove('entity-search-section-hidden'); }); } setMatchCount(matchCountLocal); setMatches(isActive ? matchedEntries : []); setActiveIndex(-1); }, []); const scrollToEntry = useCallback((entry: RowEntry) => { document.querySelectorAll('.entity-search-jump-highlight').forEach((el) => { el.classList.remove('entity-search-jump-highlight'); }); const anchor = document.getElementById(entry.anchorId); if (anchor) { history.pushState(null, '', `#${entry.anchorId}`); anchor.scrollIntoView({behavior: 'smooth', block: 'center'}); void entry.row.offsetWidth; entry.row.classList.add('entity-search-jump-highlight'); } }, []); const handleChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value; setQuery(value); applyFilter(value, activeChip); }, [applyFilter, activeChip], ); const handleClear = useCallback(() => { setQuery(''); setActiveChip(null); applyFilter('', null); inputRef.current?.focus(); }, [applyFilter]); // Click anywhere outside the search bar → reset all filters useEffect(() => { if (query.trim().length === 0 && activeChip === null) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; // Don't reset if clicking inside the search container if (containerRef.current?.contains(target)) return; // Don't reset if clicking inside an entity table or its buttons if ((target as Element).closest?.('.entity-copy-btn, .entity-back-link, table')) return; setQuery(''); setActiveChip(null); applyFilter('', null); }; document.addEventListener('click', handleClickOutside); return () => document.removeEventListener('click', handleClickOutside); }, [query, activeChip, applyFilter]); const handleChipClick = useCallback( (chipMatch: string) => { const next = chipMatch === activeChip ? null : chipMatch; setActiveChip(next); applyFilter(query, next); }, [applyFilter, query, activeChip], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Escape') { handleClear(); return; } const visibleMatches = matches.slice(0, MAX_RESULTS); if (visibleMatches.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex((prev) => { const next = prev < visibleMatches.length - 1 ? prev + 1 : 0; resultsRef.current?.children[next]?.scrollIntoView({block: 'nearest'}); return next; }); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex((prev) => { const next = prev > 0 ? prev - 1 : visibleMatches.length - 1; resultsRef.current?.children[next]?.scrollIntoView({block: 'nearest'}); return next; }); } else if (e.key === 'Enter' && activeIndex >= 0 && activeIndex < visibleMatches.length) { e.preventDefault(); scrollToEntry(visibleMatches[activeIndex]); } }, [handleClear, matches, activeIndex, scrollToEntry], ); /** Find the best matching translated name for highlighting in dropdown */ const getMatchingName = useCallback( (entry: RowEntry, needle: string): string | null => { if (!needle) return null; const lower = needle.toLowerCase(); // Check non-English names first (user is likely searching in their language) for (let i = 1; i < entry.translatedNames.length; i++) { if (entry.translatedNames[i].toLowerCase().includes(lower)) { return entry.translatedNames[i]; } } // Then English if (entry.englishName.toLowerCase().includes(lower)) { return null; // Already shown as primary } return null; }, [], ); const isFiltering = query.trim().length > 0 || activeChip !== null; const visibleMatches = matches.slice(0, MAX_RESULTS); const hasMore = matches.length > MAX_RESULTS; return (
{/* ── Category filter chips ── */}
{PLATFORM_CHIPS.map((chip) => ( ))} Press / to search
{/* ── Search input ── */}
0} aria-controls="entity-search-results" aria-activedescendant={ activeIndex >= 0 ? `entity-result-${activeIndex}` : undefined } autoComplete="off" spellCheck={false} role="combobox" /> {isFiltering && ( )}
{/* ── Results dropdown ── */} {isFiltering && (
{matchCount === 0 ? (
No matching entities found
) : ( <> {query.trim().length > 0 && (
    {visibleMatches.map((entry, i) => { const matchedTranslation = getMatchingName(entry, query); return (
  • scrollToEntry(entry)} onMouseEnter={() => setActiveIndex(i)} > {entry.englishName} {entry.key} {matchedTranslation && ( {matchedTranslation} )}
  • ); })}
)}
{matchCount} of {total} entities {hasMore && query.trim().length > 0 && ( {' '}— showing first {MAX_RESULTS}, type more to narrow down )}
)}
)}
); }