3 Commits
Author SHA1 Message Date
zemion 016869aba0 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:13 +02:00
zemion 101dc3814c Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:32 +02:00
zemion 3442b5fa4e refactor: open search from titlebar command 2026-08-05 00:03:33 +02:00
11 changed files with 55 additions and 90 deletions
+8 -1
View File
@@ -4,7 +4,14 @@
**Repository type:** module (platform). **Repository type:** module (platform).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
Permission-aware global and contextual search for GovOPlaN. Permission-aware global and contextual search for GovOPlaN. Search is the first
command in the titlebar action group. Clicking its icon, pressing `F3`, or
pressing `Ctrl`/`Cmd`+`K` opens the same full query field and result overlay.
The titlebar command, result overlay, filters, and Search administration route
announce stable help contexts. Pressing `F1` while one of those controls is
focused opens its Search documentation, with the current page retained as a
fallback.
The route, overlay, state, accessibility, and consequence mapping is recorded in The route, overlay, state, accessibility, and consequence mapping is recorded in
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md). [`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
+1 -1
View File
@@ -6,7 +6,7 @@ authorization, and optional external engines remain provider capabilities.
| Surface | Task and archetype | Consequence and state contract | | Surface | Task and archetype | Consequence and state contract |
| --- | --- | --- | | --- | --- | --- |
| Title-bar search and anchored overlay | Global or context-sensitive focused lookup | F3 and Ctrl/Cmd+K open the same focus-contained Core dialog. Arrow keys move through the listbox, Enter opens the selected result, Escape closes it and restores focus. | | Title-bar Search command and overlay | Global or context-sensitive focused lookup | The left-most titlebar command, F3, and Ctrl/Cmd+K open the same focus-contained Core dialog with a full-width query field. Arrow keys move through the listbox, Enter opens the selected result, Escape closes it and restores focus. |
| Overlay filters | Progressive-disclosure filter popover | Module and resource filters only narrow authorized results. Active filters stay visible and removable by keyboard. | | Overlay filters | Progressive-disclosure filter popover | Module and resource filters only narrow authorized results. Active filters stay visible and removable by keyboard. |
| `/search` | Full-page search/results fallback | Query and filters are URL-stable. Loading, empty, provider-partial, failed, and paged states remain inside the result region. | | `/search` | Full-page search/results fallback | Query and filters are URL-stable. Loading, empty, provider-partial, failed, and paged states remain inside the result region. |
| Result entries | Permission-filtered list-detail destinations | A source module supplies the title, safe summary, breadcrumbs, and destination. Search does not infer or bypass source authorization. | | Result entries | Permission-filtered list-detail destinations | A source module supplies the title, safe summary, breadcrumbs, and destination. Search does not infer or bypass source authorization. |
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/search-webui", "name": "@govoplan/search-webui",
"version": "0.1.15", "version": "0.1.17",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.15", "@govoplan/core-webui": "^0.1.17",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-search" name = "govoplan-search"
version = "0.1.15" version = "0.1.17"
description = "Permission-aware global and contextual search for GovOPlaN." description = "Permission-aware global and contextual search for GovOPlaN."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.15"] dependencies = ["govoplan-core>=0.1.17"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN search module.""" """GovOPlaN search module."""
__version__ = "0.1.15" __version__ = "0.1.17"
+12 -2
View File
@@ -34,7 +34,7 @@ from govoplan_search.backend.db import models as search_models
MODULE_ID = "search" MODULE_ID = "search"
MODULE_NAME = "Search" MODULE_NAME = "Search"
MODULE_VERSION = "0.1.15" MODULE_VERSION = "0.1.17"
READ_SCOPE = "search:result:read" READ_SCOPE = "search:result:read"
INDEX_SCOPE = "search:index:write" INDEX_SCOPE = "search:index:write"
ADMIN_SCOPE = "search:index:admin" ADMIN_SCOPE = "search:index:admin"
@@ -209,7 +209,8 @@ manifest = ModuleManifest(
"Search works with the built-in database index and can aggregate " "Search works with the built-in database index and can aggregate "
"optional providers. Source modules announce searchable types, " "optional providers. Source modules announce searchable types, "
"context scopes, and ACL-aware index entries. External engines " "context scopes, and ACL-aware index entries. External engines "
"remain optional adapters. F3 or the title-bar field opens the " "remain optional adapters. The title-bar Search command, F3, "
"or Ctrl/Cmd+K opens the "
"keyboard-navigable search overlay; filters never broaden the " "keyboard-navigable search overlay; filters never broaden the "
"current principal's source permissions. Provider failures are " "current principal's source permissions. Provider failures are "
"shown as partial diagnostics without discarding safe results." "shown as partial diagnostics without discarding safe results."
@@ -222,6 +223,15 @@ manifest = ModuleManifest(
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("administrator", "user"), audience=("administrator", "user"),
related_modules=("connectors", "views"), related_modules=("connectors", "views"),
metadata={
"kind": "reference",
"help_contexts": [
"search.global",
"search.results",
"search.filters",
"search.admin.index",
],
},
links=( links=(
DocumentationLink( DocumentationLink(
label="Search interface pattern audit", label="Search interface pattern audit",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/search-webui", "name": "@govoplan/search-webui",
"version": "0.1.15", "version": "0.1.17",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -18,7 +18,7 @@
"test:interface-pattern": "node scripts/test-interface-pattern.mjs" "test:interface-pattern": "node scripts/test-interface-pattern.mjs"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.15", "@govoplan/core-webui": "^0.1.17",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
@@ -9,18 +9,20 @@ const source = readFileSync("src/components/GlobalSearch.tsx", "utf8");
const layoutSource = readFileSync("src/components/searchOverlayLayout.ts", "utf8"); const layoutSource = readFileSync("src/components/searchOverlayLayout.ts", "utf8");
const styles = readFileSync("src/styles/search.css", "utf8"); const styles = readFileSync("src/styles/search.css", "utf8");
assert(source.includes("onFocus={handleSourceFocus}"), "focusing the titlebar field opens Search"); assert(source.includes("titlebar-icon-link titlebar-search-button"), "Search uses the shared titlebar icon-button appearance");
assert(source.includes("suppressRestoredFocusRef.current"), "restored dialog focus does not immediately reopen Search"); assert(source.includes("onClick={openOverlay}"), "clicking the titlebar Search command opens Search");
assert(!source.includes("sourceInputRef"), "the titlebar no longer reserves a persistent Search field");
assert(source.includes("<Dialog"), "Search uses the shared Dialog component"); 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("portal"), "the Search dialog portals above the complete shell");
assert(source.includes("calculateSearchOverlayLayout"), "the overlay is anchored to the titlebar field"); 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("listSearchProviders"), "the overlay loads the complete filter catalogue");
assert(source.includes("limit: 50"), "the overlay requests full result windows rather than titlebar suggestions"); 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("response?.next_cursor"), "the overlay retains cursor pagination");
assert(source.includes('usePlatformUiCapabilities<SearchContextsUiCapability>("search.contexts")'), "contextual Search contributions are consumed"); assert(source.includes('usePlatformUiCapabilities<SearchContextsUiCapability>("search.contexts")'), "contextual Search contributions are consumed");
assert(!source.includes("navigate(`/search"), "normal Search interaction no longer opens a page route"); assert(!source.includes("navigate(`/search"), "normal Search interaction no longer opens a page route");
assert(layoutSource.includes("anchor.left - left"), "desktop input placement is derived from the original field"); assert(layoutSource.includes("const inputWidth = width"), "the opened query field spans the Search overlay");
assert(styles.includes(".global-search-source.is-overlay-open"), "the original field is hidden while its overlay counterpart is active"); assert(layoutSource.includes("(viewportWidth - width) / 2"), "the Search overlay is centered in the viewport");
assert(styles.includes("margin-top: 8px"), "results follow the opened query field without overlap");
assert(styles.includes(".search-overlay-results-panel"), "full Search results have a bounded overlay panel"); assert(styles.includes(".search-overlay-results-panel"), "full Search results have a bounded overlay panel");
console.log("Search overlay structure checks passed."); console.log("Search overlay structure checks passed.");
+16 -42
View File
@@ -73,14 +73,12 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
const [filtersOpen, setFiltersOpen] = useState(false); const [filtersOpen, setFiltersOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1); const [activeIndex, setActiveIndex] = useState(-1);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLButtonElement>(null);
const sourceInputRef = useRef<HTMLInputElement>(null);
const overlayInputRef = useRef<HTMLInputElement>(null); const overlayInputRef = useRef<HTMLInputElement>(null);
const filtersRef = useRef<HTMLDivElement>(null); const filtersRef = useRef<HTMLDivElement>(null);
const resultsRef = useRef<HTMLDivElement>(null); const resultsRef = useRef<HTMLDivElement>(null);
const requestSequenceRef = useRef(0); const requestSequenceRef = useRef(0);
const loadMoreControllerRef = useRef<AbortController | null>(null); const loadMoreControllerRef = useRef<AbortController | null>(null);
const suppressRestoredFocusRef = useRef(false);
const effectiveModules = useMemo( const effectiveModules = useMemo(
() => scope === "context" && currentContext () => scope === "context" && currentContext
@@ -152,7 +150,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
const closeOverlay = useCallback(() => { const closeOverlay = useCallback(() => {
loadMoreControllerRef.current?.abort(); loadMoreControllerRef.current?.abort();
suppressRestoredFocusRef.current = true;
setOpen(false); setOpen(false);
setFiltersOpen(false); setFiltersOpen(false);
setActiveIndex(-1); setActiveIndex(-1);
@@ -163,14 +160,6 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
setOpen(true); setOpen(true);
}, [measureOverlay]); }, [measureOverlay]);
const handleSourceFocus = useCallback(() => {
if (suppressRestoredFocusRef.current) {
suppressRestoredFocusRef.current = false;
return;
}
openOverlay();
}, [openOverlay]);
useEffect(() => { useEffect(() => {
function focusSearch(event: KeyboardEvent) { function focusSearch(event: KeyboardEvent) {
const commandSearch = const commandSearch =
@@ -395,41 +384,26 @@ export default function GlobalSearch({ settings }: GlobalSearchProps) {
return ( return (
<> <>
<div <button
ref={rootRef} ref={rootRef}
className={`global-search global-search-source${open ? " is-overlay-open" : ""}`}> type="button"
<Search size={16} aria-hidden="true" /> data-help-context-id="search.global"
<input data-help-module-id="search"
ref={sourceInputRef} data-help-scope="action"
type="search" className={`titlebar-icon-link titlebar-search-button${open ? " is-context-active" : ""}`}
value={query} title="Search (F3 / Ctrl+K)"
readOnly aria-label="Global search"
tabIndex={open ? -1 : 0} aria-keyshortcuts="F3 Control+K Meta+K"
placeholder={currentContext?.placeholder ?? "Search"} aria-haspopup="dialog"
aria-label="Global search" aria-expanded={open}
aria-keyshortcuts="F3 Control+K Meta+K" onClick={openOverlay}>
aria-expanded={open} <Search size={18} aria-hidden="true" />
onFocus={handleSourceFocus} </button>
onClick={openOverlay}
/>
{query &&
<button
type="button"
className="global-search-clear"
tabIndex={open ? -1 : 0}
aria-label="Clear search"
onClick={() => {
setQuery("");
setResponse(null);
}}>
<X size={14} />
</button>
}
</div>
<Dialog <Dialog
open={open && Boolean(layout)} open={open && Boolean(layout)}
title="Search" title="Search"
helpContextId="search.results"
onClose={closeOverlay} onClose={closeOverlay}
showCloseButton={false} showCloseButton={false}
portal portal
+3 -14
View File
@@ -33,16 +33,9 @@ export function calculateSearchOverlayLayout(
const mobile = viewportWidth < MOBILE_BREAKPOINT; const mobile = viewportWidth < MOBILE_BREAKPOINT;
const margin = mobile ? MOBILE_MARGIN : DESKTOP_MARGIN; const margin = mobile ? MOBILE_MARGIN : DESKTOP_MARGIN;
const width = Math.max(1, Math.min(DESKTOP_PANEL_WIDTH, viewportWidth - margin * 2)); const width = Math.max(1, Math.min(DESKTOP_PANEL_WIDTH, viewportWidth - margin * 2));
const centeredLeft = anchor.left + anchor.width / 2 - width / 2; const left = Math.max(margin, (viewportWidth - width) / 2);
const left = mobile const inputWidth = width;
? margin const inputOffset = 0;
: 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 top = Math.max(0, anchor.top);
const inputHeight = Math.max(1, anchor.height); const inputHeight = Math.max(1, anchor.height);
const availableResultsHeight = viewportHeight - top - inputHeight - RESULTS_GAP - margin; const availableResultsHeight = viewportHeight - top - inputHeight - RESULTS_GAP - margin;
@@ -89,7 +82,3 @@ function normalizePath(value: string): string {
const normalized = `/${String(value || "").trim().replace(/^\/+|\/+$/g, "")}`; const normalized = `/${String(value || "").trim().replace(/^\/+|\/+$/g, "")}`;
return normalized === "/" ? normalized : normalized.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);
}
+1 -18
View File
@@ -53,10 +53,6 @@
color: var(--text-strong); color: var(--text-strong);
} }
.global-search-source.is-overlay-open {
visibility: hidden;
}
.search-overlay-backdrop { .search-overlay-backdrop {
display: block; display: block;
padding: 0; padding: 0;
@@ -110,7 +106,7 @@
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
flex-direction: column; flex-direction: column;
margin-top: -44px; margin-top: 8px;
overflow: hidden; overflow: hidden;
border: var(--border-line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
@@ -464,19 +460,6 @@
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.global-search-source {
width: 34px;
min-width: 34px;
padding: 0 8px;
}
.global-search-source input,
.global-search-source .global-search-clear {
width: 0;
padding: 0;
opacity: 0;
}
.search-overlay-results-panel { .search-overlay-results-panel {
border-radius: 4px; border-radius: 4px;
} }