Release govoplan-search v0.1.20: centralize filtering and refresh behavior
Module Package Release / publish-packages (push) Successful in 11s

This commit is contained in:
2026-09-08 01:32:53 +02:00
parent 6c5545af66
commit 6ad7c6e0ef
20 changed files with 530 additions and 788 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/search-webui",
"version": "0.1.19",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,11 +14,12 @@
"./styles/search.css": "./src/styles/search.css"
},
"scripts": {
"test:search-filters": "node --test scripts/test-search-filters.mjs",
"test:search-overlay": "node scripts/test-search-overlay-structure.mjs",
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+8
View File
@@ -5,13 +5,21 @@ const page = fs.readFileSync("src/features/search/SearchPage.tsx", "utf8");
const overlay = fs.readFileSync("src/components/GlobalSearch.tsx", "utf8");
const admin = fs.readFileSync("src/features/search/SearchAdminPanel.tsx", "utf8");
const styles = fs.readFileSync("src/styles/search.css", "utf8");
const filters = fs.readFileSync("src/components/SearchFilters.tsx", "utf8");
const lifecycle = fs.readFileSync("src/components/useSearchResults.ts", "utf8");
for (const source of [page, overlay]) {
assert.ok(source.includes("DocumentationHelpLink"), "Search surfaces expose configured-system help");
assert.ok(source.includes("DismissibleAlert"), "Search failures use the shared alert contract");
assert.ok(!source.includes("window.alert("), "Search must not use browser alerts");
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(source), "Search uses semantic interactive elements");
assert.ok(source.includes("<SearchFilters"), "Search route and overlay share one filter composition");
assert.ok(source.includes("useSearchResults"), "Search route and overlay share one request lifecycle");
assert.ok(!source.includes("search-filter-popover"), "Search surfaces must not duplicate dropdown implementations");
}
assert.ok(filters.includes("MultiSelectFilter"), "Search facets reuse Core's multi-selection dropdown");
assert.ok(lifecycle.includes("currentIdentity.current === identity"), "Late responses are gated by the current request identity");
assert.ok(lifecycle.includes("controllerRef.current?.abort()"), "Cursor and initial reads are cancelled together");
assert.ok(page.includes("PageScrollViewport"), "The full search route owns bounded result scrolling");
assert.ok(overlay.includes("<Dialog"), "The title-bar search uses the shared focus-contained dialog");
+102
View File
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import test from "node:test";
const requireCore = createRequire(new URL("../../../govoplan-core/webui/package.json", import.meta.url));
const ts = requireCore("typescript");
function loadTypeScript(relativePath, dependencies = {}) {
const source = readFileSync(new URL(relativePath, import.meta.url), "utf8");
const { outputText } = ts.transpileModule(source, { compilerOptions: {
target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS,
} });
const module = { exports: {} };
new Function("require", "module", "exports", outputText)((id) => {
if (id in dependencies) return dependencies[id];
throw new Error(`Unexpected runtime dependency: ${id}`);
}, module, module.exports);
return module.exports;
}
const { readSearchFilter, writeSearchFilter, effectiveSearchFilters, searchFilterOptions } = loadTypeScript("../src/components/searchFilters.ts");
test("legacy missing filters remain all; explicit none wins over repeated values", () => {
assert.equal(readSearchFilter(new URLSearchParams("q=permit"), "module"), null);
assert.deepEqual(readSearchFilter(new URLSearchParams("module=files&module=cases&module=files"), "module"), ["cases", "files"]);
assert.deepEqual(readSearchFilter(new URLSearchParams("module=cases&module_none=1"), "module"), []);
assert.deepEqual(readSearchFilter(new URLSearchParams("resource_type=case&resource_type_none=1"), "resource_type"), []);
});
test("filter URL roundtrips preserve query, context, language and the other filter", () => {
const original = new URLSearchParams("q=permit&context=cases.current&language=german&resource_type=case&other=keep");
const selected = writeSearchFilter(original, "module", ["files", "cases", "files"]);
assert.deepEqual(selected.getAll("module"), ["cases", "files"]);
assert.equal(original.has("module"), false);
const none = writeSearchFilter(selected, "module", []);
assert.equal(none.get("module_none"), "1");
assert.equal(none.has("module"), false);
assert.deepEqual(readSearchFilter(none, "module"), []);
const all = writeSearchFilter(none, "module", null);
assert.equal(all.toString(), original.toString());
assert.equal(readSearchFilter(all, "module"), null);
});
test("global all, none and subset selections keep distinct effective requests", () => {
assert.deepEqual(effectiveSearchFilters(null, null), { modules: undefined, resourceTypes: undefined, matchNone: false });
assert.equal(effectiveSearchFilters([], null).matchNone, true);
assert.equal(effectiveSearchFilters(null, []).matchNone, true);
assert.deepEqual(effectiveSearchFilters(["cases", "files"], ["case", "file"]), {
modules: ["cases", "files"], resourceTypes: ["case", "file"], matchNone: false,
});
});
test("context bounds never broaden an empty resource-type intersection", () => {
const context = { id: "files.context", moduleId: "files", label: "Files", pathPrefixes: ["/files"], resourceTypes: ["file", "folder"] };
assert.deepEqual(effectiveSearchFilters(null, null, context), { modules: ["files"], resourceTypes: ["file", "folder"], matchNone: false });
assert.deepEqual(effectiveSearchFilters(["cases"], ["case", "file"], context), { modules: ["files"], resourceTypes: ["file"], matchNone: false });
assert.deepEqual(effectiveSearchFilters(null, ["case"], context), { modules: ["files"], resourceTypes: [], matchNone: true });
assert.equal(effectiveSearchFilters(null, [], context).matchNone, true);
// The hidden global module selection must not override the current context.
assert.equal(effectiveSearchFilters([], null, context).matchNone, false);
assert.equal(effectiveSearchFilters(null, null, { ...context, resourceTypes: [] }).matchNone, false);
});
const catalogue = [
{ provider_id: "files.source", module_id: "files", resource_type: "file", label: "Files", order: 1 },
{ provider_id: "files.source", module_id: "files", resource_type: "folder", label: "Folders", order: 2 },
{ provider_id: "cases.source", module_id: "cases", resource_type: "case", label: "Cases", order: 3 },
];
const observed = [{ module_id: "mail", resource_type: "message" }];
const labels = new Map([["files", "Dateien"], ["cases", "Fälle"]]);
test("filter choices combine catalogue, observed values and removable stale selections", () => {
const all = searchFilterOptions(catalogue, observed, labels, ["files", "retired-source"], ["unknown-type"]);
assert.deepEqual(new Set(all.modules.map((item) => item.value)), new Set(["files", "cases", "mail", "retired-source"]));
assert.equal(all.modules.find((item) => item.value === "files").label, "Dateien");
assert.deepEqual(new Set(all.resourceTypes.map((item) => item.value)), new Set(["file", "folder", "unknown-type"]));
assert.deepEqual(searchFilterOptions(catalogue, observed, labels, [], null).resourceTypes, []);
const context = { id: "files.context", moduleId: "files", label: "Files", pathPrefixes: ["/files"], resourceTypes: ["file"] };
assert.deepEqual(searchFilterOptions(catalogue, observed, labels, null, null, context).resourceTypes, [{ value: "file", label: "Files" }]);
});
test("explicit none is a frontend-only empty response; legacy API empty arrays still request all", async () => {
const paths = [], calls = [];
const response = { query: "permit", results: [], diagnostics: [], next_cursor: null, has_more: false };
const { search } = loadTypeScript("../src/api/search.ts", { "@govoplan/core-webui": {
apiPath: (path, parameters) => { paths.push({ path, parameters }); return path; },
apiFetch: async (...args) => { calls.push(args); return response; },
} });
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
assert.deepEqual(await search(settings, { query: "permit", matchNone: true, modules: [] }), response);
assert.equal(calls.length, 0);
assert.equal(paths.length, 0);
await search(settings, { query: "permit", modules: [], resourceTypes: [] });
assert.equal(calls.length, 1);
assert.deepEqual(paths[0].parameters.module, []);
assert.deepEqual(paths[0].parameters.resource_type, []);
assert.equal("matchNone" in paths[0].parameters, false);
await search(settings, { query: "permit", modules: ["cases", "files"], resourceTypes: ["case", "file"] });
assert.deepEqual(paths[1].parameters.module, ["cases", "files"]);
assert.deepEqual(paths[1].parameters.resource_type, ["case", "file"]);
});
@@ -8,6 +8,8 @@ function assert(condition, 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");
const requestSource = readFileSync("src/components/useSearchResults.ts", "utf8");
const filterSource = readFileSync("src/components/SearchFilters.tsx", "utf8");
assert(source.includes("titlebar-icon-link titlebar-search-button"), "Search uses the shared titlebar icon-button appearance");
assert(source.includes("onClick={openOverlay}"), "clicking the titlebar Search command opens Search");
@@ -15,7 +17,8 @@ assert(!source.includes("sourceInputRef"), "the titlebar no longer reserves a pe
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 position is derived from the titlebar command");
assert(source.includes("listSearchProviders"), "the overlay loads the complete filter catalogue");
assert(source.includes("useSearchCatalogue") && requestSource.includes("listSearchProviders"), "the overlay loads the complete filter catalogue through the shared Search hook");
assert(source.includes("<SearchFilters") && filterSource.includes("MultiSelectFilter"), "the overlay uses Search-owned facets with Core-owned dropdown behavior");
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");
+3
View File
@@ -102,6 +102,8 @@ export type SearchModuleReconcile = {
export type SearchRequest = {
query: string;
/** UI-only explicit empty selection; never serialized to the Search API. */
matchNone?: boolean;
modules?: string[];
resourceTypes?: string[];
contextKind?: "global" | "module" | "resource";
@@ -117,6 +119,7 @@ export function search(
request: SearchRequest,
signal?: AbortSignal
): Promise<SearchResponse> {
if (request.matchNone) return Promise.resolve({ query: request.query, results: [], diagnostics: [], next_cursor: null, has_more: false });
return apiFetch<SearchResponse>(
settings,
apiPath("/api/v1/search", {
+24 -338
View File
@@ -1,4 +1,4 @@
import { ChevronDown, ExternalLink, Filter, Search, X } from "lucide-react";
import { ChevronDown, ExternalLink, Search, X } from "lucide-react";
import {
useCallback,
useEffect,
@@ -12,7 +12,6 @@ import {
import { useLocation } from "react-router";
import { ActionToolbar,
Button,
CountBadge,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
@@ -26,13 +25,10 @@ import { ActionToolbar,
type GlobalSearchProps,
type SearchContextsUiCapability
} from "@govoplan/core-webui";
import {
listSearchProviders,
search,
type SearchResourceType,
type SearchResponse,
type SearchResult
} from "../api/search";
import type { SearchResult } from "../api/search";
import SearchFilters from "./SearchFilters";
import { effectiveSearchFilters, humanizeIdentifier, searchAuthorityKey, searchFilterOptions, type SearchFilterSelection } from "./searchFilters";
import { useSearchCatalogue, useSearchResults } from "./useSearchResults";
import {
calculateSearchOverlayLayout,
selectSearchContext,
@@ -43,7 +39,7 @@ import {
const MIN_QUERY_LENGTH = 2;
type SearchScope = "global" | "context";
export default function GlobalSearch({ settings }: GlobalSearchProps) {
export default function GlobalSearch({ settings, auth }: GlobalSearchProps) {
const navigate = useGuardedNavigate();
const location = useLocation();
const platformModules = usePlatformModules();
@@ -62,75 +58,27 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
);
const [query, setQuery] = useState("");
const [modules, setModules] = useState<string[]>([]);
const [resourceTypes, setResourceTypes] = useState<string[]>([]);
const [modules, setModules] = useState<SearchFilterSelection>(null);
const [resourceTypes, setResourceTypes] = useState<SearchFilterSelection>(null);
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<HTMLButtonElement>(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 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 context = scope === "context" ? currentContext : null;
const authority = searchAuthorityKey(auth);
const resourceCatalogue = useSearchCatalogue(settings, open, authority);
const { response, loading, error, loadMore, requestIdentity } = useSearchResults(settings, {
query: query.trim(), ...effectiveSearchFilters(modules, resourceTypes, context),
contextKind: context ? "module" : "global", contextId: context?.id, limit: 50,
}, open && query.trim().length >= MIN_QUERY_LENGTH, 180, authority);
const options = searchFilterOptions(resourceCatalogue, response?.results ?? [], moduleLabels, modules, resourceTypes, context);
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]);
useEffect(() => { setActiveIndex(-1); }, [requestIdentity]);
const measureOverlay = useCallback(() => {
const rect = rootRef.current?.getBoundingClientRect();
@@ -150,9 +98,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
}, []);
const closeOverlay = useCallback(() => {
loadMoreControllerRef.current?.abort();
setOpen(false);
setFiltersOpen(false);
setActiveIndex(-1);
}, []);
@@ -178,23 +124,13 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
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);
if (rootRef.current) observer?.observe(rootRef.current);
window.addEventListener("resize", measureOverlay);
return () => {
observer?.disconnect();
@@ -202,86 +138,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
};
}, [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
@@ -294,25 +150,9 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
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([]);
if (!context) setModules(null);
setResourceTypes(null);
}
function handleOverlaySubmit(event: FormEvent) {
@@ -334,55 +174,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
}
}
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 (
<>
<button
@@ -455,7 +246,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
aria-label="Clear search"
onClick={() => {
setQuery("");
setResponse(null);
setActiveIndex(-1);
overlayInputRef.current?.focus();
}}>
@@ -486,83 +276,9 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
}}
/>
}
<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 &&
<CountBadge size="compact" aria-label={`${activeFilterCount} active filters`}>
{activeFilterCount}
</CountBadge>
}
</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>
<SearchFilters modules={modules} resourceTypes={resourceTypes} options={options}
hideModules={Boolean(context)} onModulesChange={setModules}
onResourceTypesChange={setResourceTypes} onClear={clearFilters} />
{loading && <LoadingIndicator size="sm" label="Searching" />}
<DocumentationHelpLink
reference={{ topicId: "search.global-and-contextual", documentationType: "user" }}
@@ -575,30 +291,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
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>
}
</ActionToolbar>
<PageScrollViewport
@@ -608,7 +300,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
role="listbox"
aria-label="Search results">
{error &&
<DismissibleAlert tone="danger" onDismiss={() => setError("")}>
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
@@ -673,9 +365,3 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
</>
);
}
function humanizeIdentifier(value: string): string {
return value
.replace(/[._:-]+/g, " ")
.replace(/\b\w/g, (character) => character.toUpperCase());
}
+22
View File
@@ -0,0 +1,22 @@
import { Button, MultiSelectFilter, ToolbarGroup } from "@govoplan/core-webui";
import { searchFilterOptions, type SearchFilterSelection } from "./searchFilters";
type Props = {
modules: SearchFilterSelection;
resourceTypes: SearchFilterSelection;
options: ReturnType<typeof searchFilterOptions>;
hideModules?: boolean;
onModulesChange: (value: SearchFilterSelection) => void;
onResourceTypesChange: (value: SearchFilterSelection) => void;
onClear: () => void;
};
/** Search owns the facets; Core owns dropdown, selection, focus, and layout. */
export default function SearchFilters({ modules, resourceTypes, options, hideModules = false, onModulesChange, onResourceTypesChange, onClear }: Props) {
const active = (!hideModules && modules !== null) || resourceTypes !== null;
return <ToolbarGroup data-help-context-id="search.filters" data-help-module-id="search">
{!hideModules && <MultiSelectFilter label="i18n:govoplan-search.filter_modules" options={options.modules} value={modules} onChange={onModulesChange} />}
<MultiSelectFilter label="i18n:govoplan-search.filter_types" options={options.resourceTypes} value={resourceTypes} onChange={onResourceTypesChange} />
{active && <Button type="button" variant="ghost" onClick={onClear}>i18n:govoplan-search.clear_filters</Button>}
</ToolbarGroup>;
}
+74
View File
@@ -0,0 +1,74 @@
import type { AuthInfo, SearchContextContribution } from "@govoplan/core-webui";
import type { SearchRequest, SearchResourceType, SearchResult } from "../api/search";
/** Same selection contract as Core's list filter: null = all, [] = none. */
export type SearchFilterSelection = string[] | null;
export type SearchFilterName = "module" | "resource_type";
/** Cookie-backed tenant switches need not change the API settings or token. */
export function searchAuthorityKey(auth: AuthInfo | null): string {
return JSON.stringify([auth?.user.id, auth?.user.account_id, auth?.principal?.membership_id,
auth?.active_tenant?.id ?? auth?.tenant.id, [...(auth?.scopes ?? [])].sort()]);
}
export function readSearchFilter(params: URLSearchParams, name: SearchFilterName): SearchFilterSelection {
if (params.get(`${name}_none`) === "1") return [];
const values = [...new Set(params.getAll(name).filter(Boolean))].sort();
return values.length ? values : null;
}
export function writeSearchFilter(params: URLSearchParams, name: SearchFilterName, value: SearchFilterSelection): URLSearchParams {
const next = new URLSearchParams(params);
next.delete(name);
next.delete(`${name}_none`);
if (value?.length === 0) next.set(`${name}_none`, "1");
else for (const item of [...new Set(value ?? [])].sort()) next.append(name, item);
return next;
}
export function effectiveSearchFilters(
modules: SearchFilterSelection,
resourceTypes: SearchFilterSelection,
context?: SearchContextContribution | null
): Pick<SearchRequest, "modules" | "resourceTypes" | "matchNone"> {
const effectiveModules = context ? [context.moduleId] : modules;
const allowedTypes = context?.resourceTypes;
const effectiveTypes = allowedTypes?.length
? resourceTypes === null ? allowedTypes : resourceTypes.filter((value) => allowedTypes.includes(value))
: resourceTypes;
return {
modules: effectiveModules ?? undefined,
resourceTypes: effectiveTypes ?? undefined,
// An empty context intersection must never turn into an unrestricted API query.
matchNone: effectiveModules?.length === 0 || effectiveTypes?.length === 0,
};
}
export function humanizeIdentifier(value: string): string {
return value.replace(/[._:-]+/g, " ").replace(/\b\w/g, (character) => character.toUpperCase());
}
export function searchFilterOptions(
catalogue: SearchResourceType[],
results: SearchResult[],
moduleLabels: ReadonlyMap<string, string>,
modules: SearchFilterSelection,
resourceTypes: SearchFilterSelection,
context?: SearchContextContribution | null
) {
const moduleIds = new Set([...catalogue.map((item) => item.module_id), ...results.map((item) => item.module_id), ...(modules ?? [])]);
const effectiveModules = context ? [context.moduleId] : modules;
const labels = new Map<string, string>();
for (const item of [...catalogue, ...results]) {
if (effectiveModules !== null && !effectiveModules.includes(item.module_id)) continue;
if (context?.resourceTypes?.length && !context.resourceTypes.includes(item.resource_type)) continue;
if (!labels.has(item.resource_type)) labels.set(item.resource_type, "label" in item ? item.label : humanizeIdentifier(item.resource_type));
}
// Keep stale or unknown selections removable; do not silently broaden the query.
for (const value of resourceTypes ?? []) if (!labels.has(value)) labels.set(value, humanizeIdentifier(value));
const byLabel = (left: { label: string }, right: { label: string }) => left.label.localeCompare(right.label);
return {
modules: [...moduleIds].map((value) => ({ value, label: moduleLabels.get(value) ?? humanizeIdentifier(value) })).sort(byLabel),
resourceTypes: [...labels].map(([value, label]) => ({ value, label })).sort(byLabel),
};
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui";
import { listSearchProviders, search, type SearchRequest, type SearchResourceType, type SearchResponse } from "../api/search";
export function useSearchCatalogue(settings: ApiSettings, enabled = true, authority = ""): SearchResourceType[] {
const identity = useMemo(() => ({}), [settings, enabled, authority]);
const [state, setState] = useState<{ identity: object; resources: SearchResourceType[] } | null>(null);
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
setState(null);
listSearchProviders(settings, controller.signal)
.then((result) => { if (!controller.signal.aborted) setState({ identity, resources: result.resources ?? [] }); })
.catch(() => { if (!controller.signal.aborted) setState(null); });
return () => controller.abort();
}, [settings, enabled, identity]);
return enabled && state?.identity === identity ? state.resources : [];
}
type ResultState = { identity: object; response: SearchResponse | null; loading: boolean; error: string };
/** One request lifecycle for route and overlay, including cursor cancellation. */
export function useSearchResults(settings: ApiSettings, request: SearchRequest, enabled = true, delay = 0, authority = "") {
const requestKey = JSON.stringify(request);
const identity = useMemo(() => ({ settings, request: JSON.parse(requestKey) as SearchRequest, enabled, delay }), [settings, requestKey, enabled, delay, authority]);
const currentIdentity = useRef(identity);
const controllerRef = useRef<AbortController | null>(null);
const [state, setState] = useState<ResultState | null>(null);
// Gate rendering as well as promise completion: stale results cannot be opened
// with Enter during the debounce interval or while an effect is being cleaned up.
currentIdentity.current = identity;
const visible = state?.identity === identity ? state : null;
useEffect(() => {
controllerRef.current?.abort();
if (!enabled) return;
const controller = new AbortController();
controllerRef.current = controller;
setState({ identity, response: null, loading: true, error: "" });
const timer = window.setTimeout(() => {
search(settings, identity.request, controller.signal)
.then((response) => {
if (!controller.signal.aborted && currentIdentity.current === identity) setState({ identity, response, loading: false, error: "" });
})
.catch((reason) => {
if (!controller.signal.aborted && currentIdentity.current === identity) setState({ identity, response: null, loading: false, error: reason instanceof Error ? reason.message : "Search failed." });
});
}, identity.request.matchNone ? 0 : delay);
return () => { window.clearTimeout(timer); controller.abort(); controllerRef.current?.abort(); };
}, [identity, settings, enabled, delay]);
async function loadMore() {
const cursor = visible?.response?.next_cursor;
if (!cursor || visible.loading || currentIdentity.current !== identity) return;
// Also guards a second click before React has rendered the loading state.
if (controllerRef.current?.signal.aborted === false && controllerRef.current !== null) controllerRef.current.abort();
const controller = new AbortController();
controllerRef.current = controller;
setState((current) => current?.identity === identity ? { ...current, loading: true, error: "" } : current);
try {
const next = await search(settings, { ...identity.request, cursor }, controller.signal);
if (controller.signal.aborted || currentIdentity.current !== identity) return;
setState((current) => current?.identity === identity && current.response ? {
identity, loading: false, error: "", response: {
...next,
results: [...current.response.results, ...next.results],
diagnostics: [...current.response.diagnostics, ...next.diagnostics.filter((item) => !current.response!.diagnostics.some((previous) => previous.provider_id === item.provider_id))],
},
} : current);
} catch (reason) {
if (!controller.signal.aborted && currentIdentity.current === identity) setState((current) => current?.identity === identity ? { ...current, loading: false, error: reason instanceof Error ? reason.message : "Search failed." } : current);
}
}
return { response: visible?.response ?? null, error: visible?.error ?? "", loading: enabled && (visible?.loading ?? true), loadMore, requestIdentity: identity };
}
+26 -294
View File
@@ -1,185 +1,32 @@
import { ChevronDown, ExternalLink, Filter, Search, X } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FormEvent
} from "react";
import { ChevronDown, ExternalLink, Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router";
import { ActionToolbar,
Button,
CountBadge,
DocumentationHelpLink,
DismissibleAlert,
IconButton,
LoadingIndicator,
PageScrollViewport,
useGuardedNavigate,
usePlatformModules,
type PlatformRouteContext
import { ActionToolbar, Button, DocumentationHelpLink, DismissibleAlert, LoadingIndicator,
PageScrollViewport, useGuardedNavigate, usePlatformModules, type PlatformRouteContext
} from "@govoplan/core-webui";
import {
listSearchProviders,
search,
type SearchResourceType,
type SearchResponse
} from "../../api/search";
import SearchFilters from "../../components/SearchFilters";
import { effectiveSearchFilters, readSearchFilter, searchAuthorityKey, searchFilterOptions, writeSearchFilter } from "../../components/searchFilters";
import { useSearchCatalogue, useSearchResults } from "../../components/useSearchResults";
export default function SearchPage({ settings }: PlatformRouteContext) {
export default function SearchPage({ settings, auth }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const platformModules = usePlatformModules();
const [params, setParams] = useSearchParams();
const query = params.get("q") ?? "";
const moduleKey = params.getAll("module").join("\u001f");
const resourceTypeKey = params.getAll("resource_type").join("\u001f");
const modules = readSearchFilter(params, "module");
const resourceTypes = readSearchFilter(params, "resource_type");
const contextId = params.get("context") ?? undefined;
const modules = useMemo(
() => moduleKey ? moduleKey.split("\u001f") : [],
[moduleKey]
);
const resourceTypes = useMemo(
() => resourceTypeKey ? resourceTypeKey.split("\u001f") : [],
[resourceTypeKey]
);
const [draft, setDraft] = useState(query);
const [response, setResponse] = useState<SearchResponse | null>(null);
const [resourceCatalogue, setResourceCatalogue] = useState<SearchResourceType[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const filtersRef = useRef<HTMLDivElement>(null);
const requestKey = useMemo(
() => JSON.stringify([query, moduleKey, resourceTypeKey, contextId]),
[contextId, moduleKey, query, resourceTypeKey]
);
const authority = searchAuthorityKey(auth);
const resourceCatalogue = useSearchCatalogue(settings, true, authority);
const { response, loading, error, loadMore } = useSearchResults(settings, {
query, ...effectiveSearchFilters(modules, resourceTypes),
contextKind: modules?.length ? "module" : "global", contextId, limit: 50,
}, Boolean(query.trim()), 0, authority);
const moduleLabels = useMemo(() => new Map(platformModules.map((module) => [module.id, module.label])), [platformModules]);
const options = searchFilterOptions(resourceCatalogue, response?.results ?? [], moduleLabels, modules, resourceTypes);
useEffect(() => {
setDraft(query);
}, [query]);
useEffect(() => {
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();
}, [settings]);
useEffect(() => {
function closeFilters(event: MouseEvent) {
if (filtersRef.current && !filtersRef.current.contains(event.target as Node)) {
setFiltersOpen(false);
}
}
function closeFiltersWithKeyboard(event: KeyboardEvent) {
if (event.key === "Escape") setFiltersOpen(false);
}
window.addEventListener("mousedown", closeFilters);
window.addEventListener("keydown", closeFiltersWithKeyboard);
return () => {
window.removeEventListener("mousedown", closeFilters);
window.removeEventListener("keydown", closeFiltersWithKeyboard);
};
}, []);
const moduleLabels = useMemo(
() => new Map(platformModules.map((module) => [module.id, module.label])),
[platformModules]
);
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 (modules.length === 0 || modules.includes(resource.module_id)) {
labels.set(resource.resource_type, resource.label);
}
}
for (const result of response?.results ?? []) {
if (modules.length === 0 || modules.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));
}, [modules, resourceCatalogue, resourceTypes, response]);
const activeFilterCount = modules.length + resourceTypes.length;
const loadResults = useCallback((cursor?: string) => {
const controller = new AbortController();
setLoading(true);
setError("");
search(
settings,
{
query,
modules,
resourceTypes,
contextKind: modules.length ? "module" : "global",
contextId,
limit: 50,
cursor
},
controller.signal
).
then((next) => {
setResponse((current) =>
cursor && current ?
{
...next,
results: [...current.results, ...next.results],
diagnostics: [
...current.diagnostics,
...next.diagnostics.filter((item) =>
!current.diagnostics.some(
(currentItem) => currentItem.provider_id === item.provider_id
)
)
]
} :
next
);
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Search failed.");
}
}).
finally(() => setLoading(false));
return controller;
}, [contextId, modules, query, resourceTypes, settings]);
useEffect(() => {
if (!query.trim()) {
setResponse(null);
return;
}
setResponse(null);
const controller = loadResults();
return () => controller.abort();
}, [loadResults, requestKey]);
useEffect(() => { setDraft(query); }, [query]);
function submit(event: FormEvent) {
event.preventDefault();
@@ -189,23 +36,8 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
setParams(next);
}
function toggleFilter(name: "module" | "resource_type", value: string) {
const selected = name === "module" ? modules : resourceTypes;
const nextValues = selected.includes(value) ?
selected.filter((item) => item !== value) :
[...selected, value];
const next = new URLSearchParams(params);
next.delete(name);
for (const item of [...nextValues].sort()) next.append(name, item);
setParams(next);
}
function clearFilters() {
const next = new URLSearchParams(params);
next.delete("module");
next.delete("resource_type");
next.delete("context");
setParams(next);
setParams(writeSearchFilter(writeSearchFilter(params, "module", null), "resource_type", null));
}
return (
@@ -222,113 +54,19 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
/>
<Button type="submit" variant="primary">Search</Button>
</form>
<div className="search-filter-menu" ref={filtersRef}>
<Button
type="button"
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 &&
<CountBadge size="compact" aria-label={`${activeFilterCount} active filters`}>
{activeFilterCount}
</CountBadge>
}
</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>
<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>
<SearchFilters modules={modules} resourceTypes={resourceTypes} options={options}
onModulesChange={(value) => setParams(writeSearchFilter(params, "module", value))}
onResourceTypesChange={(value) => setParams(writeSearchFilter(params, "resource_type", value))}
onClear={clearFilters} />
{loading && <LoadingIndicator size="sm" label="Searching" />}
<DocumentationHelpLink
reference={{ topicId: "search.global-and-contextual", documentationType: "user" }}
label="Open search documentation"
/>
{activeFilterCount > 0 &&
<div className="search-active-filters" aria-label="Active search filters">
{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>
}
</ActionToolbar>
<PageScrollViewport className="search-results-viewport">
{error &&
<DismissibleAlert tone="danger" onDismiss={() => setError("")}>
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
@@ -371,7 +109,7 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
variant="secondary"
className="search-load-more"
disabled={loading}
onClick={() => loadResults(response.next_cursor ?? undefined)}>
onClick={() => void loadMore()}>
<ChevronDown size={16} />
<span>Load more</span>
</Button>
@@ -380,9 +118,3 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
</main>
);
}
function humanizeIdentifier(value: string): string {
return value.
replace(/[._:-]+/g, " ").
replace(/\b\w/g, (character) => character.toUpperCase());
}
@@ -0,0 +1,14 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const searchFilterTranslations: PlatformTranslations = {
en: {
"i18n:govoplan-search.filter_modules": "Modules",
"i18n:govoplan-search.filter_types": "Result types",
"i18n:govoplan-search.clear_filters": "Clear filters",
},
de: {
"i18n:govoplan-search.filter_modules": "Module",
"i18n:govoplan-search.filter_types": "Ergebnistypen",
"i18n:govoplan-search.clear_filters": "Filter zurücksetzen",
},
};
+2
View File
@@ -5,6 +5,7 @@ import type {
SearchRuntimeUiCapability
} from "@govoplan/core-webui";
import GlobalSearch from "./components/GlobalSearch";
import { searchFilterTranslations } from "./i18n/searchFilterTranslations";
import "./styles/search.css";
@@ -36,6 +37,7 @@ export const searchModule: PlatformWebModule = {
id: "search",
label: "Search",
version: "0.1.14",
translations: searchFilterTranslations,
optionalDependencies: [
"access",
"views",
-140
View File
@@ -139,10 +139,6 @@
padding: 0;
}
.search-overlay-toolbar .search-active-filters {
flex-basis: 100%;
}
.search-overlay-results-viewport {
min-height: 0;
flex: 1 1 auto;
@@ -224,142 +220,6 @@
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-hover));
}
.search-filter-menu {
position: relative;
flex: 0 0 auto;
}
.search-filter-trigger {
min-height: 38px;
}
.search-filter-trigger.is-active {
border-color: var(--input-border-focus);
color: var(--text-strong);
}
.search-filter-popover {
position: absolute;
z-index: 400;
top: calc(100% + 7px);
left: 0;
display: flex;
width: min(340px, calc(100vw - 40px));
max-height: min(520px, calc(100vh - 150px));
flex-direction: column;
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-compact);
background: var(--surface);
box-shadow: var(--shadow-menu);
}
.search-filter-popover-header,
.search-filter-popover-footer {
display: flex;
align-items: center;
flex: 0 0 auto;
padding: 9px 12px;
}
.search-filter-popover-header {
justify-content: space-between;
border-bottom: var(--border-line);
}
.search-filter-popover-header .icon-button {
width: 30px;
height: 30px;
padding: 0;
}
.search-filter-popover-footer {
justify-content: flex-end;
border-top: var(--border-line);
}
.search-filter-group {
min-height: 0;
margin: 0;
border: 0;
border-bottom: var(--border-line);
padding: 11px 12px 12px;
}
.search-filter-group:last-of-type {
border-bottom: 0;
}
.search-filter-group legend {
color: var(--muted);
padding: 0;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.search-filter-options {
display: grid;
max-height: 150px;
gap: 2px;
overflow: auto;
margin-top: 7px;
}
.search-filter-options label {
display: flex;
align-items: center;
gap: 8px;
min-height: 31px;
border-radius: var(--radius-sm);
cursor: pointer;
padding: 4px 7px;
color: var(--text);
}
.search-filter-options label:hover {
background: var(--sidebar-hover-bg);
color: var(--text-strong);
}
.search-filter-options input {
margin: 0;
accent-color: var(--accent);
}
.search-filter-empty {
color: var(--muted);
padding: 5px 7px;
font-size: 12px;
}
.search-active-filters {
display: flex;
flex: 1 0 100%;
flex-wrap: wrap;
gap: 7px;
}
.search-active-filters button {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 27px;
border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--control-bg);
color: var(--text);
cursor: pointer;
padding: 3px 8px;
font: inherit;
font-size: 12px;
}
.search-active-filters button:hover {
border-color: var(--input-border-focus);
background: var(--sidebar-hover-bg);
}
.search-results-viewport {
min-height: 0;
flex: 1;