@enigmax/primitives 0.1.1 → 0.2.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.
@@ -0,0 +1,77 @@
1
+ import { useRef, useState, useEffect, useMemo, type RefObject } from "react";
2
+ import { createSearch, type SearchOptions, type SearchMatch, type SearchInstance } from "@/core/search";
3
+
4
+ export interface UseSearchResult<T> {
5
+ /** Attach to the search field, or drive it yourself with `search`. */
6
+ inputRef: RefObject<HTMLInputElement | null>;
7
+ query: string;
8
+ results: SearchMatch<T>[];
9
+ /** Debounced. */
10
+ search: (query: string) => void;
11
+ /** Skips the debounce. */
12
+ searchNow: (query: string) => void;
13
+ clear: () => void;
14
+ }
15
+
16
+ /**
17
+ * Search-as-you-type over a list.
18
+ *
19
+ * Pass `fuse` (Fuse.js's constructor) for fuzzy matching, `matcher` to replace the
20
+ * engine, or neither for the built-in accent-insensitive substring search.
21
+ *
22
+ * ```tsx
23
+ * import Fuse from "fuse.js";
24
+ *
25
+ * const { inputRef, results } = useSearch({ items: docs, keys: ["title", "body"], fuse: Fuse });
26
+ * return <input ref={inputRef} type="search" placeholder="Search" />;
27
+ * ```
28
+ */
29
+ export function useSearch<T>(options: SearchOptions<T> = {}): UseSearchResult<T> {
30
+ const inputRef = useRef<HTMLInputElement | null>(null);
31
+ const instanceRef = useRef<SearchInstance<T> | null>(null);
32
+ const optionsRef = useRef(options);
33
+ optionsRef.current = options;
34
+
35
+ const [query, setQuery] = useState("");
36
+ const [results, setResults] = useState<SearchMatch<T>[]>([]);
37
+
38
+ // Built once. The engine indexes its items on construction, so rebuilding it per
39
+ // render would re-index the whole list on every keystroke.
40
+ const instance = useMemo(() => {
41
+ const created = createSearch<T>({
42
+ ...optionsRef.current,
43
+ onResults: (next, nextQuery) => {
44
+ setResults(next);
45
+ setQuery(nextQuery);
46
+ optionsRef.current.onResults?.(next, nextQuery);
47
+ }
48
+ });
49
+ instanceRef.current = created;
50
+ return created;
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ }, []);
53
+
54
+ useEffect(() => () => instance.destroy(), [instance]);
55
+
56
+ // New data must re-index and re-run the visible query, or the list shows matches
57
+ // against items that are gone.
58
+ useEffect(() => { instance.setItems(options.items ?? []); }, [instance, options.items]);
59
+
60
+ useEffect(() => {
61
+ const input = inputRef.current;
62
+ if (!input) return;
63
+ return instance.attach(input);
64
+ }, [instance]);
65
+
66
+ return {
67
+ inputRef,
68
+ query,
69
+ results,
70
+ search: (next: string) => instance.search(next),
71
+ searchNow: (next: string) => { instance.searchNow(next); },
72
+ clear: () => {
73
+ if (inputRef.current) inputRef.current.value = "";
74
+ instance.searchNow("");
75
+ }
76
+ };
77
+ }
@@ -1,282 +0,0 @@
1
- // src/core/marquee.ts
2
- var DEFAULT_DECAY = 0.12;
3
- var DRAG_THRESHOLD = 6;
4
- var STILL_MS = 90;
5
- var MAX_DT = 0.05;
6
- var VELOCITY_SMOOTHING = 0.6;
7
- var TOUCH_HOVER_SUPPRESS_MS = 1e3;
8
- var IDLE_EPSILON = 0.01;
9
- var DEFAULTS = {
10
- speed: 60,
11
- reverse: false,
12
- vertical: false,
13
- draggable: true,
14
- hoverScale: 1,
15
- decay: DEFAULT_DECAY,
16
- manageStyles: true,
17
- copies: "clone"
18
- };
19
- function createMarquee(lane, track, options = {}) {
20
- let opts = { ...DEFAULTS, ...options };
21
- let period = 0;
22
- let copyCount = Math.max(2, track.children.length);
23
- let offset = 0;
24
- let velocity = 0;
25
- let running = false;
26
- let paused = false;
27
- let destroyed = false;
28
- let frame = 0;
29
- let lastTime = 0;
30
- let dragging = false;
31
- let pointerId = null;
32
- let lastPointer = 0;
33
- let startPointer = 0;
34
- let lastMoveTime = 0;
35
- let maxTravel = 0;
36
- let hovering = false;
37
- let lastTouchAt = 0;
38
- const motionQuery = typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
39
- let reducedMotion = motionQuery?.matches ?? false;
40
- function target() {
41
- if (reducedMotion || paused) return 0;
42
- const cruise = opts.reverse ? -opts.speed : opts.speed;
43
- return hovering ? cruise * opts.hoverScale : cruise;
44
- }
45
- function axisSize(element) {
46
- return opts.vertical ? element.offsetHeight : element.offsetWidth;
47
- }
48
- function axisStart(element) {
49
- return opts.vertical ? element.offsetTop : element.offsetLeft;
50
- }
51
- function applyStyles() {
52
- if (!opts.manageStyles) return;
53
- lane.style.overflow = "hidden";
54
- lane.style.touchAction = opts.vertical ? "pan-x" : "pan-y";
55
- lane.style.userSelect = "none";
56
- lane.style.webkitUserSelect = "none";
57
- track.style.willChange = "transform";
58
- }
59
- function render() {
60
- const x = opts.vertical ? 0 : -offset;
61
- const y = opts.vertical ? -offset : 0;
62
- track.style.transform = `translate3d(${x}px, ${y}px, 0)`;
63
- }
64
- function readPeriod() {
65
- const first = track.children[0];
66
- const second = track.children[1];
67
- if (!first) return 0;
68
- if (second) {
69
- const step = axisStart(second) - axisStart(first);
70
- if (step > 0) return step;
71
- }
72
- return axisSize(first);
73
- }
74
- function requiredCopies(measured) {
75
- if (measured <= 0) return 2;
76
- return Math.max(2, Math.ceil(axisSize(lane) / measured) + 1);
77
- }
78
- function syncClones(count) {
79
- const source = track.children[0];
80
- if (!source) return;
81
- while (track.children.length > count) track.removeChild(track.lastElementChild);
82
- while (track.children.length < count) {
83
- const clone = source.cloneNode(true);
84
- clone.setAttribute("aria-hidden", "true");
85
- clone.dataset.enigmaMarqueeCopy = String(track.children.length);
86
- track.appendChild(clone);
87
- }
88
- }
89
- function measure() {
90
- if (destroyed) return;
91
- const measured = readPeriod();
92
- const needed = requiredCopies(measured);
93
- const changed = measured !== period || needed !== copyCount;
94
- period = measured;
95
- if (needed !== copyCount) {
96
- copyCount = needed;
97
- if (opts.copies === "clone") syncClones(needed);
98
- opts.onCopyCountChange?.(needed);
99
- }
100
- if (period > 0) offset = wrap(offset);
101
- render();
102
- if (changed) opts.onMeasure?.(period, copyCount);
103
- start();
104
- }
105
- function wrap(value) {
106
- if (period <= 0) return value;
107
- return (value % period + period) % period;
108
- }
109
- function tick(now) {
110
- frame = 0;
111
- if (destroyed) return;
112
- const dt = Math.min((now - lastTime) / 1e3, MAX_DT);
113
- lastTime = now;
114
- if (!dragging) {
115
- velocity += (target() - velocity) * (1 - Math.pow(opts.decay, dt));
116
- offset = wrap(offset + velocity * dt);
117
- }
118
- render();
119
- const idle = !dragging && Math.abs(velocity) < IDLE_EPSILON && Math.abs(target()) < IDLE_EPSILON;
120
- if (idle) {
121
- velocity = 0;
122
- running = false;
123
- return;
124
- }
125
- frame = requestAnimationFrame(tick);
126
- }
127
- function start() {
128
- if (destroyed || running) return;
129
- running = true;
130
- lastTime = performance.now();
131
- frame = requestAnimationFrame(tick);
132
- }
133
- function pointerDelta(event) {
134
- return opts.vertical ? event.clientY : event.clientX;
135
- }
136
- function onPointerDown(event) {
137
- if (event.pointerType !== "mouse") noteTouch();
138
- if (!opts.draggable || event.button > 0 || pointerId !== null) return;
139
- pointerId = event.pointerId;
140
- dragging = true;
141
- lastPointer = pointerDelta(event);
142
- startPointer = lastPointer;
143
- lastMoveTime = event.timeStamp;
144
- maxTravel = 0;
145
- lane.dataset.dragging = "true";
146
- window.addEventListener("pointermove", onPointerMove);
147
- window.addEventListener("pointerup", onPointerUp);
148
- window.addEventListener("pointercancel", onPointerUp);
149
- start();
150
- }
151
- function onPointerMove(event) {
152
- if (event.pointerId !== pointerId) return;
153
- const position = pointerDelta(event);
154
- const delta = position - lastPointer;
155
- lastPointer = position;
156
- const dt = Math.max((event.timeStamp - lastMoveTime) / 1e3, 1e-3);
157
- lastMoveTime = event.timeStamp;
158
- maxTravel = Math.max(maxTravel, Math.abs(position - startPointer));
159
- offset = wrap(offset - delta);
160
- const instant = -delta / dt;
161
- velocity = velocity * VELOCITY_SMOOTHING + instant * (1 - VELOCITY_SMOOTHING);
162
- }
163
- function onPointerUp(event) {
164
- if (event.pointerId !== pointerId) return;
165
- pointerId = null;
166
- dragging = false;
167
- delete lane.dataset.dragging;
168
- window.removeEventListener("pointermove", onPointerMove);
169
- window.removeEventListener("pointerup", onPointerUp);
170
- window.removeEventListener("pointercancel", onPointerUp);
171
- if (event.timeStamp - lastMoveTime > STILL_MS) velocity = 0;
172
- start();
173
- }
174
- function onClickCapture(event) {
175
- if (maxTravel <= DRAG_THRESHOLD) return;
176
- event.preventDefault();
177
- event.stopPropagation();
178
- }
179
- function onClick(event) {
180
- if (maxTravel > DRAG_THRESHOLD) return;
181
- opts.onItemClick?.(event);
182
- }
183
- function onPointerEnter(event) {
184
- if (event.pointerType !== "mouse") {
185
- noteTouch();
186
- return;
187
- }
188
- if (Date.now() - lastTouchAt < TOUCH_HOVER_SUPPRESS_MS) return;
189
- hovering = true;
190
- lane.dataset.hovering = "true";
191
- start();
192
- }
193
- function onPointerLeave() {
194
- hovering = false;
195
- delete lane.dataset.hovering;
196
- start();
197
- }
198
- function noteTouch() {
199
- lastTouchAt = Date.now();
200
- if (!hovering) return;
201
- hovering = false;
202
- delete lane.dataset.hovering;
203
- start();
204
- }
205
- function onMotionChange(event) {
206
- reducedMotion = event.matches;
207
- lane.dataset.reducedMotion = String(reducedMotion);
208
- start();
209
- }
210
- const resizeObserver = typeof ResizeObserver === "function" ? new ResizeObserver(() => measure()) : null;
211
- function init() {
212
- applyStyles();
213
- lane.dataset.enigmaMarquee = "";
214
- lane.dataset.reducedMotion = String(reducedMotion);
215
- track.dataset.enigmaMarqueeTrack = "";
216
- for (const child of Array.from(track.children)) {
217
- child.dataset.enigmaMarqueeCopy ??= "0";
218
- }
219
- lane.addEventListener("pointerdown", onPointerDown);
220
- lane.addEventListener("pointerenter", onPointerEnter);
221
- lane.addEventListener("pointerleave", onPointerLeave);
222
- lane.addEventListener("touchstart", noteTouch, { passive: true });
223
- lane.addEventListener("click", onClickCapture, true);
224
- lane.addEventListener("click", onClick);
225
- motionQuery?.addEventListener("change", onMotionChange);
226
- resizeObserver?.observe(lane);
227
- const first = track.children[0];
228
- if (first) resizeObserver?.observe(first);
229
- measure();
230
- void document.fonts?.ready.then(() => measure());
231
- }
232
- init();
233
- return {
234
- get offset() {
235
- return offset;
236
- },
237
- get period() {
238
- return period;
239
- },
240
- get copyCount() {
241
- return copyCount;
242
- },
243
- get dragging() {
244
- return dragging;
245
- },
246
- get reducedMotion() {
247
- return reducedMotion;
248
- },
249
- update(next) {
250
- const styleKeys = next.vertical !== void 0 || next.manageStyles !== void 0;
251
- opts = { ...opts, ...next };
252
- if (styleKeys) applyStyles();
253
- measure();
254
- },
255
- measure,
256
- pause() {
257
- paused = true;
258
- start();
259
- },
260
- resume() {
261
- paused = false;
262
- start();
263
- },
264
- destroy() {
265
- destroyed = true;
266
- if (frame) cancelAnimationFrame(frame);
267
- resizeObserver?.disconnect();
268
- motionQuery?.removeEventListener("change", onMotionChange);
269
- lane.removeEventListener("pointerdown", onPointerDown);
270
- lane.removeEventListener("pointerenter", onPointerEnter);
271
- lane.removeEventListener("pointerleave", onPointerLeave);
272
- lane.removeEventListener("touchstart", noteTouch);
273
- lane.removeEventListener("click", onClickCapture, true);
274
- lane.removeEventListener("click", onClick);
275
- window.removeEventListener("pointermove", onPointerMove);
276
- window.removeEventListener("pointerup", onPointerUp);
277
- window.removeEventListener("pointercancel", onPointerUp);
278
- }
279
- };
280
- }
281
-
282
- export { createMarquee };