Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f9d760bb0 | ||
|
|
016869aba0 | ||
|
|
101dc3814c | ||
|
|
3442b5fa4e |
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/search-webui",
|
"name": "@govoplan/search-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.18",
|
||||||
"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.18",
|
||||||
"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
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-search"
|
name = "govoplan-search"
|
||||||
version = "0.1.15"
|
version = "0.1.18"
|
||||||
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.18"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN search module."""
|
"""GovOPlaN search module."""
|
||||||
|
|
||||||
__version__ = "0.1.15"
|
__version__ = "0.1.18"
|
||||||
|
|||||||
@@ -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.18"
|
||||||
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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/search-webui",
|
"name": "@govoplan/search-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.18",
|
||||||
"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.18",
|
||||||
"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.");
|
||||||
|
|||||||
@@ -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
|
|
||||||
tabIndex={open ? -1 : 0}
|
|
||||||
placeholder={currentContext?.placeholder ?? "Search"}
|
|
||||||
aria-label="Global search"
|
aria-label="Global search"
|
||||||
aria-keyshortcuts="F3 Control+K Meta+K"
|
aria-keyshortcuts="F3 Control+K Meta+K"
|
||||||
|
aria-haspopup="dialog"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onFocus={handleSourceFocus}
|
onClick={openOverlay}>
|
||||||
onClick={openOverlay}
|
<Search size={18} aria-hidden="true" />
|
||||||
/>
|
|
||||||
{query &&
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="global-search-clear"
|
|
||||||
tabIndex={open ? -1 : 0}
|
|
||||||
aria-label="Clear search"
|
|
||||||
onClick={() => {
|
|
||||||
setQuery("");
|
|
||||||
setResponse(null);
|
|
||||||
}}>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
</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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user