diff --git a/src/frontend/src/components/Header.tsx b/src/frontend/src/components/Header.tsx index dc73a462..b96d9cf4 100644 --- a/src/frontend/src/components/Header.tsx +++ b/src/frontend/src/components/Header.tsx @@ -745,6 +745,7 @@ export const Header = forwardRef( activeQueryTarget={activeQueryTarget} onQueryTargetChange={onQueryTargetChange} activeQueryField={activeQueryField} + optionsLayout="popover" /> diff --git a/src/frontend/src/components/SearchBar.tsx b/src/frontend/src/components/SearchBar.tsx index 70d901fc..32b9e088 100644 --- a/src/frontend/src/components/SearchBar.tsx +++ b/src/frontend/src/components/SearchBar.tsx @@ -1,5 +1,5 @@ -import type { InputHTMLAttributes } from 'react'; -import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import type { ComponentType, InputHTMLAttributes, ReactNode } from 'react'; +import { forwardRef, useId, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { useSearchMode } from '../contexts/SearchModeContext'; import { useSearchBarAutocomplete } from '../hooks/searchBar/useSearchBarAutocomplete'; @@ -9,7 +9,9 @@ import type { DynamicFieldOption } from '../services/api'; import type { ContentType, MetadataSearchField, QueryTargetOption, SortOption } from '../types'; import { SearchBarAutocompleteSession } from './SearchBarAutocompleteSession'; import { SearchBarDynamicOptionsSession } from './SearchBarDynamicOptionsSession'; -import { Tooltip } from './shared/Tooltip'; +import { ToggleSwitch } from './shared/ToggleSwitch'; + +type SearchOptionsLayout = 'popover' | 'inline'; interface SearchBarProps { value: string | number | boolean; @@ -41,6 +43,7 @@ interface SearchBarProps { onQueryTargetChange?: (target: string) => void; activeQueryField?: MetadataSearchField | null; disabled?: boolean; + optionsLayout: SearchOptionsLayout; } export interface SearchBarHandle { @@ -51,7 +54,24 @@ const EMPTY_SORT_OPTIONS: SortOption[] = []; const EMPTY_AUTOCOMPLETE_OPTIONS: DynamicFieldOption[] = []; const EMPTY_QUERY_TARGETS: QueryTargetOption[] = []; -const SEARCH_CONTROLS_PANEL_ID = 'search-bar-controls-panel'; +const OPTIONS_LAYOUT_CLASSES: Record< + SearchOptionsLayout, + { panel: string; group: string; options: string; option: string } +> = { + popover: { + panel: 'flex flex-col divide-y divide-(--border-muted)', + group: 'flex flex-col gap-2 py-3 first:pt-0 last:pb-0', + options: 'grid grid-cols-2 gap-2', + option: '', + }, + inline: { + panel: + 'animate-fade-in-down flex flex-col gap-4 px-1 pt-2 sm:flex-row sm:flex-wrap sm:items-start sm:gap-x-8 sm:gap-y-2', + group: 'flex min-w-0 flex-col gap-2', + options: 'flex flex-col gap-1 sm:flex-row sm:flex-wrap sm:gap-2', + option: 'max-sm:border-transparent', + }, +}; const BookIcon = () => ( ( ); +const CONTENT_TYPE_OPTIONS: { type: ContentType; label: string; Icon: ComponentType }[] = [ + { type: 'ebook', label: 'Books', Icon: BookIcon }, + { type: 'audiobook', label: 'Audiobooks', Icon: AudiobookIcon }, +]; + +const OptionsGroupHeader = ({ label, action }: { label: string; action: ReactNode }) => ( +
+ {label} + {action} +
+); + const getDefaultPlaceholder = ( contentType: ContentType, activeQueryTarget: QueryTargetOption | undefined, @@ -191,6 +223,7 @@ export const SearchBar = forwardRef( onQueryTargetChange, activeQueryField, disabled = false, + optionsLayout, }, ref, ) => { @@ -202,13 +235,16 @@ export const SearchBar = forwardRef( const submitLatest = useLatestCallback(() => onSubmit()); const selectorRef = useRef(null); const hasSearchQuery = hasActiveValue(value); - const [isSelectorOpen, setIsSelectorOpen] = useState(false); const [isSelectOpen, setIsSelectOpen] = useState(false); const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false); const selectTriggerRef = useRef(null); const selectPanelRef = useRef(null); const autocompletePanelRef = useRef(null); - const controlsPanelRef = useRef(null); + const optionsPanelId = useId(); + const isPopover = optionsLayout === 'popover'; + const layoutClasses = OPTIONS_LAYOUT_CLASSES[optionsLayout]; + const [isOptionsOpen, setIsOptionsOpen] = useState(false); + const closeOptions = () => setIsOptionsOpen(false); const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1; const showContentTypeSelector = @@ -233,11 +269,8 @@ export const SearchBar = forwardRef( const combinedSelectionActive = combinedMode && !isManualTarget; const combinedSelectorLocked = combinedModeLocked && !isManualTarget; const combinedToggleAvailable = !!onCombinedModeChange && !isManualTarget; - const combinedLineColor = combinedSelectionActive - ? 'bg-emerald-500' - : 'bg-(--border-muted) group-hover:bg-zinc-400 dark:group-hover:bg-zinc-500'; - useDismiss(isSelectorOpen, [selectorRef, controlsPanelRef], () => setIsSelectorOpen(false)); + useDismiss(isOptionsOpen && isPopover, [selectorRef], closeOptions); useDismiss(isSelectOpen, [selectPanelRef, selectTriggerRef], () => setIsSelectOpen(false)); useDismiss(isAutocompleteOpen, [autocompletePanelRef, inputRef], () => setIsAutocompleteOpen(false), @@ -341,13 +374,11 @@ export const SearchBar = forwardRef( onCombinedModeChange?.(false); }; - const handleCombinedModeSelect = () => { - if (combinedMode) { - onCombinedModeChange?.(false); - } else { + const handleCombinedModeToggle = (enabled: boolean) => { + if (enabled) { onContentTypeChange?.('ebook'); - onCombinedModeChange?.(true); } + onCombinedModeChange?.(enabled); }; const handleQueryTargetSelect = (targetKey: string) => { @@ -360,6 +391,9 @@ export const SearchBar = forwardRef( setIsSelectOpen(shouldOpenSelect); setIsAutocompleteOpen(false); resetAutocomplete(); + if (shouldOpenSelect) { + closeOptions(); + } }; const effectivePlaceholder = getDefaultPlaceholder( @@ -552,6 +586,114 @@ export const SearchBar = forwardRef( selectorIcon = ; } + const activeOptionClasses = + searchMode === 'direct' + ? 'border-sky-700/70 bg-sky-700 text-white' + : 'border-emerald-500/70 bg-emerald-600 text-white'; + const optionClasses = (isActive: boolean) => + `flex min-w-0 cursor-pointer items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${ + isActive ? activeOptionClasses : 'hover-surface border-(--border-muted) text-(--text)' + } ${layoutClasses.option}`; + + const optionsButton = onAdvancedToggle ? ( + + ) : null; + + const optionsPanel = ( +
+ {showContentTypeSelector && ( +
+ +
+ {CONTENT_TYPE_OPTIONS.map(({ type, label, Icon }) => { + const isActive = contentType === type || combinedSelectionActive; + return ( + + ); + })} +
+ {combinedToggleAvailable && ( +
+ Combined + +
+ )} +
+ )} + + {queryTargets.length > 1 && ( +
+ +
+ {queryTargets.map((target) => { + const isActive = target.key === activeTarget?.key; + return ( + + ); + })} +
+
+ )} +
+ ); + const dynamicOptionsSession = dynamicEndpoint ? ( ( - )} - -
- - - {combinedToggleAvailable && ( -
- - - -
- )} -
- - )} - - {queryTargets.length > 1 && ( -
-
- - Search By - - {!showContentTypeSelector && onAdvancedToggle && ( - - )} -
-
- {queryTargets.map((target) => { - const isActive = target.key === activeTarget?.key; - return ( - - ); - })} -
-
- )} - - )} + {showQueryTargetSelector && !isPopover && isOptionsOpen && optionsPanel} ); }, diff --git a/src/frontend/src/components/SearchSection.tsx b/src/frontend/src/components/SearchSection.tsx index 0726a430..023f7118 100644 --- a/src/frontend/src/components/SearchSection.tsx +++ b/src/frontend/src/components/SearchSection.tsx @@ -113,6 +113,7 @@ export const SearchSection = ({ activeQueryTarget={activeQueryTarget} onQueryTargetChange={onQueryTargetChange} activeQueryField={activeQueryField} + optionsLayout="inline" /> {activeQueryTarget === 'manual' && (