import { forwardRef, useEffect, useRef, type InputHTMLAttributes, type FocusEvent, type KeyboardEvent, type ReactNode, type Ref, type TextareaHTMLAttributes, } from "react"; import clsx from "clsx"; import { Icon, type IconName } from "./icons"; import { IconButton } from "./Button"; import styles from "./Input.module.css"; import { setContext } from "@/platform/context"; import { useTx } from "@/platform/i18n"; import { shortcutLabel } from "@/platform/keymap"; export interface InputProps extends Omit, "size"> { invalid?: boolean; mono?: boolean; /** `bare` drops the frame (for inputs embedded in popups / headers). */ variant?: "framed" | "bare"; } export const Input = forwardRef(function Input( { invalid, mono, variant = "framed", className, spellCheck = false, autoComplete = "off", placeholder, "aria-label": ariaLabel, ...rest }, ref, ) { const tx = useTx(); return ( ); }); export interface SearchToggle { id: string; icon: IconName | (string & {}); title: string; shortcut?: string; pressed: boolean; onToggle: (pressed: boolean) => void; } export interface SearchFieldProps extends Omit, "onChange" | "value" | "size"> { value: string; onChange: (value: string) => void; leadingIcon?: IconName | (string & {}) | null; /** Trailing toggle buttons such as Match Case / Words / Regex. */ toggles?: SearchToggle[]; /** Extra trailing content (e.g. a result counter). */ trailing?: ReactNode; clearable?: boolean; invalid?: boolean; size?: "normal" | "large"; variant?: "framed" | "bare"; className?: string; /** * IDEA's multi-line search field: always a text area that grows with the * text, plus a New Line button (⇧⌘↵ / ⇧Ctrl↵ inserts one too). */ multiline?: boolean; /** Turns the leading magnifier into IDEA's search-history button (magnifier ▾). */ onHistory?: () => void; } const isNewLineKey = (e: KeyboardEvent) => e.key === "Enter" && e.shiftKey && (e.metaKey || e.ctrlKey) && !e.altKey; interface FieldCommands { newLine?: () => void; history?: () => void; } let focusedField: FieldCommands | null = null; /** * New Line / Recent Searches in the focused search field, for the shortcut * actions that win over editor bindings of the same keys (contexts * `searchFieldNewLine` / `searchFieldHistory`). */ export function runSearchFieldCommand(command: keyof FieldCommands): boolean { const run = focusedField?.[command]; run?.(); return !!run; } export const SearchField = forwardRef(function SearchField( { value, onChange, leadingIcon = "search", toggles, trailing, clearable = true, invalid, size = "normal", variant = "framed", className, placeholder, multiline, onHistory, "aria-label": ariaLabel, ...rest }, ref, ) { const tx = useTx(); const inner = useRef(null); const setRef = (el: HTMLInputElement | HTMLTextAreaElement | null) => { inner.current = el; if (typeof ref === "function") ref(el as HTMLInputElement); else if (ref) ref.current = el as HTMLInputElement; }; const insertNewLine = () => { const el = inner.current; const start = el?.selectionStart ?? value.length; const end = el?.selectionEnd ?? value.length; onChange(`${value.slice(0, start)}\n${value.slice(end)}`); setTimeout(() => { el?.focus(); el?.setSelectionRange(start + 1, start + 1); }, 0); }; const commands = useRef({}); // oxlint-disable-next-line react/refs -- latest-callback ref, read only from event handlers commands.current.newLine = multiline ? insertNewLine : undefined; // oxlint-disable-next-line react/refs commands.current.history = onHistory; const { onKeyDown, onFocus, onBlur, ...fieldProps } = rest; const keyDown = (e: KeyboardEvent) => { if (multiline && isNewLineKey(e)) { e.preventDefault(); insertNewLine(); return; } onKeyDown?.(e); }; const focus = (e: FocusEvent) => { focusedField = commands.current; setContext("searchFieldNewLine", !!multiline); setContext("searchFieldHistory", !!onHistory); onFocus?.(e); }; const blur = (e: FocusEvent) => { if (focusedField === commands.current) { focusedField = null; setContext("searchFieldNewLine", false); setContext("searchFieldHistory", false); } onBlur?.(e); }; const handlers = { onKeyDown: keyDown, onFocus: focus, onBlur: blur }; // Removing a focused field fires no blur. useEffect(() => { const own = commands.current; return () => { if (focusedField !== own) return; focusedField = null; setContext("searchFieldNewLine", false); setContext("searchFieldHistory", false); }; }, []); // A multi-line query (e.g. Find over a multi-line selection) is shown as such. const lines = value.split("\n").length; const iconSize = size === "large" ? 15 : 14; return (
1 && styles.multiline, className, )} data-invalid={invalid || undefined} > {onHistory ? ( ) : ( leadingIcon && )} {lines > 1 || multiline ? (