feat: present global search in an anchored overlay
This commit is contained in:
@@ -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<SearchResult[]>([]);
|
||||
const location = useLocation();
|
||||
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 [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 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(() => {
|
||||
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<HTMLElement>(`[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<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
|
||||
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<HTMLInputElement>) {
|
||||
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 (
|
||||
<div className="global-search" ref={rootRef}>
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={value}
|
||||
placeholder="Search"
|
||||
aria-label="Global search"
|
||||
aria-keyshortcuts="F3 Control+K Meta+K"
|
||||
aria-expanded={open}
|
||||
aria-controls="global-search-results"
|
||||
onFocus={() => setOpen(results.length > 0)}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{value &&
|
||||
<button
|
||||
type="button"
|
||||
className="global-search-clear"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setValue("");
|
||||
setResults([]);
|
||||
inputRef.current?.focus();
|
||||
}}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
}
|
||||
{open && results.length > 0 &&
|
||||
<div id="global-search-results" className="global-search-results" role="listbox">
|
||||
{results.map((result, index) =>
|
||||
<button
|
||||
key={`${result.module_id}:${result.resource_type}:${result.resource_id}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
className={index === activeIndex ? "active" : ""}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => openResult(result)}>
|
||||
<span className="global-search-result-title">{result.title}</span>
|
||||
<span>{result.module_id} · {result.resource_type}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="global-search-all"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={openResultsPage}>
|
||||
<Search size={14} />
|
||||
<span>All results</span>
|
||||
<>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`global-search global-search-source${open ? " is-overlay-open" : ""}`}>
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
ref={sourceInputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
readOnly
|
||||
tabIndex={open ? -1 : 0}
|
||||
placeholder={currentContext?.placeholder ?? "Search"}
|
||||
aria-label="Global search"
|
||||
aria-keyshortcuts="F3 Control+K Meta+K"
|
||||
aria-expanded={open}
|
||||
onFocus={handleSourceFocus}
|
||||
onClick={openOverlay}
|
||||
/>
|
||||
{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>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</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
|
||||
type="button"
|
||||
className="global-search-clear"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setResponse(null);
|
||||
setActiveIndex(-1);
|
||||
overlayInputRef.current?.focus();
|
||||
}}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<section
|
||||
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"
|
||||
variant="secondary"
|
||||
className={`search-filter-trigger${activeFilterCount ? " is-active" : ""}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={filtersOpen}
|
||||
onClick={() => setFiltersOpen((current) => !current)}>
|
||||
<Filter size={16} aria-hidden="true" />
|
||||
<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>
|
||||
)}
|
||||
{resourceTypes.map((resourceType) =>
|
||||
<button
|
||||
type="button"
|
||||
key={`resource-type:${resourceType}`}
|
||||
onClick={() => toggleFilter("resource_type", resourceType)}
|
||||
aria-label={`Remove ${humanizeIdentifier(resourceType)} filter`}>
|
||||
<span>{humanizeIdentifier(resourceType)}</span>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user