@enigmax/primitives 0.3.0 → 0.4.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.
- package/dist/chunk-UZFEEFMF.js +145 -0
- package/dist/{chunk-OKNWKBQI.js → chunk-W2DRHYC2.js} +1 -145
- package/dist/index.d.ts +3 -68
- package/dist/index.js +2 -1
- package/dist/react/index.d.ts +4 -2
- package/dist/react/index.js +2 -1
- package/dist/search/index.d.ts +29 -0
- package/dist/search/index.js +12 -0
- package/dist/search-CsO3L1Lw.d.ts +68 -0
- package/package.json +6 -2
- package/registry.json +7 -4
- package/src/search/index.ts +35 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// src/core/search.ts
|
|
2
|
+
var DEFAULT_DEBOUNCE = 120;
|
|
3
|
+
function fold(value) {
|
|
4
|
+
return value.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
function readPath(item, path) {
|
|
7
|
+
let cursor = item;
|
|
8
|
+
for (const step of path.split(".")) {
|
|
9
|
+
if (cursor == null || typeof cursor !== "object") return "";
|
|
10
|
+
cursor = cursor[step];
|
|
11
|
+
}
|
|
12
|
+
return cursor == null ? "" : String(cursor);
|
|
13
|
+
}
|
|
14
|
+
function substringMatcher(query, items, keys) {
|
|
15
|
+
const needle = fold(query);
|
|
16
|
+
const results = [];
|
|
17
|
+
for (const item of items) {
|
|
18
|
+
const fields = keys.length ? keys : [""];
|
|
19
|
+
let best = null;
|
|
20
|
+
for (let index = 0; index < fields.length; index++) {
|
|
21
|
+
const key = fields[index];
|
|
22
|
+
const haystack = fold(key ? readPath(item, key) : String(item));
|
|
23
|
+
const at = haystack.indexOf(needle);
|
|
24
|
+
if (at < 0) continue;
|
|
25
|
+
const score = haystack === needle ? 0 : (at + 1) / (haystack.length + 1) + index * 0.01;
|
|
26
|
+
if (!best || score < best.score) best = { item, score, key: key || void 0 };
|
|
27
|
+
}
|
|
28
|
+
if (best) results.push(best);
|
|
29
|
+
}
|
|
30
|
+
return results.sort((left, right) => left.score - right.score);
|
|
31
|
+
}
|
|
32
|
+
function createSearch(options = {}) {
|
|
33
|
+
let opts = { ...options };
|
|
34
|
+
let items = opts.items ?? [];
|
|
35
|
+
let query = "";
|
|
36
|
+
let results = [];
|
|
37
|
+
let timer = null;
|
|
38
|
+
let engine = null;
|
|
39
|
+
let destroyed = false;
|
|
40
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
41
|
+
function buildEngine() {
|
|
42
|
+
engine = null;
|
|
43
|
+
if (opts.matcher || !opts.fuse) return;
|
|
44
|
+
engine = new opts.fuse(items, { keys: opts.keys ?? [], includeScore: true, includeMatches: true, threshold: 0.4, ...opts.fuseOptions });
|
|
45
|
+
}
|
|
46
|
+
function run(next) {
|
|
47
|
+
const trimmed = next.trim();
|
|
48
|
+
const minLength = opts.minLength ?? 1;
|
|
49
|
+
let found;
|
|
50
|
+
if (trimmed.length < minLength) {
|
|
51
|
+
found = (opts.empty ?? "none") === "all" ? items.map((item) => ({ item, score: 0 })) : [];
|
|
52
|
+
} else if (opts.matcher) {
|
|
53
|
+
found = opts.matcher(trimmed, items);
|
|
54
|
+
} else if (engine) {
|
|
55
|
+
found = engine.search(trimmed).map((hit) => ({
|
|
56
|
+
item: hit.item,
|
|
57
|
+
score: hit.score ?? 0,
|
|
58
|
+
key: hit.matches?.[0]?.key
|
|
59
|
+
}));
|
|
60
|
+
} else {
|
|
61
|
+
found = substringMatcher(trimmed, items, opts.keys ?? []);
|
|
62
|
+
}
|
|
63
|
+
return opts.limit != null ? found.slice(0, opts.limit) : found;
|
|
64
|
+
}
|
|
65
|
+
function publish(next) {
|
|
66
|
+
query = next;
|
|
67
|
+
results = run(next);
|
|
68
|
+
opts.onResults?.(results, query);
|
|
69
|
+
for (const listener of listeners) listener(results, query);
|
|
70
|
+
return results;
|
|
71
|
+
}
|
|
72
|
+
function clearTimer() {
|
|
73
|
+
if (timer === null) return;
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
timer = null;
|
|
76
|
+
}
|
|
77
|
+
buildEngine();
|
|
78
|
+
return {
|
|
79
|
+
get query() {
|
|
80
|
+
return query;
|
|
81
|
+
},
|
|
82
|
+
get results() {
|
|
83
|
+
return results;
|
|
84
|
+
},
|
|
85
|
+
search(next) {
|
|
86
|
+
if (destroyed) return;
|
|
87
|
+
clearTimer();
|
|
88
|
+
const wait = opts.debounce ?? DEFAULT_DEBOUNCE;
|
|
89
|
+
if (wait <= 0) {
|
|
90
|
+
publish(next);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
timer = setTimeout(() => {
|
|
94
|
+
timer = null;
|
|
95
|
+
publish(next);
|
|
96
|
+
}, wait);
|
|
97
|
+
},
|
|
98
|
+
searchNow(next) {
|
|
99
|
+
clearTimer();
|
|
100
|
+
return publish(next);
|
|
101
|
+
},
|
|
102
|
+
setItems(next) {
|
|
103
|
+
items = next;
|
|
104
|
+
buildEngine();
|
|
105
|
+
if (query) publish(query);
|
|
106
|
+
},
|
|
107
|
+
update(next) {
|
|
108
|
+
opts = { ...opts, ...next };
|
|
109
|
+
if (next.items) items = next.items;
|
|
110
|
+
buildEngine();
|
|
111
|
+
if (query) publish(query);
|
|
112
|
+
},
|
|
113
|
+
attach(input) {
|
|
114
|
+
const onInput = () => this.search(input.value);
|
|
115
|
+
const onKeyDown = (event) => {
|
|
116
|
+
if (event.key !== "Escape" || !input.value) return;
|
|
117
|
+
event.stopPropagation();
|
|
118
|
+
input.value = "";
|
|
119
|
+
this.searchNow("");
|
|
120
|
+
};
|
|
121
|
+
input.addEventListener("input", onInput);
|
|
122
|
+
input.addEventListener("keydown", onKeyDown);
|
|
123
|
+
input.dataset.enigmaSearch = "";
|
|
124
|
+
return () => {
|
|
125
|
+
input.removeEventListener("input", onInput);
|
|
126
|
+
input.removeEventListener("keydown", onKeyDown);
|
|
127
|
+
delete input.dataset.enigmaSearch;
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
subscribe(listener) {
|
|
131
|
+
listeners.add(listener);
|
|
132
|
+
return () => {
|
|
133
|
+
listeners.delete(listener);
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
destroy() {
|
|
137
|
+
destroyed = true;
|
|
138
|
+
clearTimer();
|
|
139
|
+
listeners.clear();
|
|
140
|
+
engine = null;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export { createSearch };
|
|
@@ -470,148 +470,4 @@ function createInput(input, options = {}) {
|
|
|
470
470
|
};
|
|
471
471
|
}
|
|
472
472
|
|
|
473
|
-
|
|
474
|
-
var DEFAULT_DEBOUNCE = 120;
|
|
475
|
-
function fold(value) {
|
|
476
|
-
return value.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
|
|
477
|
-
}
|
|
478
|
-
function readPath(item, path) {
|
|
479
|
-
let cursor = item;
|
|
480
|
-
for (const step of path.split(".")) {
|
|
481
|
-
if (cursor == null || typeof cursor !== "object") return "";
|
|
482
|
-
cursor = cursor[step];
|
|
483
|
-
}
|
|
484
|
-
return cursor == null ? "" : String(cursor);
|
|
485
|
-
}
|
|
486
|
-
function substringMatcher(query, items, keys) {
|
|
487
|
-
const needle = fold(query);
|
|
488
|
-
const results = [];
|
|
489
|
-
for (const item of items) {
|
|
490
|
-
const fields = keys.length ? keys : [""];
|
|
491
|
-
let best = null;
|
|
492
|
-
for (let index = 0; index < fields.length; index++) {
|
|
493
|
-
const key = fields[index];
|
|
494
|
-
const haystack = fold(key ? readPath(item, key) : String(item));
|
|
495
|
-
const at = haystack.indexOf(needle);
|
|
496
|
-
if (at < 0) continue;
|
|
497
|
-
const score = haystack === needle ? 0 : (at + 1) / (haystack.length + 1) + index * 0.01;
|
|
498
|
-
if (!best || score < best.score) best = { item, score, key: key || void 0 };
|
|
499
|
-
}
|
|
500
|
-
if (best) results.push(best);
|
|
501
|
-
}
|
|
502
|
-
return results.sort((left, right) => left.score - right.score);
|
|
503
|
-
}
|
|
504
|
-
function createSearch(options = {}) {
|
|
505
|
-
let opts = { ...options };
|
|
506
|
-
let items = opts.items ?? [];
|
|
507
|
-
let query = "";
|
|
508
|
-
let results = [];
|
|
509
|
-
let timer = null;
|
|
510
|
-
let engine = null;
|
|
511
|
-
let destroyed = false;
|
|
512
|
-
const listeners = /* @__PURE__ */ new Set();
|
|
513
|
-
function buildEngine() {
|
|
514
|
-
engine = null;
|
|
515
|
-
if (opts.matcher || !opts.fuse) return;
|
|
516
|
-
engine = new opts.fuse(items, { keys: opts.keys ?? [], includeScore: true, includeMatches: true, threshold: 0.4, ...opts.fuseOptions });
|
|
517
|
-
}
|
|
518
|
-
function run(next) {
|
|
519
|
-
const trimmed = next.trim();
|
|
520
|
-
const minLength = opts.minLength ?? 1;
|
|
521
|
-
let found;
|
|
522
|
-
if (trimmed.length < minLength) {
|
|
523
|
-
found = (opts.empty ?? "none") === "all" ? items.map((item) => ({ item, score: 0 })) : [];
|
|
524
|
-
} else if (opts.matcher) {
|
|
525
|
-
found = opts.matcher(trimmed, items);
|
|
526
|
-
} else if (engine) {
|
|
527
|
-
found = engine.search(trimmed).map((hit) => ({
|
|
528
|
-
item: hit.item,
|
|
529
|
-
score: hit.score ?? 0,
|
|
530
|
-
key: hit.matches?.[0]?.key
|
|
531
|
-
}));
|
|
532
|
-
} else {
|
|
533
|
-
found = substringMatcher(trimmed, items, opts.keys ?? []);
|
|
534
|
-
}
|
|
535
|
-
return opts.limit != null ? found.slice(0, opts.limit) : found;
|
|
536
|
-
}
|
|
537
|
-
function publish(next) {
|
|
538
|
-
query = next;
|
|
539
|
-
results = run(next);
|
|
540
|
-
opts.onResults?.(results, query);
|
|
541
|
-
for (const listener of listeners) listener(results, query);
|
|
542
|
-
return results;
|
|
543
|
-
}
|
|
544
|
-
function clearTimer() {
|
|
545
|
-
if (timer === null) return;
|
|
546
|
-
clearTimeout(timer);
|
|
547
|
-
timer = null;
|
|
548
|
-
}
|
|
549
|
-
buildEngine();
|
|
550
|
-
return {
|
|
551
|
-
get query() {
|
|
552
|
-
return query;
|
|
553
|
-
},
|
|
554
|
-
get results() {
|
|
555
|
-
return results;
|
|
556
|
-
},
|
|
557
|
-
search(next) {
|
|
558
|
-
if (destroyed) return;
|
|
559
|
-
clearTimer();
|
|
560
|
-
const wait = opts.debounce ?? DEFAULT_DEBOUNCE;
|
|
561
|
-
if (wait <= 0) {
|
|
562
|
-
publish(next);
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
timer = setTimeout(() => {
|
|
566
|
-
timer = null;
|
|
567
|
-
publish(next);
|
|
568
|
-
}, wait);
|
|
569
|
-
},
|
|
570
|
-
searchNow(next) {
|
|
571
|
-
clearTimer();
|
|
572
|
-
return publish(next);
|
|
573
|
-
},
|
|
574
|
-
setItems(next) {
|
|
575
|
-
items = next;
|
|
576
|
-
buildEngine();
|
|
577
|
-
if (query) publish(query);
|
|
578
|
-
},
|
|
579
|
-
update(next) {
|
|
580
|
-
opts = { ...opts, ...next };
|
|
581
|
-
if (next.items) items = next.items;
|
|
582
|
-
buildEngine();
|
|
583
|
-
if (query) publish(query);
|
|
584
|
-
},
|
|
585
|
-
attach(input) {
|
|
586
|
-
const onInput = () => this.search(input.value);
|
|
587
|
-
const onKeyDown = (event) => {
|
|
588
|
-
if (event.key !== "Escape" || !input.value) return;
|
|
589
|
-
event.stopPropagation();
|
|
590
|
-
input.value = "";
|
|
591
|
-
this.searchNow("");
|
|
592
|
-
};
|
|
593
|
-
input.addEventListener("input", onInput);
|
|
594
|
-
input.addEventListener("keydown", onKeyDown);
|
|
595
|
-
input.dataset.enigmaSearch = "";
|
|
596
|
-
return () => {
|
|
597
|
-
input.removeEventListener("input", onInput);
|
|
598
|
-
input.removeEventListener("keydown", onKeyDown);
|
|
599
|
-
delete input.dataset.enigmaSearch;
|
|
600
|
-
};
|
|
601
|
-
},
|
|
602
|
-
subscribe(listener) {
|
|
603
|
-
listeners.add(listener);
|
|
604
|
-
return () => {
|
|
605
|
-
listeners.delete(listener);
|
|
606
|
-
};
|
|
607
|
-
},
|
|
608
|
-
destroy() {
|
|
609
|
-
destroyed = true;
|
|
610
|
-
clearTimer();
|
|
611
|
-
listeners.clear();
|
|
612
|
-
engine = null;
|
|
613
|
-
}
|
|
614
|
-
};
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
export { createInput, createMarquee, createSearch };
|
|
473
|
+
export { createInput, createMarquee };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { F as FuseConstructor, a as FuseLike, S as SearchInstance, b as SearchMatch, c as SearchOptions, d as createSearch } from './search-CsO3L1Lw.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Draggable infinite marquee: the behaviour engine, with no styling of its own.
|
|
3
5
|
*
|
|
@@ -145,71 +147,4 @@ interface InputInstance {
|
|
|
145
147
|
*/
|
|
146
148
|
declare function createInput(input: HTMLInputElement, options?: InputOptions): InputInstance;
|
|
147
149
|
|
|
148
|
-
|
|
149
|
-
* Search-as-you-type: debouncing, ordering, cancellation and the field wiring, with the
|
|
150
|
-
* matching left pluggable.
|
|
151
|
-
*
|
|
152
|
-
* Fuse.js is NOT a dependency of this package. Hand it the constructor and you get fuzzy
|
|
153
|
-
* matching configured exactly as if you had called it yourself - `fuseOptions` is passed
|
|
154
|
-
* through untouched. Hand it nothing and a built-in accent-insensitive substring matcher
|
|
155
|
-
* runs instead, so the primitive works with zero dependencies. Hand it a `matcher` and
|
|
156
|
-
* every part of this is replaced by your own.
|
|
157
|
-
*/
|
|
158
|
-
interface SearchMatch<T> {
|
|
159
|
-
item: T;
|
|
160
|
-
/** Lower is better, matching Fuse's convention. 0 is an exact hit. */
|
|
161
|
-
score: number;
|
|
162
|
-
/** Which key matched, when the matcher reports it. */
|
|
163
|
-
key?: string;
|
|
164
|
-
}
|
|
165
|
-
/** The shape of Fuse's constructor, declared here so the package need not depend on it. */
|
|
166
|
-
interface FuseLike<T> {
|
|
167
|
-
search(query: string): {
|
|
168
|
-
item: T;
|
|
169
|
-
score?: number;
|
|
170
|
-
matches?: {
|
|
171
|
-
key?: string;
|
|
172
|
-
}[];
|
|
173
|
-
}[];
|
|
174
|
-
}
|
|
175
|
-
type FuseConstructor = new <T>(items: readonly T[], options?: Record<string, unknown>) => FuseLike<T>;
|
|
176
|
-
interface SearchOptions<T> {
|
|
177
|
-
items?: readonly T[];
|
|
178
|
-
/** Fields to search. Dotted paths work with the built-in matcher and with Fuse. */
|
|
179
|
-
keys?: string[];
|
|
180
|
-
/**
|
|
181
|
-
* Pass Fuse.js's constructor to get fuzzy matching. Omit it for the built-in
|
|
182
|
-
* substring matcher. Ignored when `matcher` is set.
|
|
183
|
-
*/
|
|
184
|
-
fuse?: FuseConstructor;
|
|
185
|
-
/** Handed to Fuse verbatim, so any Fuse option behaves exactly as documented there. */
|
|
186
|
-
fuseOptions?: Record<string, unknown>;
|
|
187
|
-
/** Replaces the engine entirely. Return the results in the order you want them. */
|
|
188
|
-
matcher?: (query: string, items: readonly T[]) => SearchMatch<T>[];
|
|
189
|
-
/** ms to wait after the last keystroke. 0 searches on every one. */
|
|
190
|
-
debounce?: number;
|
|
191
|
-
/** Queries shorter than this return nothing rather than everything. */
|
|
192
|
-
minLength?: number;
|
|
193
|
-
/** Cap the result list. */
|
|
194
|
-
limit?: number;
|
|
195
|
-
/** What an empty query returns. "none" (default) or "all". */
|
|
196
|
-
empty?: "none" | "all";
|
|
197
|
-
onResults?: (results: SearchMatch<T>[], query: string) => void;
|
|
198
|
-
}
|
|
199
|
-
interface SearchInstance<T> {
|
|
200
|
-
readonly query: string;
|
|
201
|
-
readonly results: SearchMatch<T>[];
|
|
202
|
-
/** Debounced. */
|
|
203
|
-
search(query: string): void;
|
|
204
|
-
/** Skips the debounce, e.g. on Enter. */
|
|
205
|
-
searchNow(query: string): SearchMatch<T>[];
|
|
206
|
-
setItems(items: readonly T[]): void;
|
|
207
|
-
update(options: Partial<SearchOptions<T>>): void;
|
|
208
|
-
/** Bind to a field: input events search, Escape clears. Returns an unbind. */
|
|
209
|
-
attach(input: HTMLInputElement): () => void;
|
|
210
|
-
subscribe(listener: (results: SearchMatch<T>[], query: string) => void): () => void;
|
|
211
|
-
destroy(): void;
|
|
212
|
-
}
|
|
213
|
-
declare function createSearch<T>(options?: SearchOptions<T>): SearchInstance<T>;
|
|
214
|
-
|
|
215
|
-
export { type FuseConstructor, type FuseLike, type InputAction, type InputActionState, type InputIcon, type InputInstance, type InputOptions, type MarqueeInstance, type MarqueeOptions, type SearchInstance, type SearchMatch, type SearchOptions, createInput, createMarquee, createSearch };
|
|
150
|
+
export { type InputAction, type InputActionState, type InputIcon, type InputInstance, type InputOptions, type MarqueeInstance, type MarqueeOptions, createInput, createMarquee };
|
package/dist/index.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export { createInput, createMarquee
|
|
1
|
+
export { createInput, createMarquee } from './chunk-W2DRHYC2.js';
|
|
2
|
+
export { createSearch } from './chunk-UZFEEFMF.js';
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { RefObject } from 'react';
|
|
2
|
-
import { MarqueeOptions, InputOptions
|
|
3
|
-
export {
|
|
2
|
+
import { MarqueeOptions, InputOptions } from '../index.js';
|
|
3
|
+
export { InputAction, InputIcon, MarqueeInstance } from '../index.js';
|
|
4
|
+
import { b as SearchMatch, c as SearchOptions } from '../search-CsO3L1Lw.js';
|
|
5
|
+
export { F as FuseConstructor } from '../search-CsO3L1Lw.js';
|
|
4
6
|
|
|
5
7
|
interface UseMarqueeResult {
|
|
6
8
|
/** Attach to the clipping viewport. */
|
package/dist/react/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createMarquee, createInput
|
|
1
|
+
import { createMarquee, createInput } from '../chunk-W2DRHYC2.js';
|
|
2
|
+
import { createSearch } from '../chunk-UZFEEFMF.js';
|
|
2
3
|
import { useRef, useState, useMemo, useEffect, useLayoutEffect } from 'react';
|
|
3
4
|
|
|
4
5
|
var useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { c as SearchOptions, S as SearchInstance } from '../search-CsO3L1Lw.js';
|
|
2
|
+
export { b as SearchMatch } from '../search-CsO3L1Lw.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Search with Fuse.js already wired: the batteries-included entry point.
|
|
6
|
+
*
|
|
7
|
+
* `@enigmax/primitives` stays dependency-free, so someone who only wants the marquee
|
|
8
|
+
* never pulls a search engine into their bundle. Importing THIS subpath is the explicit
|
|
9
|
+
* opt-in, and it is what `enigma add search` sets a project up for - it installs fuse.js
|
|
10
|
+
* alongside the package.
|
|
11
|
+
*
|
|
12
|
+
* Everything else is identical to `createSearch` from the main entry: pass `fuseOptions`
|
|
13
|
+
* to configure Fuse, or `matcher` to replace it outright.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Search as you type, fuzzy by default.
|
|
18
|
+
*
|
|
19
|
+
* ```js
|
|
20
|
+
* import { createSearch } from "@enigmax/primitives/search";
|
|
21
|
+
*
|
|
22
|
+
* const search = createSearch({ items: docs, keys: ["title", "body"] });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Pass `fuse: undefined` explicitly to fall back to the built-in substring matcher.
|
|
26
|
+
*/
|
|
27
|
+
declare function createSearch<T>(options?: SearchOptions<T>): SearchInstance<T>;
|
|
28
|
+
|
|
29
|
+
export { SearchInstance, SearchOptions, createSearch };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createSearch } from '../chunk-UZFEEFMF.js';
|
|
2
|
+
import Fuse from 'fuse.js';
|
|
3
|
+
|
|
4
|
+
function createSearch2(options = {}) {
|
|
5
|
+
return createSearch({
|
|
6
|
+
// A caller-supplied `fuse` or `matcher` still wins; this only fills the blank.
|
|
7
|
+
fuse: Fuse,
|
|
8
|
+
...options
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export { createSearch2 as createSearch };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search-as-you-type: debouncing, ordering, cancellation and the field wiring, with the
|
|
3
|
+
* matching left pluggable.
|
|
4
|
+
*
|
|
5
|
+
* Fuse.js is NOT a dependency of this package. Hand it the constructor and you get fuzzy
|
|
6
|
+
* matching configured exactly as if you had called it yourself - `fuseOptions` is passed
|
|
7
|
+
* through untouched. Hand it nothing and a built-in accent-insensitive substring matcher
|
|
8
|
+
* runs instead, so the primitive works with zero dependencies. Hand it a `matcher` and
|
|
9
|
+
* every part of this is replaced by your own.
|
|
10
|
+
*/
|
|
11
|
+
interface SearchMatch<T> {
|
|
12
|
+
item: T;
|
|
13
|
+
/** Lower is better, matching Fuse's convention. 0 is an exact hit. */
|
|
14
|
+
score: number;
|
|
15
|
+
/** Which key matched, when the matcher reports it. */
|
|
16
|
+
key?: string;
|
|
17
|
+
}
|
|
18
|
+
/** The shape of Fuse's constructor, declared here so the package need not depend on it. */
|
|
19
|
+
interface FuseLike<T> {
|
|
20
|
+
search(query: string): {
|
|
21
|
+
item: T;
|
|
22
|
+
score?: number;
|
|
23
|
+
matches?: {
|
|
24
|
+
key?: string;
|
|
25
|
+
}[];
|
|
26
|
+
}[];
|
|
27
|
+
}
|
|
28
|
+
type FuseConstructor = new <T>(items: readonly T[], options?: Record<string, unknown>) => FuseLike<T>;
|
|
29
|
+
interface SearchOptions<T> {
|
|
30
|
+
items?: readonly T[];
|
|
31
|
+
/** Fields to search. Dotted paths work with the built-in matcher and with Fuse. */
|
|
32
|
+
keys?: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Pass Fuse.js's constructor to get fuzzy matching. Omit it for the built-in
|
|
35
|
+
* substring matcher. Ignored when `matcher` is set.
|
|
36
|
+
*/
|
|
37
|
+
fuse?: FuseConstructor;
|
|
38
|
+
/** Handed to Fuse verbatim, so any Fuse option behaves exactly as documented there. */
|
|
39
|
+
fuseOptions?: Record<string, unknown>;
|
|
40
|
+
/** Replaces the engine entirely. Return the results in the order you want them. */
|
|
41
|
+
matcher?: (query: string, items: readonly T[]) => SearchMatch<T>[];
|
|
42
|
+
/** ms to wait after the last keystroke. 0 searches on every one. */
|
|
43
|
+
debounce?: number;
|
|
44
|
+
/** Queries shorter than this return nothing rather than everything. */
|
|
45
|
+
minLength?: number;
|
|
46
|
+
/** Cap the result list. */
|
|
47
|
+
limit?: number;
|
|
48
|
+
/** What an empty query returns. "none" (default) or "all". */
|
|
49
|
+
empty?: "none" | "all";
|
|
50
|
+
onResults?: (results: SearchMatch<T>[], query: string) => void;
|
|
51
|
+
}
|
|
52
|
+
interface SearchInstance<T> {
|
|
53
|
+
readonly query: string;
|
|
54
|
+
readonly results: SearchMatch<T>[];
|
|
55
|
+
/** Debounced. */
|
|
56
|
+
search(query: string): void;
|
|
57
|
+
/** Skips the debounce, e.g. on Enter. */
|
|
58
|
+
searchNow(query: string): SearchMatch<T>[];
|
|
59
|
+
setItems(items: readonly T[]): void;
|
|
60
|
+
update(options: Partial<SearchOptions<T>>): void;
|
|
61
|
+
/** Bind to a field: input events search, Escape clears. Returns an unbind. */
|
|
62
|
+
attach(input: HTMLInputElement): () => void;
|
|
63
|
+
subscribe(listener: (results: SearchMatch<T>[], query: string) => void): () => void;
|
|
64
|
+
destroy(): void;
|
|
65
|
+
}
|
|
66
|
+
declare function createSearch<T>(options?: SearchOptions<T>): SearchInstance<T>;
|
|
67
|
+
|
|
68
|
+
export { type FuseConstructor as F, type SearchInstance as S, type FuseLike as a, type SearchMatch as b, type SearchOptions as c, createSearch as d };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enigmax/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Headless interaction primitives: the behaviour, the timing and the accessibility of components like a draggable infinite marquee, with no styles of their own. Framework-agnostic core plus thin adapters.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
"types": "./dist/react/index.d.ts",
|
|
15
15
|
"import": "./dist/react/index.js"
|
|
16
16
|
},
|
|
17
|
-
"./registry.json": "./registry.json"
|
|
17
|
+
"./registry.json": "./registry.json",
|
|
18
|
+
"./search": {
|
|
19
|
+
"types": "./dist/search/index.d.ts",
|
|
20
|
+
"import": "./dist/search/index.js"
|
|
21
|
+
}
|
|
18
22
|
},
|
|
19
23
|
"scripts": {
|
|
20
24
|
"build": "tsup",
|
package/registry.json
CHANGED
|
@@ -180,15 +180,15 @@
|
|
|
180
180
|
{
|
|
181
181
|
"name": "search",
|
|
182
182
|
"title": "Search as you type",
|
|
183
|
-
"description": "
|
|
183
|
+
"description": "Search as you type, fuzzy by default with Fuse.js. Debouncing, ranking and cancellation are the primitive's; the matching is replaceable, and dropping Fuse falls back to a built-in accent-insensitive substring matcher.",
|
|
184
184
|
"targets": [
|
|
185
185
|
"vanilla",
|
|
186
186
|
"astro",
|
|
187
187
|
"react"
|
|
188
188
|
],
|
|
189
189
|
"entry": {
|
|
190
|
-
"vanilla": "@enigmax/primitives",
|
|
191
|
-
"astro": "@enigmax/primitives",
|
|
190
|
+
"vanilla": "@enigmax/primitives/search",
|
|
191
|
+
"astro": "@enigmax/primitives/search",
|
|
192
192
|
"react": "@enigmax/primitives/react"
|
|
193
193
|
},
|
|
194
194
|
"exports": {
|
|
@@ -241,7 +241,10 @@
|
|
|
241
241
|
"docs": "docs/notes/primitives.md#search",
|
|
242
242
|
"recipes": [
|
|
243
243
|
"tailwind"
|
|
244
|
-
]
|
|
244
|
+
],
|
|
245
|
+
"dependencies": {
|
|
246
|
+
"fuse.js": "^7.0.0"
|
|
247
|
+
}
|
|
245
248
|
}
|
|
246
249
|
]
|
|
247
250
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search with Fuse.js already wired: the batteries-included entry point.
|
|
3
|
+
*
|
|
4
|
+
* `@enigmax/primitives` stays dependency-free, so someone who only wants the marquee
|
|
5
|
+
* never pulls a search engine into their bundle. Importing THIS subpath is the explicit
|
|
6
|
+
* opt-in, and it is what `enigma add search` sets a project up for - it installs fuse.js
|
|
7
|
+
* alongside the package.
|
|
8
|
+
*
|
|
9
|
+
* Everything else is identical to `createSearch` from the main entry: pass `fuseOptions`
|
|
10
|
+
* to configure Fuse, or `matcher` to replace it outright.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import Fuse from "fuse.js";
|
|
14
|
+
import { createSearch as createBaseSearch, type SearchOptions, type SearchInstance } from "@/core/search";
|
|
15
|
+
|
|
16
|
+
export type { SearchOptions, SearchInstance, SearchMatch } from "@/core/search";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Search as you type, fuzzy by default.
|
|
20
|
+
*
|
|
21
|
+
* ```js
|
|
22
|
+
* import { createSearch } from "@enigmax/primitives/search";
|
|
23
|
+
*
|
|
24
|
+
* const search = createSearch({ items: docs, keys: ["title", "body"] });
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Pass `fuse: undefined` explicitly to fall back to the built-in substring matcher.
|
|
28
|
+
*/
|
|
29
|
+
export function createSearch<T>(options: SearchOptions<T> = {}): SearchInstance<T> {
|
|
30
|
+
return createBaseSearch<T>({
|
|
31
|
+
// A caller-supplied `fuse` or `matcher` still wins; this only fills the blank.
|
|
32
|
+
fuse: Fuse as never,
|
|
33
|
+
...options
|
|
34
|
+
});
|
|
35
|
+
}
|