702 lines
25 KiB
TypeScript
702 lines
25 KiB
TypeScript
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,
|
|
usePlatformModules,
|
|
usePlatformUiCapabilities,
|
|
type GlobalSearchProps,
|
|
type SearchContextsUiCapability
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
listSearchProviders,
|
|
search,
|
|
type SearchResourceType,
|
|
type SearchResponse,
|
|
type SearchResult
|
|
} from "../api/search";
|
|
import {
|
|
calculateSearchOverlayLayout,
|
|
selectSearchContext,
|
|
type SearchOverlayLayout
|
|
} from "./searchOverlayLayout";
|
|
|
|
|
|
const MIN_QUERY_LENGTH = 2;
|
|
type SearchScope = "global" | "context";
|
|
|
|
export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
|
const navigate = useGuardedNavigate();
|
|
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 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) return;
|
|
event.preventDefault();
|
|
openOverlay();
|
|
}
|
|
window.addEventListener("keydown", focusSearch);
|
|
return () => window.removeEventListener("keydown", focusSearch);
|
|
}, [openOverlay]);
|
|
|
|
useEffect(() => {
|
|
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", 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 timer = window.setTimeout(() => {
|
|
setLoading(true);
|
|
setError("");
|
|
search(
|
|
settings,
|
|
{
|
|
query: normalizedQuery,
|
|
modules: effectiveModules,
|
|
resourceTypes: effectiveResourceTypes,
|
|
contextKind: scope === "context" && currentContext ? "module" : "global",
|
|
contextId: scope === "context" ? currentContext?.id : undefined,
|
|
limit: 50
|
|
},
|
|
controller.signal
|
|
)
|
|
.then((next) => {
|
|
if (sequence !== requestSequenceRef.current) return;
|
|
setResponse(next);
|
|
setActiveIndex(-1);
|
|
})
|
|
.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);
|
|
});
|
|
}, 180);
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
}, [
|
|
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) {
|
|
closeOverlay();
|
|
navigate(result.url);
|
|
}
|
|
|
|
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;
|
|
}
|
|
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();
|
|
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
|
|
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>
|
|
|
|
<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());
|
|
}
|