import clsx from "clsx"; import { useRef, type FocusEventHandler, type InputHTMLAttributes, type KeyboardEventHandler, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { Search, X } from "lucide-react"; import { useSearchFocusKey } from "../lib/keymap"; import { PaletteKeycap } from "./PaletteLauncher"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { InnerInput } from "./ui/Input"; import { fieldClass } from "./ui/fieldStyles"; // The floating search pill in the top header. One component for every module that searches // (feed, Plex, channels, playlists): a 40px glass pill with the A4 field edge (the page's one hero // field, the YouTube/Gmail shape), holding a search icon, the input, a clear button and a "Go" // trigger (Enter also fires it). What sits inside the pill is round too, so the radii nest. The feed passes its live-YouTube // escalation as the Go action; the other modules filter as you type and Go just commits/blurs. // // Optional slots keep the box reusable while letting one module enrich it: `leading` replaces the // search icon with a control (Plex's search scope-pill), and `below` renders an absolutely // positioned panel attached under the box (Plex's instant typeahead results). `onFocus`/`onBlur`/ // `onKeyDown` pass through to the input so a caller can drive that panel (open on focus, arrow-key // navigation); the built-in Enter→Go fires only if the caller didn't `preventDefault()` first. export default function SearchBar({ value, onChange, placeholder, onGo, goLabel, goIcon, goDisabled, goTitle, leading, below, onFocus, onBlur, onKeyDown, inputProps, }: { value: string; onChange: (q: string) => void; placeholder: string; // Enter / the Go button call this. Omit to hide the Go button entirely. onGo?: () => void; goLabel?: string; goIcon?: ReactNode; goDisabled?: boolean; goTitle?: string; leading?: ReactNode; below?: ReactNode; onFocus?: FocusEventHandler; onBlur?: FocusEventHandler; onKeyDown?: KeyboardEventHandler; // Extra attributes spread onto the (e.g. Plex's combobox ARIA). Spread FIRST so the // component's own value/handlers always win. inputProps?: InputHTMLAttributes; }) { const { t } = useTranslation(); // `s` puts the caret here from anywhere on the page (E11 S4) — the header mounts exactly one box. const inputRef = useRef(null); useSearchFocusKey(inputRef); const fire = () => { if (onGo && !goDisabled) onGo(); }; // Responsive rules on the SHARED search box (every module's header, not just Plex), keyed on the // content column's width via `@container/appmain` (E5 S7): `min-w-0` by default so it never forces // overflow on a truly tiny column; once the column has room (≥420px) a `min-w-[15rem]` floor makes // the box WRAP to its own line via the header's flex-wrap instead of shrinking the input to ~0; on // a narrow column (≤640px) it also takes a full line of its own (basis-full) and sorts LAST, so the // small pills (module name + Plex scope toggle / feed sync chip) share the line above it. return (
{/* `free` + its own padding: the Go button sits 6px in from the pill's end, not a field's 12. */}
{leading ?? } onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} onKeyDown={(e) => { onKeyDown?.(e); if (e.defaultPrevented) return; if (e.key === "Enter") fire(); // Escape hands the keyboard back to the page, where `j`/`k` and the `g` chords live — `s` // got you in, so there has to be a key that gets you out. The query stays as typed. A caller // that uses Escape itself (Plex's typeahead closing its panel) preventDefaults first. else if (e.key === "Escape") e.currentTarget.blur(); }} placeholder={placeholder} /> {value && ( onChange("")} label={t("header.clearSearch")}> )} {onGo && ( )}
{below}
); }