All files / src/components/SearchPanel HighlightedSearchInput.tsx

100% Statements 60/60
100% Branches 8/8
100% Functions 3/3
100% Lines 60/60

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 1111x 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>
  );
}