feat: present global search in an anchored overlay

This commit is contained in:
2026-07-30 14:27:10 +02:00
parent bed704eb7a
commit e6bb9d359f
5 changed files with 878 additions and 179 deletions
+3
View File
@@ -13,6 +13,9 @@
}, },
"./styles/search.css": "./src/styles/search.css" "./styles/search.css": "./src/styles/search.css"
}, },
"scripts": {
"test:search-overlay": "node scripts/test-search-overlay-structure.mjs"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.14", "@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -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("<Dialog"), "Search uses the shared Dialog component");
assert(source.includes("portal"), "the Search dialog portals above the complete shell");
assert(source.includes("calculateSearchOverlayLayout"), "the overlay is anchored to the titlebar field");
assert(source.includes("listSearchProviders"), "the overlay loads the complete filter catalogue");
assert(source.includes("limit: 50"), "the overlay requests full result windows rather than titlebar suggestions");
assert(source.includes("response?.next_cursor"), "the overlay retains cursor pagination");
assert(source.includes('usePlatformUiCapabilities<SearchContextsUiCapability>("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.");
+615 -98
View File
@@ -1,184 +1,701 @@
import { Search, X } from "lucide-react"; import { ChevronDown, ExternalLink, Filter, Search, X } from "lucide-react";
import { import {
useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo,
useRef, useRef,
useState, useState,
type FormEvent,
type KeyboardEvent as ReactKeyboardEvent type KeyboardEvent as ReactKeyboardEvent
} from "react"; } from "react";
import { useLocation } from "react-router-dom";
import { import {
Button,
Dialog,
DismissibleAlert,
IconButton,
LoadingIndicator,
PageScrollViewport,
SegmentedControl,
useGuardedNavigate, useGuardedNavigate,
type GlobalSearchProps usePlatformModules,
usePlatformUiCapabilities,
type GlobalSearchProps,
type SearchContextsUiCapability
} from "@govoplan/core-webui"; } 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) { export default function GlobalSearch({ settings }: GlobalSearchProps) {
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const [value, setValue] = useState(""); const location = useLocation();
const [results, setResults] = useState<SearchResult[]>([]); const platformModules = usePlatformModules();
const contextCapabilities = usePlatformUiCapabilities<SearchContextsUiCapability>("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<string[]>([]);
const [resourceTypes, setResourceTypes] = useState<string[]>([]);
const [scope, setScope] = useState<SearchScope>(currentContext ? "context" : "global");
const [response, setResponse] = useState<SearchResponse | null>(null);
const [resourceCatalogue, setResourceCatalogue] = useState<SearchResourceType[]>([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [layout, setLayout] = useState<SearchOverlayLayout | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1); const [activeIndex, setActiveIndex] = useState(-1);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const sourceInputRef = useRef<HTMLInputElement>(null);
const overlayInputRef = useRef<HTMLInputElement>(null);
const filtersRef = useRef<HTMLDivElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const requestSequenceRef = useRef(0);
const loadMoreControllerRef = useRef<AbortController | null>(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<string, string>();
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(() => { useEffect(() => {
function focusSearch(event: KeyboardEvent) { function focusSearch(event: KeyboardEvent) {
const commandSearch = const commandSearch =
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k"; (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k";
if (event.key === "F3" || commandSearch) { if (event.key !== "F3" && !commandSearch) return;
event.preventDefault(); event.preventDefault();
inputRef.current?.focus(); openOverlay();
inputRef.current?.select();
}
} }
window.addEventListener("keydown", focusSearch); window.addEventListener("keydown", focusSearch);
return () => window.removeEventListener("keydown", focusSearch); return () => window.removeEventListener("keydown", focusSearch);
}, []); }, [openOverlay]);
useEffect(() => { useEffect(() => {
function closeOutside(event: MouseEvent) { if (open) return;
if (rootRef.current && !rootRef.current.contains(event.target as Node)) { setScope(currentContext ? "context" : "global");
setOpen(false); }, [currentContext, open]);
}
}
window.addEventListener("mousedown", closeOutside);
return () => window.removeEventListener("mousedown", closeOutside);
}, []);
useEffect(() => { useEffect(() => {
const query = value.trim(); if (scope !== "context" || !currentContext?.resourceTypes?.length) return;
if (query.length < MIN_SUGGESTION_LENGTH) { setResourceTypes((selected) => {
setResults([]); const compatible = selected.filter((resourceType) =>
setOpen(false); currentContext.resourceTypes?.includes(resourceType)
return; );
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", closeFilters);
return () => window.removeEventListener("mousedown", closeFilters);
}, [filtersOpen, open]);
useEffect(() => {
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 controller = new AbortController();
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
setLoading(true);
setError("");
search( search(
settings, settings,
{ {
query, query: normalizedQuery,
contextKind: "global", modules: effectiveModules,
limit: 6 resourceTypes: effectiveResourceTypes,
contextKind: scope === "context" && currentContext ? "module" : "global",
contextId: scope === "context" ? currentContext?.id : undefined,
limit: 50
}, },
controller.signal controller.signal
). )
then((response) => { .then((next) => {
setResults(response.results); if (sequence !== requestSequenceRef.current) return;
setOpen(true); setResponse(next);
setActiveIndex(-1); setActiveIndex(-1);
}). })
catch((error) => { .catch((reason) => {
if ((error as Error).name !== "AbortError") { if ((reason as Error).name !== "AbortError" && sequence === requestSequenceRef.current) {
setResults([]); setError(reason instanceof Error ? reason.message : "Search failed.");
setResponse(null);
} }
})
.finally(() => {
if (sequence === requestSequenceRef.current) setLoading(false);
}); });
}, 220); }, 180);
return () => { return () => {
window.clearTimeout(timer); window.clearTimeout(timer);
controller.abort(); controller.abort();
}; };
}, [settings, value]); }, [
currentContext,
effectiveModuleKey,
effectiveResourceTypeKey,
open,
query,
scope,
settings
]);
useEffect(() => {
if (activeIndex < 0) return;
resultsRef.current
?.querySelector<HTMLElement>(`[data-search-result-index="${activeIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
function openResult(result: SearchResult) { function openResult(result: SearchResult) {
setOpen(false); closeOverlay();
setValue("");
navigate(result.url); navigate(result.url);
} }
function openResultsPage() { function toggleFilter(name: "module" | "resource_type", value: string) {
const query = value.trim(); if (name === "module") {
if (!query) return; setModules((selected) =>
const params = new URLSearchParams({ q: query }); selected.includes(value)
setOpen(false); ? selected.filter((item) => item !== value)
navigate(`/search?${params.toString()}`); : [...selected, value].sort()
}
function handleKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
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
); );
return; 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(); event.preventDefault();
if (activeIndex >= 0 && results[activeIndex]) { const activeResult = activeIndex >= 0 ? response?.results[activeIndex] : null;
openResult(results[activeIndex]); if (activeResult) openResult(activeResult);
} else { }
openResultsPage();
function handleOverlayKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
const results = response?.results ?? [];
if (event.key === "ArrowDown" && results.length > 0) {
event.preventDefault();
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 ( return (
<div className="global-search" ref={rootRef}> <>
<div
ref={rootRef}
className={`global-search global-search-source${open ? " is-overlay-open" : ""}`}>
<Search size={16} aria-hidden="true" /> <Search size={16} aria-hidden="true" />
<input <input
ref={inputRef} ref={sourceInputRef}
type="search" type="search"
value={value} value={query}
placeholder="Search" readOnly
tabIndex={open ? -1 : 0}
placeholder={currentContext?.placeholder ?? "Search"}
aria-label="Global search" aria-label="Global search"
aria-keyshortcuts="F3 Control+K Meta+K" aria-keyshortcuts="F3 Control+K Meta+K"
aria-expanded={open} aria-expanded={open}
aria-controls="global-search-results" onFocus={handleSourceFocus}
onFocus={() => setOpen(results.length > 0)} onClick={openOverlay}
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
/> />
{value && {query &&
<button
type="button"
className="global-search-clear"
tabIndex={open ? -1 : 0}
aria-label="Clear search"
onClick={() => {
setQuery("");
setResponse(null);
}}>
<X size={14} />
</button>
}
</div>
<Dialog
open={open && Boolean(layout)}
title="Search"
onClose={closeOverlay}
showCloseButton={false}
portal
className="search-overlay-dialog"
backdropClassName="search-overlay-backdrop"
headerClassName="search-overlay-dialog-header"
bodyClassName="search-overlay-dialog-body"
panelStyle={layout
? {
top: layout.top,
left: layout.left,
width: layout.width,
maxHeight: layout.maxHeight
}
: undefined}>
{layout &&
<>
<div
className="search-overlay-input-position"
style={{
width: layout.inputWidth,
height: layout.inputHeight,
marginLeft: layout.inputOffset
}}>
<form className="global-search global-search-overlay-input" onSubmit={handleOverlaySubmit}>
<Search size={16} aria-hidden="true" />
<input
ref={overlayInputRef}
autoFocus
type="search"
value={query}
placeholder={
scope === "context" && currentContext?.placeholder
? currentContext.placeholder
: "Search"
}
aria-label="Global search"
aria-keyshortcuts="F3 Control+K Meta+K"
aria-expanded={true}
aria-controls="global-search-overlay-results"
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleOverlayKeyDown}
/>
{query &&
<button <button
type="button" type="button"
className="global-search-clear" className="global-search-clear"
aria-label="Clear search" aria-label="Clear search"
onClick={() => { onClick={() => {
setValue(""); setQuery("");
setResults([]); setResponse(null);
inputRef.current?.focus(); setActiveIndex(-1);
overlayInputRef.current?.focus();
}}> }}>
<X size={14} /> <X size={14} />
</button> </button>
} }
{open && results.length > 0 && </form>
<div id="global-search-results" className="global-search-results" role="listbox"> </div>
{results.map((result, index) =>
<button <section
key={`${result.module_id}:${result.resource_type}:${result.resource_id}`} className="search-overlay-results-panel"
style={{ height: layout.resultsHeight }}>
<div className="search-overlay-toolbar">
{currentContext &&
<SegmentedControl<SearchScope>
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);
}}
/>
}
<div className="search-filter-menu" ref={filtersRef}>
<Button
type="button" type="button"
role="option" variant="secondary"
aria-selected={index === activeIndex} className={`search-filter-trigger${activeFilterCount ? " is-active" : ""}`}
className={index === activeIndex ? "active" : ""} aria-haspopup="dialog"
onMouseDown={(event) => event.preventDefault()} aria-expanded={filtersOpen}
onClick={() => openResult(result)}> onClick={() => setFiltersOpen((current) => !current)}>
<span className="global-search-result-title">{result.title}</span> <Filter size={16} aria-hidden="true" />
<span>{result.module_id} · {result.resource_type}</span> <span>Filters</span>
{activeFilterCount > 0 &&
<span className="search-filter-count" aria-label={`${activeFilterCount} active filters`}>
{activeFilterCount}
</span>
}
</Button>
{filtersOpen &&
<div className="search-filter-popover" role="dialog" aria-label="Filter search results">
<div className="search-filter-popover-header">
<strong>Filter results</strong>
<IconButton
label="Close filters"
icon={<X size={16} />}
variant="ghost"
onClick={() => setFiltersOpen(false)}
/>
</div>
{scope === "global" &&
<fieldset className="search-filter-group">
<legend>Modules</legend>
<div className="search-filter-options">
{moduleOptions.map((option) =>
<label key={option.value}>
<input
type="checkbox"
checked={modules.includes(option.value)}
onChange={() => toggleFilter("module", option.value)}
/>
<span>{option.label}</span>
</label>
)}
{moduleOptions.length === 0 &&
<span className="search-filter-empty">No module filters available.</span>
}
</div>
</fieldset>
}
<fieldset className="search-filter-group">
<legend>Result types</legend>
<div className="search-filter-options">
{resourceTypeOptions.map((option) =>
<label key={option.value}>
<input
type="checkbox"
checked={resourceTypes.includes(option.value)}
onChange={() => toggleFilter("resource_type", option.value)}
/>
<span>{option.label}</span>
</label>
)}
{resourceTypeOptions.length === 0 &&
<span className="search-filter-empty">No result type filters available.</span>
}
</div>
</fieldset>
<div className="search-filter-popover-footer">
<Button
type="button"
variant="ghost"
disabled={activeFilterCount === 0}
onClick={clearFilters}>
Clear filters
</Button>
</div>
</div>
}
</div>
{loading && <LoadingIndicator size="sm" label="Searching" />}
<IconButton
label="Close search"
icon={<X size={17} />}
variant="ghost"
className="search-overlay-close"
onClick={closeOverlay}
/>
{activeFilterCount > 0 &&
<div className="search-active-filters" aria-label="Active search filters">
{scope === "global" && modules.map((moduleId) =>
<button
type="button"
key={`module:${moduleId}`}
onClick={() => toggleFilter("module", moduleId)}
aria-label={`Remove ${moduleLabels.get(moduleId) ?? moduleId} filter`}>
<span>{moduleLabels.get(moduleId) ?? humanizeIdentifier(moduleId)}</span>
<X size={13} />
</button> </button>
)} )}
{resourceTypes.map((resourceType) =>
<button <button
type="button" type="button"
className="global-search-all" key={`resource-type:${resourceType}`}
onMouseDown={(event) => event.preventDefault()} onClick={() => toggleFilter("resource_type", resourceType)}
onClick={openResultsPage}> aria-label={`Remove ${humanizeIdentifier(resourceType)} filter`}>
<Search size={14} /> <span>{humanizeIdentifier(resourceType)}</span>
<span>All results</span> <X size={13} />
</button> </button>
)}
</div> </div>
} }
</div> </div>
<PageScrollViewport
id="global-search-overlay-results"
ref={resultsRef}
className="search-overlay-results-viewport"
role="listbox"
aria-label="Search results">
{error &&
<DismissibleAlert tone="danger" onDismiss={() => setError("")}>
{error}
</DismissibleAlert>
}
{response?.diagnostics.map((diagnostic) =>
<DismissibleAlert
key={diagnostic.provider_id}
tone="warning"
compact>
{diagnostic.message}
</DismissibleAlert>
)}
{query.trim().length < MIN_QUERY_LENGTH &&
<div className="search-empty search-overlay-empty">
Type at least {MIN_QUERY_LENGTH} characters to search.
</div>
}
{query.trim().length >= MIN_QUERY_LENGTH && !loading && response?.results.length === 0 &&
<div className="search-empty search-overlay-empty">No results for {query.trim()}.</div>
}
<div className="search-result-list">
{response?.results.map((result, index) =>
<button
type="button"
role="option"
aria-selected={activeIndex === index}
data-search-result-index={index}
className={`search-result${activeIndex === index ? " active" : ""}`}
key={`${result.module_id}:${result.resource_type}:${result.resource_id}`}
onMouseMove={() => setActiveIndex(index)}
onClick={() => openResult(result)}>
<span className="search-result-heading">
<strong>{result.title}</strong>
{result.external_reference?.canonical_url &&
<ExternalLink size={14} aria-label="External result" />
}
</span>
{result.summary && <span className="search-result-summary">{result.summary}</span>}
<span className="search-result-meta">
{result.breadcrumbs.length > 0
? result.breadcrumbs.join(" · ")
: `${moduleLabels.get(result.module_id) ?? humanizeIdentifier(result.module_id)} · ${humanizeIdentifier(result.resource_type)}`}
</span>
</button>
)}
</div>
{response?.next_cursor &&
<Button
type="button"
variant="secondary"
className="search-load-more"
disabled={loading}
onClick={() => void loadMore()}>
<ChevronDown size={16} />
<span>Load more</span>
</Button>
}
</PageScrollViewport>
</section>
</>
}
</Dialog>
</>
); );
} }
function humanizeIdentifier(value: string): string {
return value
.replace(/[._:-]+/g, " ")
.replace(/\b\w/g, (character) => character.toUpperCase());
}
@@ -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);
}
+116 -58
View File
@@ -53,61 +53,111 @@
color: var(--text-strong); 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; position: absolute;
z-index: 300; width: 1px;
top: calc(100% + 7px); min-height: 0;
left: 50%; height: 1px;
width: min(460px, 80vw); overflow: hidden;
transform: translateX(-50%); 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; overflow: hidden;
border: var(--border-line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: var(--surface); 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; 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; align-items: center;
gap: 7px; flex: 0 0 auto;
border-bottom: 0; flex-wrap: wrap;
color: var(--text-strong); gap: 8px;
font-size: 12px; min-height: 48px;
font-weight: 700; 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 { .search-page {
@@ -357,6 +407,11 @@
background: var(--sidebar-hover-bg); background: var(--sidebar-hover-bg);
} }
.search-result.active {
background: var(--sidebar-hover-bg);
box-shadow: inset 3px 0 0 var(--accent);
}
.search-result-heading { .search-result-heading {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -408,25 +463,28 @@
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.global-search { .global-search-source {
width: 34px; width: 34px;
min-width: 34px; min-width: 34px;
padding: 0 8px; padding: 0 8px;
} }
.global-search:focus-within { .global-search-source input,
position: absolute; .global-search-source .global-search-clear {
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 {
width: 0; width: 0;
padding: 0; padding: 0;
opacity: 0; opacity: 0;
} }
.search-overlay-results-panel {
border-radius: 4px;
}
.search-overlay-toolbar {
gap: 6px;
}
.search-overlay-scope {
max-width: calc(100% - 86px);
}
} }