feat: present global search in an anchored overlay
This commit is contained in:
@@ -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.");
|
||||||
@@ -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]);
|
||||||
|
|
||||||
|
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);
|
window.addEventListener("mousedown", closeFilters);
|
||||||
return () => window.removeEventListener("mousedown", closeOutside);
|
return () => window.removeEventListener("mousedown", closeFilters);
|
||||||
}, []);
|
}, [filtersOpen, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const query = value.trim();
|
if (!open) return undefined;
|
||||||
if (query.length < MIN_SUGGESTION_LENGTH) {
|
const normalizedQuery = query.trim();
|
||||||
setResults([]);
|
requestSequenceRef.current += 1;
|
||||||
setOpen(false);
|
const sequence = requestSequenceRef.current;
|
||||||
return;
|
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();
|
||||||
|
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();
|
event.preventDefault();
|
||||||
if (activeIndex >= 0 && results[activeIndex]) {
|
setActiveIndex((current) => (current + 1) % results.length);
|
||||||
openResult(results[activeIndex]);
|
return;
|
||||||
} else {
|
}
|
||||||
openResultsPage();
|
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}>
|
<>
|
||||||
<Search size={16} aria-hidden="true" />
|
<div
|
||||||
<input
|
ref={rootRef}
|
||||||
ref={inputRef}
|
className={`global-search global-search-source${open ? " is-overlay-open" : ""}`}>
|
||||||
type="search"
|
<Search size={16} aria-hidden="true" />
|
||||||
value={value}
|
<input
|
||||||
placeholder="Search"
|
ref={sourceInputRef}
|
||||||
aria-label="Global search"
|
type="search"
|
||||||
aria-keyshortcuts="F3 Control+K Meta+K"
|
value={query}
|
||||||
aria-expanded={open}
|
readOnly
|
||||||
aria-controls="global-search-results"
|
tabIndex={open ? -1 : 0}
|
||||||
onFocus={() => setOpen(results.length > 0)}
|
placeholder={currentContext?.placeholder ?? "Search"}
|
||||||
onChange={(event) => setValue(event.target.value)}
|
aria-label="Global search"
|
||||||
onKeyDown={handleKeyDown}
|
aria-keyshortcuts="F3 Control+K Meta+K"
|
||||||
/>
|
aria-expanded={open}
|
||||||
{value &&
|
onFocus={handleSourceFocus}
|
||||||
<button
|
onClick={openOverlay}
|
||||||
type="button"
|
/>
|
||||||
className="global-search-clear"
|
{query &&
|
||||||
aria-label="Clear search"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
setValue("");
|
className="global-search-clear"
|
||||||
setResults([]);
|
tabIndex={open ? -1 : 0}
|
||||||
inputRef.current?.focus();
|
aria-label="Clear search"
|
||||||
}}>
|
onClick={() => {
|
||||||
<X size={14} />
|
setQuery("");
|
||||||
</button>
|
setResponse(null);
|
||||||
}
|
}}>
|
||||||
{open && results.length > 0 &&
|
<X size={14} />
|
||||||
<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>
|
|
||||||
</button>
|
</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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user