Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | 1x 1x 1x 1x 1x 1x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x | import { highlightSearchQuery } from '@/utils/search-query/highlightSearchQuery';
import { ChangeEvent, CSSProperties, KeyboardEvent, useEffect, useMemo, useRef } from 'react';
import styles from './HighlightedSearchInput.module.scss';
/** Props for {@link HighlightedSearchInput}. */
interface HighlightedSearchInputProps {
/** Current query text (controlled). */
value: string;
/** Called with the new value on every keystroke. */
onChange: (value: string) => void;
/** Placeholder shown when empty. */
placeholder?: string;
/** Disables input (e.g. while searching). */
disabled?: boolean;
/** Accessible label for the input. */
ariaLabel?: string;
/** Extra class names applied to the wrapper (e.g. layout classes from the parent). */
className?: string;
/** Inline style applied to the wrapper — used to set font-size/flex/margins per call site. */
style?: CSSProperties;
/** Forwarded to the input (e.g. submit-on-Enter handling). */
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
/** Notified when validity changes: `blocked` is true (with a `message`) for invalid queries. */
onValidityChange?: (blocked: boolean, message?: string) => void;
/** When true, focuses the input on mount (e.g. on entering the search panel). */
autoFocus?: boolean;
}
/**
* Search text input with live syntax highlighting for advanced (boolean) queries. A plain
* query renders as an ordinary input; once the query becomes advanced, the typed text is made
* transparent and a colored backdrop (operators, parentheses by depth, quoted phrases, terms)
* is shown behind the caret. Invalid advanced queries (malformed or with no inclusive term)
* are flagged and reported via {@link HighlightedSearchInputProps.onValidityChange}.
* @param props - The input props.
* @returns The highlighted search input element.
* @example
* ```tsx
* <HighlightedSearchInput
* value={query}
* onChange={setQuery}
* onValidityChange={(blocked, msg) => setError(blocked ? msg : undefined)}
* />
* ```
* @source
*/
export default function HighlightedSearchInput({
value,
onChange,
placeholder,
disabled,
ariaLabel,
className,
style,
onKeyDown,
onValidityChange,
autoFocus,
}: HighlightedSearchInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
// Focus the input on mount when requested, so entering the search panel puts the
// caret straight in the query box (the panel remounts on each entry).
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
}, [autoFocus]);
const highlight = useMemo(() => highlightSearchQuery(value), [value]);
// Latest-callback ref so validity is reported only when it actually changes, regardless of
// whether the parent memoizes the handler.
const onValidityChangeRef = useRef(onValidityChange);
onValidityChangeRef.current = onValidityChange;
useEffect(() => {
onValidityChangeRef.current?.(highlight.state === 'error', highlight.message);
}, [highlight.state, highlight.message]);
// Keep the backdrop scrolled in lockstep with the input's horizontal scroll.
const syncScroll = (): void => {
if (inputRef.current && backdropRef.current) {
backdropRef.current.scrollLeft = inputRef.current.scrollLeft;
}
};
useEffect(syncScroll, [value]);
return (
<div className={`${styles.wrapper} ${className ?? ''}`} style={style}>
<div
ref={backdropRef}
className={styles.backdrop}
aria-hidden="true"
dangerouslySetInnerHTML={{ __html: highlight.html }}
/>
<input
ref={inputRef}
type="text"
className={styles.input}
value={value}
placeholder={placeholder}
disabled={disabled}
aria-label={ariaLabel}
spellCheck={false}
autoComplete="off"
onChange={(event: ChangeEvent<HTMLInputElement>) => onChange(event.target.value)}
onKeyDown={onKeyDown}
onScroll={syncScroll}
/>
</div>
);
}
|