feat: present global search in an anchored overlay
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user