diff --git a/config/lattice.php b/config/lattice.php
index a6b68000..ae23d3b3 100644
--- a/config/lattice.php
+++ b/config/lattice.php
@@ -41,6 +41,11 @@
'middleware' => ['web', 'auth'],
],
+ 'search' => [
+ 'endpoint' => 'lattice/search',
+ 'middleware' => ['web', 'auth'],
+ ],
+
'actions' => [
'endpoint' => 'lattice/actions/{action}',
'middleware' => ['web', 'auth'],
diff --git a/lang/de/lattice.php b/lang/de/lattice.php
index 28c34805..676b8507 100644
--- a/lang/de/lattice.php
+++ b/lang/de/lattice.php
@@ -110,6 +110,17 @@
'send' => 'Senden',
'input-label' => 'Nachrichteneingabe',
],
+ 'search' => [
+ 'placeholder' => 'Suchen…',
+ 'title' => 'Suche',
+ 'results' => 'Ergebnisse',
+ 'recent' => 'Zuletzt',
+ 'open' => 'Öffnen',
+ 'empty' => 'Keine Ergebnisse gefunden.',
+ 'loading' => 'Suche läuft…',
+ 'error' => 'Etwas ist schiefgelaufen.',
+ 'previewEmpty' => 'Ergebnis auswählen, um eine Vorschau anzuzeigen.',
+ ],
'remote' => [
'data-list' => [
'loading' => 'Lädt...',
diff --git a/lang/en/lattice.php b/lang/en/lattice.php
index 1cb8698a..ccf023f8 100644
--- a/lang/en/lattice.php
+++ b/lang/en/lattice.php
@@ -110,6 +110,17 @@
'send' => 'Send',
'input-label' => 'Message input',
],
+ 'search' => [
+ 'placeholder' => 'Search…',
+ 'title' => 'Search',
+ 'results' => 'Results',
+ 'recent' => 'Recent',
+ 'open' => 'Open',
+ 'empty' => 'No results found.',
+ 'loading' => 'Searching…',
+ 'error' => 'Something went wrong.',
+ 'previewEmpty' => 'Select a result to preview.',
+ ],
'remote' => [
'data-list' => [
'loading' => 'Loading...',
diff --git a/package-lock.json b/package-lock.json
index ec2c76f2..661a1b7d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@lattice-php/lattice",
- "version": "0.2.0",
+ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@lattice-php/lattice",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "MIT",
"dependencies": {
"@lattice-php/vite-svg-sprite": "^0.2.1",
@@ -279,9 +279,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -299,9 +296,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -319,9 +313,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -339,9 +330,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2484,9 +2472,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2504,9 +2489,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2524,9 +2506,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2544,9 +2523,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2564,9 +2540,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2584,9 +2557,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2604,9 +2574,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2624,9 +2591,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2644,9 +2608,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2670,9 +2631,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2696,9 +2654,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2722,9 +2677,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2748,9 +2700,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2774,9 +2723,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2800,9 +2746,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2826,9 +2769,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3615,9 +3555,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3635,9 +3572,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3655,9 +3589,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3675,9 +3606,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3695,9 +3623,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3715,9 +3640,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3735,9 +3657,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3755,9 +3674,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3981,9 +3897,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3998,9 +3911,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4015,9 +3925,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4032,9 +3939,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4049,9 +3953,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4066,9 +3967,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4083,9 +3981,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4100,9 +3995,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4308,9 +4200,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4328,9 +4217,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4348,9 +4234,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4368,9 +4251,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4388,9 +4268,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4408,9 +4285,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4428,9 +4302,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4448,9 +4319,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4655,9 +4523,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4675,9 +4540,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4695,9 +4557,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4715,9 +4574,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4735,9 +4591,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4755,9 +4608,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4775,9 +4625,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4795,9 +4642,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5781,9 +5625,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5801,9 +5642,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5821,9 +5659,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5841,9 +5676,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5861,9 +5693,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5881,9 +5710,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6103,9 +5929,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6120,9 +5943,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6137,9 +5957,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6154,9 +5971,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6171,9 +5985,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6188,9 +5999,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6205,9 +6013,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6222,9 +6027,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6239,9 +6041,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6256,9 +6055,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6273,9 +6069,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6290,9 +6083,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6307,9 +6097,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6883,9 +6670,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6903,9 +6687,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6923,9 +6704,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6943,9 +6721,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -11586,9 +11361,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11610,9 +11382,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11634,9 +11403,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11658,9 +11424,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -14244,9 +14007,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -14264,9 +14024,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -14284,9 +14041,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -14304,9 +14058,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -14324,9 +14075,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -14344,9 +14092,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -14364,9 +14109,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -14384,9 +14126,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
diff --git a/package.json b/package.json
index 0b46245e..476a6702 100644
--- a/package.json
+++ b/package.json
@@ -55,6 +55,14 @@
"types": "./dist/chat/*.d.ts",
"import": "./dist/chat/*.js"
},
+ "./search": {
+ "types": "./dist/search/index.d.ts",
+ "import": "./dist/search/index.js"
+ },
+ "./search/*": {
+ "types": "./dist/search/*.d.ts",
+ "import": "./dist/search/*.js"
+ },
"./clipboard": {
"types": "./dist/clipboard/index.d.ts",
"import": "./dist/clipboard/index.js"
@@ -186,7 +194,7 @@
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"type-coverage": "type-coverage --project tsconfig.json --at-least 99.5",
- "check": "npm run lint:fix && npm run format && npm run typecheck && npm run type-coverage && npm run test && npm run build:lib",
+ "check": "npm run lint:fix && npm run format && npm run typecheck && npm run type-coverage && npm run test && npm run build && npm run build:lib",
"docs:typecheck": "tsc --noEmit -p docs/tsconfig.json",
"lint": "oxlint resources/js workbench/resources/js",
"lint:fix": "oxlint resources/js workbench/resources/js --fix",
diff --git a/resources/icons/package.svg b/resources/icons/package.svg
new file mode 100644
index 00000000..e1d12d86
--- /dev/null
+++ b/resources/icons/package.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/resources/icons/receipt.svg b/resources/icons/receipt.svg
new file mode 100644
index 00000000..04b424df
--- /dev/null
+++ b/resources/icons/receipt.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/resources/icons/users.svg b/resources/icons/users.svg
new file mode 100644
index 00000000..b201db65
--- /dev/null
+++ b/resources/icons/users.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/resources/js/index.ts b/resources/js/index.ts
index 309d2235..48616840 100644
--- a/resources/js/index.ts
+++ b/resources/js/index.ts
@@ -1,6 +1,23 @@
export { registry } from "./registry";
export { useChat } from "./chat/use-chat";
export { chatPlugin } from "./chat";
+export { useSearch } from "./search/use-search";
+export {
+ SearchBox,
+ SearchInput,
+ SearchCategories,
+ SearchResults,
+ SearchRecent,
+ SearchPreview,
+ searchComponents,
+} from "./search";
+export type {
+ SearchResult,
+ SearchCategory,
+ SearchPagination,
+ UseSearchOptions,
+ UseSearchReturn,
+} from "./search/types";
export {
getActionEffects,
isActionEffect,
diff --git a/resources/js/registry.ts b/resources/js/registry.ts
index b24c5e7d..c1c9e01d 100644
--- a/resources/js/registry.ts
+++ b/resources/js/registry.ts
@@ -3,6 +3,7 @@ import { actionComponents } from "./action";
import { chatPlugin } from "./chat";
import { coreComponents } from "./core/components";
import { formComponents } from "./form";
+import { searchComponents } from "./search";
import { remoteComponents } from "./remote";
import { layoutComponents } from "./layout/components";
import { tableComponents } from "./table";
@@ -14,5 +15,6 @@ export const registry = createRegistry(
layoutComponents,
tableComponents,
chatPlugin,
+ searchComponents,
remoteComponents,
);
diff --git a/resources/js/search/components/categories.tsx b/resources/js/search/components/categories.tsx
new file mode 100644
index 00000000..ddd0b713
--- /dev/null
+++ b/resources/js/search/components/categories.tsx
@@ -0,0 +1,39 @@
+import { Icon } from "@lattice-php/lattice/icons";
+import { cn } from "@lattice-php/lattice/lib/utils";
+import type { RendererComponent } from "@lattice-php/lattice/core/types";
+import { useSearchContext } from "../context";
+
+const SearchCategories: RendererComponent<"search.categories"> = () => {
+ const { categories, activeCategory, setCategory } = useSearchContext();
+
+ return (
+
+ {categories.map((category) => (
+
+ ))}
+
+ );
+};
+
+export default SearchCategories;
diff --git a/resources/js/search/components/input.tsx b/resources/js/search/components/input.tsx
new file mode 100644
index 00000000..639ad0aa
--- /dev/null
+++ b/resources/js/search/components/input.tsx
@@ -0,0 +1,26 @@
+import { Icon } from "@lattice-php/lattice/icons";
+import type { RendererComponent } from "@lattice-php/lattice/core/types";
+import { useT } from "@lattice-php/lattice/i18n";
+import { useSearchContext } from "../context";
+
+const SearchInput: RendererComponent<"search.input"> = () => {
+ const { query, setQuery } = useSearchContext();
+ const { t } = useT("lattice");
+
+ return (
+
+
+ setQuery(event.target.value)}
+ placeholder={t("search.placeholder", "Search…")}
+ type="search"
+ value={query}
+ />
+
+ );
+};
+
+export default SearchInput;
diff --git a/resources/js/search/components/preview.tsx b/resources/js/search/components/preview.tsx
new file mode 100644
index 00000000..29d359bb
--- /dev/null
+++ b/resources/js/search/components/preview.tsx
@@ -0,0 +1,40 @@
+import { Button } from "@lattice-php/lattice/core/components/button";
+import type { RendererComponent } from "@lattice-php/lattice/core/types";
+import { useT } from "@lattice-php/lattice/i18n";
+import { useSearchContext } from "../context";
+
+const SearchPreview: RendererComponent<"search.preview"> = () => {
+ const { results, recent, focusedId, openResult } = useSearchContext();
+ const { t } = useT("lattice");
+
+ const focused = [...results, ...recent].find((result) => result.item.id === focusedId) ?? null;
+
+ if (focused === null) {
+ return (
+
+ {t("search.previewEmpty", "Select a result to preview.")}
+
+ );
+ }
+
+ const detail = [focused.item.subtitle, focused.item.additionalInfo].filter(Boolean).join(" · ");
+
+ return (
+
+
+ {focused.item.title}
+ {detail !== "" ? {detail} : null}
+ {focused.item.badge ? (
+
+ {focused.item.badge}
+
+ ) : null}
+
+
+
+ );
+};
+
+export default SearchPreview;
diff --git a/resources/js/search/components/recent.tsx b/resources/js/search/components/recent.tsx
new file mode 100644
index 00000000..e2dfe8b4
--- /dev/null
+++ b/resources/js/search/components/recent.tsx
@@ -0,0 +1,32 @@
+import type { RendererComponent } from "@lattice-php/lattice/core/types";
+import { useT } from "@lattice-php/lattice/i18n";
+import { useSearchContext } from "../context";
+import { ResultRow } from "./result-row";
+
+const SearchRecent: RendererComponent<"search.recent"> = () => {
+ const { query, recent, focusedId, setFocusedId, openResult } = useSearchContext();
+ const { t } = useT("lattice");
+
+ if (query.trim() !== "" || recent.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {t("search.recent", "Recent")}
+
+ {recent.map((result) => (
+ setFocusedId(result.item.id)}
+ onOpen={() => openResult(result)}
+ result={result}
+ />
+ ))}
+
+ );
+};
+
+export default SearchRecent;
diff --git a/resources/js/search/components/result-row.tsx b/resources/js/search/components/result-row.tsx
new file mode 100644
index 00000000..5d87e12b
--- /dev/null
+++ b/resources/js/search/components/result-row.tsx
@@ -0,0 +1,58 @@
+import { Icon } from "@lattice-php/lattice/icons";
+import { cn } from "@lattice-php/lattice/lib/utils";
+import type { SearchResult } from "../types";
+
+function secondary(item: SearchResult["item"]): string {
+ return [item.subtitle, item.additionalInfo]
+ .filter((value): value is string => Boolean(value))
+ .join(" · ");
+}
+
+export function ResultRow({
+ result,
+ focused,
+ onOpen,
+ onFocus,
+}: {
+ result: SearchResult;
+ focused: boolean;
+ onOpen: () => void;
+ onFocus: () => void;
+}) {
+ const detail = secondary(result.item);
+
+ return (
+
+ );
+}
diff --git a/resources/js/search/components/results.test.tsx b/resources/js/search/components/results.test.tsx
new file mode 100644
index 00000000..c74752d6
--- /dev/null
+++ b/resources/js/search/components/results.test.tsx
@@ -0,0 +1,77 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { SearchProvider } from "../context";
+import type { SearchResult, UseSearchReturn } from "../types";
+import SearchResults from "./results";
+
+function row(id: string, title: string): SearchResult {
+ return {
+ category: { name: "products" },
+ item: {
+ id,
+ title,
+ subtitle: "SKU-" + id,
+ additionalInfo: null,
+ link: "/products/" + id,
+ badge: null,
+ },
+ };
+}
+
+function harness(overrides: Partial = {}) {
+ const setFocusedId = vi.fn<(id: string | null) => void>();
+ const openResult = vi.fn<(result: SearchResult) => void>();
+ const loadMore = vi.fn<() => void>();
+ const value: UseSearchReturn = {
+ query: "wid",
+ setQuery: vi.fn<(value: string) => void>(),
+ categories: [],
+ activeCategory: "products",
+ setCategory: vi.fn<(name: string | null) => void>(),
+ results: [row("1", "Widget 1"), row("2", "Widget 2")],
+ recent: [],
+ pagination: { page: 1, perPage: 20, total: 2, hasMore: false, nextPage: null },
+ status: "success",
+ error: null,
+ focusedId: "1",
+ setFocusedId,
+ openResult,
+ loadMore,
+ refreshRecent: vi.fn<() => void>(),
+ ...overrides,
+ };
+
+ render(
+
+ {null}
+ ,
+ );
+
+ return { setFocusedId, openResult, loadMore };
+}
+
+describe("SearchResults", () => {
+ it("renders rows with title and joined subtitle", () => {
+ harness();
+ expect(screen.getByText("Widget 1")).toBeInTheDocument();
+ expect(screen.getByText("SKU-1")).toBeInTheDocument();
+ });
+
+ it("moves focus with arrow keys and opens on Enter", () => {
+ const { setFocusedId, openResult } = harness();
+ const list = screen.getByRole("listbox");
+
+ fireEvent.keyDown(list, { key: "ArrowDown" });
+ expect(setFocusedId).toHaveBeenCalledWith("2");
+
+ fireEvent.keyDown(list, { key: "Enter" });
+ expect(openResult).toHaveBeenCalledWith(
+ expect.objectContaining({ item: expect.objectContaining({ id: "1" }) }),
+ );
+ });
+
+ it("shows the empty state when there are no results", () => {
+ harness({ results: [], status: "success" });
+ expect(screen.getByText(/no results/i)).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/search/components/results.tsx b/resources/js/search/components/results.tsx
new file mode 100644
index 00000000..47fba436
--- /dev/null
+++ b/resources/js/search/components/results.tsx
@@ -0,0 +1,92 @@
+import { useEffect, useRef, type KeyboardEvent } from "react";
+import type { RendererComponent } from "@lattice-php/lattice/core/types";
+import { useT } from "@lattice-php/lattice/i18n";
+import { useSearchContext } from "../context";
+import { ResultRow } from "./result-row";
+
+const SearchResults: RendererComponent<"search.results"> = () => {
+ const { results, focusedId, setFocusedId, openResult, loadMore, status, pagination } =
+ useSearchContext();
+ const { t } = useT("lattice");
+ const sentinelRef = useRef(null);
+
+ useEffect(() => {
+ const node = sentinelRef.current;
+ if (!node || pagination?.hasMore !== true) {
+ return;
+ }
+
+ const observer = new IntersectionObserver((entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ loadMore();
+ }
+ });
+ observer.observe(node);
+
+ return () => observer.disconnect();
+ }, [loadMore, pagination?.hasMore]);
+
+ function onKeyDown(event: KeyboardEvent): void {
+ if (results.length === 0) {
+ return;
+ }
+
+ const index = Math.max(
+ 0,
+ results.findIndex((result) => result.item.id === focusedId),
+ );
+
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setFocusedId(results[Math.min(results.length - 1, index + 1)]?.item.id ?? null);
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setFocusedId(results[Math.max(0, index - 1)]?.item.id ?? null);
+ } else if (event.key === "Enter") {
+ event.preventDefault();
+ const focused = results[index];
+ if (focused) {
+ openResult(focused);
+ }
+ }
+ }
+
+ if (status === "error") {
+ return (
+ {t("search.error", "Something went wrong.")}
+ );
+ }
+
+ if (status === "loading" && results.length === 0) {
+ return {t("search.loading", "Searching…")}
;
+ }
+
+ if (results.length === 0) {
+ return (
+ {t("search.empty", "No results found.")}
+ );
+ }
+
+ return (
+
+ {results.map((result) => (
+
setFocusedId(result.item.id)}
+ onOpen={() => openResult(result)}
+ result={result}
+ />
+ ))}
+
+
+ );
+};
+
+export default SearchResults;
diff --git a/resources/js/search/components/search-box.test.tsx b/resources/js/search/components/search-box.test.tsx
new file mode 100644
index 00000000..8a93e566
--- /dev/null
+++ b/resources/js/search/components/search-box.test.tsx
@@ -0,0 +1,74 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import SearchBox from "./search-box";
+
+afterEach(() => vi.unstubAllGlobals());
+
+const emptyPayload = JSON.stringify({
+ data: [],
+ categories: [],
+ pagination: { page: 1, perPage: 20, total: 0, hasMore: false, nextPage: null },
+ state: { query: "", category: null, perPage: 20, countsIncluded: true },
+});
+
+function renderRoot() {
+ const node = {
+ type: "search.box",
+ props: {
+ endpoint: "/lattice/search",
+ placeholder: "Search…",
+ title: "Search",
+ shortcut: true,
+ perPage: 20,
+ },
+ } as never;
+
+ return render({null});
+}
+
+describe("SearchBox root", () => {
+ it("opens on Cmd+K and shows the default composition", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response(emptyPayload, { status: 200 })),
+ );
+ renderRoot();
+
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
+
+ await waitFor(() => expect(screen.getByRole("searchbox")).toBeInTheDocument());
+ });
+
+ it("fires the recent=1 request exactly once when opened (no render loop)", async () => {
+ vi.useRealTimers();
+
+ const fetchMock = vi.fn(async () => new Response(emptyPayload, { status: 200 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ renderRoot();
+
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
+
+ // Wait for the dialog to appear and the initial fetch to settle.
+ await waitFor(() => expect(screen.getByRole("searchbox")).toBeInTheDocument());
+
+ // Give any potential loop iterations time to run.
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const recentCalls = fetchMock.mock.calls.filter((c) => String(c[0]).includes("recent=1"));
+
+ expect(recentCalls).toHaveLength(1);
+ });
+
+ it("ignores the shortcut while typing in an input", () => {
+ renderRoot();
+ const external = document.createElement("input");
+ document.body.appendChild(external);
+ external.focus();
+
+ fireEvent.keyDown(external, { key: "k", metaKey: true });
+
+ expect(screen.queryByRole("searchbox")).not.toBeInTheDocument();
+ external.remove();
+ });
+});
diff --git a/resources/js/search/components/search-box.tsx b/resources/js/search/components/search-box.tsx
new file mode 100644
index 00000000..d616c841
--- /dev/null
+++ b/resources/js/search/components/search-box.tsx
@@ -0,0 +1,110 @@
+import { useEffect, useState, type ReactNode } from "react";
+import { Dialog, DialogContent } from "@lattice-php/lattice/core/components/dialog";
+import { Icon } from "@lattice-php/lattice/icons";
+import type { RendererComponent } from "@lattice-php/lattice/core/types";
+import { useT } from "@lattice-php/lattice/i18n";
+import { SearchProvider } from "../context";
+import { useSearch } from "../use-search";
+import SearchCategories from "./categories";
+import SearchInput from "./input";
+import SearchPreview from "./preview";
+import SearchRecent from "./recent";
+import SearchResults from "./results";
+
+function isEditingTarget(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) {
+ return false;
+ }
+
+ const tag = target.tagName;
+
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
+}
+
+function DefaultComposition() {
+ const passthrough = { type: "search.slot", props: {} } as never;
+
+ return (
+
+
{null}
+
{null}
+
+
+ {null}
+
+
+ {null}
+
+
+ {null}
+
+
+
+ );
+}
+
+const SearchBox: RendererComponent<"search.box"> = ({ node, children }) => {
+ const { endpoint, placeholder, title, shortcut, perPage } = node.props;
+ const { t } = useT("lattice");
+ const [open, setOpen] = useState(false);
+ const search = useSearch({
+ endpoint: endpoint ?? "/lattice/search",
+ perPage: perPage ?? 20,
+ });
+
+ useEffect(() => {
+ if (shortcut === false) {
+ return;
+ }
+
+ function onKeyDown(event: KeyboardEvent): void {
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
+ if (isEditingTarget(event.target)) {
+ return;
+ }
+
+ event.preventDefault();
+ setOpen((value) => !value);
+ }
+ }
+
+ window.addEventListener("keydown", onKeyDown);
+
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [shortcut]);
+
+ const { refreshRecent } = search;
+ useEffect(() => {
+ if (open) {
+ refreshRecent();
+ }
+ }, [open, refreshRecent]);
+
+ const composed: ReactNode = node.schema?.length ? children : (children ?? );
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default SearchBox;
diff --git a/resources/js/search/components/slots.test.tsx b/resources/js/search/components/slots.test.tsx
new file mode 100644
index 00000000..a4069ffe
--- /dev/null
+++ b/resources/js/search/components/slots.test.tsx
@@ -0,0 +1,80 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { SearchProvider } from "../context";
+import type { SearchResult, UseSearchReturn } from "../types";
+import SearchCategories from "./categories";
+import SearchInput from "./input";
+import SearchRecent from "./recent";
+
+function row(id: string): SearchResult {
+ return {
+ category: { name: "products" },
+ item: {
+ id,
+ title: "Widget " + id,
+ subtitle: null,
+ additionalInfo: null,
+ link: "/p/" + id,
+ badge: null,
+ },
+ };
+}
+
+function value(overrides: Partial = {}): UseSearchReturn {
+ return {
+ query: "",
+ setQuery: vi.fn<(value: string) => void>(),
+ categories: [{ name: "products", label: "Products", icon: "package", count: 3 }],
+ activeCategory: "products",
+ setCategory: vi.fn<(name: string | null) => void>(),
+ results: [],
+ recent: [],
+ pagination: null,
+ status: "idle",
+ error: null,
+ focusedId: null,
+ setFocusedId: vi.fn<(id: string | null) => void>(),
+ loadMore: vi.fn<() => void>(),
+ openResult: vi.fn<(result: SearchResult) => void>(),
+ refreshRecent: vi.fn<() => void>(),
+ ...overrides,
+ };
+}
+
+function node(type: string) {
+ return { type, props: {} } as never;
+}
+
+describe("search slots", () => {
+ it("input forwards typing to setQuery", () => {
+ const v = value();
+ render(
+
+ {null}
+ ,
+ );
+ fireEvent.change(screen.getByRole("searchbox"), { target: { value: "wid" } });
+ expect(v.setQuery).toHaveBeenCalledWith("wid");
+ });
+
+ it("categories render label + count and select on click", () => {
+ const v = value();
+ render(
+
+ {null}
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: /Products/ }));
+ expect(v.setCategory).toHaveBeenCalledWith("products");
+ expect(screen.getByText("3")).toBeInTheDocument();
+ });
+
+ it("recent hides while a query is active", () => {
+ const { container } = render(
+
+ {null}
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/resources/js/search/context.tsx b/resources/js/search/context.tsx
new file mode 100644
index 00000000..4a767e40
--- /dev/null
+++ b/resources/js/search/context.tsx
@@ -0,0 +1,24 @@
+import { createContext, useContext, type ReactNode } from "react";
+import type { UseSearchReturn } from "./types";
+
+const SearchContext = createContext(null);
+
+export function SearchProvider({
+ value,
+ children,
+}: {
+ value: UseSearchReturn;
+ children: ReactNode;
+}) {
+ return {children};
+}
+
+export function useSearchContext(): UseSearchReturn {
+ const context = useContext(SearchContext);
+
+ if (context === null) {
+ throw new Error("useSearchContext must be used within a root.");
+ }
+
+ return context;
+}
diff --git a/resources/js/search/index.test.ts b/resources/js/search/index.test.ts
new file mode 100644
index 00000000..60832ab7
--- /dev/null
+++ b/resources/js/search/index.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+import { searchComponents } from "./index";
+
+describe("searchComponents", () => {
+ it("registers every search wire type", () => {
+ expect(Object.keys(searchComponents.components ?? {})).toEqual(
+ expect.arrayContaining([
+ "search.box",
+ "search.input",
+ "search.categories",
+ "search.results",
+ "search.recent",
+ "search.preview",
+ ]),
+ );
+ });
+});
diff --git a/resources/js/search/index.ts b/resources/js/search/index.ts
new file mode 100644
index 00000000..dd6805ba
--- /dev/null
+++ b/resources/js/search/index.ts
@@ -0,0 +1,38 @@
+import { createPlugin, eagerComponent } from "@lattice-php/lattice/core/registry";
+import SearchBox from "./components/search-box";
+import SearchCategories from "./components/categories";
+import SearchInput from "./components/input";
+import SearchPreview from "./components/preview";
+import SearchRecent from "./components/recent";
+import SearchResults from "./components/results";
+
+export const searchComponents = createPlugin({
+ name: "lattice/search",
+ components: {
+ "search.box": eagerComponent(SearchBox),
+ "search.input": eagerComponent(SearchInput),
+ "search.categories": eagerComponent(SearchCategories),
+ "search.results": eagerComponent(SearchResults),
+ "search.recent": eagerComponent(SearchRecent),
+ "search.preview": eagerComponent(SearchPreview),
+ },
+});
+
+export { default as SearchBox } from "./components/search-box";
+export { default as SearchInput } from "./components/input";
+export { default as SearchCategories } from "./components/categories";
+export { default as SearchResults } from "./components/results";
+export { default as SearchRecent } from "./components/recent";
+export { default as SearchPreview } from "./components/preview";
+export { SearchProvider, useSearchContext } from "./context";
+export { useSearch } from "./use-search";
+export type {
+ SearchResult,
+ SearchCategory,
+ SearchPagination,
+ SearchResponse,
+ RecordResponse,
+ SearchStatus,
+ UseSearchOptions,
+ UseSearchReturn,
+} from "./types";
diff --git a/resources/js/search/types.ts b/resources/js/search/types.ts
new file mode 100644
index 00000000..247bc7c0
--- /dev/null
+++ b/resources/js/search/types.ts
@@ -0,0 +1,63 @@
+export const SEARCH_DEBOUNCE_MS = 250;
+
+export type SearchResult = {
+ category: { name: string };
+ item: {
+ id: string;
+ title: string;
+ subtitle: string | null;
+ additionalInfo: string | null;
+ link: string;
+ badge: string | null;
+ };
+};
+
+export type SearchCategory = {
+ name: string;
+ label: string;
+ icon: string | null;
+ count: number | null;
+};
+export type SearchPagination = {
+ page: number;
+ perPage: number;
+ total: number;
+ hasMore: boolean;
+ nextPage: number | null;
+};
+
+export type SearchState = {
+ query: string;
+ category: string | null;
+ perPage: number;
+ countsIncluded: boolean;
+ mode: string;
+};
+export type SearchResponse = {
+ data: SearchResult[];
+ categories: SearchCategory[];
+ pagination: SearchPagination;
+ state: SearchState;
+};
+export type RecordResponse = { data: SearchResult | null; state: { recorded: boolean } };
+
+export type SearchStatus = "idle" | "loading" | "success" | "error";
+export type UseSearchOptions = { endpoint: string; perPage?: number };
+
+export type UseSearchReturn = {
+ query: string;
+ setQuery: (value: string) => void;
+ categories: SearchCategory[];
+ activeCategory: string | null;
+ setCategory: (name: string | null) => void;
+ results: SearchResult[];
+ recent: SearchResult[];
+ pagination: SearchPagination | null;
+ status: SearchStatus;
+ error: string | null;
+ focusedId: string | null;
+ setFocusedId: (id: string | null) => void;
+ loadMore: () => void;
+ openResult: (result: SearchResult) => void;
+ refreshRecent: () => void;
+};
diff --git a/resources/js/search/use-search.test.ts b/resources/js/search/use-search.test.ts
new file mode 100644
index 00000000..0ec0ecbe
--- /dev/null
+++ b/resources/js/search/use-search.test.ts
@@ -0,0 +1,154 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { router } from "@inertiajs/react";
+import { SEARCH_DEBOUNCE_MS } from "./types";
+import { useSearch } from "./use-search";
+
+vi.mock("@inertiajs/react", () => ({ router: { visit: vi.fn<(href: string) => void>() } }));
+
+function jsonResponse(body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+const page1 = {
+ data: [
+ {
+ category: { name: "products" },
+ item: {
+ id: "1",
+ title: "Widget 1",
+ subtitle: null,
+ additionalInfo: null,
+ link: "/products/1",
+ badge: null,
+ },
+ },
+ ],
+ categories: [{ name: "products", label: "Products", icon: "package", count: null }],
+ pagination: { page: 1, perPage: 1, total: 2, hasMore: true, nextPage: 2 },
+ state: { query: "wid", category: "products", perPage: 1, countsIncluded: false },
+};
+const page2 = {
+ data: [
+ {
+ category: { name: "products" },
+ item: {
+ id: "2",
+ title: "Widget 2",
+ subtitle: null,
+ additionalInfo: null,
+ link: "/products/2",
+ badge: null,
+ },
+ },
+ ],
+ categories: page1.categories,
+ pagination: { page: 2, perPage: 1, total: 2, hasMore: false, nextPage: null },
+ state: { query: "wid", category: "products", perPage: 1, countsIncluded: false },
+};
+
+describe("useSearch", () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it("debounces the query then fetches results", async () => {
+ const fetchMock = vi.fn(async () => jsonResponse(page1));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const { result } = renderHook(() => useSearch({ endpoint: "/lattice/search", perPage: 1 }));
+ act(() => result.current.setQuery("wid"));
+
+ expect(fetchMock).not.toHaveBeenCalled(); // still within debounce window
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS);
+ });
+
+ await waitFor(() => expect(result.current.results).toHaveLength(1));
+ expect(result.current.results[0]?.item.id).toBe("1");
+ });
+
+ it("appends the next page on loadMore", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse(page1))
+ .mockResolvedValueOnce(jsonResponse(page2));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const { result } = renderHook(() => useSearch({ endpoint: "/lattice/search", perPage: 1 }));
+ act(() => result.current.setQuery("wid"));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS);
+ });
+ await waitFor(() => expect(result.current.results).toHaveLength(1));
+
+ act(() => result.current.loadMore());
+ await waitFor(() => expect(result.current.results).toHaveLength(2));
+ expect(result.current.results[1]?.item.id).toBe("2");
+ });
+
+ it("ignores a stale response whose echoed query no longer matches", async () => {
+ const fetchMock = vi.fn(async (_url, _init) => {
+ const url = String(_url);
+ // The first (stale) request resolves last but echoes the old query.
+ return jsonResponse(url.includes("query=old") ? page1 : page2);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const { result } = renderHook(() => useSearch({ endpoint: "/lattice/search", perPage: 1 }));
+ act(() => result.current.setQuery("old"));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS);
+ });
+ act(() => result.current.setQuery("new"));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS);
+ });
+
+ await waitFor(() => expect(result.current.results[0]?.item.id).toBe("2"));
+ });
+
+ it("records the selection over POST then navigates to the re-resolved link", async () => {
+ vi.useRealTimers();
+ const recordResponse = {
+ data: {
+ category: { name: "products" },
+ item: {
+ id: "9",
+ title: "Canonical",
+ subtitle: null,
+ additionalInfo: null,
+ link: "/products/9",
+ badge: null,
+ },
+ },
+ state: { recorded: false },
+ };
+ const fetchMock = vi.fn(async () => jsonResponse(recordResponse));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const { result } = renderHook(() => useSearch({ endpoint: "/lattice/search", perPage: 1 }));
+ // The client-supplied link is deliberately wrong; navigation must use the re-resolved row.
+ const clicked = {
+ category: { name: "products" },
+ item: {
+ id: "9",
+ title: "Client Title",
+ subtitle: null,
+ additionalInfo: null,
+ link: "/client/9",
+ badge: null,
+ },
+ };
+
+ result.current.openResult(clicked);
+
+ await waitFor(() => expect(router.visit).toHaveBeenCalledWith("/products/9"));
+ expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST");
+ });
+});
diff --git a/resources/js/search/use-search.ts b/resources/js/search/use-search.ts
new file mode 100644
index 00000000..85c39837
--- /dev/null
+++ b/resources/js/search/use-search.ts
@@ -0,0 +1,192 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { router } from "@inertiajs/react";
+import { apiJson } from "@lattice-php/lattice/core/api";
+import {
+ SEARCH_DEBOUNCE_MS,
+ type RecordResponse,
+ type SearchCategory,
+ type SearchPagination,
+ type SearchResponse,
+ type SearchResult,
+ type UseSearchOptions,
+ type UseSearchReturn,
+} from "./types";
+
+function buildUrl(endpoint: string, params: Record): string {
+ const search = new URLSearchParams(params).toString();
+
+ return search === "" ? endpoint : `${endpoint}?${search}`;
+}
+
+export function useSearch({ endpoint, perPage = 20 }: UseSearchOptions): UseSearchReturn {
+ const [query, setQueryState] = useState("");
+ const [categories, setCategories] = useState([]);
+ const [activeCategory, setActiveCategory] = useState(null);
+ const [results, setResults] = useState([]);
+ const [recent, setRecent] = useState([]);
+ const [pagination, setPagination] = useState(null);
+ const [status, setStatus] = useState("idle");
+ const [error, setError] = useState(null);
+ const [focusedId, setFocusedId] = useState(null);
+
+ const abortRef = useRef(null);
+ const requestIdRef = useRef(0);
+ const debounceRef = useRef | null>(null);
+
+ const run = useCallback(
+ async (
+ nextQuery: string,
+ category: string | null,
+ page: number,
+ append: boolean,
+ ): Promise => {
+ abortRef.current?.abort();
+ const controller = new AbortController();
+ abortRef.current = controller;
+ const requestId = (requestIdRef.current += 1);
+
+ setStatus("loading");
+ setError(null);
+
+ try {
+ const params: Record = {
+ query: nextQuery,
+ page: String(page),
+ per_page: String(perPage),
+ counts: "1",
+ };
+ if (category !== null) {
+ params.category = category;
+ }
+
+ const payload = await apiJson(buildUrl(endpoint, params), {
+ signal: controller.signal,
+ });
+
+ // Stale-response guard: drop anything superseded by a newer request.
+ if (requestId !== requestIdRef.current) {
+ return;
+ }
+
+ setCategories(payload.categories);
+ setActiveCategory(payload.state.category);
+ setPagination(payload.pagination);
+ setResults((current) => (append ? [...current, ...payload.data] : payload.data));
+ setFocusedId((current) => current ?? payload.data[0]?.item.id ?? null);
+ setStatus("success");
+ } catch (caught) {
+ if (caught instanceof DOMException && caught.name === "AbortError") {
+ return;
+ }
+
+ setError(caught instanceof Error ? caught.message : String(caught));
+ setStatus("error");
+ }
+ },
+ [endpoint, perPage],
+ );
+
+ const setQuery = useCallback(
+ (value: string): void => {
+ setQueryState(value);
+ setFocusedId(null);
+
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ }
+
+ debounceRef.current = setTimeout(async () => {
+ await run(value, activeCategory, 1, false);
+ }, SEARCH_DEBOUNCE_MS);
+ },
+ [activeCategory, run],
+ );
+
+ const setCategory = useCallback(
+ (name: string | null): void => {
+ setActiveCategory(name);
+ setFocusedId(null);
+ void run(query, name, 1, false);
+ },
+ [query, run],
+ );
+
+ const loadMore = useCallback((): void => {
+ if (pagination?.nextPage == null || status === "loading") {
+ return;
+ }
+
+ void run(query, activeCategory, pagination.nextPage, true);
+ }, [activeCategory, pagination, query, run, status]);
+
+ const refreshRecent = useCallback(async (): Promise => {
+ try {
+ const payload = await apiJson(
+ buildUrl(endpoint, { recent: "1", per_page: String(perPage) }),
+ );
+ setRecent(payload.data);
+ } catch {
+ setRecent([]);
+ }
+ }, [endpoint, perPage]);
+
+ const openResult = useCallback(
+ async (result: SearchResult): Promise => {
+ let target = result;
+
+ try {
+ const payload = await apiJson(endpoint, {
+ method: "POST",
+ body: JSON.stringify({ category: result.category.name, id: result.item.id }),
+ throwOnError: false,
+ });
+ if (payload.data) {
+ target = payload.data;
+ }
+ } catch {
+ // Navigation should still proceed with the row we already have.
+ }
+
+ router.visit(target.item.link);
+ },
+ [endpoint],
+ );
+
+ useEffect(() => {
+ return () => {
+ abortRef.current?.abort();
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ }
+ };
+ }, []);
+
+ const handleOpenResult = useCallback(
+ (result: SearchResult): void => {
+ void openResult(result);
+ },
+ [openResult],
+ );
+
+ const handleRefreshRecent = useCallback((): void => {
+ void refreshRecent();
+ }, [refreshRecent]);
+
+ return {
+ query,
+ setQuery,
+ categories,
+ activeCategory,
+ setCategory,
+ results,
+ recent,
+ pagination,
+ status,
+ error,
+ focusedId,
+ setFocusedId,
+ loadMore,
+ openResult: handleOpenResult,
+ refreshRecent: handleRefreshRecent,
+ };
+}
diff --git a/resources/js/test/setup.ts b/resources/js/test/setup.ts
index 6be848fa..1b5127de 100644
--- a/resources/js/test/setup.ts
+++ b/resources/js/test/setup.ts
@@ -1,6 +1,13 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react";
-import { afterEach } from "vitest";
+import { afterEach, vi } from "vitest";
+
+// Allow @testing-library/dom's `jestFakeTimersAreEnabled()` to detect vitest
+// fake timers. Without this, `waitFor` uses a fake `setTimeout(resolve, 0)`
+// inside its async wrapper that never fires when fake timers are active.
+// See: https://github.com/testing-library/dom-testing-library/blob/main/src/helpers.ts
+// @ts-expect-error — jest is not declared as a global in this project
+globalThis.jest = vi;
configure({ testIdAttribute: "data-test" });
diff --git a/resources/js/types/generated.ts b/resources/js/types/generated.ts
index 38fc480f..d42cb26e 100644
--- a/resources/js/types/generated.ts
+++ b/resources/js/types/generated.ts
@@ -361,6 +361,37 @@ export type CoreNode =
key?: string;
props: RawBlock;
}
+ | {
+ type: "search.box";
+ key?: string;
+ props: SearchBox;
+ schema?: Node[];
+ }
+ | {
+ type: "search.categories";
+ key?: string;
+ props: SearchCategories;
+ }
+ | {
+ type: "search.input";
+ key?: string;
+ props: SearchInput;
+ }
+ | {
+ type: "search.preview";
+ key?: string;
+ props: SearchPreview;
+ }
+ | {
+ type: "search.recent";
+ key?: string;
+ props: SearchRecent;
+ }
+ | {
+ type: "search.results";
+ key?: string;
+ props: SearchResults;
+ }
| {
type: "section";
key?: string;
@@ -1013,6 +1044,18 @@ export type RowAction = {
};
export type RowActionType = "duplicate" | "remove";
export type RowLayout = "stack" | "table";
+export type SearchBox = {
+ endpoint: string | null;
+ perPage: number;
+ placeholder: string | null;
+ shortcut: boolean;
+ title: string | null;
+};
+export type SearchCategories = Record;
+export type SearchInput = Record;
+export type SearchPreview = Record;
+export type SearchRecent = Record;
+export type SearchResults = Record;
export type Section = {
collapsed: boolean;
collapsible: boolean;
diff --git a/routes/web.php b/routes/web.php
index bd43e5bd..30b573a0 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -7,6 +7,7 @@
use Lattice\Lattice\Http\Controllers\FormController;
use Lattice\Lattice\Http\Controllers\FragmentController;
use Lattice\Lattice\Http\Controllers\RemoteSourceTokenController;
+use Lattice\Lattice\Http\Controllers\SearchController;
use Lattice\Lattice\Http\Controllers\TableController;
Route::middleware(config('lattice.forms.middleware', ['web', 'auth']))
@@ -38,3 +39,11 @@
->match(['post', 'put', 'patch', 'delete'], config('lattice.bulk-actions.endpoint', 'lattice/bulk-actions/{bulkAction}'), BulkActionController::class)
->where('bulkAction', '.*')
->name('lattice.bulk-actions.handle');
+
+Route::middleware(config('lattice.search.middleware', ['web', 'auth']))
+ ->get(config('lattice.search.endpoint', 'lattice/search'), [SearchController::class, 'search'])
+ ->name('lattice.search.query');
+
+Route::middleware(config('lattice.search.middleware', ['web', 'auth']))
+ ->post(config('lattice.search.endpoint', 'lattice/search'), [SearchController::class, 'record'])
+ ->name('lattice.search.record');
diff --git a/src/Attributes/AsSearchProvider.php b/src/Attributes/AsSearchProvider.php
new file mode 100644
index 00000000..9b4517c7
--- /dev/null
+++ b/src/Attributes/AsSearchProvider.php
@@ -0,0 +1,7 @@
+endpoint = $endpoint;
+
+ return $this;
+ }
+
+ public function placeholder(string $placeholder): static
+ {
+ $this->placeholder = $placeholder;
+
+ return $this;
+ }
+
+ public function title(string $title): static
+ {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ public function shortcut(bool $shortcut = true): static
+ {
+ $this->shortcut = $shortcut;
+
+ return $this;
+ }
+
+ public function perPage(int $perPage): static
+ {
+ $this->perPage = $perPage;
+
+ return $this;
+ }
+}
diff --git a/src/Core/Components/SearchCategories.php b/src/Core/Components/SearchCategories.php
new file mode 100644
index 00000000..dc3adc9f
--- /dev/null
+++ b/src/Core/Components/SearchCategories.php
@@ -0,0 +1,15 @@
+ AsFragment::class,
'remote-sources' => AsRemoteSource::class,
'layouts' => AsLayout::class,
+ 'search' => AsSearchProvider::class,
];
public const string PAGE_ATTRIBUTE = AsPage::class;
diff --git a/src/Core/Enums/Icon.php b/src/Core/Enums/Icon.php
index e2f63f3b..4838337c 100644
--- a/src/Core/Enums/Icon.php
+++ b/src/Core/Enums/Icon.php
@@ -42,11 +42,13 @@ enum Icon: string
case Loader2 = 'loader-2';
case Minus = 'minus';
case MoreHorizontal = 'more-horizontal';
+ case Package = 'package';
case PanelLeft = 'panel-left';
case Pencil = 'pencil';
case PencilLine = 'pencil-line';
case Plus = 'plus';
case Quote = 'quote';
+ case Receipt = 'receipt';
case RotateCcw = 'rotate-ccw';
case Rows3 = 'rows-3';
case Search = 'search';
@@ -57,5 +59,6 @@ enum Icon: string
case Table = 'table';
case Trash2 = 'trash-2';
case Underline = 'underline';
+ case Users = 'users';
case X = 'x';
}
diff --git a/src/Http/Controllers/SearchController.php b/src/Http/Controllers/SearchController.php
new file mode 100644
index 00000000..7fa3a221
--- /dev/null
+++ b/src/Http/Controllers/SearchController.php
@@ -0,0 +1,125 @@
+providers->authorized($request);
+
+ if ($request->wantsRecent()) {
+ return response()->json([
+ 'data' => $this->history->recent($request, $request->perPage()),
+ 'categories' => $this->categories($request, $authorized),
+ 'pagination' => SearchPagination::forPage(1, $request->perPage(), 0),
+ 'state' => $this->state($request, null, 'recent'),
+ ]);
+ }
+
+ $categories = $this->categories($request, $authorized);
+ $active = $this->activeCategory($request, $categories);
+ $provider = $active !== null ? ($authorized[$active] ?? null) : null;
+
+ $results = $provider instanceof SearchResultProvider
+ ? $provider->search(new SearchQuery($request->queryString(), $active, $request->page(), $request->perPage(), app()->getLocale()))
+ : new SearchResults([], 0);
+
+ return response()->json([
+ 'data' => $results->rows,
+ 'categories' => $categories,
+ 'pagination' => SearchPagination::forPage($request->page(), $request->perPage(), $results->total),
+ 'state' => $this->state($request, $active),
+ ]);
+ }
+
+ public function record(RecordSelectionRequest $request): JsonResponse
+ {
+ /** @var string $category */
+ $category = $request->validated('category');
+ /** @var string $id */
+ $id = $request->validated('id');
+
+ $provider = $this->providers->forCategory($category);
+
+ abort_if($provider === null, 404);
+ abort_unless($provider->authorize($request), 403);
+
+ $result = $provider->resolve($id, $request);
+ $recorded = $result !== null && $this->history->record($request, $result);
+
+ return response()->json([
+ 'data' => $result,
+ 'state' => ['recorded' => $recorded],
+ ]);
+ }
+
+ /**
+ * @param array $authorized
+ * @return array
+ */
+ private function categories(SearchRequest $request, array $authorized): array
+ {
+ return array_values(array_map(function (SearchResultProvider $provider) use ($request): SearchCategory {
+ $category = $provider->category();
+
+ if (! $request->wantsCounts()) {
+ return $category;
+ }
+
+ return $category->withCount($provider->count(
+ new SearchQuery($request->queryString(), $category->name, 1, $request->perPage(), app()->getLocale()),
+ ));
+ }, $authorized));
+ }
+
+ /**
+ * @param array $categories
+ */
+ private function activeCategory(SearchRequest $request, array $categories): ?string
+ {
+ $names = array_map(fn (SearchCategory $category): string => $category->name, $categories);
+
+ $requested = $request->category();
+ if ($requested !== null && in_array($requested, $names, true)) {
+ return $requested;
+ }
+
+ if ($request->wantsCounts() && $categories !== []) {
+ usort($categories, fn (SearchCategory $a, SearchCategory $b): int => ($b->count ?? 0) <=> ($a->count ?? 0));
+
+ return $categories[0]->name;
+ }
+
+ return $names[0] ?? null;
+ }
+
+ /** @return array{query:string,category:?string,perPage:int,countsIncluded:bool,mode:string} */
+ private function state(SearchRequest $request, ?string $active, string $mode = 'results'): array
+ {
+ return [
+ 'query' => $request->queryString(),
+ 'category' => $active,
+ 'perPage' => $request->perPage(),
+ 'countsIncluded' => $request->wantsCounts(),
+ 'mode' => $mode,
+ ];
+ }
+}
diff --git a/src/Http/Requests/RecordSelectionRequest.php b/src/Http/Requests/RecordSelectionRequest.php
new file mode 100644
index 00000000..5d799373
--- /dev/null
+++ b/src/Http/Requests/RecordSelectionRequest.php
@@ -0,0 +1,23 @@
+ */
+ public function rules(): array
+ {
+ return [
+ 'category' => ['required', 'string', 'max:255'],
+ 'id' => ['required', 'string', 'max:255'],
+ ];
+ }
+}
diff --git a/src/Http/Requests/SearchRequest.php b/src/Http/Requests/SearchRequest.php
new file mode 100644
index 00000000..5d379570
--- /dev/null
+++ b/src/Http/Requests/SearchRequest.php
@@ -0,0 +1,59 @@
+ */
+ public function rules(): array
+ {
+ return [
+ 'query' => ['nullable', 'string', 'max:255'],
+ 'category' => ['nullable', 'string', 'max:255'],
+ 'page' => ['nullable', 'integer', 'min:1'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
+ 'counts' => ['nullable', 'boolean'],
+ 'recent' => ['nullable', 'boolean'],
+ ];
+ }
+
+ public function queryString(): string
+ {
+ return (string) $this->input('query', '');
+ }
+
+ public function page(): int
+ {
+ return (int) $this->input('page', 1);
+ }
+
+ public function perPage(): int
+ {
+ return (int) $this->input('per_page', 20);
+ }
+
+ public function wantsCounts(): bool
+ {
+ return $this->boolean('counts');
+ }
+
+ public function wantsRecent(): bool
+ {
+ return $this->boolean('recent');
+ }
+
+ public function category(): ?string
+ {
+ $category = $this->input('category');
+
+ return is_string($category) && $category !== '' ? $category : null;
+ }
+}
diff --git a/src/LatticeRegistry.php b/src/LatticeRegistry.php
index 3fe8a9a0..1a8db6e0 100644
--- a/src/LatticeRegistry.php
+++ b/src/LatticeRegistry.php
@@ -18,6 +18,8 @@
use Lattice\Lattice\Layouts\LayoutRegistry;
use Lattice\Lattice\Remote\RemoteSourceDefinition;
use Lattice\Lattice\Remote\RemoteSourceRegistry;
+use Lattice\Lattice\Search\Contracts\SearchResultProvider;
+use Lattice\Lattice\Search\SearchResultProviderRegistry;
use Lattice\Lattice\Tables\TableDefinition;
use Lattice\Lattice\Tables\TableRegistry;
@@ -32,6 +34,7 @@ public function __construct(
private PageRegistry $pages,
private TableRegistry $tables,
private RemoteSourceRegistry $remoteSources,
+ private SearchResultProviderRegistry $searchProviders,
) {}
/**
@@ -120,4 +123,17 @@ public function remoteSourceRegistry(): RemoteSourceRegistry
{
return $this->remoteSources;
}
+
+ /**
+ * @param class-string|array> $providers
+ */
+ public function searchProviders(string|array $providers): void
+ {
+ $this->searchProviders->register($providers);
+ }
+
+ public function searchProviderRegistry(): SearchResultProviderRegistry
+ {
+ return $this->searchProviders;
+ }
}
diff --git a/src/LatticeServiceProvider.php b/src/LatticeServiceProvider.php
index e3144d4a..232c3ea8 100644
--- a/src/LatticeServiceProvider.php
+++ b/src/LatticeServiceProvider.php
@@ -31,6 +31,9 @@
use Lattice\Lattice\Http\PageRegistry;
use Lattice\Lattice\Layouts\LayoutRegistry;
use Lattice\Lattice\Remote\RemoteSourceRegistry;
+use Lattice\Lattice\Search\Contracts\SearchHistoryRecorder;
+use Lattice\Lattice\Search\NullSearchHistoryRecorder;
+use Lattice\Lattice\Search\SearchResultProviderRegistry;
use Lattice\Lattice\Support\Evaluation\Evaluator;
use Lattice\Lattice\Support\TypeScript\AugmentProfile;
use Lattice\Lattice\Support\TypeScript\TypeScriptProfile;
@@ -68,6 +71,8 @@ public function packageRegistered(): void
$this->app->singleton(BulkActionRegistry::class);
$this->app->singleton(PageRegistry::class);
$this->app->singleton(RemoteSourceRegistry::class);
+ $this->app->singleton(SearchResultProviderRegistry::class);
+ $this->app->bind(SearchHistoryRecorder::class, NullSearchHistoryRecorder::class);
$this->app->singleton(ComponentReferenceSigner::class);
$this->app->alias(ComponentReferenceSigner::class, SignsComponentReferences::class);
$this->app->singleton(LatticeRegistry::class);
diff --git a/src/Search/Contracts/SearchHistoryRecorder.php b/src/Search/Contracts/SearchHistoryRecorder.php
new file mode 100644
index 00000000..8bc51f09
--- /dev/null
+++ b/src/Search/Contracts/SearchHistoryRecorder.php
@@ -0,0 +1,16 @@
+ */
+ public function recent(Request $request, int $limit): array;
+}
diff --git a/src/Search/Contracts/SearchResultProvider.php b/src/Search/Contracts/SearchResultProvider.php
new file mode 100644
index 00000000..a9d13809
--- /dev/null
+++ b/src/Search/Contracts/SearchResultProvider.php
@@ -0,0 +1,23 @@
+name, $this->label, $this->icon, $count);
+ }
+
+ /** @return array{name:string,label:string,icon:?string,count:?int} */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'name' => $this->name,
+ 'label' => $this->label,
+ 'icon' => $this->icon,
+ 'count' => $this->count,
+ ];
+ }
+}
diff --git a/src/Search/SearchPagination.php b/src/Search/SearchPagination.php
new file mode 100644
index 00000000..f2d1acc2
--- /dev/null
+++ b/src/Search/SearchPagination.php
@@ -0,0 +1,36 @@
+ $this->page,
+ 'perPage' => $this->perPage,
+ 'total' => $this->total,
+ 'hasMore' => $this->hasMore,
+ 'nextPage' => $this->nextPage,
+ ];
+ }
+}
diff --git a/src/Search/SearchQuery.php b/src/Search/SearchQuery.php
new file mode 100644
index 00000000..29c5daaa
--- /dev/null
+++ b/src/Search/SearchQuery.php
@@ -0,0 +1,15 @@
+} */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'category' => ['name' => $this->category],
+ 'item' => $this->item->jsonSerialize(),
+ ];
+ }
+}
diff --git a/src/Search/SearchResultItem.php b/src/Search/SearchResultItem.php
new file mode 100644
index 00000000..39dd0135
--- /dev/null
+++ b/src/Search/SearchResultItem.php
@@ -0,0 +1,31 @@
+ $this->id,
+ 'title' => $this->title,
+ 'subtitle' => $this->subtitle,
+ 'additionalInfo' => $this->additionalInfo,
+ 'link' => $this->link,
+ 'badge' => $this->badge,
+ ];
+ }
+}
diff --git a/src/Search/SearchResultProviderRegistry.php b/src/Search/SearchResultProviderRegistry.php
new file mode 100644
index 00000000..2e7afaef
--- /dev/null
+++ b/src/Search/SearchResultProviderRegistry.php
@@ -0,0 +1,77 @@
+> */
+ private array $registered = [];
+
+ public function __construct(
+ private readonly Container $container,
+ private readonly DiscoveryManifest $manifest,
+ ) {}
+
+ /**
+ * @param class-string|array> $providers
+ */
+ public function register(string|array $providers): void
+ {
+ foreach ((array) $providers as $provider) {
+ $this->registered[$this->keyFor($provider)] = $provider;
+ }
+ }
+
+ /** @return array */
+ public function all(): array
+ {
+ /** @var array> $discovered */
+ $discovered = $this->manifest->forGroup('search');
+
+ return array_map(
+ fn (string $class): SearchResultProvider => $this->container->make($class),
+ array_merge($discovered, $this->registered),
+ );
+ }
+
+ public function forCategory(string $name): ?SearchResultProvider
+ {
+ return $this->all()[$name] ?? null;
+ }
+
+ /** @return array */
+ public function authorized(Request $request): array
+ {
+ return array_filter(
+ $this->all(),
+ fn (SearchResultProvider $provider): bool => $provider->authorize($request),
+ );
+ }
+
+ /**
+ * @param class-string $provider
+ */
+ private function keyFor(string $provider): string
+ {
+ if (! is_subclass_of($provider, SearchResultProvider::class)) {
+ throw new InvalidArgumentException("[{$provider}] must implement ".SearchResultProvider::class.'.');
+ }
+
+ $attribute = Attributes::get($provider, AsSearchProvider::class);
+
+ if (! $attribute instanceof AsSearchProvider) {
+ throw new InvalidArgumentException("[{$provider}] is missing the #[AsSearchProvider] attribute.");
+ }
+
+ return $attribute->key;
+ }
+}
diff --git a/src/Search/SearchResults.php b/src/Search/SearchResults.php
new file mode 100644
index 00000000..0b92a161
--- /dev/null
+++ b/src/Search/SearchResults.php
@@ -0,0 +1,13 @@
+ $rows */
+ public function __construct(
+ public array $rows,
+ public int $total,
+ ) {}
+}
diff --git a/tests/Feature/Http/PageTest.php b/tests/Feature/Http/PageTest.php
index 7967a8e9..e5b6167e 100644
--- a/tests/Feature/Http/PageTest.php
+++ b/tests/Feature/Http/PageTest.php
@@ -120,8 +120,9 @@
->where('lattice.layout.schema.0.schema.0.schema.0.schema.0.schema.1.schema.1.schema.2.props.href', '/builder-table')
->where('lattice.layout.schema.0.schema.0.schema.0.schema.1.schema.0.type', 'menu-item')
->where('lattice.layout.schema.0.schema.0.schema.0.schema.1.schema.0.props.label', 'Log out')
- ->where('lattice.layout.schema.0.schema.1.schema.0.type', 'breadcrumbs')
- ->where('lattice.layout.schema.0.schema.1.schema.1.type', 'outlet')
+ ->where('lattice.layout.schema.0.schema.1.schema.0.type', 'search.box')
+ ->where('lattice.layout.schema.0.schema.1.schema.1.type', 'breadcrumbs')
+ ->where('lattice.layout.schema.0.schema.1.schema.2.type', 'outlet')
->where('lattice.layout.schema.1.type', 'floating-panel')
->where('lattice.layout.schema.1.key', 'locale-switcher-panel')
->where('lattice.layout.schema.1.props.label', 'Language')
diff --git a/tests/Feature/Search/DiscoveryTest.php b/tests/Feature/Search/DiscoveryTest.php
new file mode 100644
index 00000000..5049690d
--- /dev/null
+++ b/tests/Feature/Search/DiscoveryTest.php
@@ -0,0 +1,14 @@
+forGroup('search');
+
+ expect($group)->toHaveKey('products');
+ expect($group['products'])->toBe(DiscoveredProductsSearchProvider::class);
+});
diff --git a/tests/Feature/Search/FacadeTest.php b/tests/Feature/Search/FacadeTest.php
new file mode 100644
index 00000000..7ae3a0c3
--- /dev/null
+++ b/tests/Feature/Search/FacadeTest.php
@@ -0,0 +1,17 @@
+ []]);
+ app(SearchResultProviderRegistry::class); // ensure singleton built before manifest clear
+ app(DiscoveryManifest::class)->clear();
+
+ Lattice::searchProviders([DiscoveredProductsSearchProvider::class]);
+
+ expect(Lattice::searchProviderRegistry()->forCategory('products'))->not->toBeNull();
+});
diff --git a/tests/Feature/Search/RecordEndpointTest.php b/tests/Feature/Search/RecordEndpointTest.php
new file mode 100644
index 00000000..7157fae6
--- /dev/null
+++ b/tests/Feature/Search/RecordEndpointTest.php
@@ -0,0 +1,49 @@
+ '7', 'title' => 'Canonical Widget']];
+ discoverFixtures();
+ app()->bind(SearchHistoryRecorder::class, NullSearchHistoryRecorder::class);
+ actingAs(UserFactory::new()->create());
+});
+
+test('record re-resolves through the provider and ignores client payload', function () {
+ $response = postJson('/lattice/search', [
+ 'category' => 'products',
+ 'id' => '7',
+ 'item' => ['title' => 'TAMPERED', 'link' => 'https://evil.example/x'],
+ ]);
+
+ $response->assertOk();
+ expect($response->json('data.item.title'))->toBe('Canonical Widget')
+ ->and($response->json('data.item.link'))->toBe('/products/7')
+ ->and($response->json('state.recorded'))->toBeFalse();
+});
+
+test('record validates the payload', function () {
+ postJson('/lattice/search', [])->assertStatus(422);
+});
+
+test('record 404s an unknown category and 403s an unauthorized one', function () {
+ postJson('/lattice/search', ['category' => 'missing', 'id' => '7'])->assertStatus(404);
+
+ DiscoveredProductsSearchProvider::$authorized = false;
+ postJson('/lattice/search', ['category' => 'products', 'id' => '7'])->assertStatus(403);
+});
+
+test('recent returns an empty list with the default recorder', function () {
+ actingAs(UserFactory::new()->create());
+ $response = \Pest\Laravel\getJson('/lattice/search?recent=1');
+ $response->assertOk();
+ expect($response->json('data'))->toBe([]);
+});
diff --git a/tests/Feature/Search/RegistryTest.php b/tests/Feature/Search/RegistryTest.php
new file mode 100644
index 00000000..be688f0a
--- /dev/null
+++ b/tests/Feature/Search/RegistryTest.php
@@ -0,0 +1,28 @@
+forCategory('products'))->toBeInstanceOf(SearchResultProvider::class);
+ expect($registry->forCategory('missing'))->toBeNull();
+ expect(array_keys($registry->all()))->toContain('products');
+});
+
+test('the registry filters unauthorized providers', function () {
+ DiscoveredProductsSearchProvider::$authorized = false;
+
+ $authorized = app(SearchResultProviderRegistry::class)->authorized(new Request);
+
+ expect($authorized)->not->toHaveKey('products');
+});
diff --git a/tests/Feature/Search/SearchEndpointTest.php b/tests/Feature/Search/SearchEndpointTest.php
new file mode 100644
index 00000000..7cfa9a37
--- /dev/null
+++ b/tests/Feature/Search/SearchEndpointTest.php
@@ -0,0 +1,62 @@
+ ['id' => (string) $n, 'title' => "Widget {$n}"],
+ range(1, 25),
+ );
+ discoverFixtures();
+ actingAs(UserFactory::new()->create());
+});
+
+test('config exposes the default endpoint and middleware', function () {
+ expect(config('lattice.search.endpoint'))->toBe('lattice/search');
+ expect(config('lattice.search.middleware'))->toBe(['web', 'auth']);
+});
+
+test('search returns the closed response envelope', function () {
+ $response = getJson('/lattice/search?query=Widget&category=products&per_page=10');
+
+ $response->assertOk()
+ ->assertJsonStructure([
+ 'data' => [['category' => ['name'], 'item' => ['id', 'title', 'subtitle', 'additionalInfo', 'link', 'badge']]],
+ 'categories' => [['name', 'label', 'icon', 'count']],
+ 'pagination' => ['page', 'perPage', 'total', 'hasMore', 'nextPage'],
+ 'state' => ['query', 'category', 'perPage', 'countsIncluded'],
+ ]);
+
+ expect($response->json('pagination.total'))->toBe(25)
+ ->and($response->json('pagination.hasMore'))->toBeTrue()
+ ->and($response->json('pagination.nextPage'))->toBe(2)
+ ->and($response->json('data'))->toHaveCount(10)
+ ->and($response->json('data.0.category.name'))->toBe('products')
+ ->and($response->json('state.query'))->toBe('Widget');
+});
+
+test('counts are present only when requested', function () {
+ expect(getJson('/lattice/search?query=Widget&category=products')->json('categories.0.count'))->toBeNull();
+ expect(getJson('/lattice/search?query=Widget&category=products&counts=1')->json('categories.0.count'))->toBe(25);
+ expect(getJson('/lattice/search?query=Widget&category=products')->json('state.countsIncluded'))->toBeFalse();
+});
+
+test('unauthorized providers are excluded', function () {
+ DiscoveredProductsSearchProvider::$authorized = false;
+
+ $response = getJson('/lattice/search?query=Widget');
+
+ expect($response->json('categories'))->toBe([])
+ ->and($response->json('data'))->toBe([]);
+});
+
+test('search validates pagination parameters', function () {
+ getJson('/lattice/search?per_page=999')->assertStatus(422);
+ getJson('/lattice/search?page=0')->assertStatus(422);
+});
diff --git a/tests/Feature/Search/SessionRecorderTest.php b/tests/Feature/Search/SessionRecorderTest.php
new file mode 100644
index 00000000..9c6b9b07
--- /dev/null
+++ b/tests/Feature/Search/SessionRecorderTest.php
@@ -0,0 +1,41 @@
+setLaravelSession(new Store('test', new ArraySessionHandler(120)));
+
+ return $request;
+}
+
+test('records a selection and reads it back', function () {
+ $request = sessionRecorderRequest();
+ $recorder = new SessionSearchHistoryRecorder;
+ $result = new SearchResult('products', new SearchResultItem(id: '1', title: 'Widget', link: '/products/1'));
+
+ expect($recorder->record($request, $result))->toBeTrue();
+
+ $recent = $recorder->recent($request, 10);
+ expect($recent)->toHaveCount(1);
+ expect($recent[0]->item->id)->toBe('1')
+ ->and($recent[0]->category)->toBe('products');
+});
+
+test('recording the same selection twice dedupes to one entry', function () {
+ $request = sessionRecorderRequest();
+ $recorder = new SessionSearchHistoryRecorder;
+ $result = new SearchResult('products', new SearchResultItem(id: '1', title: 'Widget', link: '/products/1'));
+
+ $recorder->record($request, $result);
+ $recorder->record($request, $result);
+
+ expect($recorder->recent($request, 10))->toHaveCount(1);
+});
diff --git a/tests/Feature/Search/WorkbenchProviderTest.php b/tests/Feature/Search/WorkbenchProviderTest.php
new file mode 100644
index 00000000..2b90e624
--- /dev/null
+++ b/tests/Feature/Search/WorkbenchProviderTest.php
@@ -0,0 +1,22 @@
+create(['name' => 'Aurora Lamp', 'sku' => 'LAMP-1', 'status' => 'active']);
+ Product::factory()->create(['name' => 'Borealis Chair', 'sku' => 'CHAIR-1', 'status' => 'active']);
+
+ actingAs(UserFactory::new()->create());
+
+ $response = getJson('/lattice/search?query=Aurora&category=products&counts=1');
+
+ $response->assertOk();
+ expect($response->json('data'))->toHaveCount(1)
+ ->and($response->json('data.0.item.title'))->toContain('Aurora')
+ ->and($response->json('data.0.category.name'))->toBe('products');
+});
diff --git a/tests/Fixtures/Discovery/DiscoveredProductsSearchProvider.php b/tests/Fixtures/Discovery/DiscoveredProductsSearchProvider.php
new file mode 100644
index 00000000..fafdd9f5
--- /dev/null
+++ b/tests/Fixtures/Discovery/DiscoveredProductsSearchProvider.php
@@ -0,0 +1,79 @@
+ */
+ public static array $rows = [];
+
+ public static bool $authorized = true;
+
+ public function authorize(Request $request): bool
+ {
+ return self::$authorized;
+ }
+
+ public function category(): SearchCategory
+ {
+ return new SearchCategory('products', 'Products', 'package');
+ }
+
+ public function count(SearchQuery $query): int
+ {
+ return count($this->matching($query->query));
+ }
+
+ public function search(SearchQuery $query): SearchResults
+ {
+ $matching = $this->matching($query->query);
+ $offset = ($query->page - 1) * $query->perPage;
+ $slice = array_slice($matching, $offset, $query->perPage);
+
+ $rows = array_map(
+ fn (array $row): SearchResult => new SearchResult('products', new SearchResultItem(
+ id: $row['id'], title: $row['title'], link: '/products/'.$row['id'],
+ )),
+ $slice,
+ );
+
+ return new SearchResults($rows, count($matching));
+ }
+
+ public function resolve(string $id, Request $request): ?SearchResult
+ {
+ foreach (self::$rows as $row) {
+ if ($row['id'] === $id) {
+ return new SearchResult('products', new SearchResultItem(
+ id: $row['id'], title: $row['title'], link: '/products/'.$row['id'],
+ ));
+ }
+ }
+
+ return null;
+ }
+
+ /** @return array */
+ private function matching(string $query): array
+ {
+ if ($query === '') {
+ return self::$rows;
+ }
+
+ return array_values(array_filter(
+ self::$rows,
+ fn (array $row): bool => str_contains(strtolower($row['title']), strtolower($query)),
+ ));
+ }
+}
diff --git a/tests/Unit/Search/ComponentWireShapeTest.php b/tests/Unit/Search/ComponentWireShapeTest.php
new file mode 100644
index 00000000..7e9696ee
--- /dev/null
+++ b/tests/Unit/Search/ComponentWireShapeTest.php
@@ -0,0 +1,33 @@
+endpoint('/lattice/search')
+ ->placeholder('Search…')
+ ->title('Search')
+ ->schema([
+ SearchInput::make(),
+ SearchResults::make(),
+ ]),
+ );
+
+ expect($node)->toMatchArray([
+ 'type' => 'search.box',
+ 'props' => [
+ 'endpoint' => '/lattice/search',
+ 'placeholder' => 'Search…',
+ 'title' => 'Search',
+ 'shortcut' => true,
+ 'perPage' => 20,
+ ],
+ ]);
+
+ expect($node['schema'][0]['type'])->toBe('search.input');
+ expect($node['schema'][1]['type'])->toBe('search.results');
+});
diff --git a/tests/Unit/Search/NullSearchHistoryRecorderTest.php b/tests/Unit/Search/NullSearchHistoryRecorderTest.php
new file mode 100644
index 00000000..e1388888
--- /dev/null
+++ b/tests/Unit/Search/NullSearchHistoryRecorderTest.php
@@ -0,0 +1,15 @@
+record(new Request, $result))->toBeFalse();
+ expect($recorder->recent(new Request, 10))->toBe([]);
+});
diff --git a/tests/Unit/Search/WireShapeTest.php b/tests/Unit/Search/WireShapeTest.php
new file mode 100644
index 00000000..7f56fb86
--- /dev/null
+++ b/tests/Unit/Search/WireShapeTest.php
@@ -0,0 +1,50 @@
+jsonSerialize())->toBe([
+ 'category' => ['name' => 'products'],
+ 'item' => [
+ 'id' => '42', 'title' => 'Widget X', 'subtitle' => 'SKU-42',
+ 'additionalInfo' => '€19.99', 'link' => '/products/42', 'badge' => 'new',
+ ],
+ ]);
+});
+
+test('category count is null until stamped', function () {
+ $category = new SearchCategory('products', 'Products', 'package');
+ expect($category->jsonSerialize()['count'])->toBeNull();
+ expect($category->withCount(96)->jsonSerialize())->toBe([
+ 'name' => 'products', 'label' => 'Products', 'icon' => 'package', 'count' => 96,
+ ]);
+});
+
+test('pagination reports the next page when more remain', function () {
+ expect((new SearchPagination(1, 20, 96, true, 2))->jsonSerialize())->toBe([
+ 'page' => 1, 'perPage' => 20, 'total' => 96, 'hasMore' => true, 'nextPage' => 2,
+ ]);
+});
+
+test('SearchPagination::forPage calculates pagination with items remaining', function () {
+ $pagination = SearchPagination::forPage(1, 20, 96);
+ expect($pagination->jsonSerialize())->toBe([
+ 'page' => 1, 'perPage' => 20, 'total' => 96, 'hasMore' => true, 'nextPage' => 2,
+ ]);
+});
+
+test('SearchPagination::forPage handles exact last page boundary', function () {
+ $pagination = SearchPagination::forPage(5, 20, 100);
+ expect($pagination->jsonSerialize())->toBe([
+ 'page' => 5, 'perPage' => 20, 'total' => 100, 'hasMore' => false, 'nextPage' => null,
+ ]);
+});
diff --git a/vite.config.ts b/vite.config.ts
index c52c69db..751325b5 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -31,6 +31,8 @@ const latticeIcons = [
"align-center", "align-justify", "align-left", "align-right", "bold", "code",
"columns-3", "heading-1", "heading-2", "heading-3", "highlighter", "italic",
"list", "list-ordered", "quote", "rows-3", "smile", "strikethrough", "table", "underline",
+ // Global-search category icons
+ "package", "receipt", "users",
];
function libraryEntries(): string[] {
diff --git a/workbench/app/Layouts/AppLayout.php b/workbench/app/Layouts/AppLayout.php
index cd696846..11870bdf 100644
--- a/workbench/app/Layouts/AppLayout.php
+++ b/workbench/app/Layouts/AppLayout.php
@@ -11,6 +11,7 @@
use Lattice\Lattice\Core\Components\ChatBox;
use Lattice\Lattice\Core\Components\FloatingPanel;
use Lattice\Lattice\Core\Components\RawBlock;
+use Lattice\Lattice\Core\Components\SearchBox;
use Lattice\Lattice\Core\Components\Stack;
use Lattice\Lattice\Core\Enums\Align;
use Lattice\Lattice\Core\Enums\ButtonVariant;
@@ -59,6 +60,9 @@ public function schema(PageSchema $schema, Request $request): PageSchema
Stack::make('app-main')
->width(Width::Fill)
->schema([
+ SearchBox::make('search')
+ ->placeholder(__('workbench.search.placeholder'))
+ ->title(__('workbench.search.title')),
Breadcrumbs::make(),
Outlet::make(),
RawBlock::make('chat-scroll-clearance')->html(''),
diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php
index f5bfaa81..3541c4c7 100644
--- a/workbench/app/Providers/WorkbenchServiceProvider.php
+++ b/workbench/app/Providers/WorkbenchServiceProvider.php
@@ -14,8 +14,10 @@
use Laravel\Boost\Install\SkillComposer;
use Laravel\Boost\Support\Config;
use Laravel\Roster\Roster;
+use Lattice\Lattice\Search\Contracts\SearchHistoryRecorder;
use Lattice\Lattice\Support\TypeScript\TypeScriptProfile;
use Workbench\App\Models\User;
+use Workbench\App\Search\SessionSearchHistoryRecorder;
use Workbench\App\Support\BoostConfig;
use Workbench\App\Support\BoostGuidelineComposer;
use Workbench\App\Support\BoostSkillComposer;
@@ -40,6 +42,10 @@ public function register(): void
// Rebind so lattice:typescript regenerates the package's own built-in types.
$this->app->bind(TypeScriptProfile::class, BaseProfile::class);
+ $this->app->bind(
+ SearchHistoryRecorder::class,
+ SessionSearchHistoryRecorder::class,
+ );
$this->useWorkbenchDatabase();
$this->readBoostConfigFromPackageRoot();
$this->serveLatticeTranslations();
diff --git a/workbench/app/Search/BusinessPartnerSearchProvider.php b/workbench/app/Search/BusinessPartnerSearchProvider.php
new file mode 100644
index 00000000..e4a60776
--- /dev/null
+++ b/workbench/app/Search/BusinessPartnerSearchProvider.php
@@ -0,0 +1,75 @@
+query($query->query)->count();
+ }
+
+ public function search(SearchQuery $query): SearchResults
+ {
+ $builder = $this->query($query->query);
+ $total = $builder->count();
+
+ $rows = $builder
+ ->forPage($query->page, $query->perPage)
+ ->get()
+ ->map(fn (BusinessPartner $partner): SearchResult => $this->toResult($partner))
+ ->all();
+
+ return new SearchResults($rows, $total);
+ }
+
+ public function resolve(string $id, Request $request): ?SearchResult
+ {
+ $partner = BusinessPartner::query()->find($id);
+
+ return $partner === null ? null : $this->toResult($partner);
+ }
+
+ /** @return Builder */
+ private function query(string $term): Builder
+ {
+ return BusinessPartner::query()
+ ->when($term !== '', function (Builder $builder) use ($term): void {
+ $builder->where('name', 'like', "%{$term}%")->orWhere('email', 'like', "%{$term}%");
+ })
+ ->orderBy('name');
+ }
+
+ private function toResult(BusinessPartner $partner): SearchResult
+ {
+ return new SearchResult('business-partners', new SearchResultItem(
+ id: (string) $partner->getKey(),
+ title: $partner->name,
+ link: '/business-partners/'.$partner->getKey(),
+ subtitle: $partner->email,
+ ));
+ }
+}
diff --git a/workbench/app/Search/ProductSearchProvider.php b/workbench/app/Search/ProductSearchProvider.php
new file mode 100644
index 00000000..27663e20
--- /dev/null
+++ b/workbench/app/Search/ProductSearchProvider.php
@@ -0,0 +1,77 @@
+query($query->query)->count();
+ }
+
+ public function search(SearchQuery $query): SearchResults
+ {
+ $builder = $this->query($query->query);
+ $total = $builder->count();
+
+ $products = $builder
+ ->forPage($query->page, $query->perPage)
+ ->get();
+
+ $rows = $products->map(fn (Product $product): SearchResult => $this->toResult($product))->all();
+
+ return new SearchResults($rows, $total);
+ }
+
+ public function resolve(string $id, Request $request): ?SearchResult
+ {
+ $product = Product::query()->find($id);
+
+ return $product === null ? null : $this->toResult($product);
+ }
+
+ /** @return Builder */
+ private function query(string $term): Builder
+ {
+ return Product::query()
+ ->when($term !== '', function ($builder) use ($term): void {
+ $builder->where('name', 'like', "%{$term}%")->orWhere('sku', 'like', "%{$term}%");
+ })
+ ->orderBy('name');
+ }
+
+ private function toResult(Product $product): SearchResult
+ {
+ return new SearchResult('products', new SearchResultItem(
+ id: (string) $product->getKey(),
+ title: $product->name,
+ link: '/products/'.$product->getKey(),
+ subtitle: $product->sku,
+ additionalInfo: null,
+ badge: $product->status === 'active' ? null : $product->status,
+ ));
+ }
+}
diff --git a/workbench/app/Search/SalesOrderSearchProvider.php b/workbench/app/Search/SalesOrderSearchProvider.php
new file mode 100644
index 00000000..f0c7bb25
--- /dev/null
+++ b/workbench/app/Search/SalesOrderSearchProvider.php
@@ -0,0 +1,75 @@
+query($query->query)->count();
+ }
+
+ public function search(SearchQuery $query): SearchResults
+ {
+ $builder = $this->query($query->query);
+ $total = $builder->count();
+
+ $rows = $builder
+ ->with('businessPartner')
+ ->forPage($query->page, $query->perPage)
+ ->get()
+ ->map(fn (SalesOrder $order): SearchResult => $this->toResult($order))
+ ->all();
+
+ return new SearchResults($rows, $total);
+ }
+
+ public function resolve(string $id, Request $request): ?SearchResult
+ {
+ $order = SalesOrder::query()->with('businessPartner')->find($id);
+
+ return $order === null ? null : $this->toResult($order);
+ }
+
+ /** @return Builder */
+ private function query(string $term): Builder
+ {
+ return SalesOrder::query()
+ ->when($term !== '', fn (Builder $builder) => $builder->where('number', 'like', "%{$term}%"))
+ ->orderByDesc('id');
+ }
+
+ private function toResult(SalesOrder $order): SearchResult
+ {
+ return new SearchResult('sales-orders', new SearchResultItem(
+ id: (string) $order->getKey(),
+ title: $order->number,
+ link: '/sales-orders/'.$order->getKey(),
+ subtitle: $order->businessPartner?->name,
+ badge: $order->status->value,
+ ));
+ }
+}
diff --git a/workbench/app/Search/SessionSearchHistoryRecorder.php b/workbench/app/Search/SessionSearchHistoryRecorder.php
new file mode 100644
index 00000000..47430b9f
--- /dev/null
+++ b/workbench/app/Search/SessionSearchHistoryRecorder.php
@@ -0,0 +1,44 @@
+ $stored */
+ $stored = $request->session()->get(self::KEY, []);
+ $recent = collect($stored)
+ ->reject(fn (array $row): bool => $row['category']['name'] === $result->category && $row['item']['id'] === $result->item->id)
+ ->prepend($result->jsonSerialize())
+ ->take(10)
+ ->values()
+ ->all();
+
+ $request->session()->put(self::KEY, $recent);
+
+ return true;
+ }
+
+ public function recent(Request $request, int $limit): array
+ {
+ /** @var list $stored */
+ $stored = $request->session()->get(self::KEY, []);
+
+ return collect($stored)
+ ->take($limit)
+ ->map(fn (array $row): SearchResult => new SearchResult($row['category']['name'], new SearchResultItem(
+ id: $row['item']['id'], title: $row['item']['title'], link: $row['item']['link'],
+ subtitle: $row['item']['subtitle'] ?? null, additionalInfo: $row['item']['additionalInfo'] ?? null, badge: $row['item']['badge'] ?? null,
+ )))
+ ->all();
+ }
+}
diff --git a/workbench/lang/de/workbench.php b/workbench/lang/de/workbench.php
index 42710674..019697b0 100644
--- a/workbench/lang/de/workbench.php
+++ b/workbench/lang/de/workbench.php
@@ -2,6 +2,13 @@
declare(strict_types=1);
return [
+ 'search' => [
+ 'placeholder' => 'Produkte, Partner, Aufträge suchen…',
+ 'title' => 'Suche',
+ 'products' => 'Produkte',
+ 'business-partners' => 'Geschäftspartner',
+ 'sales-orders' => 'Aufträge',
+ ],
'assistant' => [
'placeholder' => 'Frag den Assistenten…',
'title' => 'Assistent',
diff --git a/workbench/lang/en/workbench.php b/workbench/lang/en/workbench.php
index ba804e05..95924ad6 100644
--- a/workbench/lang/en/workbench.php
+++ b/workbench/lang/en/workbench.php
@@ -2,6 +2,13 @@
declare(strict_types=1);
return [
+ 'search' => [
+ 'placeholder' => 'Search products, partners, orders…',
+ 'title' => 'Search',
+ 'products' => 'Products',
+ 'business-partners' => 'Business partners',
+ 'sales-orders' => 'Sales orders',
+ ],
'assistant' => [
'placeholder' => 'Ask the assistant…',
'title' => 'Assistant',
diff --git a/workbench/resources/js/sprite-icons.ts b/workbench/resources/js/sprite-icons.ts
index 06f254d3..75913ab3 100644
--- a/workbench/resources/js/sprite-icons.ts
+++ b/workbench/resources/js/sprite-icons.ts
@@ -38,11 +38,13 @@ export const iconNames = [
"loader-2",
"minus",
"more-horizontal",
+ "package",
"panel-left",
"pencil",
"pencil-line",
"plus",
"quote",
+ "receipt",
"rotate-ccw",
"rows-3",
"search",
@@ -54,6 +56,7 @@ export const iconNames = [
"table",
"trash-2",
"underline",
+ "users",
"x",
] as const;
@@ -98,11 +101,13 @@ declare module "@lattice-php/lattice" {
"loader-2": true;
minus: true;
"more-horizontal": true;
+ package: true;
"panel-left": true;
pencil: true;
"pencil-line": true;
plus: true;
quote: true;
+ receipt: true;
"rotate-ccw": true;
"rows-3": true;
search: true;
@@ -114,6 +119,7 @@ declare module "@lattice-php/lattice" {
table: true;
"trash-2": true;
underline: true;
+ users: true;
x: true;
}
}