feat: implement permission-aware search baseline
This commit is contained in:
28
webui/package.json
Normal file
28
webui/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@govoplan/search-webui",
|
||||
"version": "0.1.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/search.css": "./src/styles/search.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": ">=7.18.2 <8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
70
webui/src/api/search.ts
Normal file
70
webui/src/api/search.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
apiFetch,
|
||||
apiPath,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
|
||||
export type SearchExternalReference = {
|
||||
system: string;
|
||||
object_type: string;
|
||||
object_id: string;
|
||||
maturity: string;
|
||||
connector_id?: string | null;
|
||||
canonical_url?: string | null;
|
||||
};
|
||||
|
||||
export type SearchResult = {
|
||||
provider_id: string;
|
||||
module_id: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
url: string;
|
||||
score: number;
|
||||
highlights: string[];
|
||||
breadcrumbs: string[];
|
||||
external_reference?: SearchExternalReference | null;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SearchResponse = {
|
||||
query: string;
|
||||
results: SearchResult[];
|
||||
diagnostics: Array<{
|
||||
provider_id: string;
|
||||
status: "unavailable";
|
||||
message: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SearchRequest = {
|
||||
query: string;
|
||||
modules?: string[];
|
||||
resourceTypes?: string[];
|
||||
contextKind?: "global" | "module" | "resource";
|
||||
contextId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export function search(
|
||||
settings: ApiSettings,
|
||||
request: SearchRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<SearchResponse> {
|
||||
return apiFetch<SearchResponse>(
|
||||
settings,
|
||||
apiPath("/api/v1/search", {
|
||||
q: request.query,
|
||||
module: request.modules,
|
||||
resource_type: request.resourceTypes,
|
||||
context_kind: request.contextKind,
|
||||
context_id: request.contextId,
|
||||
limit: request.limit,
|
||||
offset: request.offset
|
||||
}),
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
223
webui/src/components/GlobalSearch.tsx
Normal file
223
webui/src/components/GlobalSearch.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { Search, X } from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent
|
||||
} from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import {
|
||||
useGuardedNavigate,
|
||||
usePlatformUiCapabilities,
|
||||
type GlobalSearchProps,
|
||||
type SearchContextContribution,
|
||||
type SearchContextsUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import { search, type SearchResult } from "../api/search";
|
||||
|
||||
|
||||
const MIN_SUGGESTION_LENGTH = 2;
|
||||
|
||||
function currentContext(
|
||||
pathname: string,
|
||||
contexts: SearchContextContribution[]
|
||||
): SearchContextContribution | null {
|
||||
return [...contexts].
|
||||
filter((context) =>
|
||||
context.pathPrefixes.some((prefix) =>
|
||||
pathname === prefix || pathname.startsWith(`${prefix}/`)
|
||||
)
|
||||
).
|
||||
sort((left, right) => {
|
||||
const leftLength = Math.max(...left.pathPrefixes.map((item) => item.length));
|
||||
const rightLength = Math.max(...right.pathPrefixes.map((item) => item.length));
|
||||
return rightLength - leftLength || (left.order ?? 100) - (right.order ?? 100);
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const location = useLocation();
|
||||
const contextCapabilities =
|
||||
usePlatformUiCapabilities<SearchContextsUiCapability>("search.contexts");
|
||||
const contexts = useMemo(
|
||||
() => contextCapabilities.flatMap((capability) => capability.contexts),
|
||||
[contextCapabilities]
|
||||
);
|
||||
const context = useMemo(
|
||||
() => currentContext(location.pathname, contexts),
|
||||
[contexts, location.pathname]
|
||||
);
|
||||
const [value, setValue] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function focusSearch(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", focusSearch);
|
||||
return () => window.removeEventListener("keydown", focusSearch);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function closeOutside(event: MouseEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
window.addEventListener("mousedown", closeOutside);
|
||||
return () => window.removeEventListener("mousedown", closeOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const query = value.trim();
|
||||
if (query.length < MIN_SUGGESTION_LENGTH) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
search(
|
||||
settings,
|
||||
{
|
||||
query,
|
||||
modules: context ? [context.moduleId] : undefined,
|
||||
resourceTypes: context?.resourceTypes,
|
||||
contextKind: context ? "module" : "global",
|
||||
contextId: context?.id,
|
||||
limit: 6
|
||||
},
|
||||
controller.signal
|
||||
).
|
||||
then((response) => {
|
||||
setResults(response.results);
|
||||
setOpen(true);
|
||||
setActiveIndex(-1);
|
||||
}).
|
||||
catch((error) => {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
setResults([]);
|
||||
}
|
||||
});
|
||||
}, 220);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [context, settings, value]);
|
||||
|
||||
function openResult(result: SearchResult) {
|
||||
setOpen(false);
|
||||
setValue("");
|
||||
navigate(result.url);
|
||||
}
|
||||
|
||||
function openResultsPage() {
|
||||
const query = value.trim();
|
||||
if (!query) return;
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (context) {
|
||||
params.set("module", context.moduleId);
|
||||
params.set("context", context.id);
|
||||
for (const resourceType of context.resourceTypes ?? []) {
|
||||
params.append("resource_type", resourceType);
|
||||
}
|
||||
}
|
||||
setOpen(false);
|
||||
navigate(`/search?${params.toString()}`);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown" && results.length) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
setActiveIndex((current) => (current + 1) % results.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp" && results.length) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
setActiveIndex((current) =>
|
||||
current <= 0 ? results.length - 1 : current - 1
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (activeIndex >= 0 && results[activeIndex]) {
|
||||
openResult(results[activeIndex]);
|
||||
} else {
|
||||
openResultsPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="global-search" ref={rootRef}>
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={value}
|
||||
placeholder={context?.placeholder ?? "Search"}
|
||||
aria-label={context ? `Search ${context.label}` : "Search"}
|
||||
aria-expanded={open}
|
||||
aria-controls="global-search-results"
|
||||
onFocus={() => setOpen(results.length > 0)}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{value &&
|
||||
<button
|
||||
type="button"
|
||||
className="global-search-clear"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setValue("");
|
||||
setResults([]);
|
||||
inputRef.current?.focus();
|
||||
}}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
}
|
||||
{open && results.length > 0 &&
|
||||
<div id="global-search-results" className="global-search-results" role="listbox">
|
||||
{results.map((result, index) =>
|
||||
<button
|
||||
key={`${result.module_id}:${result.resource_type}:${result.resource_id}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
className={index === activeIndex ? "active" : ""}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => openResult(result)}>
|
||||
<span className="global-search-result-title">{result.title}</span>
|
||||
<span>{result.module_id} · {result.resource_type}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="global-search-all"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={openResultsPage}>
|
||||
<Search size={14} />
|
||||
<span>All results</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
webui/src/features/search/SearchPage.tsx
Normal file
129
webui/src/features/search/SearchPage.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ExternalLink, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
DismissibleAlert,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
useGuardedNavigate,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import { search, type SearchResponse } from "../../api/search";
|
||||
|
||||
|
||||
export default function SearchPage({ settings }: PlatformRouteContext) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const query = params.get("q") ?? "";
|
||||
const modules = params.getAll("module");
|
||||
const resourceTypes = params.getAll("resource_type");
|
||||
const [draft, setDraft] = useState(query);
|
||||
const [response, setResponse] = useState<SearchResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const requestKey = useMemo(
|
||||
() => JSON.stringify([query, modules, resourceTypes]),
|
||||
[query, modules, resourceTypes]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setResponse(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
search(
|
||||
settings,
|
||||
{
|
||||
query,
|
||||
modules,
|
||||
resourceTypes,
|
||||
contextKind: modules.length ? "module" : "global",
|
||||
contextId: params.get("context") ?? undefined,
|
||||
limit: 100
|
||||
},
|
||||
controller.signal
|
||||
).
|
||||
then(setResponse).
|
||||
catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") {
|
||||
setError(reason instanceof Error ? reason.message : "Search failed.");
|
||||
}
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [requestKey, settings]);
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const next = new URLSearchParams(params);
|
||||
if (draft.trim()) next.set("q", draft.trim());
|
||||
else next.delete("q");
|
||||
setParams(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="search-page">
|
||||
<div className="search-page-toolbar">
|
||||
<form className="search-page-form" onSubmit={submit}>
|
||||
<Search size={18} aria-hidden="true" />
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
aria-label="Search query"
|
||||
placeholder="Search"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
{loading && <LoadingIndicator size="sm" label="Searching" />}
|
||||
</div>
|
||||
<PageScrollViewport className="search-results-viewport">
|
||||
{error &&
|
||||
<DismissibleAlert tone="danger" onDismiss={() => setError("")}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{response?.diagnostics.map((diagnostic) =>
|
||||
<DismissibleAlert
|
||||
key={diagnostic.provider_id}
|
||||
tone="warning"
|
||||
compact>
|
||||
{diagnostic.message}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{query && response && response.results.length === 0 &&
|
||||
<div className="search-empty">No results for “{query}”.</div>
|
||||
}
|
||||
<div className="search-result-list">
|
||||
{response?.results.map((result) =>
|
||||
<button
|
||||
type="button"
|
||||
className="search-result"
|
||||
key={`${result.module_id}:${result.resource_type}:${result.resource_id}`}
|
||||
onClick={() => navigate(result.url)}>
|
||||
<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(" · ") :
|
||||
`${result.module_id} · ${result.resource_type}`}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PageScrollViewport>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
2
webui/src/index.ts
Normal file
2
webui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default, searchModule } from "./module";
|
||||
export * from "./api/search";
|
||||
59
webui/src/module.ts
Normal file
59
webui/src/module.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type {
|
||||
PlatformWebModule,
|
||||
SearchRuntimeUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import GlobalSearch from "./components/GlobalSearch";
|
||||
import "./styles/search.css";
|
||||
|
||||
|
||||
const SearchPage = lazy(() => import("./features/search/SearchPage"));
|
||||
|
||||
const searchRuntime: SearchRuntimeUiCapability = {
|
||||
GlobalSearch
|
||||
};
|
||||
|
||||
export const searchModule: PlatformWebModule = {
|
||||
id: "search",
|
||||
label: "Search",
|
||||
version: "0.1.14",
|
||||
optionalDependencies: [
|
||||
"access",
|
||||
"views",
|
||||
"connectors",
|
||||
"wiki",
|
||||
"projects",
|
||||
"tickets",
|
||||
"cases"
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
path: "/search",
|
||||
anyOf: ["search:result:read"],
|
||||
order: 12,
|
||||
surfaceId: "search.results",
|
||||
render: (context) => createElement(SearchPage, context)
|
||||
}
|
||||
],
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "search.global",
|
||||
moduleId: "search",
|
||||
kind: "selector",
|
||||
label: "Global search",
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: "search.results",
|
||||
moduleId: "search",
|
||||
kind: "route",
|
||||
label: "Search results",
|
||||
order: 20
|
||||
}
|
||||
],
|
||||
uiCapabilities: {
|
||||
"search.runtime": searchRuntime
|
||||
}
|
||||
};
|
||||
|
||||
export default searchModule;
|
||||
244
webui/src/styles/search.css
Normal file
244
webui/src/styles/search.css
Normal file
@@ -0,0 +1,244 @@
|
||||
.global-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: min(360px, 28vw);
|
||||
min-width: 190px;
|
||||
height: 34px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 4px;
|
||||
background: var(--control-bg);
|
||||
color: var(--muted);
|
||||
padding: 0 8px;
|
||||
box-shadow: var(--shadow-control-inset);
|
||||
}
|
||||
|
||||
.global-search:focus-within {
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.global-search input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
padding: 0 7px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.global-search input::-webkit-search-cancel-button { display: none; }
|
||||
|
||||
.global-search-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.global-search-clear:hover {
|
||||
background: var(--titlebar-hover-bg);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.global-search-results {
|
||||
position: absolute;
|
||||
z-index: 300;
|
||||
top: calc(100% + 7px);
|
||||
right: 0;
|
||||
width: min(460px, 80vw);
|
||||
overflow: hidden;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-menu);
|
||||
}
|
||||
|
||||
.global-search-results button {
|
||||
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;
|
||||
gap: 7px;
|
||||
border-bottom: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-page {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.search-page-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.search-page-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: min(760px, 100%);
|
||||
height: 38px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 4px;
|
||||
background: var(--control-bg);
|
||||
color: var(--muted);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.search-page-form:focus-within {
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.search-page-form input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
padding: 0 10px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.search-page-form button {
|
||||
align-self: stretch;
|
||||
border: 0;
|
||||
border-left: var(--border-line);
|
||||
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end));
|
||||
color: var(--control-text);
|
||||
cursor: pointer;
|
||||
padding: 0 16px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-page-form button:hover {
|
||||
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-hover));
|
||||
}
|
||||
|
||||
.search-results-viewport {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-result-list {
|
||||
display: grid;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: var(--border-line);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
padding: 14px 12px;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.search-result:hover {
|
||||
background: var(--sidebar-hover-bg);
|
||||
}
|
||||
|
||||
.search-result-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--text-strong);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.search-result-summary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-result-meta,
|
||||
.search-empty {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.global-search {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.global-search:focus-within {
|
||||
position: absolute;
|
||||
right: 170px;
|
||||
width: min(360px, calc(100vw - 190px));
|
||||
}
|
||||
|
||||
.global-search:not(:focus-within) input,
|
||||
.global-search:not(:focus-within) .global-search-clear {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user