From e6bb9d359f9d1ede1fad034ef0e072e92d2a22d2 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 30 Jul 2026 14:27:10 +0200 Subject: [PATCH] feat: present global search in an anchored overlay --- webui/package.json | 3 + .../scripts/test-search-overlay-structure.mjs | 26 + webui/src/components/GlobalSearch.tsx | 759 +++++++++++++++--- webui/src/components/searchOverlayLayout.ts | 95 +++ webui/src/styles/search.css | 174 ++-- 5 files changed, 878 insertions(+), 179 deletions(-) create mode 100644 webui/scripts/test-search-overlay-structure.mjs create mode 100644 webui/src/components/searchOverlayLayout.ts diff --git a/webui/package.json b/webui/package.json index ccd53c7..05b94e8 100644 --- a/webui/package.json +++ b/webui/package.json @@ -13,6 +13,9 @@ }, "./styles/search.css": "./src/styles/search.css" }, + "scripts": { + "test:search-overlay": "node scripts/test-search-overlay-structure.mjs" + }, "peerDependencies": { "@govoplan/core-webui": "^0.1.14", "lucide-react": "^1.23.0", diff --git a/webui/scripts/test-search-overlay-structure.mjs b/webui/scripts/test-search-overlay-structure.mjs new file mode 100644 index 0000000..d416a15 --- /dev/null +++ b/webui/scripts/test-search-overlay-structure.mjs @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; + + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +const source = readFileSync("src/components/GlobalSearch.tsx", "utf8"); +const layoutSource = readFileSync("src/components/searchOverlayLayout.ts", "utf8"); +const styles = readFileSync("src/styles/search.css", "utf8"); + +assert(source.includes("onFocus={handleSourceFocus}"), "focusing the titlebar field opens Search"); +assert(source.includes("suppressRestoredFocusRef.current"), "restored dialog focus does not immediately reopen Search"); +assert(source.includes("("search.contexts")'), "contextual Search contributions are consumed"); +assert(!source.includes("navigate(`/search"), "normal Search interaction no longer opens a page route"); +assert(layoutSource.includes("anchor.left - left"), "desktop input placement is derived from the original field"); +assert(styles.includes(".global-search-source.is-overlay-open"), "the original field is hidden while its overlay counterpart is active"); +assert(styles.includes(".search-overlay-results-panel"), "full Search results have a bounded overlay panel"); + +console.log("Search overlay structure checks passed."); diff --git a/webui/src/components/GlobalSearch.tsx b/webui/src/components/GlobalSearch.tsx index 1e05bc3..ddf7d11 100644 --- a/webui/src/components/GlobalSearch.tsx +++ b/webui/src/components/GlobalSearch.tsx @@ -1,184 +1,701 @@ -import { Search, X } from "lucide-react"; +import { ChevronDown, ExternalLink, Filter, Search, X } from "lucide-react"; import { + useCallback, useEffect, + useLayoutEffect, + useMemo, useRef, useState, + type FormEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { useLocation } from "react-router-dom"; import { + Button, + Dialog, + DismissibleAlert, + IconButton, + LoadingIndicator, + PageScrollViewport, + SegmentedControl, useGuardedNavigate, - type GlobalSearchProps + usePlatformModules, + usePlatformUiCapabilities, + type GlobalSearchProps, + type SearchContextsUiCapability } from "@govoplan/core-webui"; -import { search, type SearchResult } from "../api/search"; +import { + listSearchProviders, + search, + type SearchResourceType, + type SearchResponse, + type SearchResult +} from "../api/search"; +import { + calculateSearchOverlayLayout, + selectSearchContext, + type SearchOverlayLayout +} from "./searchOverlayLayout"; -const MIN_SUGGESTION_LENGTH = 2; +const MIN_QUERY_LENGTH = 2; +type SearchScope = "global" | "context"; export default function GlobalSearch({ settings }: GlobalSearchProps) { const navigate = useGuardedNavigate(); - const [value, setValue] = useState(""); - const [results, setResults] = useState([]); + const location = useLocation(); + const platformModules = usePlatformModules(); + const contextCapabilities = usePlatformUiCapabilities("search.contexts"); + const availableContexts = useMemo( + () => contextCapabilities.flatMap((capability) => capability.contexts), + [contextCapabilities] + ); + const currentContext = useMemo( + () => selectSearchContext(availableContexts, location.pathname), + [availableContexts, location.pathname] + ); + const moduleLabels = useMemo( + () => new Map(platformModules.map((module) => [module.id, module.label])), + [platformModules] + ); + + const [query, setQuery] = useState(""); + const [modules, setModules] = useState([]); + const [resourceTypes, setResourceTypes] = useState([]); + const [scope, setScope] = useState(currentContext ? "context" : "global"); + const [response, setResponse] = useState(null); + const [resourceCatalogue, setResourceCatalogue] = useState([]); const [open, setOpen] = useState(false); + const [layout, setLayout] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [filtersOpen, setFiltersOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); + const rootRef = useRef(null); - const inputRef = useRef(null); + const sourceInputRef = useRef(null); + const overlayInputRef = useRef(null); + const filtersRef = useRef(null); + const resultsRef = useRef(null); + const requestSequenceRef = useRef(0); + const loadMoreControllerRef = useRef(null); + const suppressRestoredFocusRef = useRef(false); + + const effectiveModules = useMemo( + () => scope === "context" && currentContext + ? [currentContext.moduleId] + : modules, + [currentContext, modules, scope] + ); + const effectiveResourceTypes = useMemo(() => { + if (scope !== "context" || !currentContext?.resourceTypes?.length) return resourceTypes; + if (resourceTypes.length === 0) return currentContext.resourceTypes; + return resourceTypes.filter((resourceType) => currentContext.resourceTypes?.includes(resourceType)); + }, [currentContext, resourceTypes, scope]); + const effectiveModuleKey = effectiveModules.join("\u001f"); + const effectiveResourceTypeKey = effectiveResourceTypes.join("\u001f"); + const activeFilterCount = (scope === "global" ? modules.length : 0) + resourceTypes.length; + + const moduleOptions = useMemo(() => { + const values = new Set(resourceCatalogue.map((resource) => resource.module_id)); + for (const result of response?.results ?? []) values.add(result.module_id); + for (const moduleId of modules) values.add(moduleId); + return [...values] + .map((value) => ({ + value, + label: moduleLabels.get(value) ?? humanizeIdentifier(value) + })) + .sort((left, right) => left.label.localeCompare(right.label)); + }, [moduleLabels, modules, resourceCatalogue, response]); + + const resourceTypeOptions = useMemo(() => { + const labels = new Map(); + for (const resource of resourceCatalogue) { + if (effectiveModules.length === 0 || effectiveModules.includes(resource.module_id)) { + labels.set(resource.resource_type, resource.label); + } + } + for (const result of response?.results ?? []) { + if (effectiveModules.length === 0 || effectiveModules.includes(result.module_id)) { + if (!labels.has(result.resource_type)) { + labels.set(result.resource_type, humanizeIdentifier(result.resource_type)); + } + } + } + for (const resourceType of resourceTypes) { + if (!labels.has(resourceType)) { + labels.set(resourceType, humanizeIdentifier(resourceType)); + } + } + return [...labels] + .map(([value, label]) => ({ value, label })) + .sort((left, right) => left.label.localeCompare(right.label)); + }, [effectiveModules, resourceCatalogue, resourceTypes, response]); + + const measureOverlay = useCallback(() => { + const rect = rootRef.current?.getBoundingClientRect(); + if (!rect) return null; + const next = calculateSearchOverlayLayout( + { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height + }, + window.innerWidth, + window.innerHeight + ); + setLayout(next); + return next; + }, []); + + const closeOverlay = useCallback(() => { + loadMoreControllerRef.current?.abort(); + suppressRestoredFocusRef.current = true; + setOpen(false); + setFiltersOpen(false); + setActiveIndex(-1); + }, []); + + const openOverlay = useCallback(() => { + if (!measureOverlay()) return; + setOpen(true); + }, [measureOverlay]); + + const handleSourceFocus = useCallback(() => { + if (suppressRestoredFocusRef.current) { + suppressRestoredFocusRef.current = false; + return; + } + openOverlay(); + }, [openOverlay]); useEffect(() => { function focusSearch(event: KeyboardEvent) { const commandSearch = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k"; - if (event.key === "F3" || commandSearch) { - event.preventDefault(); - inputRef.current?.focus(); - inputRef.current?.select(); - } + if (event.key !== "F3" && !commandSearch) return; + event.preventDefault(); + openOverlay(); } window.addEventListener("keydown", focusSearch); return () => window.removeEventListener("keydown", focusSearch); - }, []); + }, [openOverlay]); useEffect(() => { - function closeOutside(event: MouseEvent) { - if (rootRef.current && !rootRef.current.contains(event.target as Node)) { - setOpen(false); + if (open) return; + setScope(currentContext ? "context" : "global"); + }, [currentContext, open]); + + useEffect(() => { + if (scope !== "context" || !currentContext?.resourceTypes?.length) return; + setResourceTypes((selected) => { + const compatible = selected.filter((resourceType) => + currentContext.resourceTypes?.includes(resourceType) + ); + return compatible.length === selected.length ? selected : compatible; + }); + }, [currentContext, scope]); + + useLayoutEffect(() => { + if (!open) return undefined; + measureOverlay(); + const observer = typeof ResizeObserver === "undefined" || !rootRef.current + ? null + : new ResizeObserver(measureOverlay); + observer?.observe(rootRef.current); + window.addEventListener("resize", measureOverlay); + return () => { + observer?.disconnect(); + window.removeEventListener("resize", measureOverlay); + }; + }, [measureOverlay, open]); + + useEffect(() => { + if (!open) return undefined; + const controller = new AbortController(); + listSearchProviders(settings, controller.signal) + .then((result) => setResourceCatalogue(result.resources ?? [])) + .catch((reason) => { + if ((reason as Error).name !== "AbortError") setResourceCatalogue([]); + }); + return () => controller.abort(); + }, [open, settings]); + + useEffect(() => { + if (!open || !filtersOpen) return undefined; + function closeFilters(event: MouseEvent) { + if (filtersRef.current && !filtersRef.current.contains(event.target as Node)) { + setFiltersOpen(false); } } - window.addEventListener("mousedown", closeOutside); - return () => window.removeEventListener("mousedown", closeOutside); - }, []); + window.addEventListener("mousedown", closeFilters); + return () => window.removeEventListener("mousedown", closeFilters); + }, [filtersOpen, open]); useEffect(() => { - const query = value.trim(); - if (query.length < MIN_SUGGESTION_LENGTH) { - setResults([]); - setOpen(false); - return; + if (!open) return undefined; + const normalizedQuery = query.trim(); + requestSequenceRef.current += 1; + const sequence = requestSequenceRef.current; + if (normalizedQuery.length < MIN_QUERY_LENGTH) { + setResponse(null); + setLoading(false); + setError(""); + setActiveIndex(-1); + return undefined; } + const controller = new AbortController(); const timer = window.setTimeout(() => { + setLoading(true); + setError(""); search( settings, { - query, - contextKind: "global", - limit: 6 + query: normalizedQuery, + modules: effectiveModules, + resourceTypes: effectiveResourceTypes, + contextKind: scope === "context" && currentContext ? "module" : "global", + contextId: scope === "context" ? currentContext?.id : undefined, + limit: 50 }, controller.signal - ). - then((response) => { - setResults(response.results); - setOpen(true); + ) + .then((next) => { + if (sequence !== requestSequenceRef.current) return; + setResponse(next); setActiveIndex(-1); - }). - catch((error) => { - if ((error as Error).name !== "AbortError") { - setResults([]); + }) + .catch((reason) => { + if ((reason as Error).name !== "AbortError" && sequence === requestSequenceRef.current) { + setError(reason instanceof Error ? reason.message : "Search failed."); + setResponse(null); } + }) + .finally(() => { + if (sequence === requestSequenceRef.current) setLoading(false); }); - }, 220); + }, 180); return () => { window.clearTimeout(timer); controller.abort(); }; - }, [settings, value]); + }, [ + currentContext, + effectiveModuleKey, + effectiveResourceTypeKey, + open, + query, + scope, + settings + ]); + + useEffect(() => { + if (activeIndex < 0) return; + resultsRef.current + ?.querySelector(`[data-search-result-index="${activeIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); function openResult(result: SearchResult) { - setOpen(false); - setValue(""); + closeOverlay(); navigate(result.url); } - function openResultsPage() { - const query = value.trim(); - if (!query) return; - const params = new URLSearchParams({ q: query }); - setOpen(false); - navigate(`/search?${params.toString()}`); - } - - function handleKeyDown(event: ReactKeyboardEvent) { - if (event.key === "Escape") { - setOpen(false); - return; - } - if (event.key === "ArrowDown" && results.length) { - event.preventDefault(); - setOpen(true); - setActiveIndex((current) => (current + 1) % results.length); - return; - } - if (event.key === "ArrowUp" && results.length) { - event.preventDefault(); - setOpen(true); - setActiveIndex((current) => - current <= 0 ? results.length - 1 : current - 1 + function toggleFilter(name: "module" | "resource_type", value: string) { + if (name === "module") { + setModules((selected) => + selected.includes(value) + ? selected.filter((item) => item !== value) + : [...selected, value].sort() ); return; } - if (event.key === "Enter") { + setResourceTypes((selected) => + selected.includes(value) + ? selected.filter((item) => item !== value) + : [...selected, value].sort() + ); + } + + function clearFilters() { + setModules([]); + setResourceTypes([]); + } + + function handleOverlaySubmit(event: FormEvent) { + event.preventDefault(); + const activeResult = activeIndex >= 0 ? response?.results[activeIndex] : null; + if (activeResult) openResult(activeResult); + } + + function handleOverlayKeyDown(event: ReactKeyboardEvent) { + const results = response?.results ?? []; + if (event.key === "ArrowDown" && results.length > 0) { event.preventDefault(); - if (activeIndex >= 0 && results[activeIndex]) { - openResult(results[activeIndex]); - } else { - openResultsPage(); + setActiveIndex((current) => (current + 1) % results.length); + return; + } + if (event.key === "ArrowUp" && results.length > 0) { + event.preventDefault(); + setActiveIndex((current) => current <= 0 ? results.length - 1 : current - 1); + } + } + + async function loadMore() { + const cursor = response?.next_cursor; + if (!cursor || loading) return; + loadMoreControllerRef.current?.abort(); + const controller = new AbortController(); + loadMoreControllerRef.current = controller; + setLoading(true); + setError(""); + try { + const next = await search( + settings, + { + query: query.trim(), + modules: effectiveModules, + resourceTypes: effectiveResourceTypes, + contextKind: scope === "context" && currentContext ? "module" : "global", + contextId: scope === "context" ? currentContext?.id : undefined, + limit: 50, + cursor + }, + controller.signal + ); + setResponse((current) => current + ? { + ...next, + results: [...current.results, ...next.results], + diagnostics: [ + ...current.diagnostics, + ...next.diagnostics.filter((diagnostic) => + !current.diagnostics.some((currentDiagnostic) => + currentDiagnostic.provider_id === diagnostic.provider_id + ) + ) + ] + } + : next + ); + } catch (reason) { + if ((reason as Error).name !== "AbortError") { + setError(reason instanceof Error ? reason.message : "Search failed."); + } + } finally { + if (loadMoreControllerRef.current === controller) { + loadMoreControllerRef.current = null; + setLoading(false); } } } return ( -
-
+ } + + + + {layout && + <> +
+
+
+ +
+
+ {currentContext && + + className="search-overlay-scope" + value={scope} + size="content" + width="inline" + ariaLabel="Search scope" + options={[ + { id: "context", label: currentContext.label }, + { id: "global", label: "Everywhere" } + ]} + onChange={(value) => { + setScope(value); + setActiveIndex(-1); + }} + /> + } +
+ + {filtersOpen && +
+
+ Filter results + } + variant="ghost" + onClick={() => setFiltersOpen(false)} + /> +
+ {scope === "global" && +
+ Modules +
+ {moduleOptions.map((option) => + + )} + {moduleOptions.length === 0 && + No module filters available. + } +
+
+ } +
+ Result types +
+ {resourceTypeOptions.map((option) => + + )} + {resourceTypeOptions.length === 0 && + No result type filters available. + } +
+
+
+ +
+
+ } +
+ {loading && } + } + variant="ghost" + className="search-overlay-close" + onClick={closeOverlay} + /> + {activeFilterCount > 0 && +
+ {scope === "global" && modules.map((moduleId) => + + )} + {resourceTypes.map((resourceType) => + + )} +
+ } +
+ + + {error && + setError("")}> + {error} + + } + {response?.diagnostics.map((diagnostic) => + + {diagnostic.message} + + )} + {query.trim().length < MIN_QUERY_LENGTH && +
+ Type at least {MIN_QUERY_LENGTH} characters to search. +
+ } + {query.trim().length >= MIN_QUERY_LENGTH && !loading && response?.results.length === 0 && +
No results for “{query.trim()}”.
+ } +
+ {response?.results.map((result, index) => + + )} +
+ {response?.next_cursor && + + } +
+
+ + } +
+ ); } + +function humanizeIdentifier(value: string): string { + return value + .replace(/[._:-]+/g, " ") + .replace(/\b\w/g, (character) => character.toUpperCase()); +} diff --git a/webui/src/components/searchOverlayLayout.ts b/webui/src/components/searchOverlayLayout.ts new file mode 100644 index 0000000..4ad970e --- /dev/null +++ b/webui/src/components/searchOverlayLayout.ts @@ -0,0 +1,95 @@ +import type { SearchContextContribution } from "@govoplan/core-webui"; + + +export type SearchOverlayAnchor = { + top: number; + left: number; + width: number; + height: number; +}; + +export type SearchOverlayLayout = { + top: number; + left: number; + width: number; + maxHeight: number; + inputOffset: number; + inputWidth: number; + inputHeight: number; + resultsHeight: number; +}; + +const DESKTOP_PANEL_WIDTH = 820; +const DESKTOP_MARGIN = 16; +const MOBILE_MARGIN = 12; +const MOBILE_BREAKPOINT = 900; +const RESULTS_GAP = 8; + +export function calculateSearchOverlayLayout( + anchor: SearchOverlayAnchor, + viewportWidth: number, + viewportHeight: number +): SearchOverlayLayout { + const mobile = viewportWidth < MOBILE_BREAKPOINT; + const margin = mobile ? MOBILE_MARGIN : DESKTOP_MARGIN; + const width = Math.max(1, Math.min(DESKTOP_PANEL_WIDTH, viewportWidth - margin * 2)); + const centeredLeft = anchor.left + anchor.width / 2 - width / 2; + const left = mobile + ? margin + : clamp(centeredLeft, margin, Math.max(margin, viewportWidth - width - margin)); + const inputWidth = mobile + ? width + : Math.min(Math.max(1, anchor.width), width); + const inputOffset = mobile + ? 0 + : clamp(anchor.left - left, 0, Math.max(0, width - inputWidth)); + const top = Math.max(0, anchor.top); + const inputHeight = Math.max(1, anchor.height); + const availableResultsHeight = viewportHeight - top - inputHeight - RESULTS_GAP - margin; + const resultsHeight = Math.max(96, Math.min(620, availableResultsHeight)); + + return { + top, + left, + width, + maxHeight: inputHeight + RESULTS_GAP + resultsHeight, + inputOffset, + inputWidth, + inputHeight, + resultsHeight + }; +} + +export function selectSearchContext( + contexts: readonly SearchContextContribution[], + pathname: string +): SearchContextContribution | null { + const matches = contexts.flatMap((context) => + context.pathPrefixes + .filter((prefix) => pathMatchesPrefix(pathname, prefix)) + .map((prefix) => ({ context, prefixLength: normalizePath(prefix).length })) + ); + matches.sort((left, right) => + right.prefixLength - left.prefixLength + || (left.context.order ?? 100) - (right.context.order ?? 100) + || left.context.id.localeCompare(right.context.id) + ); + return matches[0]?.context ?? null; +} + +function pathMatchesPrefix(pathname: string, prefix: string): boolean { + const normalizedPath = normalizePath(pathname); + const normalizedPrefix = normalizePath(prefix); + return normalizedPrefix === "/" + || normalizedPath === normalizedPrefix + || normalizedPath.startsWith(`${normalizedPrefix}/`); +} + +function normalizePath(value: string): string { + const normalized = `/${String(value || "").trim().replace(/^\/+|\/+$/g, "")}`; + return normalized === "/" ? normalized : normalized.replace(/\/+$/g, ""); +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(Math.max(value, minimum), maximum); +} diff --git a/webui/src/styles/search.css b/webui/src/styles/search.css index 4588b41..5161d9b 100644 --- a/webui/src/styles/search.css +++ b/webui/src/styles/search.css @@ -53,61 +53,111 @@ color: var(--text-strong); } -.global-search-results { +.global-search-source.is-overlay-open { + visibility: hidden; +} + +.search-overlay-backdrop { + display: block; + padding: 0; + backdrop-filter: blur(1px); +} + +.search-overlay-dialog { + position: fixed; + display: flex; + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.search-overlay-dialog-header { position: absolute; - z-index: 300; - top: calc(100% + 7px); - left: 50%; - width: min(460px, 80vw); - transform: translateX(-50%); + width: 1px; + min-height: 0; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + border: 0; + padding: 0; + white-space: nowrap; +} + +.search-overlay-dialog-body { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + overflow: visible; + padding: 0; +} + +.search-overlay-input-position { + min-width: 0; + flex: 0 0 auto; +} + +.global-search-overlay-input { + box-shadow: var(--focus-ring); +} + +.search-overlay-results-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + margin-top: 8px; overflow: hidden; border: var(--border-line); border-radius: 6px; background: var(--surface); - box-shadow: var(--shadow-menu); + box-shadow: var(--shadow-strong); } -.global-search-results button { +.search-overlay-toolbar { + position: relative; + z-index: 2; display: flex; - width: 100%; - min-width: 0; - flex-direction: column; - align-items: flex-start; - gap: 3px; - border: 0; - border-bottom: var(--border-line); - background: transparent; - color: var(--muted); - cursor: pointer; - padding: 9px 12px; - text-align: left; - font: inherit; - font-size: 11px; -} - -.global-search-results button:hover, -.global-search-results button.active { - background: var(--titlebar-hover-bg); -} - -.global-search-result-title { - width: 100%; - overflow: hidden; - color: var(--text-strong); - text-overflow: ellipsis; - white-space: nowrap; - font-size: 13px; - font-weight: 700; -} - -.global-search-results .global-search-all { - flex-direction: row; align-items: center; - gap: 7px; - border-bottom: 0; - color: var(--text-strong); - font-size: 12px; - font-weight: 700; + flex: 0 0 auto; + flex-wrap: wrap; + gap: 8px; + min-height: 48px; + border-bottom: var(--border-line); + background: var(--panel-header); + padding: 7px 10px; +} + +.search-overlay-scope { + max-width: min(420px, 100%); +} + +.search-overlay-close { + width: 32px; + height: 32px; + margin-left: auto; + padding: 0; +} + +.search-overlay-toolbar .search-active-filters { + flex-basis: 100%; +} + +.search-overlay-results-viewport { + min-height: 0; + flex: 1 1 auto; + padding: 8px 12px 14px; +} + +.search-overlay-empty { + display: grid; + min-height: 120px; + place-items: center; + padding: 20px; + text-align: center; } .search-page { @@ -357,6 +407,11 @@ background: var(--sidebar-hover-bg); } +.search-result.active { + background: var(--sidebar-hover-bg); + box-shadow: inset 3px 0 0 var(--accent); +} + .search-result-heading { display: flex; align-items: center; @@ -408,25 +463,28 @@ } @media (max-width: 900px) { - .global-search { + .global-search-source { width: 34px; min-width: 34px; padding: 0 8px; } - .global-search:focus-within { - position: absolute; - z-index: 2; - top: 50%; - left: 50%; - width: min(360px, calc(100vw - 190px)); - transform: translate(-50%, -50%); - } - - .global-search:not(:focus-within) input, - .global-search:not(:focus-within) .global-search-clear { + .global-search-source input, + .global-search-source .global-search-clear { width: 0; padding: 0; opacity: 0; } + + .search-overlay-results-panel { + border-radius: 4px; + } + + .search-overlay-toolbar { + gap: 6px; + } + + .search-overlay-scope { + max-width: calc(100% - 86px); + } }