From 72893262aaa7c4a3362c7b8fe2155306ee88abae Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:13:25 +0100 Subject: [PATCH] fix: search settings popup and positioning refinement (#1476) Hey @calibrain, thought i'd come back to do a quick UI fix I noticed :) A previous PR appears to have broken the search options in any context that is not the desktop homescreen, they can clip off the page on mobile, or break the search results screen. I've tweaked this to revive the popover for search results context and allowed for cleaner wrapping on mobile, plus a little bit of UI refinement. Desktop homepage: image Mobile homepage: image Desktop and mobile search results: image --- src/frontend/src/components/Header.tsx | 1 + src/frontend/src/components/SearchBar.tsx | 433 +++++++----------- src/frontend/src/components/SearchSection.tsx | 1 + 3 files changed, 177 insertions(+), 258 deletions(-) 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' && (