feat(plex): field-scoped search (S2) + instant search typeahead
Add a search scope-pill to the Plex header search box (GitHub-style, one field-scope at a time) and an instant typeahead of the top matches below it. Backend (unified_library): - New `field` param: all | title | people | summary | episode. `all` is byte-identical to the pre-scope behaviour (untouched weighted search_vector); a specific field keeps the GIN `search_vector @@ tsq` prefilter AND ANDs a per-column tsvector recheck, so scoped hits are a strict subset of Everywhere. Per-field expressions mirror the search_vector Computed def (same TS config, same left(summary,1000) truncation). - `episode` scope empties the movie/show grid and title-only-matches the grouped Episodes section; the title/people/summary scopes suppress that section. - Unknown field falls back to `all`. New DB-lane characterization tests (test_plex_field_scoped_search.py): each scope narrows correctly, field=all is identical to today, episode empties the grid and needs shows in scope. Frontend: - PlexSearchBox wraps the shared SearchBar with the scope-pill (reusing ActionMenu) and the typeahead; SearchBar gains generic leading/below/focus/ keydown slots so other modules' search boxes are untouched. - Episode scope offered only when shows are in scope (movie-only hides it), healed at the point of use like the sort state. - Typeahead reuses /api/plex/library (limit=10), honours scope+field (not the sidebar filters), shows poster+title+year, keyboard nav (arrows/Enter/Esc), and jumps straight to a movie's info page or a show's drill-down via a new provider jumpTo hand-off (mirrors the playlistOpen pattern). - `field` persisted per-account; en/hu labels added.
This commit is contained in:
@@ -5,11 +5,11 @@ import type { Me } from "../lib/api";
|
||||
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
||||
import ModuleName from "./ModuleName";
|
||||
import SearchBar from "./SearchBar";
|
||||
import PlexSearchBox from "./PlexSearchBox";
|
||||
import SyncStatus from "./SyncStatus";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||
|
||||
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
|
||||
@@ -34,8 +34,6 @@ export default function Header({
|
||||
// "go to full history" jump is a ChannelsProvider action.
|
||||
const { search: playlistsSearch } = usePlaylists();
|
||||
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
|
||||
const { q: plexQ } = usePlex();
|
||||
const { setQ: setPlexQ } = usePlexActions();
|
||||
const { search: channelsSearch } = useChannels();
|
||||
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
|
||||
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
||||
@@ -70,14 +68,7 @@ export default function Header({
|
||||
/>
|
||||
);
|
||||
} else if (page === "plex" && me.plex_enabled) {
|
||||
search = (
|
||||
<SearchBar
|
||||
value={plexQ}
|
||||
placeholder={t("plex.searchPlaceholder")}
|
||||
onChange={setPlexQ}
|
||||
onGo={blur}
|
||||
/>
|
||||
);
|
||||
search = <PlexSearchBox />;
|
||||
} else if (page === "channels") {
|
||||
search = (
|
||||
<SearchBar
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { healPlexField } from "../lib/plexSearch";
|
||||
import { healPlexSort } from "../lib/plexSort";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -90,8 +91,8 @@ export default function PlexBrowse() {
|
||||
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
|
||||
// search term (q) comes from the shared header box; the rail hands off a playlist to open via the
|
||||
// shared `playlistOpen` slot, which we consume into a history subview then clear.
|
||||
const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex();
|
||||
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
|
||||
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
|
||||
const { setScope, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
|
||||
const dq = useDebounced(q.trim(), 350);
|
||||
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
|
||||
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
|
||||
@@ -100,6 +101,8 @@ export default function PlexBrowse() {
|
||||
// The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only
|
||||
// key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
|
||||
const sortState = healPlexSort(sort, scope);
|
||||
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
|
||||
const fieldState = healPlexField(field, scope);
|
||||
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
||||
|
||||
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
||||
@@ -112,6 +115,20 @@ export default function PlexBrowse() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [openPlaylist]);
|
||||
|
||||
// The header search typeahead picked a title → open it the same way a card click would (a movie's
|
||||
// info page like the card's ⓘ, a show's drill-down), then clear the hand-off so it doesn't re-open.
|
||||
useEffect(() => {
|
||||
if (jumpTo != null) {
|
||||
sub.open(
|
||||
jumpTo.type === "show"
|
||||
? { kind: "show", id: jumpTo.id }
|
||||
: { kind: "info", id: jumpTo.id },
|
||||
);
|
||||
setJumpTo(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [jumpTo]);
|
||||
|
||||
// Opening a show/player replaces the grid entirely (the component returns early below), so the
|
||||
// scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
|
||||
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
||||
@@ -151,13 +168,14 @@ export default function PlexBrowse() {
|
||||
}, [sub]);
|
||||
|
||||
const browseQ = useInfiniteQuery({
|
||||
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters),
|
||||
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState),
|
||||
enabled: !!scope && sub.view.kind === "grid",
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.plexLibrary({
|
||||
scope,
|
||||
q: dq || undefined,
|
||||
field: fieldState,
|
||||
sort: sortState,
|
||||
show,
|
||||
filters,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
|
||||
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
@@ -19,13 +20,19 @@ import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
|
||||
// into (and is never greeted by a stale value from) the feed.
|
||||
|
||||
// A "jump straight to this title" hand-off from the header typeahead: PlexBrowse opens a movie's
|
||||
// info page (like the card's ⓘ) or a show's drill-down (like clicking a show card) by type.
|
||||
export type PlexJumpTarget = { id: string; type: "movie" | "show" };
|
||||
|
||||
type PlexState = {
|
||||
scope: string; // movie | show | both (unified cross-library scope)
|
||||
show: string;
|
||||
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
|
||||
filters: PlexFilters;
|
||||
q: string;
|
||||
field: PlexSearchField; // search scope-pill; healed against `scope` at the point of use
|
||||
playlistOpen: number | null;
|
||||
jumpTo: PlexJumpTarget | null; // typeahead → grid hand-off ("open this title")
|
||||
};
|
||||
|
||||
interface PlexActions {
|
||||
@@ -34,7 +41,9 @@ interface PlexActions {
|
||||
setSort: (v: SortState) => void;
|
||||
setFilters: (f: PlexFilters) => void;
|
||||
setQ: (q: string) => void;
|
||||
setField: (f: PlexSearchField) => void;
|
||||
setPlaylistOpen: (id: number | null) => void;
|
||||
setJumpTo: (t: PlexJumpTarget | null) => void;
|
||||
}
|
||||
|
||||
const StateContext = createContext<PlexState>({
|
||||
@@ -43,7 +52,9 @@ const StateContext = createContext<PlexState>({
|
||||
sort: PLEX_SORT_FALLBACK,
|
||||
filters: EMPTY_PLEX_FILTERS,
|
||||
q: "",
|
||||
field: "all",
|
||||
playlistOpen: null,
|
||||
jumpTo: null,
|
||||
});
|
||||
const ActionsContext = createContext<PlexActions>({
|
||||
setScope: () => {},
|
||||
@@ -51,7 +62,9 @@ const ActionsContext = createContext<PlexActions>({
|
||||
setSort: () => {},
|
||||
setFilters: () => {},
|
||||
setQ: () => {},
|
||||
setField: () => {},
|
||||
setPlaylistOpen: () => {},
|
||||
setJumpTo: () => {},
|
||||
});
|
||||
|
||||
export function usePlex(): PlexState {
|
||||
@@ -69,6 +82,7 @@ function readPlexScope(): string {
|
||||
const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all";
|
||||
const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}";
|
||||
const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? "";
|
||||
const readPlexField = (): PlexSearchField => parsePlexField(getAccountRaw(LS.plexField));
|
||||
// Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the
|
||||
// direction that used to live in plexFilters.sortDir — so an existing sort preference survives the
|
||||
// upgrade rather than snapping back to the default.
|
||||
@@ -118,7 +132,11 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
}, [filtersRaw]);
|
||||
// Survives F5 (persisted), unlike the feed search — see the scope note above.
|
||||
const [q, setQState] = useState<string>(readPlexQ);
|
||||
const [field, setFieldState] = useState<PlexSearchField>(readPlexField);
|
||||
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
|
||||
// Ephemeral hand-off like `playlistOpen`: the header typeahead sets the title to jump to;
|
||||
// PlexBrowse consumes it into an info/show history subview then clears it.
|
||||
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
|
||||
|
||||
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
|
||||
const markDirty = useAccountRehydrate(() => {
|
||||
@@ -127,6 +145,7 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
setSortState(readPlexSort());
|
||||
setFiltersRaw(readPlexFiltersRaw());
|
||||
setQState(readPlexQ());
|
||||
setFieldState(readPlexField());
|
||||
});
|
||||
|
||||
const setScope = useCallback(
|
||||
@@ -170,14 +189,22 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
const setField = useCallback(
|
||||
(v: PlexSearchField) => {
|
||||
markDirty();
|
||||
setFieldState(v);
|
||||
setAccountRaw(LS.plexField, v);
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
|
||||
const state = useMemo<PlexState>(
|
||||
() => ({ scope, show, sort, filters, q, playlistOpen }),
|
||||
[scope, show, sort, filters, q, playlistOpen],
|
||||
() => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }),
|
||||
[scope, show, sort, filters, q, field, playlistOpen, jumpTo],
|
||||
);
|
||||
const actions = useMemo<PlexActions>(
|
||||
() => ({ setScope, setShow, setSort, setFilters, setQ, setPlaylistOpen }),
|
||||
[setScope, setShow, setSort, setFilters, setQ],
|
||||
() => ({ setScope, setShow, setSort, setFilters, setQ, setField, setPlaylistOpen, setJumpTo }),
|
||||
[setScope, setShow, setSort, setFilters, setQ, setField],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, Film, Tv } from "lucide-react";
|
||||
import SearchBar from "./SearchBar";
|
||||
import ActionMenu from "./ActionMenu";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import {
|
||||
availablePlexFields,
|
||||
healPlexField,
|
||||
plexFieldLabelKey,
|
||||
} from "../lib/plexSearch";
|
||||
|
||||
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
|
||||
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
|
||||
// below it. Kept out of the generic SearchBar (and out of Header) so all Plex-specific wiring —
|
||||
// provider state, the suggest query, the jump-to hand-off — lives in one place. Everything reuses
|
||||
// existing platform pieces: ActionMenu (the pill), the /api/plex/library endpoint (limit=10 for the
|
||||
// typeahead), and the provider's jumpTo hand-off (typeahead → PlexBrowse subview).
|
||||
const MIN_CHARS = 2;
|
||||
const SUGGEST_LIMIT = 10;
|
||||
|
||||
export default function PlexSearchBox() {
|
||||
const { t } = useTranslation();
|
||||
const { q, scope, field } = usePlex();
|
||||
const { setQ, setField, setJumpTo } = usePlexActions();
|
||||
const activeField = healPlexField(field, scope);
|
||||
const dq = useDebounced(q.trim(), 200);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [hi, setHi] = useState(-1); // highlighted suggestion index (-1 = none)
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The Episode scope surfaces episodes in the grid's grouped section, not as title cards, so the
|
||||
// titles-only typeahead has nothing to show there — suppress it rather than show "no results".
|
||||
const suggestEnabled = focused && dq.length >= MIN_CHARS && activeField !== "episode";
|
||||
const suggestQ = useQuery({
|
||||
queryKey: qk.plexSuggest(scope, dq, activeField),
|
||||
enabled: suggestEnabled,
|
||||
queryFn: () =>
|
||||
api.plexLibrary({ scope, q: dq, field: activeField, limit: SUGGEST_LIMIT }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []).slice(0, SUGGEST_LIMIT) : [];
|
||||
const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0);
|
||||
|
||||
// A fresh query resets the highlight; keep it in range if the list shrank.
|
||||
useEffect(() => setHi(-1), [dq, activeField, scope]);
|
||||
|
||||
const pick = (i: number) => {
|
||||
const c = suggestions[i];
|
||||
if (!c) return;
|
||||
setJumpTo({ id: c.id, type: c.type === "show" ? "show" : "movie" });
|
||||
setFocused(false);
|
||||
setHi(-1);
|
||||
};
|
||||
|
||||
const fields = availablePlexFields(scope);
|
||||
const pillLabel = t(plexFieldLabelKey(activeField));
|
||||
|
||||
return (
|
||||
<SearchBar
|
||||
value={q}
|
||||
placeholder={t("plex.searchPlaceholder")}
|
||||
onChange={setQ}
|
||||
onFocus={() => setFocused(true)}
|
||||
// Suggestion rows preventDefault on mousedown so the input keeps focus through the click; any
|
||||
// other blur (Tab, the pill, clicking away) closes the panel.
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (!open) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHi((h) => Math.min(h + 1, suggestions.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHi((h) => Math.max(h - 1, -1));
|
||||
} else if (e.key === "Enter" && hi >= 0) {
|
||||
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
|
||||
pick(hi);
|
||||
} else if (e.key === "Escape") {
|
||||
setFocused(false);
|
||||
}
|
||||
}}
|
||||
leading={
|
||||
<ActionMenu
|
||||
ariaLabel={t("plex.searchField.aria")}
|
||||
className="shrink-0 inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
|
||||
trigger={
|
||||
<>
|
||||
<span className="max-w-28 truncate">{pillLabel}</span>
|
||||
<ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />
|
||||
</>
|
||||
}
|
||||
items={fields.map((f) => ({
|
||||
id: f,
|
||||
label: t(plexFieldLabelKey(f)),
|
||||
onSelect: () => setField(f),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
below={
|
||||
open ? (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label={t("plex.suggest.aria")}
|
||||
className="glass-menu absolute left-0 right-0 top-full z-20 mt-2 max-h-[70vh] overflow-y-auto rounded-2xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{suggestions.length === 0 && suggestQ.isFetching && (
|
||||
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
|
||||
)}
|
||||
{suggestions.map((c, i) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={hi === i}
|
||||
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
|
||||
onMouseEnter={() => setHi(i)}
|
||||
onClick={() => pick(i)}
|
||||
className={`flex w-full items-center gap-3 rounded-xl p-1.5 text-left transition ${
|
||||
hi === i ? "bg-border/60" : "hover:bg-border/40"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={c.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-12 w-8 shrink-0 rounded-md object-cover bg-border/40"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{c.title}</span>
|
||||
{c.year != null && (
|
||||
<span className="shrink-0 text-xs text-muted tabular-nums">{c.year}</span>
|
||||
)}
|
||||
{c.type === "show" ? (
|
||||
<Tv className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
) : (
|
||||
<Film className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { type FocusEventHandler, type KeyboardEventHandler, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
@@ -6,6 +6,12 @@ import { Search, X } from "lucide-react";
|
||||
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
|
||||
// clear button, and a "Go" trigger (Enter also fires it). 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,
|
||||
@@ -15,6 +21,11 @@ export default function SearchBar({
|
||||
goIcon,
|
||||
goDisabled,
|
||||
goTitle,
|
||||
leading,
|
||||
below,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (q: string) => void;
|
||||
@@ -25,46 +36,57 @@ export default function SearchBar({
|
||||
goIcon?: ReactNode;
|
||||
goDisabled?: boolean;
|
||||
goTitle?: string;
|
||||
leading?: ReactNode;
|
||||
below?: ReactNode;
|
||||
onFocus?: FocusEventHandler<HTMLInputElement>;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const fire = () => {
|
||||
if (onGo && !goDisabled) onGo();
|
||||
};
|
||||
return (
|
||||
<div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
|
||||
<Search className="w-4 h-4 shrink-0 text-muted" />
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") fire();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
onClick={() => onChange("")}
|
||||
title={t("header.clearSearch")}
|
||||
aria-label={t("header.clearSearch")}
|
||||
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onGo && (
|
||||
<button
|
||||
onClick={fire}
|
||||
disabled={goDisabled}
|
||||
title={goTitle ?? goLabel ?? t("header.goTip")}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{goIcon}
|
||||
<span className={goIcon ? "hidden md:inline" : undefined}>
|
||||
{goLabel ?? t("header.go")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl">
|
||||
<div className="h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
|
||||
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(e) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.key === "Enter" && !e.defaultPrevented) fire();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
onClick={() => onChange("")}
|
||||
title={t("header.clearSearch")}
|
||||
aria-label={t("header.clearSearch")}
|
||||
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onGo && (
|
||||
<button
|
||||
onClick={fire}
|
||||
disabled={goDisabled}
|
||||
title={goTitle ?? goLabel ?? t("header.goTip")}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{goIcon}
|
||||
<span className={goIcon ? "hidden md:inline" : undefined}>
|
||||
{goLabel ?? t("header.go")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{below}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user