feat(search): add global titlebar search filters
This commit is contained in:
@@ -43,6 +43,26 @@ export type SearchResponse = {
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
export type SearchProvider = {
|
||||
id: string;
|
||||
module_id: string;
|
||||
resource_types: string[];
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type SearchResourceType = {
|
||||
provider_id: string;
|
||||
module_id: string;
|
||||
resource_type: string;
|
||||
label: string;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type SearchProviderListResponse = {
|
||||
providers: SearchProvider[];
|
||||
resources: SearchResourceType[];
|
||||
};
|
||||
|
||||
export type SearchRequest = {
|
||||
query: string;
|
||||
modules?: string[];
|
||||
@@ -76,3 +96,14 @@ export function search(
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
export function listSearchProviders(
|
||||
settings: ApiSettings,
|
||||
signal?: AbortSignal
|
||||
): Promise<SearchProviderListResponse> {
|
||||
return apiFetch<SearchProviderListResponse>(
|
||||
settings,
|
||||
"/api/v1/search/providers",
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,21 @@
|
||||
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
|
||||
type GlobalSearchProps
|
||||
} 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);
|
||||
@@ -58,9 +25,12 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
|
||||
useEffect(() => {
|
||||
function focusSearch(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||||
const commandSearch =
|
||||
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k";
|
||||
if (event.key === "F3" || commandSearch) {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", focusSearch);
|
||||
@@ -90,10 +60,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
settings,
|
||||
{
|
||||
query,
|
||||
modules: context ? [context.moduleId] : undefined,
|
||||
resourceTypes: context?.resourceTypes,
|
||||
contextKind: context ? "module" : "global",
|
||||
contextId: context?.id,
|
||||
contextKind: "global",
|
||||
limit: 6
|
||||
},
|
||||
controller.signal
|
||||
@@ -113,7 +80,7 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [context, settings, value]);
|
||||
}, [settings, value]);
|
||||
|
||||
function openResult(result: SearchResult) {
|
||||
setOpen(false);
|
||||
@@ -125,13 +92,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
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()}`);
|
||||
}
|
||||
@@ -172,8 +132,9 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={value}
|
||||
placeholder={context?.placeholder ?? "Search"}
|
||||
aria-label={context ? `Search ${context.label}` : "Search"}
|
||||
placeholder="Search"
|
||||
aria-label="Global search"
|
||||
aria-keyshortcuts="F3 Control+K Meta+K"
|
||||
aria-expanded={open}
|
||||
aria-controls="global-search-results"
|
||||
onFocus={() => setOpen(results.length > 0)}
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { ChevronDown, ExternalLink, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { ChevronDown, ExternalLink, Filter, Search, X } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent
|
||||
} from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
DismissibleAlert,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
useGuardedNavigate,
|
||||
usePlatformModules,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import { search, type SearchResponse } from "../../api/search";
|
||||
import {
|
||||
listSearchProviders,
|
||||
search,
|
||||
type SearchResourceType,
|
||||
type SearchResponse
|
||||
} from "../../api/search";
|
||||
|
||||
|
||||
export default function SearchPage({ settings }: PlatformRouteContext) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const platformModules = usePlatformModules();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const query = params.get("q") ?? "";
|
||||
const moduleKey = params.getAll("module").join("\u001f");
|
||||
@@ -28,8 +42,11 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
||||
);
|
||||
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]
|
||||
@@ -39,6 +56,73 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
||||
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);
|
||||
@@ -101,6 +185,25 @@ 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);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="search-page">
|
||||
<div className="search-page-toolbar">
|
||||
@@ -115,7 +218,106 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
||||
/>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<div className="search-filter-menu" ref={filtersRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn 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>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost icon-button"
|
||||
aria-label="Close filters"
|
||||
onClick={() => setFiltersOpen(false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</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"
|
||||
className="btn btn-ghost"
|
||||
disabled={activeFilterCount === 0}
|
||||
onClick={clearFilters}>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{loading && <LoadingIndicator size="sm" label="Searching" />}
|
||||
{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>
|
||||
}
|
||||
</div>
|
||||
<PageScrollViewport className="search-results-viewport">
|
||||
{error &&
|
||||
@@ -170,3 +372,9 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function humanizeIdentifier(value: string): string {
|
||||
return value.
|
||||
replace(/[._:-]+/g, " ").
|
||||
replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@ import "./styles/search.css";
|
||||
const SearchPage = lazy(() => import("./features/search/SearchPage"));
|
||||
|
||||
const searchRuntime: SearchRuntimeUiCapability = {
|
||||
GlobalSearch
|
||||
GlobalSearch,
|
||||
anyOf: ["search:result:read"]
|
||||
};
|
||||
|
||||
export const searchModule: PlatformWebModule = {
|
||||
|
||||
+163
-5
@@ -2,8 +2,8 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: min(360px, 28vw);
|
||||
min-width: 190px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--control-border);
|
||||
@@ -57,8 +57,9 @@
|
||||
position: absolute;
|
||||
z-index: 300;
|
||||
top: calc(100% + 7px);
|
||||
right: 0;
|
||||
left: 50%;
|
||||
width: min(460px, 80vw);
|
||||
transform: translateX(-50%);
|
||||
overflow: hidden;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
@@ -121,6 +122,7 @@
|
||||
.search-page-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: var(--border-line);
|
||||
@@ -131,8 +133,10 @@
|
||||
.search-page-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: min(760px, 100%);
|
||||
min-width: min(280px, 100%);
|
||||
max-width: 760px;
|
||||
height: 38px;
|
||||
flex: 1 1 520px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 4px;
|
||||
background: var(--control-bg);
|
||||
@@ -173,6 +177,157 @@
|
||||
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-count {
|
||||
min-width: 19px;
|
||||
height: 19px;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.search-filter-popover {
|
||||
position: absolute;
|
||||
z-index: 400;
|
||||
top: calc(100% + 7px);
|
||||
right: 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: 6px;
|
||||
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: 4px;
|
||||
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: 4px;
|
||||
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;
|
||||
@@ -261,8 +416,11 @@
|
||||
|
||||
.global-search:focus-within {
|
||||
position: absolute;
|
||||
right: 170px;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: min(360px, calc(100vw - 190px));
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.global-search:not(:focus-within) input,
|
||||
|
||||
Reference in New Issue
Block a user