feat: present global search in an anchored overlay

This commit is contained in:
2026-07-30 14:27:10 +02:00
parent bed704eb7a
commit e6bb9d359f
5 changed files with 878 additions and 179 deletions
@@ -0,0 +1,95 @@
import type { SearchContextContribution } from "@govoplan/core-webui";
export type SearchOverlayAnchor = {
top: number;
left: number;
width: number;
height: number;
};
export type SearchOverlayLayout = {
top: number;
left: number;
width: number;
maxHeight: number;
inputOffset: number;
inputWidth: number;
inputHeight: number;
resultsHeight: number;
};
const DESKTOP_PANEL_WIDTH = 820;
const DESKTOP_MARGIN = 16;
const MOBILE_MARGIN = 12;
const MOBILE_BREAKPOINT = 900;
const RESULTS_GAP = 8;
export function calculateSearchOverlayLayout(
anchor: SearchOverlayAnchor,
viewportWidth: number,
viewportHeight: number
): SearchOverlayLayout {
const mobile = viewportWidth < MOBILE_BREAKPOINT;
const margin = mobile ? MOBILE_MARGIN : DESKTOP_MARGIN;
const width = Math.max(1, Math.min(DESKTOP_PANEL_WIDTH, viewportWidth - margin * 2));
const centeredLeft = anchor.left + anchor.width / 2 - width / 2;
const left = mobile
? margin
: clamp(centeredLeft, margin, Math.max(margin, viewportWidth - width - margin));
const inputWidth = mobile
? width
: Math.min(Math.max(1, anchor.width), width);
const inputOffset = mobile
? 0
: clamp(anchor.left - left, 0, Math.max(0, width - inputWidth));
const top = Math.max(0, anchor.top);
const inputHeight = Math.max(1, anchor.height);
const availableResultsHeight = viewportHeight - top - inputHeight - RESULTS_GAP - margin;
const resultsHeight = Math.max(96, Math.min(620, availableResultsHeight));
return {
top,
left,
width,
maxHeight: inputHeight + RESULTS_GAP + resultsHeight,
inputOffset,
inputWidth,
inputHeight,
resultsHeight
};
}
export function selectSearchContext(
contexts: readonly SearchContextContribution[],
pathname: string
): SearchContextContribution | null {
const matches = contexts.flatMap((context) =>
context.pathPrefixes
.filter((prefix) => pathMatchesPrefix(pathname, prefix))
.map((prefix) => ({ context, prefixLength: normalizePath(prefix).length }))
);
matches.sort((left, right) =>
right.prefixLength - left.prefixLength
|| (left.context.order ?? 100) - (right.context.order ?? 100)
|| left.context.id.localeCompare(right.context.id)
);
return matches[0]?.context ?? null;
}
function pathMatchesPrefix(pathname: string, prefix: string): boolean {
const normalizedPath = normalizePath(pathname);
const normalizedPrefix = normalizePath(prefix);
return normalizedPrefix === "/"
|| normalizedPath === normalizedPrefix
|| normalizedPath.startsWith(`${normalizedPrefix}/`);
}
function normalizePath(value: string): string {
const normalized = `/${String(value || "").trim().replace(/^\/+|\/+$/g, "")}`;
return normalized === "/" ? normalized : normalized.replace(/\/+$/g, "");
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum);
}