@open-mercato/search 0.6.6-develop.6397.1.c078d218fa → 0.6.6-develop.6398.1.013efe913e
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/search/__integration__/TC-SEARCH-012.spec.js +51 -0
- package/dist/modules/search/__integration__/TC-SEARCH-012.spec.js.map +7 -0
- package/dist/modules/search/frontend/components/TopbarSearchInline.js +94 -70
- package/dist/modules/search/frontend/components/TopbarSearchInline.js.map +2 -2
- package/package.json +4 -4
- package/src/modules/search/__integration__/TC-SEARCH-012.spec.ts +82 -0
- package/src/modules/search/frontend/components/TopbarSearchInline.tsx +30 -3
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:search] found
|
|
1
|
+
[build:search] found 94 entry points
|
|
2
2
|
[build:search] built successfully
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { login } from "@open-mercato/core/modules/core/__integration__/helpers/auth";
|
|
3
|
+
test.describe("TC-SEARCH-012: global search results escape the sticky header stacking context", () => {
|
|
4
|
+
test("the results panel is portaled to <body> and stays above high z-index page content", async ({ page }) => {
|
|
5
|
+
await login(page, "admin");
|
|
6
|
+
await page.goto("/backend");
|
|
7
|
+
await page.waitForLoadState("domcontentloaded");
|
|
8
|
+
await page.locator("header").getByRole("button", { name: "Open global search" }).click();
|
|
9
|
+
const input = page.locator('input[aria-controls="topbar-search-results"]');
|
|
10
|
+
await expect(input).toBeVisible();
|
|
11
|
+
await input.fill("au");
|
|
12
|
+
const panel = page.locator("#topbar-search-results");
|
|
13
|
+
await expect(panel).toBeVisible();
|
|
14
|
+
const structure = await panel.evaluate((el) => ({
|
|
15
|
+
parentIsBody: el.parentElement === document.body,
|
|
16
|
+
insideHeader: Boolean(el.closest("header")),
|
|
17
|
+
position: getComputedStyle(el).position
|
|
18
|
+
}));
|
|
19
|
+
expect(structure.parentIsBody, "results panel should be portaled directly under <body>").toBe(true);
|
|
20
|
+
expect(structure.insideHeader, "results panel must not live inside the sticky header").toBe(false);
|
|
21
|
+
expect(structure.position, "portaled panel is positioned relative to the viewport").toBe("fixed");
|
|
22
|
+
const stacking = await panel.evaluate((el) => {
|
|
23
|
+
const rect = el.getBoundingClientRect();
|
|
24
|
+
const x = Math.round(rect.left + rect.width / 2);
|
|
25
|
+
const y = Math.round(rect.top + rect.height / 2);
|
|
26
|
+
const probe = document.createElement("div");
|
|
27
|
+
probe.id = "__zorder_probe__";
|
|
28
|
+
Object.assign(probe.style, {
|
|
29
|
+
position: "fixed",
|
|
30
|
+
top: `${y - 25}px`,
|
|
31
|
+
left: `${x - 25}px`,
|
|
32
|
+
width: "50px",
|
|
33
|
+
height: "50px",
|
|
34
|
+
zIndex: "30",
|
|
35
|
+
background: "rgba(255,0,0,0.5)"
|
|
36
|
+
});
|
|
37
|
+
document.body.appendChild(probe);
|
|
38
|
+
const stack = document.elementsFromPoint(x, y);
|
|
39
|
+
const panelIdx = stack.findIndex((node) => node === el || el.contains(node));
|
|
40
|
+
const probeIdx = stack.findIndex((node) => node.id === "__zorder_probe__");
|
|
41
|
+
probe.remove();
|
|
42
|
+
return { panelIdx, probeIdx };
|
|
43
|
+
});
|
|
44
|
+
expect(stacking.panelIdx, "the results panel should be hit at its own centre").toBeGreaterThanOrEqual(0);
|
|
45
|
+
expect(
|
|
46
|
+
stacking.probeIdx === -1 || stacking.panelIdx < stacking.probeIdx,
|
|
47
|
+
"a z-index:30 page element must not paint over the results panel"
|
|
48
|
+
).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
//# sourceMappingURL=TC-SEARCH-012.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/search/__integration__/TC-SEARCH-012.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'\n\n/**\n * TC-SEARCH-012: the global (top-bar) search results panel escapes the sticky\n * header's stacking context so page content can never paint over it (#3097).\n *\n * The backend top bar is `position: sticky; z-index: 10`, which establishes a\n * stacking context. Rendered inline, the results panel's `z-index` (z-popover)\n * only ranked *within* that context, so relative to the page it was capped at the\n * header's z-index \u2014 any page element with its own stacking context above 10\n * (sticky table cells, cards, positioned widgets) bled over the results. The fix\n * portals the panel to <body> with fixed positioning.\n *\n * Self-contained: needs no indexed data. A below-minimum query (< 3 chars) opens\n * the panel in its \"type more\" hint state without issuing a search request, which\n * is enough to assert both structure and stacking. A synthetic z-index:30 overlay\n * proves the panel is painted on top \u2014 on the pre-fix inline markup that overlay\n * painted over the trapped panel instead.\n */\ntest.describe('TC-SEARCH-012: global search results escape the sticky header stacking context', () => {\n test('the results panel is portaled to <body> and stays above high z-index page content', async ({ page }) => {\n await login(page, 'admin')\n await page.goto('/backend')\n await page.waitForLoadState('domcontentloaded')\n\n // Open the collapsed top-bar search (icon button) and focus its input.\n await page.locator('header').getByRole('button', { name: 'Open global search' }).click()\n const input = page.locator('input[aria-controls=\"topbar-search-results\"]')\n await expect(input).toBeVisible()\n\n // A 2-char query (below the 3-char minimum) opens the panel in its hint state\n // \u2014 no search API call, so the assertion never depends on indexed records.\n await input.fill('au')\n const panel = page.locator('#topbar-search-results')\n await expect(panel).toBeVisible()\n\n // -- Structural guard: portaled to <body>, not nested inside the sticky header.\n const structure = await panel.evaluate((el) => ({\n parentIsBody: el.parentElement === document.body,\n insideHeader: Boolean(el.closest('header')),\n position: getComputedStyle(el).position,\n }))\n expect(structure.parentIsBody, 'results panel should be portaled directly under <body>').toBe(true)\n expect(structure.insideHeader, 'results panel must not live inside the sticky header').toBe(false)\n expect(structure.position, 'portaled panel is positioned relative to the viewport').toBe('fixed')\n\n // -- Stacking guard: page content at z-index 30 overlapping the panel stays\n // BELOW it. Pre-fix, the panel was trapped at the header's z-index (10) and\n // this overlay painted over it.\n const stacking = await panel.evaluate((el) => {\n const rect = el.getBoundingClientRect()\n const x = Math.round(rect.left + rect.width / 2)\n const y = Math.round(rect.top + rect.height / 2)\n const probe = document.createElement('div')\n probe.id = '__zorder_probe__'\n Object.assign(probe.style, {\n position: 'fixed',\n top: `${y - 25}px`,\n left: `${x - 25}px`,\n width: '50px',\n height: '50px',\n zIndex: '30',\n background: 'rgba(255,0,0,0.5)',\n })\n document.body.appendChild(probe)\n const stack = document.elementsFromPoint(x, y)\n const panelIdx = stack.findIndex((node) => node === el || el.contains(node))\n const probeIdx = stack.findIndex((node) => (node as HTMLElement).id === '__zorder_probe__')\n probe.remove()\n return { panelIdx, probeIdx }\n })\n // The panel must be present at the point and painted ABOVE the z-index:30\n // overlay. Asserting the relative order (rather than \"topmost\") keeps the\n // guard precise if some unrelated top-layer element ever overlaps the centre.\n expect(stacking.panelIdx, 'the results panel should be hit at its own centre').toBeGreaterThanOrEqual(0)\n expect(\n stacking.probeIdx === -1 || stacking.panelIdx < stacking.probeIdx,\n 'a z-index:30 page element must not paint over the results panel',\n ).toBe(true)\n })\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,aAAa;AAmBtB,KAAK,SAAS,kFAAkF,MAAM;AACpG,OAAK,qFAAqF,OAAO,EAAE,KAAK,MAAM;AAC5G,UAAM,MAAM,MAAM,OAAO;AACzB,UAAM,KAAK,KAAK,UAAU;AAC1B,UAAM,KAAK,iBAAiB,kBAAkB;AAG9C,UAAM,KAAK,QAAQ,QAAQ,EAAE,UAAU,UAAU,EAAE,MAAM,qBAAqB,CAAC,EAAE,MAAM;AACvF,UAAM,QAAQ,KAAK,QAAQ,8CAA8C;AACzE,UAAM,OAAO,KAAK,EAAE,YAAY;AAIhC,UAAM,MAAM,KAAK,IAAI;AACrB,UAAM,QAAQ,KAAK,QAAQ,wBAAwB;AACnD,UAAM,OAAO,KAAK,EAAE,YAAY;AAGhC,UAAM,YAAY,MAAM,MAAM,SAAS,CAAC,QAAQ;AAAA,MAC9C,cAAc,GAAG,kBAAkB,SAAS;AAAA,MAC5C,cAAc,QAAQ,GAAG,QAAQ,QAAQ,CAAC;AAAA,MAC1C,UAAU,iBAAiB,EAAE,EAAE;AAAA,IACjC,EAAE;AACF,WAAO,UAAU,cAAc,wDAAwD,EAAE,KAAK,IAAI;AAClG,WAAO,UAAU,cAAc,sDAAsD,EAAE,KAAK,KAAK;AACjG,WAAO,UAAU,UAAU,uDAAuD,EAAE,KAAK,OAAO;AAKhG,UAAM,WAAW,MAAM,MAAM,SAAS,CAAC,OAAO;AAC5C,YAAM,OAAO,GAAG,sBAAsB;AACtC,YAAM,IAAI,KAAK,MAAM,KAAK,OAAO,KAAK,QAAQ,CAAC;AAC/C,YAAM,IAAI,KAAK,MAAM,KAAK,MAAM,KAAK,SAAS,CAAC;AAC/C,YAAM,QAAQ,SAAS,cAAc,KAAK;AAC1C,YAAM,KAAK;AACX,aAAO,OAAO,MAAM,OAAO;AAAA,QACzB,UAAU;AAAA,QACV,KAAK,GAAG,IAAI,EAAE;AAAA,QACd,MAAM,GAAG,IAAI,EAAE;AAAA,QACf,OAAO;AAAA,QACP,QAAQ;AAAA,QACR,QAAQ;AAAA,QACR,YAAY;AAAA,MACd,CAAC;AACD,eAAS,KAAK,YAAY,KAAK;AAC/B,YAAM,QAAQ,SAAS,kBAAkB,GAAG,CAAC;AAC7C,YAAM,WAAW,MAAM,UAAU,CAAC,SAAS,SAAS,MAAM,GAAG,SAAS,IAAI,CAAC;AAC3E,YAAM,WAAW,MAAM,UAAU,CAAC,SAAU,KAAqB,OAAO,kBAAkB;AAC1F,YAAM,OAAO;AACb,aAAO,EAAE,UAAU,SAAS;AAAA,IAC9B,CAAC;AAID,WAAO,SAAS,UAAU,mDAAmD,EAAE,uBAAuB,CAAC;AACvG;AAAA,MACE,SAAS,aAAa,MAAM,SAAS,WAAW,SAAS;AAAA,MACzD;AAAA,IACF,EAAE,KAAK,IAAI;AAAA,EACb,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
4
5
|
import NextLink from "next/link";
|
|
5
6
|
import { useRouter } from "next/navigation";
|
|
6
7
|
import {
|
|
@@ -141,6 +142,7 @@ function TopbarSearchInline({
|
|
|
141
142
|
const [expanded, setExpanded] = React.useState(false);
|
|
142
143
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
143
144
|
const [showScopeHint, setShowScopeHint] = React.useState(() => hasActiveOrganizationSelection());
|
|
145
|
+
const [anchor, setAnchor] = React.useState(null);
|
|
144
146
|
const inputRef = React.useRef(null);
|
|
145
147
|
const popoverRef = React.useRef(null);
|
|
146
148
|
const containerRef = React.useRef(null);
|
|
@@ -290,6 +292,24 @@ function TopbarSearchInline({
|
|
|
290
292
|
const tooShort = trimmed.length > 0 && trimmed.length < MIN_QUERY_LENGTH;
|
|
291
293
|
const showPopover = open && (loading || results.length > 0 || error !== null || tooShort || showVectorWarning);
|
|
292
294
|
const showClear = query.length > 0;
|
|
295
|
+
React.useEffect(() => {
|
|
296
|
+
if (!showPopover) return;
|
|
297
|
+
const update = () => {
|
|
298
|
+
const el = containerRef.current;
|
|
299
|
+
if (!el) return;
|
|
300
|
+
const rect = el.getBoundingClientRect();
|
|
301
|
+
const width = Math.max(rect.width, 320);
|
|
302
|
+
const left = Math.min(Math.max(rect.left, 8), Math.max(window.innerWidth - width - 8, 8));
|
|
303
|
+
setAnchor({ top: rect.bottom + 4, left, width });
|
|
304
|
+
};
|
|
305
|
+
update();
|
|
306
|
+
window.addEventListener("resize", update);
|
|
307
|
+
window.addEventListener("scroll", update, true);
|
|
308
|
+
return () => {
|
|
309
|
+
window.removeEventListener("resize", update);
|
|
310
|
+
window.removeEventListener("scroll", update, true);
|
|
311
|
+
};
|
|
312
|
+
}, [showPopover]);
|
|
293
313
|
if (!expanded) {
|
|
294
314
|
return /* @__PURE__ */ jsx(
|
|
295
315
|
"span",
|
|
@@ -371,77 +391,81 @@ function TopbarSearchInline({
|
|
|
371
391
|
]
|
|
372
392
|
}
|
|
373
393
|
),
|
|
374
|
-
showPopover
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
/* @__PURE__ */ jsx("p", { children:
|
|
385
|
-
/* @__PURE__ */
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
"
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
/* @__PURE__ */ jsx("span", { className: "flex items-center
|
|
421
|
-
|
|
422
|
-
{
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
/* @__PURE__ */ jsx("span", {
|
|
434
|
-
/* @__PURE__ */
|
|
435
|
-
|
|
394
|
+
showPopover && anchor ? createPortal(
|
|
395
|
+
/* @__PURE__ */ jsxs(
|
|
396
|
+
"div",
|
|
397
|
+
{
|
|
398
|
+
ref: popoverRef,
|
|
399
|
+
id: "topbar-search-results",
|
|
400
|
+
role: "listbox",
|
|
401
|
+
style: { position: "fixed", top: anchor.top, left: anchor.left, width: anchor.width },
|
|
402
|
+
className: "z-popover rounded-md border bg-popover text-popover-foreground shadow-md",
|
|
403
|
+
children: [
|
|
404
|
+
error ? /* @__PURE__ */ jsx("p", { className: "border-b px-3 py-2 text-sm text-destructive", children: error }) : null,
|
|
405
|
+
showVectorWarning && !error ? /* @__PURE__ */ jsxs("div", { className: "border-b bg-status-warning-bg px-3 py-2 text-xs text-status-warning-text", children: [
|
|
406
|
+
/* @__PURE__ */ jsx("p", { children: missingConfigMessage }),
|
|
407
|
+
/* @__PURE__ */ jsx(
|
|
408
|
+
NextLink,
|
|
409
|
+
{
|
|
410
|
+
href: "/backend/config/search",
|
|
411
|
+
onClick: () => collapseAndReset(),
|
|
412
|
+
className: "mt-1 inline-flex items-center font-medium underline underline-offset-2 hover:no-underline",
|
|
413
|
+
children: t("search.dialog.warnings.configureLink", "Configure search settings")
|
|
414
|
+
}
|
|
415
|
+
)
|
|
416
|
+
] }) : null,
|
|
417
|
+
showScopeHint ? /* @__PURE__ */ jsx("p", { className: "border-b px-3 py-1.5 text-overline font-medium uppercase tracking-wider text-muted-foreground", children: t("search.scopeHint.currentOrg", "Scoped to current organization") }) : null,
|
|
418
|
+
tooShort && !error ? /* @__PURE__ */ jsx("div", { className: "px-3 py-3 text-sm text-muted-foreground", children: t("search.dialog.empty.hint", { count: MIN_QUERY_LENGTH }) }) : null,
|
|
419
|
+
!tooShort && results.length === 0 && !loading && !error ? /* @__PURE__ */ jsx("div", { className: "px-3 py-3 text-sm text-muted-foreground", children: t("search.dialog.empty.none", "No results") }) : null,
|
|
420
|
+
results.length > 0 ? /* @__PURE__ */ jsx("div", { ref: listRef, className: "max-h-[380px] overflow-y-auto p-1", children: results.map((result, index) => {
|
|
421
|
+
const presenter = result.presenter;
|
|
422
|
+
const isActive = index === selectedIndex;
|
|
423
|
+
const hasLink = pickPrimaryLink(result) !== null;
|
|
424
|
+
const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null;
|
|
425
|
+
return /* @__PURE__ */ jsxs(
|
|
426
|
+
"button",
|
|
427
|
+
{
|
|
428
|
+
type: "button",
|
|
429
|
+
"data-active": isActive,
|
|
430
|
+
onClick: () => openResult(result),
|
|
431
|
+
onMouseEnter: () => setSelectedIndex(index),
|
|
432
|
+
role: "option",
|
|
433
|
+
"aria-selected": isActive,
|
|
434
|
+
className: cn(
|
|
435
|
+
"flex w-full items-start gap-3 rounded-sm px-2 py-2 text-left transition-colors",
|
|
436
|
+
isActive ? "bg-muted" : "hover:bg-muted/60",
|
|
437
|
+
!hasLink && "opacity-60"
|
|
438
|
+
),
|
|
439
|
+
children: [
|
|
440
|
+
Icon ? /* @__PURE__ */ jsx("span", { className: "mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-md border bg-background", children: /* @__PURE__ */ jsx(Icon, { className: "size-4 text-muted-foreground", "aria-hidden": "true" }) }) : /* @__PURE__ */ jsx("span", { className: "mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-md border bg-background", children: /* @__PURE__ */ jsx(FileText, { className: "size-4 text-muted-foreground", "aria-hidden": "true" }) }),
|
|
441
|
+
/* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [
|
|
442
|
+
/* @__PURE__ */ jsx("span", { className: "flex items-center gap-2", children: /* @__PURE__ */ jsx(
|
|
443
|
+
"span",
|
|
444
|
+
{
|
|
445
|
+
className: cn(
|
|
446
|
+
"truncate text-sm font-medium",
|
|
447
|
+
!hasLink ? "text-muted-foreground" : "text-foreground"
|
|
448
|
+
),
|
|
449
|
+
children: presenter?.title ?? result.recordId
|
|
450
|
+
}
|
|
451
|
+
) }),
|
|
452
|
+
/* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2 text-overline text-muted-foreground", children: [
|
|
453
|
+
/* @__PURE__ */ jsx("span", { className: "truncate", children: formatEntityId(result.entityId) }),
|
|
454
|
+
presenter?.subtitle ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
455
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xB7" }),
|
|
456
|
+
/* @__PURE__ */ jsx("span", { className: "truncate", children: presenter.subtitle })
|
|
457
|
+
] }) : null
|
|
458
|
+
] })
|
|
436
459
|
] })
|
|
437
|
-
]
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
)
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
460
|
+
]
|
|
461
|
+
},
|
|
462
|
+
`${result.entityId}:${result.recordId}`
|
|
463
|
+
);
|
|
464
|
+
}) }) : null
|
|
465
|
+
]
|
|
466
|
+
}
|
|
467
|
+
),
|
|
468
|
+
document.body
|
|
445
469
|
) : null
|
|
446
470
|
]
|
|
447
471
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/search/frontend/components/TopbarSearchInline.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport NextLink from 'next/link'\nimport { useRouter } from 'next/navigation'\nimport {\n Search,\n Loader2,\n Zap,\n User,\n Users,\n Building,\n StickyNote,\n Briefcase,\n CheckSquare,\n FileText,\n Mail,\n Phone,\n Calendar,\n Clock,\n Star,\n Tag,\n Flag,\n Heart,\n Bookmark,\n Package,\n Truck,\n ShoppingCart,\n CreditCard,\n DollarSign,\n Target,\n Award,\n Trophy,\n Rocket,\n Lightbulb,\n MessageSquare,\n Bell,\n Settings,\n Globe,\n MapPin,\n Link as LinkIcon,\n Folder,\n Database,\n Activity,\n X,\n} from 'lucide-react'\nimport type { LucideIcon } from 'lucide-react'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport type { SearchResult, SearchResultLink } from '@open-mercato/shared/modules/search'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n getCurrentOrganizationScope,\n subscribeOrganizationScopeChanged,\n} from '@open-mercato/shared/lib/frontend/organizationEvents'\nimport { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'\nimport { parseSelectedOrganizationCookie } from '@open-mercato/core/modules/directory/utils/scopeCookies'\nimport { ForbiddenError } from '@open-mercato/ui/backend/utils/api'\nimport { IconButton } from '@open-mercato/ui/primitives/icon-button'\nimport { resolveSearchMinTokenLength } from '@open-mercato/shared/lib/search/config'\nimport { fetchGlobalSearchResults } from '../utils'\n\nconst MIN_QUERY_LENGTH = resolveSearchMinTokenLength()\n\nfunction normalizeLinks(links?: SearchResultLink[] | null): SearchResultLink[] {\n if (!Array.isArray(links)) return []\n return links.filter((link) => typeof link?.href === 'string')\n}\n\nfunction pickPrimaryLink(result: SearchResult): string | null {\n if (result.url) return result.url\n const links = normalizeLinks(result.links)\n if (!links.length) return null\n const primary = links.find((link) => link.kind === 'primary')\n return (primary ?? links[0]).href\n}\n\nfunction hasActiveOrganizationSelection(): boolean {\n const fromEvent = getCurrentOrganizationScope().organizationId\n if (typeof fromEvent === 'string' && fromEvent.trim().length > 0) return true\n const cookieHeader = typeof document === 'undefined' ? null : document.cookie\n const cookieValue = parseSelectedOrganizationCookie(cookieHeader)\n if (!cookieValue) return false\n return !isAllOrganizationsSelection(cookieValue)\n}\n\nfunction humanizeSegment(segment: string): string {\n return segment\n .split(/[_-]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ')\n}\n\nconst ICON_MAP: Record<string, LucideIcon> = {\n bolt: Zap,\n zap: Zap,\n user: User,\n users: Users,\n building: Building,\n 'sticky-note': StickyNote,\n briefcase: Briefcase,\n 'check-square': CheckSquare,\n 'file-text': FileText,\n mail: Mail,\n phone: Phone,\n calendar: Calendar,\n clock: Clock,\n star: Star,\n tag: Tag,\n flag: Flag,\n heart: Heart,\n bookmark: Bookmark,\n package: Package,\n truck: Truck,\n 'shopping-cart': ShoppingCart,\n 'credit-card': CreditCard,\n 'dollar-sign': DollarSign,\n target: Target,\n award: Award,\n trophy: Trophy,\n rocket: Rocket,\n lightbulb: Lightbulb,\n 'message-square': MessageSquare,\n bell: Bell,\n settings: Settings,\n globe: Globe,\n 'map-pin': MapPin,\n link: LinkIcon,\n folder: Folder,\n database: Database,\n activity: Activity,\n}\n\nfunction resolveIcon(name?: string): LucideIcon | null {\n if (!name) return null\n return ICON_MAP[name.toLowerCase()] ?? null\n}\n\nfunction formatEntityId(entityId: string): string {\n if (!entityId.includes(':')) return humanizeSegment(entityId)\n const [module, entity] = entityId.split(':')\n return `${humanizeSegment(module)} \u00B7 ${humanizeSegment(entity)}`\n}\n\nexport type TopbarSearchInlineProps = {\n /** Whether embedding provider is configured for vector search */\n embeddingConfigured: boolean\n /** Warning text to show when vector search is enabled but not configured */\n missingConfigMessage: string\n}\n\nexport function TopbarSearchInline({\n embeddingConfigured,\n missingConfigMessage,\n}: TopbarSearchInlineProps) {\n const router = useRouter()\n const t = useT()\n const [query, setQuery] = React.useState('')\n const [results, setResults] = React.useState<SearchResult[]>([])\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const [open, setOpen] = React.useState(false)\n const [expanded, setExpanded] = React.useState(false)\n const [selectedIndex, setSelectedIndex] = React.useState(0)\n const [showScopeHint, setShowScopeHint] = React.useState<boolean>(() => hasActiveOrganizationSelection())\n const inputRef = React.useRef<HTMLInputElement | null>(null)\n const popoverRef = React.useRef<HTMLDivElement | null>(null)\n const containerRef = React.useRef<HTMLElement | null>(null)\n const listRef = React.useRef<HTMLDivElement | null>(null)\n const abortRef = React.useRef<AbortController | null>(null)\n\n const expandAndFocus = React.useCallback(() => {\n setExpanded(true)\n // Wait one tick so the input is mounted/transitioned before focusing\n requestAnimationFrame(() => {\n inputRef.current?.focus()\n inputRef.current?.select()\n })\n }, [])\n\n const collapseAndReset = React.useCallback(() => {\n setOpen(false)\n setExpanded(false)\n setQuery('')\n inputRef.current?.blur()\n }, [])\n\n React.useEffect(() => {\n setShowScopeHint(hasActiveOrganizationSelection())\n return subscribeOrganizationScopeChanged((detail) => {\n setShowScopeHint(Boolean(detail.organizationId && detail.organizationId.trim().length > 0))\n })\n }, [])\n\n // Cmd/Ctrl+K expands + focuses the input\n React.useEffect(() => {\n const handler = (event: KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {\n event.preventDefault()\n expandAndFocus()\n }\n }\n window.addEventListener('keydown', handler)\n return () => window.removeEventListener('keydown', handler)\n }, [expandAndFocus])\n\n // Click outside closes the popover AND collapses input (unless user has typed something)\n React.useEffect(() => {\n if (!expanded) return\n const handler = (event: MouseEvent) => {\n const target = event.target as Node | null\n if (!target) return\n if (containerRef.current?.contains(target)) return\n if (popoverRef.current?.contains(target)) return\n collapseAndReset()\n }\n document.addEventListener('mousedown', handler)\n return () => document.removeEventListener('mousedown', handler)\n }, [expanded, collapseAndReset])\n\n // Fetch results on query change (debounced)\n React.useEffect(() => {\n abortRef.current?.abort()\n const trimmed = query.trim()\n if (trimmed.length < MIN_QUERY_LENGTH) {\n setResults([])\n setError(null)\n setLoading(false)\n return\n }\n\n const controller = new AbortController()\n abortRef.current = controller\n setLoading(true)\n\n const handle = setTimeout(async () => {\n try {\n const data = await fetchGlobalSearchResults(query, {\n limit: 10,\n signal: controller.signal,\n })\n setResults(data.results)\n setError(data.error ?? null)\n setSelectedIndex(0)\n } catch (err: unknown) {\n if (controller.signal.aborted) return\n const abortError = err as { name?: string }\n if (abortError?.name === 'AbortError') return\n if (err instanceof ForbiddenError) {\n setError(t('search.dialog.errors.noPermission'))\n } else {\n setError(err instanceof Error ? err.message : t('search.dialog.errors.searchFailed'))\n }\n setResults([])\n } finally {\n if (!controller.signal.aborted) setLoading(false)\n }\n }, 220)\n\n return () => {\n clearTimeout(handle)\n controller.abort()\n }\n }, [query, t])\n\n // Auto-scroll active item into view\n React.useEffect(() => {\n const container = listRef.current\n const active = container?.querySelector<HTMLElement>('[data-active=\"true\"]')\n if (!container || !active) return\n const { top: containerTop, bottom: containerBottom } = container.getBoundingClientRect()\n const { top: activeTop, bottom: activeBottom } = active.getBoundingClientRect()\n if (activeTop < containerTop) {\n container.scrollTop -= containerTop - activeTop\n } else if (activeBottom > containerBottom) {\n container.scrollTop += activeBottom - containerBottom\n }\n }, [selectedIndex])\n\n const openResult = React.useCallback(\n (result: SearchResult | undefined) => {\n if (!result) return\n const href = pickPrimaryLink(result)\n if (!href) return\n router.push(href)\n collapseAndReset()\n },\n [router, collapseAndReset],\n )\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLInputElement>) => {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n if (!open) setOpen(true)\n setSelectedIndex((prev) => (results.length ? (prev + 1) % results.length : 0))\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSelectedIndex((prev) => {\n if (!results.length) return 0\n return prev <= 0 ? results.length - 1 : prev - 1\n })\n return\n }\n if (event.key === 'Escape') {\n event.preventDefault()\n if (open) {\n setOpen(false)\n } else if (query) {\n setQuery('')\n } else {\n collapseAndReset()\n }\n return\n }\n if (event.key === 'Enter') {\n event.preventDefault()\n const target = results[selectedIndex]\n openResult(target)\n return\n }\n },\n [open, query, results, selectedIndex, openResult, collapseAndReset],\n )\n\n const showVectorWarning = !embeddingConfigured && !error\n const trimmed = query.trim()\n const tooShort = trimmed.length > 0 && trimmed.length < MIN_QUERY_LENGTH\n const showPopover =\n open && (loading || results.length > 0 || error !== null || tooShort || showVectorWarning)\n const showClear = query.length > 0\n\n if (!expanded) {\n return (\n <span\n ref={(el) => {\n containerRef.current = el\n }}\n className=\"inline-flex\"\n >\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"lg\"\n onClick={expandAndFocus}\n aria-label={t('search.dialog.actions.openGlobalSearch', 'Open global search')}\n title={t('search.dialog.actions.openGlobalSearch', 'Open global search')}\n >\n <Search className=\"size-4\" aria-hidden=\"true\" />\n </IconButton>\n </span>\n )\n }\n\n return (\n <div\n ref={(el) => {\n containerRef.current = el\n }}\n data-search-expanded=\"true\"\n className=\"relative min-w-0 sm:w-[260px] md:w-[320px] max-sm:absolute max-sm:inset-x-3 max-sm:top-1/2 max-sm:-translate-y-1/2 max-sm:z-popover\"\n >\n <div\n className={cn(\n 'flex h-9 items-center gap-2 rounded-md border border-input bg-background px-3 text-sm shadow-xs transition-colors hover:bg-muted/40',\n open ? 'border-foreground bg-background shadow-focus' : '',\n )}\n onClick={() => inputRef.current?.focus()}\n >\n <Search className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <input\n ref={inputRef}\n type=\"text\"\n value={query}\n onChange={(event) => {\n setQuery(event.target.value)\n if (!open) setOpen(true)\n }}\n onFocus={() => setOpen(true)}\n onKeyDown={handleKeyDown}\n placeholder={t('search.dialog.actions.search', 'Search')}\n aria-label={t('search.dialog.actions.openGlobalSearch', 'Search')}\n aria-autocomplete=\"list\"\n aria-expanded={open}\n aria-controls=\"topbar-search-results\"\n className=\"flex-1 min-w-0 bg-transparent placeholder:text-muted-foreground/70 focus:outline-none\"\n // Inline search; no need for native browser autocomplete or autocapitalize\n autoComplete=\"off\"\n spellCheck={false}\n />\n {loading ? (\n <Loader2 className=\"size-4 shrink-0 animate-spin text-muted-foreground\" aria-hidden=\"true\" />\n ) : showClear ? (\n <button\n type=\"button\"\n onClick={(event) => {\n event.stopPropagation()\n setQuery('')\n inputRef.current?.focus()\n }}\n className=\"rounded-sm p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n aria-label={t('search.dialog.actions.clear', 'Clear search')}\n >\n <X className=\"size-3.5\" aria-hidden=\"true\" />\n </button>\n ) : (\n <kbd className=\"hidden md:inline-flex shrink-0 items-center rounded border bg-muted/50 px-1.5 text-overline font-medium uppercase tracking-wider text-muted-foreground\">\n \u2318K\n </kbd>\n )}\n </div>\n\n {showPopover ? (\n <div\n ref={popoverRef}\n id=\"topbar-search-results\"\n role=\"listbox\"\n className=\"absolute left-0 right-0 top-[calc(100%+4px)] z-popover min-w-[320px] rounded-md border bg-popover text-popover-foreground shadow-md\"\n >\n {error ? (\n <p className=\"border-b px-3 py-2 text-sm text-destructive\">{error}</p>\n ) : null}\n {showVectorWarning && !error ? (\n <div className=\"border-b bg-status-warning-bg px-3 py-2 text-xs text-status-warning-text\">\n <p>{missingConfigMessage}</p>\n <NextLink\n href=\"/backend/config/search\"\n onClick={() => collapseAndReset()}\n className=\"mt-1 inline-flex items-center font-medium underline underline-offset-2 hover:no-underline\"\n >\n {t('search.dialog.warnings.configureLink', 'Configure search settings')}\n </NextLink>\n </div>\n ) : null}\n {showScopeHint ? (\n <p className=\"border-b px-3 py-1.5 text-overline font-medium uppercase tracking-wider text-muted-foreground\">\n {t('search.scopeHint.currentOrg', 'Scoped to current organization')}\n </p>\n ) : null}\n\n {tooShort && !error ? (\n <div className=\"px-3 py-3 text-sm text-muted-foreground\">\n {t('search.dialog.empty.hint', { count: MIN_QUERY_LENGTH })}\n </div>\n ) : null}\n\n {!tooShort && results.length === 0 && !loading && !error ? (\n <div className=\"px-3 py-3 text-sm text-muted-foreground\">\n {t('search.dialog.empty.none', 'No results')}\n </div>\n ) : null}\n\n {results.length > 0 ? (\n <div ref={listRef} className=\"max-h-[380px] overflow-y-auto p-1\">\n {results.map((result, index) => {\n const presenter = result.presenter\n const isActive = index === selectedIndex\n const hasLink = pickPrimaryLink(result) !== null\n const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null\n return (\n <button\n key={`${result.entityId}:${result.recordId}`}\n type=\"button\"\n data-active={isActive}\n onClick={() => openResult(result)}\n onMouseEnter={() => setSelectedIndex(index)}\n role=\"option\"\n aria-selected={isActive}\n className={cn(\n 'flex w-full items-start gap-3 rounded-sm px-2 py-2 text-left transition-colors',\n isActive ? 'bg-muted' : 'hover:bg-muted/60',\n !hasLink && 'opacity-60',\n )}\n >\n {Icon ? (\n <span className=\"mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-md border bg-background\">\n <Icon className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />\n </span>\n ) : (\n <span className=\"mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-md border bg-background\">\n <FileText className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />\n </span>\n )}\n <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <span className=\"flex items-center gap-2\">\n <span\n className={cn(\n 'truncate text-sm font-medium',\n !hasLink ? 'text-muted-foreground' : 'text-foreground',\n )}\n >\n {presenter?.title ?? result.recordId}\n </span>\n </span>\n <span className=\"flex items-center gap-2 text-overline text-muted-foreground\">\n <span className=\"truncate\">{formatEntityId(result.entityId)}</span>\n {presenter?.subtitle ? (\n <>\n <span aria-hidden=\"true\">\u00B7</span>\n <span className=\"truncate\">{presenter.subtitle}</span>\n </>\n ) : null}\n </span>\n </span>\n </button>\n )\n })}\n </div>\n ) : null}\n </div>\n ) : null}\n </div>\n )\n}\n\nexport default TopbarSearchInline\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { createPortal } from 'react-dom'\nimport NextLink from 'next/link'\nimport { useRouter } from 'next/navigation'\nimport {\n Search,\n Loader2,\n Zap,\n User,\n Users,\n Building,\n StickyNote,\n Briefcase,\n CheckSquare,\n FileText,\n Mail,\n Phone,\n Calendar,\n Clock,\n Star,\n Tag,\n Flag,\n Heart,\n Bookmark,\n Package,\n Truck,\n ShoppingCart,\n CreditCard,\n DollarSign,\n Target,\n Award,\n Trophy,\n Rocket,\n Lightbulb,\n MessageSquare,\n Bell,\n Settings,\n Globe,\n MapPin,\n Link as LinkIcon,\n Folder,\n Database,\n Activity,\n X,\n} from 'lucide-react'\nimport type { LucideIcon } from 'lucide-react'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport type { SearchResult, SearchResultLink } from '@open-mercato/shared/modules/search'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n getCurrentOrganizationScope,\n subscribeOrganizationScopeChanged,\n} from '@open-mercato/shared/lib/frontend/organizationEvents'\nimport { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'\nimport { parseSelectedOrganizationCookie } from '@open-mercato/core/modules/directory/utils/scopeCookies'\nimport { ForbiddenError } from '@open-mercato/ui/backend/utils/api'\nimport { IconButton } from '@open-mercato/ui/primitives/icon-button'\nimport { resolveSearchMinTokenLength } from '@open-mercato/shared/lib/search/config'\nimport { fetchGlobalSearchResults } from '../utils'\n\nconst MIN_QUERY_LENGTH = resolveSearchMinTokenLength()\n\nfunction normalizeLinks(links?: SearchResultLink[] | null): SearchResultLink[] {\n if (!Array.isArray(links)) return []\n return links.filter((link) => typeof link?.href === 'string')\n}\n\nfunction pickPrimaryLink(result: SearchResult): string | null {\n if (result.url) return result.url\n const links = normalizeLinks(result.links)\n if (!links.length) return null\n const primary = links.find((link) => link.kind === 'primary')\n return (primary ?? links[0]).href\n}\n\nfunction hasActiveOrganizationSelection(): boolean {\n const fromEvent = getCurrentOrganizationScope().organizationId\n if (typeof fromEvent === 'string' && fromEvent.trim().length > 0) return true\n const cookieHeader = typeof document === 'undefined' ? null : document.cookie\n const cookieValue = parseSelectedOrganizationCookie(cookieHeader)\n if (!cookieValue) return false\n return !isAllOrganizationsSelection(cookieValue)\n}\n\nfunction humanizeSegment(segment: string): string {\n return segment\n .split(/[_-]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ')\n}\n\nconst ICON_MAP: Record<string, LucideIcon> = {\n bolt: Zap,\n zap: Zap,\n user: User,\n users: Users,\n building: Building,\n 'sticky-note': StickyNote,\n briefcase: Briefcase,\n 'check-square': CheckSquare,\n 'file-text': FileText,\n mail: Mail,\n phone: Phone,\n calendar: Calendar,\n clock: Clock,\n star: Star,\n tag: Tag,\n flag: Flag,\n heart: Heart,\n bookmark: Bookmark,\n package: Package,\n truck: Truck,\n 'shopping-cart': ShoppingCart,\n 'credit-card': CreditCard,\n 'dollar-sign': DollarSign,\n target: Target,\n award: Award,\n trophy: Trophy,\n rocket: Rocket,\n lightbulb: Lightbulb,\n 'message-square': MessageSquare,\n bell: Bell,\n settings: Settings,\n globe: Globe,\n 'map-pin': MapPin,\n link: LinkIcon,\n folder: Folder,\n database: Database,\n activity: Activity,\n}\n\nfunction resolveIcon(name?: string): LucideIcon | null {\n if (!name) return null\n return ICON_MAP[name.toLowerCase()] ?? null\n}\n\nfunction formatEntityId(entityId: string): string {\n if (!entityId.includes(':')) return humanizeSegment(entityId)\n const [module, entity] = entityId.split(':')\n return `${humanizeSegment(module)} \u00B7 ${humanizeSegment(entity)}`\n}\n\nexport type TopbarSearchInlineProps = {\n /** Whether embedding provider is configured for vector search */\n embeddingConfigured: boolean\n /** Warning text to show when vector search is enabled but not configured */\n missingConfigMessage: string\n}\n\nexport function TopbarSearchInline({\n embeddingConfigured,\n missingConfigMessage,\n}: TopbarSearchInlineProps) {\n const router = useRouter()\n const t = useT()\n const [query, setQuery] = React.useState('')\n const [results, setResults] = React.useState<SearchResult[]>([])\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const [open, setOpen] = React.useState(false)\n const [expanded, setExpanded] = React.useState(false)\n const [selectedIndex, setSelectedIndex] = React.useState(0)\n const [showScopeHint, setShowScopeHint] = React.useState<boolean>(() => hasActiveOrganizationSelection())\n const [anchor, setAnchor] = React.useState<{ top: number; left: number; width: number } | null>(null)\n const inputRef = React.useRef<HTMLInputElement | null>(null)\n const popoverRef = React.useRef<HTMLDivElement | null>(null)\n const containerRef = React.useRef<HTMLElement | null>(null)\n const listRef = React.useRef<HTMLDivElement | null>(null)\n const abortRef = React.useRef<AbortController | null>(null)\n\n const expandAndFocus = React.useCallback(() => {\n setExpanded(true)\n // Wait one tick so the input is mounted/transitioned before focusing\n requestAnimationFrame(() => {\n inputRef.current?.focus()\n inputRef.current?.select()\n })\n }, [])\n\n const collapseAndReset = React.useCallback(() => {\n setOpen(false)\n setExpanded(false)\n setQuery('')\n inputRef.current?.blur()\n }, [])\n\n React.useEffect(() => {\n setShowScopeHint(hasActiveOrganizationSelection())\n return subscribeOrganizationScopeChanged((detail) => {\n setShowScopeHint(Boolean(detail.organizationId && detail.organizationId.trim().length > 0))\n })\n }, [])\n\n // Cmd/Ctrl+K expands + focuses the input\n React.useEffect(() => {\n const handler = (event: KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {\n event.preventDefault()\n expandAndFocus()\n }\n }\n window.addEventListener('keydown', handler)\n return () => window.removeEventListener('keydown', handler)\n }, [expandAndFocus])\n\n // Click outside closes the popover AND collapses input (unless user has typed something)\n React.useEffect(() => {\n if (!expanded) return\n const handler = (event: MouseEvent) => {\n const target = event.target as Node | null\n if (!target) return\n if (containerRef.current?.contains(target)) return\n if (popoverRef.current?.contains(target)) return\n collapseAndReset()\n }\n document.addEventListener('mousedown', handler)\n return () => document.removeEventListener('mousedown', handler)\n }, [expanded, collapseAndReset])\n\n // Fetch results on query change (debounced)\n React.useEffect(() => {\n abortRef.current?.abort()\n const trimmed = query.trim()\n if (trimmed.length < MIN_QUERY_LENGTH) {\n setResults([])\n setError(null)\n setLoading(false)\n return\n }\n\n const controller = new AbortController()\n abortRef.current = controller\n setLoading(true)\n\n const handle = setTimeout(async () => {\n try {\n const data = await fetchGlobalSearchResults(query, {\n limit: 10,\n signal: controller.signal,\n })\n setResults(data.results)\n setError(data.error ?? null)\n setSelectedIndex(0)\n } catch (err: unknown) {\n if (controller.signal.aborted) return\n const abortError = err as { name?: string }\n if (abortError?.name === 'AbortError') return\n if (err instanceof ForbiddenError) {\n setError(t('search.dialog.errors.noPermission'))\n } else {\n setError(err instanceof Error ? err.message : t('search.dialog.errors.searchFailed'))\n }\n setResults([])\n } finally {\n if (!controller.signal.aborted) setLoading(false)\n }\n }, 220)\n\n return () => {\n clearTimeout(handle)\n controller.abort()\n }\n }, [query, t])\n\n // Auto-scroll active item into view\n React.useEffect(() => {\n const container = listRef.current\n const active = container?.querySelector<HTMLElement>('[data-active=\"true\"]')\n if (!container || !active) return\n const { top: containerTop, bottom: containerBottom } = container.getBoundingClientRect()\n const { top: activeTop, bottom: activeBottom } = active.getBoundingClientRect()\n if (activeTop < containerTop) {\n container.scrollTop -= containerTop - activeTop\n } else if (activeBottom > containerBottom) {\n container.scrollTop += activeBottom - containerBottom\n }\n }, [selectedIndex])\n\n const openResult = React.useCallback(\n (result: SearchResult | undefined) => {\n if (!result) return\n const href = pickPrimaryLink(result)\n if (!href) return\n router.push(href)\n collapseAndReset()\n },\n [router, collapseAndReset],\n )\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLInputElement>) => {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n if (!open) setOpen(true)\n setSelectedIndex((prev) => (results.length ? (prev + 1) % results.length : 0))\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSelectedIndex((prev) => {\n if (!results.length) return 0\n return prev <= 0 ? results.length - 1 : prev - 1\n })\n return\n }\n if (event.key === 'Escape') {\n event.preventDefault()\n if (open) {\n setOpen(false)\n } else if (query) {\n setQuery('')\n } else {\n collapseAndReset()\n }\n return\n }\n if (event.key === 'Enter') {\n event.preventDefault()\n const target = results[selectedIndex]\n openResult(target)\n return\n }\n },\n [open, query, results, selectedIndex, openResult, collapseAndReset],\n )\n\n const showVectorWarning = !embeddingConfigured && !error\n const trimmed = query.trim()\n const tooShort = trimmed.length > 0 && trimmed.length < MIN_QUERY_LENGTH\n const showPopover =\n open && (loading || results.length > 0 || error !== null || tooShort || showVectorWarning)\n const showClear = query.length > 0\n\n // The results panel is portaled to <body> so it escapes the sticky header's\n // stacking context (z-sticky) \u2014 rendered inline it stays trapped at the\n // header's z-index and page content with its own z-index paints over it\n // (#3097). Anchor it under the input and keep it aligned on scroll/resize.\n React.useEffect(() => {\n if (!showPopover) return\n const update = () => {\n const el = containerRef.current\n if (!el) return\n const rect = el.getBoundingClientRect()\n const width = Math.max(rect.width, 320)\n const left = Math.min(Math.max(rect.left, 8), Math.max(window.innerWidth - width - 8, 8))\n setAnchor({ top: rect.bottom + 4, left, width })\n }\n update()\n window.addEventListener('resize', update)\n window.addEventListener('scroll', update, true)\n return () => {\n window.removeEventListener('resize', update)\n window.removeEventListener('scroll', update, true)\n }\n }, [showPopover])\n\n if (!expanded) {\n return (\n <span\n ref={(el) => {\n containerRef.current = el\n }}\n className=\"inline-flex\"\n >\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"lg\"\n onClick={expandAndFocus}\n aria-label={t('search.dialog.actions.openGlobalSearch', 'Open global search')}\n title={t('search.dialog.actions.openGlobalSearch', 'Open global search')}\n >\n <Search className=\"size-4\" aria-hidden=\"true\" />\n </IconButton>\n </span>\n )\n }\n\n return (\n <div\n ref={(el) => {\n containerRef.current = el\n }}\n data-search-expanded=\"true\"\n className=\"relative min-w-0 sm:w-[260px] md:w-[320px] max-sm:absolute max-sm:inset-x-3 max-sm:top-1/2 max-sm:-translate-y-1/2 max-sm:z-popover\"\n >\n <div\n className={cn(\n 'flex h-9 items-center gap-2 rounded-md border border-input bg-background px-3 text-sm shadow-xs transition-colors hover:bg-muted/40',\n open ? 'border-foreground bg-background shadow-focus' : '',\n )}\n onClick={() => inputRef.current?.focus()}\n >\n <Search className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <input\n ref={inputRef}\n type=\"text\"\n value={query}\n onChange={(event) => {\n setQuery(event.target.value)\n if (!open) setOpen(true)\n }}\n onFocus={() => setOpen(true)}\n onKeyDown={handleKeyDown}\n placeholder={t('search.dialog.actions.search', 'Search')}\n aria-label={t('search.dialog.actions.openGlobalSearch', 'Search')}\n aria-autocomplete=\"list\"\n aria-expanded={open}\n aria-controls=\"topbar-search-results\"\n className=\"flex-1 min-w-0 bg-transparent placeholder:text-muted-foreground/70 focus:outline-none\"\n // Inline search; no need for native browser autocomplete or autocapitalize\n autoComplete=\"off\"\n spellCheck={false}\n />\n {loading ? (\n <Loader2 className=\"size-4 shrink-0 animate-spin text-muted-foreground\" aria-hidden=\"true\" />\n ) : showClear ? (\n <button\n type=\"button\"\n onClick={(event) => {\n event.stopPropagation()\n setQuery('')\n inputRef.current?.focus()\n }}\n className=\"rounded-sm p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n aria-label={t('search.dialog.actions.clear', 'Clear search')}\n >\n <X className=\"size-3.5\" aria-hidden=\"true\" />\n </button>\n ) : (\n <kbd className=\"hidden md:inline-flex shrink-0 items-center rounded border bg-muted/50 px-1.5 text-overline font-medium uppercase tracking-wider text-muted-foreground\">\n \u2318K\n </kbd>\n )}\n </div>\n\n {showPopover && anchor ? createPortal(\n <div\n ref={popoverRef}\n id=\"topbar-search-results\"\n role=\"listbox\"\n style={{ position: 'fixed', top: anchor.top, left: anchor.left, width: anchor.width }}\n className=\"z-popover rounded-md border bg-popover text-popover-foreground shadow-md\"\n >\n {error ? (\n <p className=\"border-b px-3 py-2 text-sm text-destructive\">{error}</p>\n ) : null}\n {showVectorWarning && !error ? (\n <div className=\"border-b bg-status-warning-bg px-3 py-2 text-xs text-status-warning-text\">\n <p>{missingConfigMessage}</p>\n <NextLink\n href=\"/backend/config/search\"\n onClick={() => collapseAndReset()}\n className=\"mt-1 inline-flex items-center font-medium underline underline-offset-2 hover:no-underline\"\n >\n {t('search.dialog.warnings.configureLink', 'Configure search settings')}\n </NextLink>\n </div>\n ) : null}\n {showScopeHint ? (\n <p className=\"border-b px-3 py-1.5 text-overline font-medium uppercase tracking-wider text-muted-foreground\">\n {t('search.scopeHint.currentOrg', 'Scoped to current organization')}\n </p>\n ) : null}\n\n {tooShort && !error ? (\n <div className=\"px-3 py-3 text-sm text-muted-foreground\">\n {t('search.dialog.empty.hint', { count: MIN_QUERY_LENGTH })}\n </div>\n ) : null}\n\n {!tooShort && results.length === 0 && !loading && !error ? (\n <div className=\"px-3 py-3 text-sm text-muted-foreground\">\n {t('search.dialog.empty.none', 'No results')}\n </div>\n ) : null}\n\n {results.length > 0 ? (\n <div ref={listRef} className=\"max-h-[380px] overflow-y-auto p-1\">\n {results.map((result, index) => {\n const presenter = result.presenter\n const isActive = index === selectedIndex\n const hasLink = pickPrimaryLink(result) !== null\n const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null\n return (\n <button\n key={`${result.entityId}:${result.recordId}`}\n type=\"button\"\n data-active={isActive}\n onClick={() => openResult(result)}\n onMouseEnter={() => setSelectedIndex(index)}\n role=\"option\"\n aria-selected={isActive}\n className={cn(\n 'flex w-full items-start gap-3 rounded-sm px-2 py-2 text-left transition-colors',\n isActive ? 'bg-muted' : 'hover:bg-muted/60',\n !hasLink && 'opacity-60',\n )}\n >\n {Icon ? (\n <span className=\"mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-md border bg-background\">\n <Icon className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />\n </span>\n ) : (\n <span className=\"mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-md border bg-background\">\n <FileText className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />\n </span>\n )}\n <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <span className=\"flex items-center gap-2\">\n <span\n className={cn(\n 'truncate text-sm font-medium',\n !hasLink ? 'text-muted-foreground' : 'text-foreground',\n )}\n >\n {presenter?.title ?? result.recordId}\n </span>\n </span>\n <span className=\"flex items-center gap-2 text-overline text-muted-foreground\">\n <span className=\"truncate\">{formatEntityId(result.entityId)}</span>\n {presenter?.subtitle ? (\n <>\n <span aria-hidden=\"true\">\u00B7</span>\n <span className=\"truncate\">{presenter.subtitle}</span>\n </>\n ) : null}\n </span>\n </span>\n </button>\n )\n })}\n </div>\n ) : null}\n </div>,\n document.body,\n ) : null}\n </div>\n )\n}\n\nexport default TopbarSearchInline\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["trimmed"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/search",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6398.1.013efe913e",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -127,9 +127,9 @@
|
|
|
127
127
|
"zod": "^4.4.3"
|
|
128
128
|
},
|
|
129
129
|
"peerDependencies": {
|
|
130
|
-
"@open-mercato/core": "0.6.6-develop.
|
|
131
|
-
"@open-mercato/queue": "0.6.6-develop.
|
|
132
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
130
|
+
"@open-mercato/core": "0.6.6-develop.6398.1.013efe913e",
|
|
131
|
+
"@open-mercato/queue": "0.6.6-develop.6398.1.013efe913e",
|
|
132
|
+
"@open-mercato/shared": "0.6.6-develop.6398.1.013efe913e"
|
|
133
133
|
},
|
|
134
134
|
"devDependencies": {
|
|
135
135
|
"@types/jest": "^30.0.0",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TC-SEARCH-012: the global (top-bar) search results panel escapes the sticky
|
|
6
|
+
* header's stacking context so page content can never paint over it (#3097).
|
|
7
|
+
*
|
|
8
|
+
* The backend top bar is `position: sticky; z-index: 10`, which establishes a
|
|
9
|
+
* stacking context. Rendered inline, the results panel's `z-index` (z-popover)
|
|
10
|
+
* only ranked *within* that context, so relative to the page it was capped at the
|
|
11
|
+
* header's z-index — any page element with its own stacking context above 10
|
|
12
|
+
* (sticky table cells, cards, positioned widgets) bled over the results. The fix
|
|
13
|
+
* portals the panel to <body> with fixed positioning.
|
|
14
|
+
*
|
|
15
|
+
* Self-contained: needs no indexed data. A below-minimum query (< 3 chars) opens
|
|
16
|
+
* the panel in its "type more" hint state without issuing a search request, which
|
|
17
|
+
* is enough to assert both structure and stacking. A synthetic z-index:30 overlay
|
|
18
|
+
* proves the panel is painted on top — on the pre-fix inline markup that overlay
|
|
19
|
+
* painted over the trapped panel instead.
|
|
20
|
+
*/
|
|
21
|
+
test.describe('TC-SEARCH-012: global search results escape the sticky header stacking context', () => {
|
|
22
|
+
test('the results panel is portaled to <body> and stays above high z-index page content', async ({ page }) => {
|
|
23
|
+
await login(page, 'admin')
|
|
24
|
+
await page.goto('/backend')
|
|
25
|
+
await page.waitForLoadState('domcontentloaded')
|
|
26
|
+
|
|
27
|
+
// Open the collapsed top-bar search (icon button) and focus its input.
|
|
28
|
+
await page.locator('header').getByRole('button', { name: 'Open global search' }).click()
|
|
29
|
+
const input = page.locator('input[aria-controls="topbar-search-results"]')
|
|
30
|
+
await expect(input).toBeVisible()
|
|
31
|
+
|
|
32
|
+
// A 2-char query (below the 3-char minimum) opens the panel in its hint state
|
|
33
|
+
// — no search API call, so the assertion never depends on indexed records.
|
|
34
|
+
await input.fill('au')
|
|
35
|
+
const panel = page.locator('#topbar-search-results')
|
|
36
|
+
await expect(panel).toBeVisible()
|
|
37
|
+
|
|
38
|
+
// -- Structural guard: portaled to <body>, not nested inside the sticky header.
|
|
39
|
+
const structure = await panel.evaluate((el) => ({
|
|
40
|
+
parentIsBody: el.parentElement === document.body,
|
|
41
|
+
insideHeader: Boolean(el.closest('header')),
|
|
42
|
+
position: getComputedStyle(el).position,
|
|
43
|
+
}))
|
|
44
|
+
expect(structure.parentIsBody, 'results panel should be portaled directly under <body>').toBe(true)
|
|
45
|
+
expect(structure.insideHeader, 'results panel must not live inside the sticky header').toBe(false)
|
|
46
|
+
expect(structure.position, 'portaled panel is positioned relative to the viewport').toBe('fixed')
|
|
47
|
+
|
|
48
|
+
// -- Stacking guard: page content at z-index 30 overlapping the panel stays
|
|
49
|
+
// BELOW it. Pre-fix, the panel was trapped at the header's z-index (10) and
|
|
50
|
+
// this overlay painted over it.
|
|
51
|
+
const stacking = await panel.evaluate((el) => {
|
|
52
|
+
const rect = el.getBoundingClientRect()
|
|
53
|
+
const x = Math.round(rect.left + rect.width / 2)
|
|
54
|
+
const y = Math.round(rect.top + rect.height / 2)
|
|
55
|
+
const probe = document.createElement('div')
|
|
56
|
+
probe.id = '__zorder_probe__'
|
|
57
|
+
Object.assign(probe.style, {
|
|
58
|
+
position: 'fixed',
|
|
59
|
+
top: `${y - 25}px`,
|
|
60
|
+
left: `${x - 25}px`,
|
|
61
|
+
width: '50px',
|
|
62
|
+
height: '50px',
|
|
63
|
+
zIndex: '30',
|
|
64
|
+
background: 'rgba(255,0,0,0.5)',
|
|
65
|
+
})
|
|
66
|
+
document.body.appendChild(probe)
|
|
67
|
+
const stack = document.elementsFromPoint(x, y)
|
|
68
|
+
const panelIdx = stack.findIndex((node) => node === el || el.contains(node))
|
|
69
|
+
const probeIdx = stack.findIndex((node) => (node as HTMLElement).id === '__zorder_probe__')
|
|
70
|
+
probe.remove()
|
|
71
|
+
return { panelIdx, probeIdx }
|
|
72
|
+
})
|
|
73
|
+
// The panel must be present at the point and painted ABOVE the z-index:30
|
|
74
|
+
// overlay. Asserting the relative order (rather than "topmost") keeps the
|
|
75
|
+
// guard precise if some unrelated top-layer element ever overlaps the centre.
|
|
76
|
+
expect(stacking.panelIdx, 'the results panel should be hit at its own centre').toBeGreaterThanOrEqual(0)
|
|
77
|
+
expect(
|
|
78
|
+
stacking.probeIdx === -1 || stacking.panelIdx < stacking.probeIdx,
|
|
79
|
+
'a z-index:30 page element must not paint over the results panel',
|
|
80
|
+
).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
+
import { createPortal } from 'react-dom'
|
|
4
5
|
import NextLink from 'next/link'
|
|
5
6
|
import { useRouter } from 'next/navigation'
|
|
6
7
|
import {
|
|
@@ -163,6 +164,7 @@ export function TopbarSearchInline({
|
|
|
163
164
|
const [expanded, setExpanded] = React.useState(false)
|
|
164
165
|
const [selectedIndex, setSelectedIndex] = React.useState(0)
|
|
165
166
|
const [showScopeHint, setShowScopeHint] = React.useState<boolean>(() => hasActiveOrganizationSelection())
|
|
167
|
+
const [anchor, setAnchor] = React.useState<{ top: number; left: number; width: number } | null>(null)
|
|
166
168
|
const inputRef = React.useRef<HTMLInputElement | null>(null)
|
|
167
169
|
const popoverRef = React.useRef<HTMLDivElement | null>(null)
|
|
168
170
|
const containerRef = React.useRef<HTMLElement | null>(null)
|
|
@@ -332,6 +334,29 @@ export function TopbarSearchInline({
|
|
|
332
334
|
open && (loading || results.length > 0 || error !== null || tooShort || showVectorWarning)
|
|
333
335
|
const showClear = query.length > 0
|
|
334
336
|
|
|
337
|
+
// The results panel is portaled to <body> so it escapes the sticky header's
|
|
338
|
+
// stacking context (z-sticky) — rendered inline it stays trapped at the
|
|
339
|
+
// header's z-index and page content with its own z-index paints over it
|
|
340
|
+
// (#3097). Anchor it under the input and keep it aligned on scroll/resize.
|
|
341
|
+
React.useEffect(() => {
|
|
342
|
+
if (!showPopover) return
|
|
343
|
+
const update = () => {
|
|
344
|
+
const el = containerRef.current
|
|
345
|
+
if (!el) return
|
|
346
|
+
const rect = el.getBoundingClientRect()
|
|
347
|
+
const width = Math.max(rect.width, 320)
|
|
348
|
+
const left = Math.min(Math.max(rect.left, 8), Math.max(window.innerWidth - width - 8, 8))
|
|
349
|
+
setAnchor({ top: rect.bottom + 4, left, width })
|
|
350
|
+
}
|
|
351
|
+
update()
|
|
352
|
+
window.addEventListener('resize', update)
|
|
353
|
+
window.addEventListener('scroll', update, true)
|
|
354
|
+
return () => {
|
|
355
|
+
window.removeEventListener('resize', update)
|
|
356
|
+
window.removeEventListener('scroll', update, true)
|
|
357
|
+
}
|
|
358
|
+
}, [showPopover])
|
|
359
|
+
|
|
335
360
|
if (!expanded) {
|
|
336
361
|
return (
|
|
337
362
|
<span
|
|
@@ -412,12 +437,13 @@ export function TopbarSearchInline({
|
|
|
412
437
|
)}
|
|
413
438
|
</div>
|
|
414
439
|
|
|
415
|
-
{showPopover ? (
|
|
440
|
+
{showPopover && anchor ? createPortal(
|
|
416
441
|
<div
|
|
417
442
|
ref={popoverRef}
|
|
418
443
|
id="topbar-search-results"
|
|
419
444
|
role="listbox"
|
|
420
|
-
|
|
445
|
+
style={{ position: 'fixed', top: anchor.top, left: anchor.left, width: anchor.width }}
|
|
446
|
+
className="z-popover rounded-md border bg-popover text-popover-foreground shadow-md"
|
|
421
447
|
>
|
|
422
448
|
{error ? (
|
|
423
449
|
<p className="border-b px-3 py-2 text-sm text-destructive">{error}</p>
|
|
@@ -509,7 +535,8 @@ export function TopbarSearchInline({
|
|
|
509
535
|
})}
|
|
510
536
|
</div>
|
|
511
537
|
) : null}
|
|
512
|
-
</div
|
|
538
|
+
</div>,
|
|
539
|
+
document.body,
|
|
513
540
|
) : null}
|
|
514
541
|
</div>
|
|
515
542
|
)
|