@enigmax/primitives 0.3.0 → 0.5.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.
@@ -11,7 +11,7 @@ var DEFAULTS = {
11
11
  reverse: false,
12
12
  vertical: false,
13
13
  draggable: true,
14
- hoverScale: 1,
14
+ hover: "off",
15
15
  decay: DEFAULT_DECAY,
16
16
  manageStyles: true,
17
17
  copies: "clone"
@@ -37,10 +37,17 @@ function createMarquee(lane, track, options = {}) {
37
37
  let lastTouchAt = 0;
38
38
  const motionQuery = typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
39
39
  let reducedMotion = motionQuery?.matches ?? false;
40
+ function hoverTarget(cruise) {
41
+ const hover = opts.hover ?? "off";
42
+ if (hover === "off") return cruise;
43
+ if (hover === "pause") return 0;
44
+ if (typeof hover === "number") return cruise * hover;
45
+ return (cruise < 0 ? -1 : 1) * Math.abs(hover.speed);
46
+ }
40
47
  function target() {
41
48
  if (reducedMotion || paused) return 0;
42
49
  const cruise = opts.reverse ? -opts.speed : opts.speed;
43
- return hovering ? cruise * opts.hoverScale : cruise;
50
+ return hovering ? hoverTarget(cruise) : cruise;
44
51
  }
45
52
  function axisSize(element) {
46
53
  return opts.vertical ? element.offsetHeight : element.offsetWidth;
@@ -470,148 +477,4 @@ function createInput(input, options = {}) {
470
477
  };
471
478
  }
472
479
 
473
- // src/core/search.ts
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 };
480
+ export { createInput, createMarquee };
@@ -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 };
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
  *
@@ -9,6 +11,21 @@
9
11
  * comments marked "non-negotiable" name the bug that the obvious implementation
10
12
  * shipped with.
11
13
  */
14
+ /**
15
+ * What a MOUSE resting on the row does to its speed. One option covers every case, so
16
+ * there is no pile of booleans to reconcile:
17
+ *
18
+ * - `"off"` ignore hover entirely (the default)
19
+ * - `"pause"` stop while the pointer is on it
20
+ * - a number multiply the cruise speed: `0.15` crawls, `1` changes nothing, `2` doubles
21
+ * - `{ speed }` an ABSOLUTE speed in px/s, whatever the cruise is; direction still
22
+ * follows `reverse`, so you give a magnitude and not a sign
23
+ *
24
+ * Touch never triggers any of it - see the pointerType note on the hover handler.
25
+ */
26
+ type MarqueeHover = number | "off" | "pause" | {
27
+ speed: number;
28
+ };
12
29
  interface MarqueeOptions {
13
30
  /**
14
31
  * Pixels per second. NOT a duration: a duration ties the speed to the item
@@ -21,8 +38,8 @@ interface MarqueeOptions {
21
38
  vertical?: boolean;
22
39
  /** Allow grabbing and throwing the row. */
23
40
  draggable?: boolean;
24
- /** Speed multiplier while a MOUSE rests on the row. 0 pauses it. */
25
- hoverScale?: number;
41
+ /** What a mouse resting on the row does to its speed. See {@link MarqueeHover}. */
42
+ hover?: MarqueeHover;
26
43
  /** Fraction of the remaining velocity gap left after one second. */
27
44
  decay?: number;
28
45
  /**
@@ -145,71 +162,4 @@ interface InputInstance {
145
162
  */
146
163
  declare function createInput(input: HTMLInputElement, options?: InputOptions): InputInstance;
147
164
 
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 };
165
+ export { type InputAction, type InputActionState, type InputIcon, type InputInstance, type InputOptions, type MarqueeHover, type MarqueeInstance, type MarqueeOptions, createInput, createMarquee };
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
- export { createInput, createMarquee, createSearch } from './chunk-OKNWKBQI.js';
1
+ export { createInput, createMarquee } from './chunk-2DVVWPHM.js';
2
+ export { createSearch } from './chunk-UZFEEFMF.js';
@@ -1,6 +1,8 @@
1
1
  import { RefObject } from 'react';
2
- import { MarqueeOptions, InputOptions, SearchMatch, SearchOptions } from '../index.js';
3
- export { FuseConstructor, InputAction, InputIcon, MarqueeInstance } from '../index.js';
2
+ import { MarqueeOptions, InputOptions } from '../index.js';
3
+ export { InputAction, InputIcon, MarqueeHover, 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. */
@@ -26,7 +28,7 @@ interface UseMarqueeResult {
26
28
  * to the item count, so the row accelerates every time content is added.
27
29
  *
28
30
  * ```tsx
29
- * const { laneRef, trackRef, copies } = useMarquee({ speed: 80, hoverScale: 0.15 });
31
+ * const { laneRef, trackRef, copies } = useMarquee({ speed: 80, hover: 0.15 });
30
32
  * return (
31
33
  * <div ref={laneRef}>
32
34
  * <div ref={trackRef} style={{ display: "flex" }}>
@@ -1,4 +1,5 @@
1
- import { createMarquee, createInput, createSearch } from '../chunk-OKNWKBQI.js';
1
+ import { createMarquee, createInput } from '../chunk-2DVVWPHM.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;
@@ -38,7 +39,7 @@ function useMarquee(options = {}) {
38
39
  }, []);
39
40
  useIsomorphicLayoutEffect(() => {
40
41
  instanceRef.current?.update(options);
41
- }, [options.speed, options.reverse, options.vertical, options.draggable, options.hoverScale, options.decay]);
42
+ }, [options.speed, options.reverse, options.vertical, options.draggable, options.hover, options.decay]);
42
43
  useIsomorphicLayoutEffect(() => {
43
44
  instanceRef.current?.measure();
44
45
  }, [copies]);
@@ -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.0",
3
+ "version": "0.5.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",
@@ -1,6 +1,6 @@
1
1
  import "./marquee.css";
2
2
  import type { ReactNode, Ref } from "react";
3
- import { useMarquee } from "@enigmax/primitives/react";
3
+ import { useMarquee, type MarqueeHover } from "@enigmax/primitives/react";
4
4
 
5
5
  /**
6
6
  * A styled marquee, yours to edit.
@@ -12,8 +12,12 @@ export interface MarqueeProps<T> {
12
12
  items: T[];
13
13
  /** Pixels per second. Never a duration - the row would speed up as items are added. */
14
14
  speed?: number;
15
- /** Speed multiplier while a mouse rests on the row. 0 pauses it. */
16
- hoverScale?: number;
15
+ /**
16
+ * What a mouse resting on the row does to its speed:
17
+ * "off" ignores hover, "pause" stops, a number multiplies the cruise speed
18
+ * (0.15 crawls, 2 doubles), { speed } sets an absolute px/s.
19
+ */
20
+ hover?: MarqueeHover;
17
21
  reverse?: boolean;
18
22
  /** Fade both ends so items enter and leave instead of being cut. */
19
23
  fade?: boolean;
@@ -24,13 +28,13 @@ export interface MarqueeProps<T> {
24
28
  export function Marquee<T>({
25
29
  items,
26
30
  speed = 70,
27
- hoverScale = 1,
31
+ hover = "off",
28
32
  reverse = false,
29
33
  fade = true,
30
34
  className = "",
31
35
  children
32
36
  }: MarqueeProps<T>) {
33
- const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hoverScale, reverse });
37
+ const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hover, reverse });
34
38
 
35
39
  return (
36
40
  <div
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode, Ref } from "react";
2
- import { useMarquee } from "@enigmax/primitives/react";
2
+ import { useMarquee, type MarqueeHover } from "@enigmax/primitives/react";
3
3
 
4
4
  /**
5
5
  * A styled marquee, yours to edit.
@@ -16,8 +16,12 @@ export interface MarqueeProps<T> {
16
16
  items: T[];
17
17
  /** Pixels per second. Never a duration - the row would speed up as items are added. */
18
18
  speed?: number;
19
- /** Speed multiplier while a mouse rests on the row. 0 pauses it. */
20
- hoverScale?: number;
19
+ /**
20
+ * What a mouse resting on the row does to its speed:
21
+ * "off" ignores hover, "pause" stops, a number multiplies the cruise speed
22
+ * (0.15 crawls, 2 doubles), { speed } sets an absolute px/s.
23
+ */
24
+ hover?: MarqueeHover;
21
25
  reverse?: boolean;
22
26
  /** Fade both ends so items enter and leave instead of being cut. */
23
27
  fade?: boolean;
@@ -28,13 +32,13 @@ export interface MarqueeProps<T> {
28
32
  export function Marquee<T>({
29
33
  items,
30
34
  speed = 70,
31
- hoverScale = 1,
35
+ hover = "off",
32
36
  reverse = false,
33
37
  fade = true,
34
38
  className = "",
35
39
  children
36
40
  }: MarqueeProps<T>) {
37
- const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hoverScale, reverse });
41
+ const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hover, reverse });
38
42
 
39
43
  return (
40
44
  <div
package/registry.json CHANGED
@@ -180,15 +180,15 @@
180
180
  {
181
181
  "name": "search",
182
182
  "title": "Search as you type",
183
- "description": "Debouncing, ranking, cancellation and the field wiring, with the matching pluggable. Pass Fuse.js for fuzzy search, your own function to replace the engine, or neither for the built-in accent-insensitive substring matcher.",
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
  }
@@ -29,6 +29,20 @@ const TOUCH_HOVER_SUPPRESS_MS = 1000;
29
29
  /** Below this the row is idle and the frame loop can stop. */
30
30
  const IDLE_EPSILON = 0.01;
31
31
 
32
+ /**
33
+ * What a MOUSE resting on the row does to its speed. One option covers every case, so
34
+ * there is no pile of booleans to reconcile:
35
+ *
36
+ * - `"off"` ignore hover entirely (the default)
37
+ * - `"pause"` stop while the pointer is on it
38
+ * - a number multiply the cruise speed: `0.15` crawls, `1` changes nothing, `2` doubles
39
+ * - `{ speed }` an ABSOLUTE speed in px/s, whatever the cruise is; direction still
40
+ * follows `reverse`, so you give a magnitude and not a sign
41
+ *
42
+ * Touch never triggers any of it - see the pointerType note on the hover handler.
43
+ */
44
+ export type MarqueeHover = number | "off" | "pause" | { speed: number; };
45
+
32
46
  export interface MarqueeOptions {
33
47
  /**
34
48
  * Pixels per second. NOT a duration: a duration ties the speed to the item
@@ -41,8 +55,8 @@ export interface MarqueeOptions {
41
55
  vertical?: boolean;
42
56
  /** Allow grabbing and throwing the row. */
43
57
  draggable?: boolean;
44
- /** Speed multiplier while a MOUSE rests on the row. 0 pauses it. */
45
- hoverScale?: number;
58
+ /** What a mouse resting on the row does to its speed. See {@link MarqueeHover}. */
59
+ hover?: MarqueeHover;
46
60
  /** Fraction of the remaining velocity gap left after one second. */
47
61
  decay?: number;
48
62
  /**
@@ -94,7 +108,7 @@ const DEFAULTS: ResolvedOptions = {
94
108
  reverse: false,
95
109
  vertical: false,
96
110
  draggable: true,
97
- hoverScale: 1,
111
+ hover: "off",
98
112
  decay: DEFAULT_DECAY,
99
113
  manageStyles: true,
100
114
  copies: "clone"
@@ -134,11 +148,21 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
134
148
  : null;
135
149
  let reducedMotion = motionQuery?.matches ?? false;
136
150
 
151
+ /** The speed a mouse on the row asks for. One option, four shapes, no flags to reconcile. */
152
+ function hoverTarget(cruise: number): number {
153
+ const hover = opts.hover ?? "off";
154
+ if (hover === "off") return cruise;
155
+ if (hover === "pause") return 0;
156
+ if (typeof hover === "number") return cruise * hover;
157
+ // An absolute speed is a magnitude: the row keeps travelling the way it was.
158
+ return (cruise < 0 ? -1 : 1) * Math.abs(hover.speed);
159
+ }
160
+
137
161
  /** Read live every frame, never captured: cruise, hover and reduced motion all ease. */
138
162
  function target(): number {
139
163
  if (reducedMotion || paused) return 0;
140
164
  const cruise = opts.reverse ? -opts.speed : opts.speed;
141
- return hovering ? cruise * opts.hoverScale : cruise;
165
+ return hovering ? hoverTarget(cruise) : cruise;
142
166
  }
143
167
 
144
168
  function axisSize(element: HTMLElement): number {
package/src/index.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { createMarquee, type MarqueeOptions, type MarqueeInstance } from "@/core/marquee";
1
+ export { createMarquee, type MarqueeOptions, type MarqueeInstance, type MarqueeHover } from "@/core/marquee";
2
2
  export { createInput, type InputOptions, type InputInstance, type InputAction, type InputIcon, type InputActionState } from "@/core/input";
3
3
  export { createSearch, type SearchOptions, type SearchInstance, type SearchMatch, type FuseConstructor, type FuseLike } from "@/core/search";
@@ -1,5 +1,5 @@
1
1
  export { useMarquee, type UseMarqueeResult } from "@/react/use-marquee";
2
- export { type MarqueeOptions, type MarqueeInstance } from "@/core/marquee";
2
+ export { type MarqueeOptions, type MarqueeInstance, type MarqueeHover } from "@/core/marquee";
3
3
  export { useInput, type UseInputResult } from "@/react/use-input";
4
4
  export { useSearch, type UseSearchResult } from "@/react/use-search";
5
5
  export { type InputOptions, type InputAction, type InputIcon } from "@/core/input";
@@ -29,7 +29,7 @@ export interface UseMarqueeResult {
29
29
  * to the item count, so the row accelerates every time content is added.
30
30
  *
31
31
  * ```tsx
32
- * const { laneRef, trackRef, copies } = useMarquee({ speed: 80, hoverScale: 0.15 });
32
+ * const { laneRef, trackRef, copies } = useMarquee({ speed: 80, hover: 0.15 });
33
33
  * return (
34
34
  * <div ref={laneRef}>
35
35
  * <div ref={trackRef} style={{ display: "flex" }}>
@@ -87,7 +87,7 @@ export function useMarquee(options: MarqueeOptions = {}): UseMarqueeResult {
87
87
 
88
88
  useIsomorphicLayoutEffect(() => {
89
89
  instanceRef.current?.update(options);
90
- }, [options.speed, options.reverse, options.vertical, options.draggable, options.hoverScale, options.decay]);
90
+ }, [options.speed, options.reverse, options.vertical, options.draggable, options.hover, options.decay]);
91
91
 
92
92
  // A copy count change means new children; the lap has to be read again.
93
93
  useIsomorphicLayoutEffect(() => {
@@ -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
+ }