@eifi1/ui-kit 0.8.1 → 0.9.0

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.
Files changed (101) hide show
  1. package/README.md +4 -0
  2. package/dist/components/amount-input.d.ts +2 -0
  3. package/dist/components/calculator.d.ts +2 -0
  4. package/dist/components/chip.d.ts +27 -5
  5. package/dist/components/chip.js +6 -2
  6. package/dist/components/chip.js.map +1 -1
  7. package/dist/components/data-table-filter-popover.d.ts +1 -1
  8. package/dist/components/data-table-filters.d.ts +1 -1
  9. package/dist/components/data-table.d.ts +1 -1
  10. package/dist/components/data-table.js +122 -91
  11. package/dist/components/data-table.js.map +1 -1
  12. package/dist/components/date-picker.d.ts +69 -3
  13. package/dist/components/date-picker.js +152 -65
  14. package/dist/components/date-picker.js.map +1 -1
  15. package/dist/components/disclosure.d.ts +11 -2
  16. package/dist/components/disclosure.js +10 -4
  17. package/dist/components/disclosure.js.map +1 -1
  18. package/dist/components/floating-panel.d.ts +29 -2
  19. package/dist/components/floating-panel.js +16 -2
  20. package/dist/components/floating-panel.js.map +1 -1
  21. package/dist/components/number-field.d.ts +2 -0
  22. package/dist/components/number-input.d.ts +2 -0
  23. package/dist/components/numpad-sheet.d.ts +2 -0
  24. package/dist/components/progress-bar.d.ts +16 -2
  25. package/dist/components/progress-bar.js +4 -2
  26. package/dist/components/progress-bar.js.map +1 -1
  27. package/dist/components/ui.d.ts +37 -14
  28. package/dist/components/ui.js +28 -22
  29. package/dist/components/ui.js.map +1 -1
  30. package/dist/components/use-table-state.d.ts +1 -1
  31. package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
  32. package/dist/data-table.d.ts +1 -1
  33. package/dist/hooks/use-overlay-history.js +84 -4
  34. package/dist/hooks/use-overlay-history.js.map +1 -1
  35. package/dist/i18n/defaults.d.ts +2 -0
  36. package/dist/i18n/defaults.js +2 -0
  37. package/dist/i18n/defaults.js.map +1 -1
  38. package/dist/i18n/kit-labels.d.ts +3 -0
  39. package/dist/i18n/kit-labels.js.map +1 -1
  40. package/dist/i18n/locales/de-CH-informal.d.ts +2 -0
  41. package/dist/i18n/locales/de-CH.d.ts +2 -0
  42. package/dist/i18n/locales/de-informal.d.ts +2 -0
  43. package/dist/i18n/locales/de.d.ts +2 -0
  44. package/dist/i18n/locales/de.js +10 -0
  45. package/dist/i18n/locales/de.js.map +1 -1
  46. package/dist/i18n/locales/es.d.ts +2 -0
  47. package/dist/i18n/locales/es.js +10 -0
  48. package/dist/i18n/locales/es.js.map +1 -1
  49. package/dist/i18n/locales/fr.d.ts +2 -0
  50. package/dist/i18n/locales/fr.js +10 -0
  51. package/dist/i18n/locales/fr.js.map +1 -1
  52. package/dist/i18n/locales/hu.d.ts +2 -0
  53. package/dist/i18n/locales/hu.js +10 -0
  54. package/dist/i18n/locales/hu.js.map +1 -1
  55. package/dist/i18n/locales/it.d.ts +2 -0
  56. package/dist/i18n/locales/it.js +10 -0
  57. package/dist/i18n/locales/it.js.map +1 -1
  58. package/dist/i18n/locales/zh.d.ts +2 -0
  59. package/dist/i18n/locales/zh.js +10 -0
  60. package/dist/i18n/locales/zh.js.map +1 -1
  61. package/dist/index.d.ts +5 -3
  62. package/dist/index.js +2 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/search/command-palette.d.ts +49 -1
  65. package/dist/search/command-palette.js +132 -29
  66. package/dist/search/command-palette.js.map +1 -1
  67. package/dist/search/global-search.d.ts +141 -0
  68. package/dist/search/global-search.js +277 -0
  69. package/dist/search/global-search.js.map +1 -0
  70. package/dist/search/search-index.d.ts +105 -0
  71. package/dist/search/search-index.js +142 -0
  72. package/dist/search/search-index.js.map +1 -0
  73. package/dist/search.d.ts +2 -0
  74. package/dist/search.js +2 -0
  75. package/dist/search.js.map +1 -1
  76. package/dist/wizard/stepper-nav.d.ts +10 -1
  77. package/dist/wizard/stepper-nav.js +2 -1
  78. package/dist/wizard/stepper-nav.js.map +1 -1
  79. package/package.json +12 -3
  80. package/src/components/chip.tsx +36 -6
  81. package/src/components/data-table.tsx +81 -5
  82. package/src/components/date-picker.tsx +328 -117
  83. package/src/components/disclosure.tsx +24 -8
  84. package/src/components/floating-panel.tsx +46 -2
  85. package/src/components/progress-bar.tsx +27 -3
  86. package/src/components/ui.tsx +88 -37
  87. package/src/hooks/use-overlay-history.ts +181 -4
  88. package/src/i18n/defaults.ts +2 -0
  89. package/src/i18n/kit-labels.tsx +2 -0
  90. package/src/i18n/locales/de.ts +10 -0
  91. package/src/i18n/locales/es.ts +10 -0
  92. package/src/i18n/locales/fr.ts +10 -0
  93. package/src/i18n/locales/hu.ts +10 -0
  94. package/src/i18n/locales/it.ts +10 -0
  95. package/src/i18n/locales/zh.ts +10 -0
  96. package/src/index.ts +4 -0
  97. package/src/search/command-palette.tsx +200 -27
  98. package/src/search/global-search.tsx +484 -0
  99. package/src/search/search-index.ts +295 -0
  100. package/src/search.ts +2 -0
  101. package/src/wizard/stepper-nav.tsx +11 -1
@@ -0,0 +1,277 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import { useHref, useInRouterContext, useNavigate } from "react-router";
5
+ import { AlertCircle, Loader2, Search } from "lucide-react";
6
+ import { Tooltip } from "../components/tooltip.js";
7
+ import { TOPBAR_TRIGGER_CLASS } from "../shell/topbar-controls.js";
8
+ import { cn } from "../lib/cn.js";
9
+ import { useKitLabels } from "../i18n/kit-labels.js";
10
+ import { CommandPalette, DEFAULT_COMMAND_PALETTE_LABELS, useCommandKey } from "./command-palette.js";
11
+ import { createSearchIndex } from "./search-index.js";
12
+ const DEFAULT_GLOBAL_SEARCH_LABELS = {
13
+ trigger: "Search",
14
+ placeholder: "Search or jump to\u2026",
15
+ shortcut: (keys) => `Search (${keys})`,
16
+ suggestions: "Try",
17
+ results: "Results"
18
+ };
19
+ function isApplePlatform() {
20
+ if (typeof navigator === "undefined") return false;
21
+ const nav = navigator;
22
+ return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform || nav.platform || nav.userAgent);
23
+ }
24
+ const pageLoad = (href) => window.location.assign(href);
25
+ const identity = (href) => href;
26
+ function signature(entries) {
27
+ return entries.map(
28
+ (e) => [e.id, e.title, (e.keywords ?? []).join(""), e.description, e.group, e.hint, e.href, e.weight, e.redact].join(
29
+ ""
30
+ )
31
+ ).join("");
32
+ }
33
+ function refocusField() {
34
+ requestAnimationFrame(() => {
35
+ const fields = document.querySelectorAll('[role="dialog"][aria-modal="true"] input[role="combobox"]');
36
+ fields[fields.length - 1]?.focus();
37
+ });
38
+ }
39
+ function GlobalSearch(props) {
40
+ const inRouter = useInRouterContext();
41
+ if (inRouter && (!props.navigate || !props.hrefFor)) return /* @__PURE__ */ jsx(RoutedGlobalSearch, { ...props });
42
+ return /* @__PURE__ */ jsx(GlobalSearchImpl, { ...props, navigate: props.navigate ?? pageLoad, hrefFor: props.hrefFor ?? identity });
43
+ }
44
+ function RoutedGlobalSearch(props) {
45
+ const routerNavigate = useNavigate();
46
+ const base = useHref("/").replace(/\/$/, "");
47
+ const navigate = useCallback((href) => void routerNavigate(href), [routerNavigate]);
48
+ const hrefFor = useCallback((href) => href.startsWith("/") ? base + href : href, [base]);
49
+ return /* @__PURE__ */ jsx(GlobalSearchImpl, { ...props, navigate: props.navigate ?? navigate, hrefFor: props.hrefFor ?? hrefFor });
50
+ }
51
+ function GlobalSearchImpl({
52
+ entries,
53
+ sources,
54
+ suggestions,
55
+ groupOrder,
56
+ indexOptions,
57
+ navigate,
58
+ hrefFor,
59
+ shortcut = true,
60
+ open: openProp,
61
+ onOpenChange,
62
+ query: queryProp,
63
+ onQueryChange,
64
+ searchOn,
65
+ redactLabels = false,
66
+ fullScreenOnPhone,
67
+ trigger = "button",
68
+ hideTriggerOnPhone = false,
69
+ triggerClassName,
70
+ labels,
71
+ paletteLabels
72
+ }) {
73
+ const l = useKitLabels("globalSearch", DEFAULT_GLOBAL_SEARCH_LABELS, labels);
74
+ const pl = useKitLabels("commandPalette", DEFAULT_COMMAND_PALETTE_LABELS, paletteLabels);
75
+ const [ownOpen, setOwnOpen] = useState(false);
76
+ const open = openProp ?? ownOpen;
77
+ const [ownQuery, setOwnQuery] = useState("");
78
+ const queryControlled = queryProp !== void 0;
79
+ const query = queryControlled ? queryProp : ownQuery;
80
+ const q = query.trim();
81
+ const setOpen = useCallback(
82
+ (next) => {
83
+ if (openProp === void 0) setOwnOpen(next);
84
+ onOpenChange?.(next);
85
+ },
86
+ [openProp, onOpenChange]
87
+ );
88
+ const setQuery = useCallback(
89
+ (next) => {
90
+ if (!queryControlled) setOwnQuery(next);
91
+ onQueryChange?.(next);
92
+ },
93
+ [queryControlled, onQueryChange]
94
+ );
95
+ const [wasOpen, setWasOpen] = useState(open);
96
+ if (wasOpen !== open) {
97
+ setWasOpen(open);
98
+ if (open && !queryControlled) setOwnQuery("");
99
+ }
100
+ const openSearch = useCallback(() => setOpen(true), [setOpen]);
101
+ const noop = useCallback(() => {
102
+ }, []);
103
+ useCommandKey(shortcut ? openSearch : noop);
104
+ const list = entries ?? EMPTY;
105
+ const sig = signature(list);
106
+ const optionsKey = JSON.stringify(indexOptions ?? {});
107
+ const groupLimit = indexOptions?.groupLimit ?? 8;
108
+ const index = useMemo(() => createSearchIndex(list, indexOptions), [sig, optionsKey]);
109
+ const byId = useMemo(() => new Map(list.map((e) => [e.id, e])), [sig]);
110
+ const sourceKey = (sources ?? []).map((s) => [s.id, s.group, s.minChars ?? 2, s.debounceMs ?? 200, s.limit, s.redact].join("")).join("");
111
+ const sourceList = useMemo(() => sources ?? [], [sourceKey]);
112
+ const suggestionsKey = (suggestions ?? []).map((s) => typeof s === "string" ? `i:${s}` : "query" in s ? `q:${s.query}` : `e:${signature([s])}`).join("");
113
+ const suggestionList = useMemo(() => suggestions ?? [], [suggestionsKey]);
114
+ const groupOrderKey = (groupOrder ?? []).join("");
115
+ const order = useMemo(() => groupOrder ?? [], [groupOrderKey]);
116
+ const live = useRef({ entries: list, sources: sources ?? [], suggestions: suggestions ?? [], navigate });
117
+ useLayoutEffect(() => {
118
+ live.current = { entries: list, sources: sources ?? [], suggestions: suggestions ?? [], navigate };
119
+ });
120
+ const [sourceResults, setSourceResults] = useState({});
121
+ useEffect(() => {
122
+ const controllers = [];
123
+ const timers = [];
124
+ const asked = open ? live.current.sources.filter((s) => q.length >= (s.minChars ?? 2)) : [];
125
+ for (const source of asked) {
126
+ const id = source.id;
127
+ const controller = new AbortController();
128
+ controllers.push(controller);
129
+ timers.push(
130
+ setTimeout(async () => {
131
+ const settle = (result) => {
132
+ if (!controller.signal.aborted) setSourceResults((prev) => ({ ...prev, [id]: result }));
133
+ };
134
+ try {
135
+ const found = await source.search(q, controller.signal);
136
+ settle({ query: q, status: "done", entries: found });
137
+ } catch (error) {
138
+ if (controller.signal.aborted) return;
139
+ source.onError?.(error);
140
+ settle({ query: q, status: "error", entries: [] });
141
+ }
142
+ }, source.debounceMs ?? 200)
143
+ );
144
+ }
145
+ return () => {
146
+ timers.forEach(clearTimeout);
147
+ controllers.forEach((c) => c.abort());
148
+ };
149
+ }, [open, q, sourceKey]);
150
+ const toItem = useCallback(
151
+ (prefix, entry, group, redact = redactLabels) => ({
152
+ id: `${prefix}${entry.id}`,
153
+ label: entry.title,
154
+ hint: entry.hint,
155
+ group,
156
+ icon: entry.icon,
157
+ redact: entry.redact ?? redact,
158
+ href: entry.href !== void 0 ? hrefFor(entry.href) : void 0,
159
+ onSelect: () => {
160
+ const current = live.current.entries.find((e) => e.id === entry.id) ?? entry;
161
+ if (current.onSelect) current.onSelect();
162
+ else if (current.href !== void 0) live.current.navigate(current.href);
163
+ }
164
+ }),
165
+ [hrefFor, redactLabels]
166
+ );
167
+ const items = useMemo(() => {
168
+ if (!q) {
169
+ const out2 = [];
170
+ suggestionList.forEach((s, i) => {
171
+ if (typeof s === "string") {
172
+ const entry = byId.get(s);
173
+ if (entry) out2.push(toItem("try:", entry, l.suggestions));
174
+ } else if ("query" in s) {
175
+ out2.push({
176
+ id: `try:q${i}`,
177
+ label: s.query,
178
+ group: l.suggestions,
179
+ icon: /* @__PURE__ */ jsx(Search, { className: "size-4" }),
180
+ onSelect: () => {
181
+ setOpen(true);
182
+ setQuery(s.query);
183
+ refocusField();
184
+ }
185
+ });
186
+ } else out2.push(toItem("try:", s, l.suggestions));
187
+ });
188
+ return out2;
189
+ }
190
+ const out = index.search(q).map((hit) => toItem("s:", hit.entry, hit.entry.group ?? l.results));
191
+ for (const source of sourceList) {
192
+ if (q.length < (source.minChars ?? 2)) continue;
193
+ const result = sourceResults[source.id];
194
+ const status = (label, icon) => ({
195
+ id: `src:${source.id}:status`,
196
+ label,
197
+ group: source.group,
198
+ icon,
199
+ kind: "status",
200
+ onSelect: noop
201
+ });
202
+ if (!result || result.query !== q) {
203
+ out.push(status(pl.loading, /* @__PURE__ */ jsx(Loader2, { className: "size-3.5 animate-spin" })));
204
+ } else if (result.status === "error") {
205
+ out.push(status(pl.error ?? DEFAULT_COMMAND_PALETTE_LABELS.error, /* @__PURE__ */ jsx(AlertCircle, { className: "size-3.5" })));
206
+ } else {
207
+ for (const entry of result.entries.slice(0, source.limit ?? groupLimit)) {
208
+ out.push(toItem(`src:${source.id}:`, entry, source.group, source.redact ?? redactLabels));
209
+ }
210
+ }
211
+ }
212
+ if (!order.length) return out;
213
+ const rank = (group) => {
214
+ const i = order.indexOf(group);
215
+ return i === -1 ? order.length : i;
216
+ };
217
+ return out.map((item, i) => ({ item, i })).sort((a, b) => rank(a.item.group) - rank(b.item.group) || a.i - b.i).map(({ item }) => item);
218
+ }, [q, suggestionList, byId, index, sourceList, sourceResults, order, groupLimit, toItem, l.suggestions, l.results, pl.loading, pl.error, setOpen, setQuery, noop, redactLabels]);
219
+ const provider = useCallback(() => items, [items]);
220
+ const resolvedPaletteLabels = useMemo(
221
+ () => ({ dialog: l.trigger, placeholder: l.placeholder, ...paletteLabels }),
222
+ [l.trigger, l.placeholder, paletteLabels]
223
+ );
224
+ const apple = useMemo(() => isApplePlatform(), []);
225
+ const keys = apple ? "\u2318K" : "Ctrl K";
226
+ const triggerProps = {
227
+ open: openSearch,
228
+ label: l.trigger,
229
+ tooltip: l.shortcut(keys),
230
+ keys,
231
+ ariaKeyShortcuts: "Meta+K Control+K"
232
+ };
233
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
234
+ typeof trigger === "function" ? trigger(triggerProps) : trigger === "button" && /* @__PURE__ */ jsx(
235
+ Tooltip,
236
+ {
237
+ label: triggerProps.tooltip,
238
+ side: "bottom",
239
+ portal: true,
240
+ className: hideTriggerOnPhone ? "hidden sm:block" : void 0,
241
+ children: /* @__PURE__ */ jsx(
242
+ "button",
243
+ {
244
+ type: "button",
245
+ onClick: openSearch,
246
+ "aria-label": l.trigger,
247
+ "aria-keyshortcuts": triggerProps.ariaKeyShortcuts,
248
+ "aria-haspopup": "dialog",
249
+ className: cn(TOPBAR_TRIGGER_CLASS, triggerClassName),
250
+ children: /* @__PURE__ */ jsx(Search, { className: "size-4" })
251
+ }
252
+ )
253
+ }
254
+ ),
255
+ /* @__PURE__ */ jsx(
256
+ CommandPalette,
257
+ {
258
+ open,
259
+ onClose: () => setOpen(false),
260
+ search: provider,
261
+ revision: items,
262
+ query,
263
+ onQueryChange: setQuery,
264
+ searchOn,
265
+ redactLabels,
266
+ fullScreenOnPhone,
267
+ labels: resolvedPaletteLabels
268
+ }
269
+ )
270
+ ] });
271
+ }
272
+ const EMPTY = [];
273
+ export {
274
+ DEFAULT_GLOBAL_SEARCH_LABELS,
275
+ GlobalSearch
276
+ };
277
+ //# sourceMappingURL=global-search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/search/global-search.tsx"],"sourcesContent":["import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { useHref, useInRouterContext, useNavigate } from \"react-router\";\nimport { AlertCircle, Loader2, Search } from \"lucide-react\";\nimport { Tooltip } from \"../components/tooltip\";\nimport { TOPBAR_TRIGGER_CLASS } from \"../shell/topbar-controls\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { CommandPalette, DEFAULT_COMMAND_PALETTE_LABELS, useCommandKey } from \"./command-palette\";\nimport type { CommandItem, CommandPaletteLabels } from \"./command-palette\";\nimport { createSearchIndex } from \"./search-index\";\nimport type { SearchEntry, SearchIndexOptions } from \"./search-index\";\n\nexport interface GlobalSearchLabels {\n /** The trigger's accessible name, and the dialog's. */\n trigger: string;\n /** The field's placeholder — says WHAT can be found, which \"Search…\" does not. */\n placeholder: string;\n /** The trigger's tooltip, given the shortcut as the platform writes it (\"⌘K\",\n * \"Ctrl K\"). A function so a language can put the keys where its grammar wants. */\n shortcut: (keys: string) => string;\n /** Heading of the suggestions shown for an empty query. */\n suggestions: string;\n /** Heading for entries that name no `group`. */\n results: string;\n}\n\nexport const DEFAULT_GLOBAL_SEARCH_LABELS: GlobalSearchLabels = {\n trigger: \"Search\",\n placeholder: \"Search or jump to…\",\n shortcut: (keys) => `Search (${keys})`,\n suggestions: \"Try\",\n results: \"Results\",\n};\n\n/**\n * A place results come from that is not the static index — a server search, a\n * local-first store too large to hand over whole. Each source is its own group, with its\n * own debounce, its own \"Searching…\" line and its own failure: the static groups never\n * wait for it, and one source failing never takes another's results with it.\n */\nexport interface GlobalSearchSource {\n /** Stable key — the effect that runs the source is keyed by it, not by identity. */\n id: string;\n /** The group its results are shown under (an entry's own `group` is ignored). */\n group: string;\n /** Called with the trimmed query. Its results are shown AS GIVEN — the source ranked\n * them — and never re-filtered by the index's matcher. `signal` aborts when the query\n * moves on or the palette closes; a stale answer is dropped either way. */\n search: (query: string, signal: AbortSignal) => Promise<readonly SearchEntry[]> | readonly SearchEntry[];\n /** Shortest query it is asked for. Default 2 — one letter matches half a database. */\n minChars?: number;\n /** Quiet period after the last keystroke before it is asked. Default 200ms. */\n debounceMs?: number;\n /** Rows kept. Default: `indexOptions.groupLimit` (8). */\n limit?: number;\n /** Mask every row of this source for session replay, hints (amounts) included. An\n * entry's own `redact` still wins. */\n redact?: boolean;\n /** Told when `search` throws or rejects (not when it is aborted). The group then\n * shows the palette's `error` line instead of rows. */\n onError?: (error: unknown) => void;\n}\n\n/** What a custom trigger gets: everything the default button uses. */\nexport interface GlobalSearchTriggerProps {\n open: () => void;\n /** Resolved `trigger` label — the accessible name. */\n label: string;\n /** Resolved `shortcut(keys)` — the tooltip. */\n tooltip: string;\n /** \"⌘K\" on Apple platforms, \"Ctrl K\" elsewhere. */\n keys: string;\n /** For the trigger's `aria-keyshortcuts`. */\n ariaKeyShortcuts: string;\n}\n\n/** A suggestion for the empty query: an entry, the `id` of one in `entries`, or a query\n * to type into the field. */\nexport type GlobalSearchSuggestion = SearchEntry | string | { query: string };\n\nexport interface GlobalSearchProps {\n /**\n * The static index: pages, actions, settings rows — already filtered by the app (by\n * role, by privacy mode). Matched and ranked by {@link createSearchIndex}.\n *\n * Changing it re-runs the open search by itself. Nothing has to be stable: the index is\n * rebuilt only when the entries' TEXT changes (ids, titles, keywords, …), so a list\n * rebuilt on every render — `data = []` while a query is pending, icons as fresh JSX —\n * neither re-indexes nor loops, and the icons and handlers used are always the latest.\n */\n entries?: readonly SearchEntry[];\n /** The async sources, each streamed into its own group. */\n sources?: readonly GlobalSearchSource[];\n /** What the empty query shows, under the `suggestions` heading. */\n suggestions?: readonly GlobalSearchSuggestion[];\n /** Groups listed here come first, in this order; the rest follow in ranking order. */\n groupOrder?: readonly string[];\n /** Ranking and capping — `groupLimit` (default 8) applies to every group. */\n indexOptions?: SearchIndexOptions;\n /**\n * How an entry's `href` is followed on a plain click or ↵. Default: react-router's\n * `navigate` when the search is rendered inside a router, else a full page load.\n */\n navigate?: (href: string) => void;\n /**\n * The browser URL of an `href`, for the row's real link (middle-/⌘-click opens a tab).\n * Default inside a router: the router's own (`#/page` under a hash router, the\n * basename prefixed under a browser router); outside one, the href itself.\n */\n hrefFor?: (href: string) => string;\n /** Register ⌘K / Ctrl K. Default true; turn it off for a second, scoped search on a\n * page that already has the app-wide one. */\n shortcut?: boolean;\n /** Controlled open state — so a button anywhere can open it. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Controlled query, as on `CommandPalette` (keksdose binds it to the URL's `q`). */\n query?: string;\n onQueryChange?: (query: string) => void;\n searchOn?: \"input\" | \"submit\";\n /** Mask every row for session replay (see `CommandPalette`). A source's or an entry's\n * own `redact` wins. */\n redactLabels?: boolean;\n fullScreenOnPhone?: boolean;\n /** `\"button\"` (default): the top-bar icon button. `\"none\"`: no trigger — the shortcut\n * or a controlled `open` opens it. A function renders a trigger of your own. */\n trigger?: \"button\" | \"none\" | ((props: GlobalSearchTriggerProps) => ReactNode);\n /** Hide the default trigger below `sm` (kastlan's phone top bar has no room). */\n hideTriggerOnPhone?: boolean;\n /** Class for the default trigger button, merged over `TOPBAR_TRIGGER_CLASS`. */\n triggerClassName?: string;\n labels?: Partial<GlobalSearchLabels>;\n /** Passed through to the palette (`empty`, `loading`, `error`, `hint`, …). */\n paletteLabels?: Partial<CommandPaletteLabels>;\n}\n\nfunction isApplePlatform(): boolean {\n if (typeof navigator === \"undefined\") return false;\n const nav = navigator as Navigator & { userAgentData?: { platform?: string } };\n return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform || nav.platform || nav.userAgent);\n}\n\nconst pageLoad = (href: string) => window.location.assign(href);\nconst identity = (href: string) => href;\n\n/** The entries' text, which is all the index reads — see `entries`. */\nfunction signature(entries: readonly SearchEntry[]): string {\n return entries\n .map((e) =>\n [e.id, e.title, (e.keywords ?? []).join(\"\\u0003\"), e.description, e.group, e.hint, e.href, e.weight, e.redact].join(\n \"\\u0001\",\n ),\n )\n .join(\"\\u0002\");\n}\n\n/** Focus the open palette's field again — after a suggestion typed itself into it, the\n * row that was clicked (and held focus) is gone. */\nfunction refocusField() {\n requestAnimationFrame(() => {\n const fields = document.querySelectorAll<HTMLInputElement>('[role=\"dialog\"][aria-modal=\"true\"] input[role=\"combobox\"]');\n fields[fields.length - 1]?.focus();\n });\n}\n\ninterface SourceResult {\n query: string;\n status: \"loading\" | \"done\" | \"error\";\n entries: readonly SearchEntry[];\n}\n\n/**\n * The app-wide ⌘K search, whole: the top-bar trigger, the shortcut, and a\n * {@link CommandPalette} fed from a ranked index ({@link createSearchIndex}) and any\n * number of async sources.\n *\n * Every consumer had built this out of `CommandPalette` by hand, and each copy had the\n * same holes — a substring match, no ranking, no typo tolerance, results in declaration\n * order, one monolithic provider in which the static groups waited on the slowest\n * request. This is that wrapper once:\n *\n * <GlobalSearch\n * entries={pages} // { id, title, keywords, group, href, icon }\n * sources={[{ id: \"tx\", group: \"Transactions\", search: (q, signal) => api.search(q, { signal }) }]}\n * suggestions={[\"page-budget\", \"action-new-account\"]}\n * />\n *\n * Inside a react-router tree it navigates with the router and builds its links with it;\n * outside one, pass `navigate` (or accept full page loads).\n */\nexport function GlobalSearch(props: GlobalSearchProps) {\n // Two components rather than a conditional hook: `useNavigate` throws outside a\n // router, and whether we are in one does not change over a component's life.\n const inRouter = useInRouterContext();\n if (inRouter && (!props.navigate || !props.hrefFor)) return <RoutedGlobalSearch {...props} />;\n return <GlobalSearchImpl {...props} navigate={props.navigate ?? pageLoad} hrefFor={props.hrefFor ?? identity} />;\n}\n\nfunction RoutedGlobalSearch(props: GlobalSearchProps) {\n const routerNavigate = useNavigate();\n // The router's href for \"/\" is its base (\"#/\" under a hash router, \"/app/\" under a\n // basename); every href is that base plus the path.\n const base = useHref(\"/\").replace(/\\/$/, \"\");\n const navigate = useCallback((href: string) => void routerNavigate(href), [routerNavigate]);\n const hrefFor = useCallback((href: string) => (href.startsWith(\"/\") ? base + href : href), [base]);\n return <GlobalSearchImpl {...props} navigate={props.navigate ?? navigate} hrefFor={props.hrefFor ?? hrefFor} />;\n}\n\nfunction GlobalSearchImpl({\n entries,\n sources,\n suggestions,\n groupOrder,\n indexOptions,\n navigate,\n hrefFor,\n shortcut = true,\n open: openProp,\n onOpenChange,\n query: queryProp,\n onQueryChange,\n searchOn,\n redactLabels = false,\n fullScreenOnPhone,\n trigger = \"button\",\n hideTriggerOnPhone = false,\n triggerClassName,\n labels,\n paletteLabels,\n}: GlobalSearchProps & { navigate: (href: string) => void; hrefFor: (href: string) => string }) {\n const l = useKitLabels(\"globalSearch\", DEFAULT_GLOBAL_SEARCH_LABELS, labels);\n const pl = useKitLabels(\"commandPalette\", DEFAULT_COMMAND_PALETTE_LABELS, paletteLabels);\n const [ownOpen, setOwnOpen] = useState(false);\n const open = openProp ?? ownOpen;\n const [ownQuery, setOwnQuery] = useState(\"\");\n const queryControlled = queryProp !== undefined;\n const query = queryControlled ? queryProp : ownQuery;\n const q = query.trim();\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (openProp === undefined) setOwnOpen(next);\n onOpenChange?.(next);\n },\n [openProp, onOpenChange],\n );\n const setQuery = useCallback(\n (next: string) => {\n if (!queryControlled) setOwnQuery(next);\n onQueryChange?.(next);\n },\n [queryControlled, onQueryChange],\n );\n\n // Each open starts blank — unless the owner controls the query (a URL `q`). Adjusted\n // during render, React's pattern for state that follows a prop, so a controlled `open`\n // gets the same reset as the trigger does.\n const [wasOpen, setWasOpen] = useState(open);\n if (wasOpen !== open) {\n setWasOpen(open);\n if (open && !queryControlled) setOwnQuery(\"\");\n }\n\n const openSearch = useCallback(() => setOpen(true), [setOpen]);\n const noop = useCallback(() => {}, []);\n useCommandKey(shortcut ? openSearch : noop);\n\n /* ── the static index ─────────────────────────────────────────────────── */\n\n // Everything below is keyed by CONTENT, never by the identity of what the app passed:\n // inline arrays and `data = []` defaults are the norm, and an identity key would\n // re-index and re-search on every render of the app. Handlers, which can close over\n // anything, are read from refs at the moment a row is chosen — never stale.\n const list = entries ?? EMPTY;\n const sig = signature(list);\n const optionsKey = JSON.stringify(indexOptions ?? {});\n const groupLimit = indexOptions?.groupLimit ?? 8;\n // `sig` and `optionsKey` stand for `list` and `indexOptions`: same text, same index.\n /* eslint-disable react-hooks/exhaustive-deps */\n const index = useMemo(() => createSearchIndex(list, indexOptions), [sig, optionsKey]);\n const byId = useMemo(() => new Map(list.map((e) => [e.id, e])), [sig]);\n\n const sourceKey = (sources ?? [])\n .map((s) => [s.id, s.group, s.minChars ?? 2, s.debounceMs ?? 200, s.limit, s.redact].join(\"\\u0001\"))\n .join(\"\\u0002\");\n const sourceList = useMemo(() => sources ?? [], [sourceKey]);\n\n const suggestionsKey = (suggestions ?? [])\n .map((s) => (typeof s === \"string\" ? `i:${s}` : \"query\" in s ? `q:${s.query}` : `e:${signature([s])}`))\n .join(\"\\u0002\");\n const suggestionList = useMemo(() => suggestions ?? [], [suggestionsKey]);\n\n const groupOrderKey = (groupOrder ?? []).join(\"\\u0002\");\n const order = useMemo(() => groupOrder ?? [], [groupOrderKey]);\n /* eslint-enable react-hooks/exhaustive-deps */\n\n // The live objects, for the moment a row is chosen or a source is asked.\n const live = useRef({ entries: list, sources: sources ?? [], suggestions: suggestions ?? [], navigate });\n useLayoutEffect(() => {\n live.current = { entries: list, sources: sources ?? [], suggestions: suggestions ?? [], navigate };\n });\n\n /* ── the async sources ────────────────────────────────────────────────── */\n\n const [sourceResults, setSourceResults] = useState<Record<string, SourceResult>>({});\n useEffect(() => {\n const controllers: AbortController[] = [];\n const timers: ReturnType<typeof setTimeout>[] = [];\n const asked = open ? live.current.sources.filter((s) => q.length >= (s.minChars ?? 2)) : [];\n for (const source of asked) {\n const id = source.id;\n const controller = new AbortController();\n controllers.push(controller);\n timers.push(\n setTimeout(async () => {\n const settle = (result: SourceResult) => {\n if (!controller.signal.aborted) setSourceResults((prev) => ({ ...prev, [id]: result }));\n };\n try {\n const found = await source.search(q, controller.signal);\n settle({ query: q, status: \"done\", entries: found });\n } catch (error) {\n if (controller.signal.aborted) return;\n source.onError?.(error);\n settle({ query: q, status: \"error\", entries: [] });\n }\n }, source.debounceMs ?? 200),\n );\n }\n return () => {\n timers.forEach(clearTimeout);\n controllers.forEach((c) => c.abort());\n };\n }, [open, q, sourceKey]);\n\n /* ── rows ─────────────────────────────────────────────────────────────── */\n\n const toItem = useCallback(\n (prefix: string, entry: SearchEntry, group: string, redact = redactLabels): CommandItem => ({\n id: `${prefix}${entry.id}`,\n label: entry.title,\n hint: entry.hint,\n group,\n icon: entry.icon,\n redact: entry.redact ?? redact,\n href: entry.href !== undefined ? hrefFor(entry.href) : undefined,\n onSelect: () => {\n // The entry as the app has it NOW — its handler may close over newer state\n // than the one this row was built from.\n const current = live.current.entries.find((e) => e.id === entry.id) ?? entry;\n if (current.onSelect) current.onSelect();\n else if (current.href !== undefined) live.current.navigate(current.href);\n },\n }),\n [hrefFor, redactLabels],\n );\n\n // `toItem` only BUILDS `onSelect` closures here; `live` is read when a row is chosen,\n // which the compiler lint cannot tell from a read during render.\n /* eslint-disable react-hooks/refs */\n const items = useMemo((): CommandItem[] => {\n if (!q) {\n const out: CommandItem[] = [];\n suggestionList.forEach((s, i) => {\n if (typeof s === \"string\") {\n const entry = byId.get(s);\n if (entry) out.push(toItem(\"try:\", entry, l.suggestions));\n } else if (\"query\" in s) {\n out.push({\n id: `try:q${i}`,\n label: s.query,\n group: l.suggestions,\n icon: <Search className=\"size-4\" />,\n onSelect: () => {\n // The palette closes before it runs a row; a query suggestion keeps it\n // open and types itself in instead.\n setOpen(true);\n setQuery(s.query);\n refocusField();\n },\n });\n } else out.push(toItem(\"try:\", s, l.suggestions));\n });\n return out;\n }\n\n const out = index.search(q).map((hit) => toItem(\"s:\", hit.entry, hit.entry.group ?? l.results));\n for (const source of sourceList) {\n if (q.length < (source.minChars ?? 2)) continue;\n const result = sourceResults[source.id];\n const status = (label: string, icon: ReactNode): CommandItem => ({\n id: `src:${source.id}:status`,\n label,\n group: source.group,\n icon,\n kind: \"status\",\n onSelect: noop,\n });\n // Anything but an answer to THIS query is still loading — including the previous\n // query's rows, which would otherwise sit under the new query as if they matched.\n if (!result || result.query !== q) {\n out.push(status(pl.loading, <Loader2 className=\"size-3.5 animate-spin\" />));\n } else if (result.status === \"error\") {\n out.push(status(pl.error ?? DEFAULT_COMMAND_PALETTE_LABELS.error!, <AlertCircle className=\"size-3.5\" />));\n } else {\n for (const entry of result.entries.slice(0, source.limit ?? groupLimit)) {\n out.push(toItem(`src:${source.id}:`, entry, source.group, source.redact ?? redactLabels));\n }\n }\n }\n\n if (!order.length) return out;\n const rank = (group: string) => {\n const i = order.indexOf(group);\n return i === -1 ? order.length : i;\n };\n // Stable: within a rank, groups keep the order their best hit gave them.\n return out\n .map((item, i) => ({ item, i }))\n .sort((a, b) => rank(a.item.group) - rank(b.item.group) || a.i - b.i)\n .map(({ item }) => item);\n }, [q, suggestionList, byId, index, sourceList, sourceResults, order, groupLimit, toItem, l.suggestions, l.results, pl.loading, pl.error, setOpen, setQuery, noop, redactLabels]);\n /* eslint-enable react-hooks/refs */\n\n // The palette re-runs its provider on a `revision` change, never on the provider's\n // identity — and `items` is everything the provider answers with.\n const provider = useCallback(() => items, [items]);\n\n const resolvedPaletteLabels = useMemo(\n () => ({ dialog: l.trigger, placeholder: l.placeholder, ...paletteLabels }),\n [l.trigger, l.placeholder, paletteLabels],\n );\n\n const apple = useMemo(() => isApplePlatform(), []);\n const keys = apple ? \"⌘K\" : \"Ctrl K\";\n const triggerProps: GlobalSearchTriggerProps = {\n open: openSearch,\n label: l.trigger,\n tooltip: l.shortcut(keys),\n keys,\n ariaKeyShortcuts: \"Meta+K Control+K\",\n };\n\n return (\n <>\n {typeof trigger === \"function\"\n ? trigger(triggerProps)\n : trigger === \"button\" && (\n <Tooltip\n label={triggerProps.tooltip}\n side=\"bottom\"\n portal\n className={hideTriggerOnPhone ? \"hidden sm:block\" : undefined}\n >\n <button\n type=\"button\"\n onClick={openSearch}\n aria-label={l.trigger}\n aria-keyshortcuts={triggerProps.ariaKeyShortcuts}\n aria-haspopup=\"dialog\"\n className={cn(TOPBAR_TRIGGER_CLASS, triggerClassName)}\n >\n <Search className=\"size-4\" />\n </button>\n </Tooltip>\n )}\n <CommandPalette\n open={open}\n onClose={() => setOpen(false)}\n search={provider}\n revision={items}\n query={query}\n onQueryChange={setQuery}\n searchOn={searchOn}\n redactLabels={redactLabels}\n fullScreenOnPhone={fullScreenOnPhone}\n labels={resolvedPaletteLabels}\n />\n </>\n );\n}\n\nconst EMPTY: readonly SearchEntry[] = [];\n"],"mappings":"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","names":["out"]}
@@ -0,0 +1,105 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ /**
4
+ * The matcher behind {@link GlobalSearch} — pure, React-free, testable on its own.
5
+ *
6
+ * WHY IT EXISTS. `CommandPalette` is deliberately domain-free: it shows whatever a
7
+ * `search(query)` provider returns. So every app wrote that provider, and every app wrote
8
+ * the same one — `label.toLowerCase().includes(q)` over its pages and actions (kastlan's
9
+ * command palette, keksdose's global search) plus a second copy with keywords for the
10
+ * settings catalogue (keksdose's `matchSettingsEntries`). A substring test answers "does
11
+ * this string contain that one", which is not what a search box is asked:
12
+ *
13
+ * - "range date" finds nothing, because the words are in the other order;
14
+ * - "Uberweisung" misses "Überweisung", and "Tooltp" misses "Tooltip";
15
+ * - "del" matches "model" as happily as "delete", and every hit ranks the same, so the
16
+ * exact page name can sit under six unrelated rows.
17
+ *
18
+ * What it does instead, per entry:
19
+ *
20
+ * - NORMALISES both sides: lower case, accents stripped (NFD minus combining marks),
21
+ * `ß` → `ss`, punctuation as a word break, and camelCase split so `DateRangePicker`
22
+ * is also the words date, range, picker.
23
+ * - Requires EVERY query word to match SOMEWHERE in the entry — title, keywords or
24
+ * description, in any order.
25
+ * - A word matches a word of the entry exactly, as its prefix, inside it (four letters or
26
+ * more, for compounds: "bereich" in "Datumsbereich"), or — for words of five letters
27
+ * or more — at edit distance one, transpositions included ("tooltp", "calender").
28
+ * - RANKS in tiers: exact title > title prefix > all words in the title > some word only
29
+ * in the keywords > some word only in the description. Within a tier, better word
30
+ * matches (exact over prefix over infix over typo) score higher, and `weight` is added
31
+ * last. The tiers are {@link SEARCH_TIER_POINTS} points apart, so a weight below that reorders within
32
+ * a tier and never across one.
33
+ * - Caps each group (`groupLimit`, default 8) after ranking, so one broad group cannot
34
+ * push every other group off the list.
35
+ */
36
+ /** One searchable thing: a page, an action, a record, a settings row. */
37
+ interface SearchEntry {
38
+ /** Unique across the entries handed to one index. */
39
+ id: string;
40
+ /** What the row says, and what ranks highest. */
41
+ title: string;
42
+ /** Other words or phrases that should find it — synonyms, a translated name, tags.
43
+ * Matched like the title, ranked below it. */
44
+ keywords?: readonly string[];
45
+ /** Longer text, matched last and ranked lowest. Not shown in the row. */
46
+ description?: string;
47
+ /** Heading the row is grouped under in the palette. */
48
+ group?: string;
49
+ /** Secondary text on the row's trailing side (the page it lives on, an amount). */
50
+ hint?: string;
51
+ /** Leading icon. */
52
+ icon?: ReactNode;
53
+ /** Where it leads — the app's own path, handed to `GlobalSearch`'s `navigate`. */
54
+ href?: string;
55
+ /** Run instead of navigating. Wins over `href` for a plain click and ↵; `href` still
56
+ * makes the row a real link for a middle-/⌘-click. */
57
+ onSelect?: () => void;
58
+ /** Added to the score. Below {@link SEARCH_TIER_POINTS} it reorders within a tier. */
59
+ weight?: number;
60
+ /** Masks the row for session replay (see `CommandItem.redact`). */
61
+ redact?: boolean;
62
+ }
63
+ type SearchMatchField = "title" | "keywords" | "description";
64
+ interface SearchHit<E extends SearchEntry = SearchEntry> {
65
+ entry: E;
66
+ score: number;
67
+ /** The weakest field any query word had to be found in. */
68
+ field: SearchMatchField;
69
+ }
70
+ interface SearchIndexOptions {
71
+ /** Hits kept per `group` after ranking. Default 8; `Infinity` for no cap. */
72
+ groupLimit?: number;
73
+ /** Hits kept in total. Default: no cap beyond `groupLimit`. */
74
+ limit?: number;
75
+ /** Shortest query word allowed an edit-distance-1 match. Default 5 — below that a
76
+ * single typo is a different word ("cat" / "car"). */
77
+ typoMinLength?: number;
78
+ /** Shortest query word matched INSIDE an entry word. Default 4. */
79
+ infixMinLength?: number;
80
+ }
81
+ interface SearchIndex<E extends SearchEntry = SearchEntry> {
82
+ /** Ranked hits for `query`; an empty query returns none. */
83
+ search: (query: string) => SearchHit<E>[];
84
+ readonly entries: readonly E[];
85
+ }
86
+ /** Points between two ranking tiers. */
87
+ declare const SEARCH_TIER_POINTS = 1000;
88
+ /**
89
+ * Lower case, accents off, punctuation to spaces — the form both sides are compared in.
90
+ * Exported so an app can normalise its own keys the same way.
91
+ */
92
+ declare function normalizeSearchText(text: string): string;
93
+ /**
94
+ * Build an index over `entries`. Normalisation happens once here, so `search` is a scan
95
+ * over prepared words — a few thousand entries per keystroke is nothing.
96
+ */
97
+ declare function createSearchIndex<E extends SearchEntry>(entries: readonly E[], options?: SearchIndexOptions): SearchIndex<E>;
98
+ /**
99
+ * One-shot form of {@link createSearchIndex}: the matching entries, best first. For a
100
+ * list that changes on every call (a server response, a store selector); build an index
101
+ * once when the entries are stable.
102
+ */
103
+ declare function matchEntries<E extends SearchEntry>(entries: readonly E[], query: string, options?: SearchIndexOptions): E[];
104
+
105
+ export { SEARCH_TIER_POINTS, type SearchEntry, type SearchHit, type SearchIndex, type SearchIndexOptions, type SearchMatchField, createSearchIndex, matchEntries, normalizeSearchText };
@@ -0,0 +1,142 @@
1
+ "use client";
2
+ const SEARCH_TIER_POINTS = 1e3;
3
+ const TIERS = {
4
+ exact: 5,
5
+ prefix: 4,
6
+ title: 3,
7
+ keywords: 2,
8
+ description: 1
9
+ };
10
+ const WORD_SCORE = { exact: 40, prefix: 30, infix: 15, typo: 10 };
11
+ const FIELD_FACTOR = { title: 3, keywords: 2, description: 1 };
12
+ function normalizeSearchText(text) {
13
+ return text.normalize("NFD").replace(/\p{M}+/gu, "").toLowerCase().replace(/ß/g, "ss").replace(/[^\p{L}\p{N}]+/gu, " ").trim();
14
+ }
15
+ function words(text) {
16
+ const out = /* @__PURE__ */ new Set();
17
+ const spaced = text.replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, "$1 $2").replace(/(\p{Lu})(\p{Lu}\p{Ll})/gu, "$1 $2");
18
+ for (const w of normalizeSearchText(text).split(" ")) if (w) out.add(w);
19
+ for (const w of normalizeSearchText(spaced).split(" ")) if (w) out.add(w);
20
+ return [...out];
21
+ }
22
+ function withinDistance(a, b, max) {
23
+ if (Math.abs(a.length - b.length) > max) return false;
24
+ if (a === b) return true;
25
+ const n = a.length;
26
+ const m = b.length;
27
+ let prev2 = [];
28
+ let prev = Array.from({ length: m + 1 }, (_, j) => j);
29
+ for (let i = 1; i <= n; i++) {
30
+ const cur = [i];
31
+ let rowMin = i;
32
+ for (let j = 1; j <= m; j++) {
33
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
34
+ let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
35
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
36
+ v = Math.min(v, prev2[j - 2] + 1);
37
+ }
38
+ cur.push(v);
39
+ if (v < rowMin) rowMin = v;
40
+ }
41
+ if (rowMin > max) return false;
42
+ prev2 = prev;
43
+ prev = cur;
44
+ }
45
+ return prev[m] <= max;
46
+ }
47
+ function matchWord(q, word, opts) {
48
+ if (word === q) return "exact";
49
+ if (word.startsWith(q)) return "prefix";
50
+ if (q.length >= opts.infixMinLength && word.includes(q)) return "infix";
51
+ if (q.length >= opts.typoMinLength) {
52
+ if (withinDistance(q, word, 1)) return "typo";
53
+ if (word.length > q.length && withinDistance(q, word.slice(0, q.length), 1)) return "typo";
54
+ }
55
+ return null;
56
+ }
57
+ function createSearchIndex(entries, options = {}) {
58
+ const { groupLimit = 8, limit = Infinity, typoMinLength = 5, infixMinLength = 4 } = options;
59
+ const wordOpts = { typoMinLength, infixMinLength };
60
+ const prepared = entries.map((entry) => {
61
+ const title = normalizeSearchText(entry.title);
62
+ return {
63
+ entry,
64
+ title,
65
+ titleCompact: title.replace(/ /g, ""),
66
+ fields: [
67
+ ["title", words(entry.title)],
68
+ ["keywords", (entry.keywords ?? []).flatMap(words)],
69
+ ["description", entry.description ? words(entry.description) : []]
70
+ ]
71
+ };
72
+ });
73
+ const search = (query) => {
74
+ const q = normalizeSearchText(query);
75
+ if (!q) return [];
76
+ const qWords = [...new Set(q.split(" "))];
77
+ const qCompact = q.replace(/ /g, "");
78
+ const hits = [];
79
+ for (const p of prepared) {
80
+ let quality = 0;
81
+ let weakest = "title";
82
+ let ok = true;
83
+ for (const qw of qWords) {
84
+ let best = 0;
85
+ let bestField = null;
86
+ for (const [field, fieldWords] of p.fields) {
87
+ for (const w of fieldWords) {
88
+ const kind = matchWord(qw, w, wordOpts);
89
+ if (!kind) continue;
90
+ const s = WORD_SCORE[kind] * FIELD_FACTOR[field];
91
+ if (s > best) {
92
+ best = s;
93
+ bestField = field;
94
+ }
95
+ }
96
+ }
97
+ if (!bestField) {
98
+ ok = false;
99
+ break;
100
+ }
101
+ quality += best;
102
+ if (FIELD_FACTOR[bestField] < FIELD_FACTOR[weakest]) weakest = bestField;
103
+ }
104
+ if (!ok) continue;
105
+ let tier = TIERS[weakest];
106
+ if (p.title === q || p.titleCompact === qCompact) tier = TIERS.exact;
107
+ else if (p.title.startsWith(q) || p.titleCompact.startsWith(qCompact) || // The whole title, mistyped once ("Tooltp") — as good as a prefix, not exact.
108
+ qCompact.length >= typoMinLength && withinDistance(qCompact, p.titleCompact, 1)) {
109
+ tier = TIERS.prefix;
110
+ }
111
+ const coverage = Math.min(1, qCompact.length / Math.max(1, p.titleCompact.length)) * 40;
112
+ const score = tier * SEARCH_TIER_POINTS + Math.min(quality / qWords.length + coverage, SEARCH_TIER_POINTS / 2 - 1) + (p.entry.weight ?? 0);
113
+ hits.push({ entry: p.entry, score, field: weakest });
114
+ }
115
+ hits.sort((a, b) => b.score - a.score || a.entry.title.localeCompare(b.entry.title));
116
+ return capByGroup(hits, groupLimit, limit);
117
+ };
118
+ return { search, entries };
119
+ }
120
+ function capByGroup(hits, groupLimit, limit) {
121
+ const perGroup = /* @__PURE__ */ new Map();
122
+ const out = [];
123
+ for (const hit of hits) {
124
+ if (out.length >= limit) break;
125
+ const g = hit.entry.group ?? "";
126
+ const n = perGroup.get(g) ?? 0;
127
+ if (n >= groupLimit) continue;
128
+ perGroup.set(g, n + 1);
129
+ out.push(hit);
130
+ }
131
+ return out;
132
+ }
133
+ function matchEntries(entries, query, options) {
134
+ return createSearchIndex(entries, options).search(query).map((hit) => hit.entry);
135
+ }
136
+ export {
137
+ SEARCH_TIER_POINTS,
138
+ createSearchIndex,
139
+ matchEntries,
140
+ normalizeSearchText
141
+ };
142
+ //# sourceMappingURL=search-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/search/search-index.ts"],"sourcesContent":["import type { ReactNode } from \"react\";\n\n/**\n * The matcher behind {@link GlobalSearch} — pure, React-free, testable on its own.\n *\n * WHY IT EXISTS. `CommandPalette` is deliberately domain-free: it shows whatever a\n * `search(query)` provider returns. So every app wrote that provider, and every app wrote\n * the same one — `label.toLowerCase().includes(q)` over its pages and actions (kastlan's\n * command palette, keksdose's global search) plus a second copy with keywords for the\n * settings catalogue (keksdose's `matchSettingsEntries`). A substring test answers \"does\n * this string contain that one\", which is not what a search box is asked:\n *\n * - \"range date\" finds nothing, because the words are in the other order;\n * - \"Uberweisung\" misses \"Überweisung\", and \"Tooltp\" misses \"Tooltip\";\n * - \"del\" matches \"model\" as happily as \"delete\", and every hit ranks the same, so the\n * exact page name can sit under six unrelated rows.\n *\n * What it does instead, per entry:\n *\n * - NORMALISES both sides: lower case, accents stripped (NFD minus combining marks),\n * `ß` → `ss`, punctuation as a word break, and camelCase split so `DateRangePicker`\n * is also the words date, range, picker.\n * - Requires EVERY query word to match SOMEWHERE in the entry — title, keywords or\n * description, in any order.\n * - A word matches a word of the entry exactly, as its prefix, inside it (four letters or\n * more, for compounds: \"bereich\" in \"Datumsbereich\"), or — for words of five letters\n * or more — at edit distance one, transpositions included (\"tooltp\", \"calender\").\n * - RANKS in tiers: exact title > title prefix > all words in the title > some word only\n * in the keywords > some word only in the description. Within a tier, better word\n * matches (exact over prefix over infix over typo) score higher, and `weight` is added\n * last. The tiers are {@link SEARCH_TIER_POINTS} points apart, so a weight below that reorders within\n * a tier and never across one.\n * - Caps each group (`groupLimit`, default 8) after ranking, so one broad group cannot\n * push every other group off the list.\n */\n\n/** One searchable thing: a page, an action, a record, a settings row. */\nexport interface SearchEntry {\n /** Unique across the entries handed to one index. */\n id: string;\n /** What the row says, and what ranks highest. */\n title: string;\n /** Other words or phrases that should find it — synonyms, a translated name, tags.\n * Matched like the title, ranked below it. */\n keywords?: readonly string[];\n /** Longer text, matched last and ranked lowest. Not shown in the row. */\n description?: string;\n /** Heading the row is grouped under in the palette. */\n group?: string;\n /** Secondary text on the row's trailing side (the page it lives on, an amount). */\n hint?: string;\n /** Leading icon. */\n icon?: ReactNode;\n /** Where it leads — the app's own path, handed to `GlobalSearch`'s `navigate`. */\n href?: string;\n /** Run instead of navigating. Wins over `href` for a plain click and ↵; `href` still\n * makes the row a real link for a middle-/⌘-click. */\n onSelect?: () => void;\n /** Added to the score. Below {@link SEARCH_TIER_POINTS} it reorders within a tier. */\n weight?: number;\n /** Masks the row for session replay (see `CommandItem.redact`). */\n redact?: boolean;\n}\n\nexport type SearchMatchField = \"title\" | \"keywords\" | \"description\";\n\nexport interface SearchHit<E extends SearchEntry = SearchEntry> {\n entry: E;\n score: number;\n /** The weakest field any query word had to be found in. */\n field: SearchMatchField;\n}\n\nexport interface SearchIndexOptions {\n /** Hits kept per `group` after ranking. Default 8; `Infinity` for no cap. */\n groupLimit?: number;\n /** Hits kept in total. Default: no cap beyond `groupLimit`. */\n limit?: number;\n /** Shortest query word allowed an edit-distance-1 match. Default 5 — below that a\n * single typo is a different word (\"cat\" / \"car\"). */\n typoMinLength?: number;\n /** Shortest query word matched INSIDE an entry word. Default 4. */\n infixMinLength?: number;\n}\n\nexport interface SearchIndex<E extends SearchEntry = SearchEntry> {\n /** Ranked hits for `query`; an empty query returns none. */\n search: (query: string) => SearchHit<E>[];\n readonly entries: readonly E[];\n}\n\n/** Points between two ranking tiers. */\nexport const SEARCH_TIER_POINTS = 1000;\n\nconst TIERS = {\n exact: 5,\n prefix: 4,\n title: 3,\n keywords: 2,\n description: 1,\n} as const;\n\n/** How well one query word matched one entry word — higher is better. */\nconst WORD_SCORE = { exact: 40, prefix: 30, infix: 15, typo: 10 } as const;\n/** How much the field a word matched in is worth, multiplied with the above. */\nconst FIELD_FACTOR: Record<SearchMatchField, number> = { title: 3, keywords: 2, description: 1 };\n\n/**\n * Lower case, accents off, punctuation to spaces — the form both sides are compared in.\n * Exported so an app can normalise its own keys the same way.\n */\nexport function normalizeSearchText(text: string): string {\n return text\n .normalize(\"NFD\")\n .replace(/\\p{M}+/gu, \"\")\n .toLowerCase()\n .replace(/ß/g, \"ss\")\n .replace(/[^\\p{L}\\p{N}]+/gu, \" \")\n .trim();\n}\n\n/** The words of a text, camelCase split too: \"DateRangePicker\" → daterangepicker, date,\n * range, picker. The joined word stays so \"daterange\" still prefixes it. */\nfunction words(text: string): string[] {\n const out = new Set<string>();\n // Split camelCase BEFORE lower-casing, or the boundaries are gone.\n const spaced = text.replace(/(\\p{Ll}|\\p{N})(\\p{Lu})/gu, \"$1 $2\").replace(/(\\p{Lu})(\\p{Lu}\\p{Ll})/gu, \"$1 $2\");\n for (const w of normalizeSearchText(text).split(\" \")) if (w) out.add(w);\n for (const w of normalizeSearchText(spaced).split(\" \")) if (w) out.add(w);\n return [...out];\n}\n\n/** Restricted Damerau–Levenshtein distance, early-exiting once it must exceed `max`. */\nfunction withinDistance(a: string, b: string, max: number): boolean {\n if (Math.abs(a.length - b.length) > max) return false;\n if (a === b) return true;\n const n = a.length;\n const m = b.length;\n let prev2: number[] = [];\n let prev = Array.from({ length: m + 1 }, (_, j) => j);\n for (let i = 1; i <= n; i++) {\n const cur = [i];\n let rowMin = i;\n for (let j = 1; j <= m; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);\n if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {\n v = Math.min(v, prev2[j - 2] + 1);\n }\n cur.push(v);\n if (v < rowMin) rowMin = v;\n }\n if (rowMin > max) return false;\n prev2 = prev;\n prev = cur;\n }\n return prev[m] <= max;\n}\n\ntype WordKind = keyof typeof WORD_SCORE;\n\nfunction matchWord(q: string, word: string, opts: Required<Pick<SearchIndexOptions, \"typoMinLength\" | \"infixMinLength\">>): WordKind | null {\n if (word === q) return \"exact\";\n if (word.startsWith(q)) return \"prefix\";\n if (q.length >= opts.infixMinLength && word.includes(q)) return \"infix\";\n if (q.length >= opts.typoMinLength) {\n // The whole word, or the word's start as long as the query (a typo in a prefix:\n // \"calender\" is one edit from \"calendar\", the start of \"calendars\").\n if (withinDistance(q, word, 1)) return \"typo\";\n if (word.length > q.length && withinDistance(q, word.slice(0, q.length), 1)) return \"typo\";\n }\n return null;\n}\n\ninterface Prepared<E> {\n entry: E;\n title: string;\n titleCompact: string;\n fields: Array<[SearchMatchField, string[]]>;\n}\n\n/**\n * Build an index over `entries`. Normalisation happens once here, so `search` is a scan\n * over prepared words — a few thousand entries per keystroke is nothing.\n */\nexport function createSearchIndex<E extends SearchEntry>(\n entries: readonly E[],\n options: SearchIndexOptions = {},\n): SearchIndex<E> {\n const { groupLimit = 8, limit = Infinity, typoMinLength = 5, infixMinLength = 4 } = options;\n const wordOpts = { typoMinLength, infixMinLength };\n const prepared: Prepared<E>[] = entries.map((entry) => {\n const title = normalizeSearchText(entry.title);\n return {\n entry,\n title,\n titleCompact: title.replace(/ /g, \"\"),\n fields: [\n [\"title\", words(entry.title)],\n [\"keywords\", (entry.keywords ?? []).flatMap(words)],\n [\"description\", entry.description ? words(entry.description) : []],\n ],\n };\n });\n\n const search = (query: string): SearchHit<E>[] => {\n const q = normalizeSearchText(query);\n if (!q) return [];\n const qWords = [...new Set(q.split(\" \"))];\n const qCompact = q.replace(/ /g, \"\");\n const hits: SearchHit<E>[] = [];\n\n for (const p of prepared) {\n let quality = 0;\n let weakest: SearchMatchField = \"title\";\n let ok = true;\n for (const qw of qWords) {\n let best = 0;\n let bestField: SearchMatchField | null = null;\n for (const [field, fieldWords] of p.fields) {\n for (const w of fieldWords) {\n const kind = matchWord(qw, w, wordOpts);\n if (!kind) continue;\n const s = WORD_SCORE[kind] * FIELD_FACTOR[field];\n if (s > best) {\n best = s;\n bestField = field;\n }\n }\n }\n if (!bestField) {\n ok = false;\n break;\n }\n quality += best;\n if (FIELD_FACTOR[bestField] < FIELD_FACTOR[weakest]) weakest = bestField;\n }\n if (!ok) continue;\n\n let tier: number = TIERS[weakest];\n if (p.title === q || p.titleCompact === qCompact) tier = TIERS.exact;\n else if (\n p.title.startsWith(q) ||\n p.titleCompact.startsWith(qCompact) ||\n // The whole title, mistyped once (\"Tooltp\") — as good as a prefix, not exact.\n (qCompact.length >= typoMinLength && withinDistance(qCompact, p.titleCompact, 1))\n ) {\n tier = TIERS.prefix;\n }\n // Within a tier: how well the words matched (≤ 120 each, averaged so a long query\n // cannot climb by word count), and how much of the title the query covers — so\n // \"Tooltip\" beats \"Popovers, menus & tooltips\" for the same one-word match.\n const coverage = Math.min(1, qCompact.length / Math.max(1, p.titleCompact.length)) * 40;\n const score =\n tier * SEARCH_TIER_POINTS +\n Math.min(quality / qWords.length + coverage, SEARCH_TIER_POINTS / 2 - 1) +\n (p.entry.weight ?? 0);\n hits.push({ entry: p.entry, score, field: weakest });\n }\n\n hits.sort((a, b) => b.score - a.score || a.entry.title.localeCompare(b.entry.title));\n return capByGroup(hits, groupLimit, limit);\n };\n\n return { search, entries };\n}\n\nfunction capByGroup<E extends SearchEntry>(hits: SearchHit<E>[], groupLimit: number, limit: number): SearchHit<E>[] {\n const perGroup = new Map<string, number>();\n const out: SearchHit<E>[] = [];\n for (const hit of hits) {\n if (out.length >= limit) break;\n const g = hit.entry.group ?? \"\";\n const n = perGroup.get(g) ?? 0;\n if (n >= groupLimit) continue;\n perGroup.set(g, n + 1);\n out.push(hit);\n }\n return out;\n}\n\n/**\n * One-shot form of {@link createSearchIndex}: the matching entries, best first. For a\n * list that changes on every call (a server response, a store selector); build an index\n * once when the entries are stable.\n */\nexport function matchEntries<E extends SearchEntry>(\n entries: readonly E[],\n query: string,\n options?: SearchIndexOptions,\n): E[] {\n return createSearchIndex(entries, options)\n .search(query)\n .map((hit) => hit.entry);\n}\n"],"mappings":";AA4FO,MAAM,qBAAqB;AAElC,MAAM,QAAQ;AAAA,EACZ,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,UAAU;AAAA,EACV,aAAa;AACf;AAGA,MAAM,aAAa,EAAE,OAAO,IAAI,QAAQ,IAAI,OAAO,IAAI,MAAM,GAAG;AAEhE,MAAM,eAAiD,EAAE,OAAO,GAAG,UAAU,GAAG,aAAa,EAAE;AAMxF,SAAS,oBAAoB,MAAsB;AACxD,SAAO,KACJ,UAAU,KAAK,EACf,QAAQ,YAAY,EAAE,EACtB,YAAY,EACZ,QAAQ,MAAM,IAAI,EAClB,QAAQ,oBAAoB,GAAG,EAC/B,KAAK;AACV;AAIA,SAAS,MAAM,MAAwB;AACrC,QAAM,MAAM,oBAAI,IAAY;AAE5B,QAAM,SAAS,KAAK,QAAQ,4BAA4B,OAAO,EAAE,QAAQ,4BAA4B,OAAO;AAC5G,aAAW,KAAK,oBAAoB,IAAI,EAAE,MAAM,GAAG,EAAG,KAAI,EAAG,KAAI,IAAI,CAAC;AACtE,aAAW,KAAK,oBAAoB,MAAM,EAAE,MAAM,GAAG,EAAG,KAAI,EAAG,KAAI,IAAI,CAAC;AACxE,SAAO,CAAC,GAAG,GAAG;AAChB;AAGA,SAAS,eAAe,GAAW,GAAW,KAAsB;AAClE,MAAI,KAAK,IAAI,EAAE,SAAS,EAAE,MAAM,IAAI,IAAK,QAAO;AAChD,MAAI,MAAM,EAAG,QAAO;AACpB,QAAM,IAAI,EAAE;AACZ,QAAM,IAAI,EAAE;AACZ,MAAI,QAAkB,CAAC;AACvB,MAAI,OAAO,MAAM,KAAK,EAAE,QAAQ,IAAI,EAAE,GAAG,CAAC,GAAG,MAAM,CAAC;AACpD,WAAS,IAAI,GAAG,KAAK,GAAG,KAAK;AAC3B,UAAM,MAAM,CAAC,CAAC;AACd,QAAI,SAAS;AACb,aAAS,IAAI,GAAG,KAAK,GAAG,KAAK;AAC3B,YAAM,OAAO,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,IAAI;AACzC,UAAI,IAAI,KAAK,IAAI,KAAK,CAAC,IAAI,GAAG,IAAI,IAAI,CAAC,IAAI,GAAG,KAAK,IAAI,CAAC,IAAI,IAAI;AAChE,UAAI,IAAI,KAAK,IAAI,KAAK,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG;AACpE,YAAI,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC;AAAA,MAClC;AACA,UAAI,KAAK,CAAC;AACV,UAAI,IAAI,OAAQ,UAAS;AAAA,IAC3B;AACA,QAAI,SAAS,IAAK,QAAO;AACzB,YAAQ;AACR,WAAO;AAAA,EACT;AACA,SAAO,KAAK,CAAC,KAAK;AACpB;AAIA,SAAS,UAAU,GAAW,MAAc,MAA+F;AACzI,MAAI,SAAS,EAAG,QAAO;AACvB,MAAI,KAAK,WAAW,CAAC,EAAG,QAAO;AAC/B,MAAI,EAAE,UAAU,KAAK,kBAAkB,KAAK,SAAS,CAAC,EAAG,QAAO;AAChE,MAAI,EAAE,UAAU,KAAK,eAAe;AAGlC,QAAI,eAAe,GAAG,MAAM,CAAC,EAAG,QAAO;AACvC,QAAI,KAAK,SAAS,EAAE,UAAU,eAAe,GAAG,KAAK,MAAM,GAAG,EAAE,MAAM,GAAG,CAAC,EAAG,QAAO;AAAA,EACtF;AACA,SAAO;AACT;AAaO,SAAS,kBACd,SACA,UAA8B,CAAC,GACf;AAChB,QAAM,EAAE,aAAa,GAAG,QAAQ,UAAU,gBAAgB,GAAG,iBAAiB,EAAE,IAAI;AACpF,QAAM,WAAW,EAAE,eAAe,eAAe;AACjD,QAAM,WAA0B,QAAQ,IAAI,CAAC,UAAU;AACrD,UAAM,QAAQ,oBAAoB,MAAM,KAAK;AAC7C,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA,cAAc,MAAM,QAAQ,MAAM,EAAE;AAAA,MACpC,QAAQ;AAAA,QACN,CAAC,SAAS,MAAM,MAAM,KAAK,CAAC;AAAA,QAC5B,CAAC,aAAa,MAAM,YAAY,CAAC,GAAG,QAAQ,KAAK,CAAC;AAAA,QAClD,CAAC,eAAe,MAAM,cAAc,MAAM,MAAM,WAAW,IAAI,CAAC,CAAC;AAAA,MACnE;AAAA,IACF;AAAA,EACF,CAAC;AAED,QAAM,SAAS,CAAC,UAAkC;AAChD,UAAM,IAAI,oBAAoB,KAAK;AACnC,QAAI,CAAC,EAAG,QAAO,CAAC;AAChB,UAAM,SAAS,CAAC,GAAG,IAAI,IAAI,EAAE,MAAM,GAAG,CAAC,CAAC;AACxC,UAAM,WAAW,EAAE,QAAQ,MAAM,EAAE;AACnC,UAAM,OAAuB,CAAC;AAE9B,eAAW,KAAK,UAAU;AACxB,UAAI,UAAU;AACd,UAAI,UAA4B;AAChC,UAAI,KAAK;AACT,iBAAW,MAAM,QAAQ;AACvB,YAAI,OAAO;AACX,YAAI,YAAqC;AACzC,mBAAW,CAAC,OAAO,UAAU,KAAK,EAAE,QAAQ;AAC1C,qBAAW,KAAK,YAAY;AAC1B,kBAAM,OAAO,UAAU,IAAI,GAAG,QAAQ;AACtC,gBAAI,CAAC,KAAM;AACX,kBAAM,IAAI,WAAW,IAAI,IAAI,aAAa,KAAK;AAC/C,gBAAI,IAAI,MAAM;AACZ,qBAAO;AACP,0BAAY;AAAA,YACd;AAAA,UACF;AAAA,QACF;AACA,YAAI,CAAC,WAAW;AACd,eAAK;AACL;AAAA,QACF;AACA,mBAAW;AACX,YAAI,aAAa,SAAS,IAAI,aAAa,OAAO,EAAG,WAAU;AAAA,MACjE;AACA,UAAI,CAAC,GAAI;AAET,UAAI,OAAe,MAAM,OAAO;AAChC,UAAI,EAAE,UAAU,KAAK,EAAE,iBAAiB,SAAU,QAAO,MAAM;AAAA,eAE7D,EAAE,MAAM,WAAW,CAAC,KACpB,EAAE,aAAa,WAAW,QAAQ;AAAA,MAEjC,SAAS,UAAU,iBAAiB,eAAe,UAAU,EAAE,cAAc,CAAC,GAC/E;AACA,eAAO,MAAM;AAAA,MACf;AAIA,YAAM,WAAW,KAAK,IAAI,GAAG,SAAS,SAAS,KAAK,IAAI,GAAG,EAAE,aAAa,MAAM,CAAC,IAAI;AACrF,YAAM,QACJ,OAAO,qBACP,KAAK,IAAI,UAAU,OAAO,SAAS,UAAU,qBAAqB,IAAI,CAAC,KACtE,EAAE,MAAM,UAAU;AACrB,WAAK,KAAK,EAAE,OAAO,EAAE,OAAO,OAAO,OAAO,QAAQ,CAAC;AAAA,IACrD;AAEA,SAAK,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,MAAM,cAAc,EAAE,MAAM,KAAK,CAAC;AACnF,WAAO,WAAW,MAAM,YAAY,KAAK;AAAA,EAC3C;AAEA,SAAO,EAAE,QAAQ,QAAQ;AAC3B;AAEA,SAAS,WAAkC,MAAsB,YAAoB,OAA+B;AAClH,QAAM,WAAW,oBAAI,IAAoB;AACzC,QAAM,MAAsB,CAAC;AAC7B,aAAW,OAAO,MAAM;AACtB,QAAI,IAAI,UAAU,MAAO;AACzB,UAAM,IAAI,IAAI,MAAM,SAAS;AAC7B,UAAM,IAAI,SAAS,IAAI,CAAC,KAAK;AAC7B,QAAI,KAAK,WAAY;AACrB,aAAS,IAAI,GAAG,IAAI,CAAC;AACrB,QAAI,KAAK,GAAG;AAAA,EACd;AACA,SAAO;AACT;AAOO,SAAS,aACd,SACA,OACA,SACK;AACL,SAAO,kBAAkB,SAAS,OAAO,EACtC,OAAO,KAAK,EACZ,IAAI,CAAC,QAAQ,IAAI,KAAK;AAC3B;","names":[]}
package/dist/search.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export { CommandItem, CommandPalette, CommandPaletteLabels, DEFAULT_COMMAND_PALETTE_LABELS, useCommandKey } from './search/command-palette.js';
2
+ export { SEARCH_TIER_POINTS, SearchEntry, SearchHit, SearchIndex, SearchIndexOptions, SearchMatchField, createSearchIndex, matchEntries, normalizeSearchText } from './search/search-index.js';
3
+ export { DEFAULT_GLOBAL_SEARCH_LABELS, GlobalSearch, GlobalSearchLabels, GlobalSearchProps, GlobalSearchSource, GlobalSearchSuggestion, GlobalSearchTriggerProps } from './search/global-search.js';
2
4
  import 'react';
package/dist/search.js CHANGED
@@ -1,3 +1,5 @@
1
1
  "use client";
2
2
  export * from "./search/command-palette.js";
3
+ export * from "./search/search-index.js";
4
+ export * from "./search/global-search.js";
3
5
  //# sourceMappingURL=search.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/search.ts"],"sourcesContent":["// `@eifi1/ui-kit/search` — the command palette / global search.\n// A re-slicing of the main barrel, not a new API.\nexport * from \"./search/command-palette\";\n"],"mappings":";AAEA,cAAc;","names":[]}
1
+ {"version":3,"sources":["../src/search.ts"],"sourcesContent":["// `@eifi1/ui-kit/search` — the command palette / global search.\n// A re-slicing of the main barrel, not a new API.\nexport * from \"./search/command-palette\";\nexport * from \"./search/search-index\";\nexport * from \"./search/global-search\";\n"],"mappings":";AAEA,cAAc;AACd,cAAc;AACd,cAAc;","names":[]}