@enigmax/primitives 0.1.2 → 0.3.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/index.d.ts CHANGED
@@ -70,4 +70,146 @@ interface MarqueeInstance {
70
70
  */
71
71
  declare function createMarquee(lane: HTMLElement, track: HTMLElement, options?: MarqueeOptions): MarqueeInstance;
72
72
 
73
- export { type MarqueeInstance, type MarqueeOptions, createMarquee };
73
+ /**
74
+ * Input affordances: the buttons that live inside a field, with no styling of their own.
75
+ *
76
+ * A password field gets its reveal toggle automatically, because a field the visitor
77
+ * cannot read back is the single most common cause of a failed sign-in. Everything about
78
+ * it is replaceable - the icon, the label, the position, the container, whether it exists
79
+ * at all - and the same mechanism takes any other action you want in there.
80
+ *
81
+ * The details below are the ones the hand-rolled version gets wrong; each is commented
82
+ * with the failure it prevents.
83
+ */
84
+ /** What an action renders. A string is parsed as HTML, so an inline SVG works. */
85
+ type InputIcon = string | Node;
86
+ interface InputActionState {
87
+ /** The field the action belongs to. */
88
+ input: HTMLInputElement;
89
+ /** True while the password is readable. Always false for a non-password field. */
90
+ revealed: boolean;
91
+ /** The field cannot be acted on: disabled or read-only. */
92
+ locked: boolean;
93
+ }
94
+ interface InputAction {
95
+ /** Stable id. Used for `data-action` and to replace a built-in (e.g. "reveal"). */
96
+ name: string;
97
+ /** Accessible name. Becomes both `aria-label` and `title`. */
98
+ label: string | ((state: InputActionState) => string);
99
+ icon: InputIcon | ((state: InputActionState) => InputIcon);
100
+ /** Runs on click and on Enter/Space, because it is a real button. */
101
+ onSelect: (state: InputActionState) => void;
102
+ /** Renders `aria-pressed`. Omit for actions that are not a toggle. */
103
+ pressed?: (state: InputActionState) => boolean;
104
+ /** Hide the action without removing it, e.g. a clear button on an empty field. */
105
+ visible?: (state: InputActionState) => boolean;
106
+ }
107
+ interface InputOptions {
108
+ /**
109
+ * The password reveal. `false` removes it. An object overrides parts of it - the
110
+ * icons, the labels - without giving up the caret and focus handling.
111
+ */
112
+ reveal?: boolean | {
113
+ /** Shown while the password is hidden; selecting it reveals. */
114
+ showIcon?: InputIcon;
115
+ /** Shown while the password is readable. */
116
+ hideIcon?: InputIcon;
117
+ showLabel?: string;
118
+ hideLabel?: string;
119
+ };
120
+ /** Extra actions, or a replacement for a built-in when `name` matches. */
121
+ actions?: InputAction[];
122
+ /** Which side the actions mount on. Position them yourself with CSS. */
123
+ position?: "start" | "end";
124
+ /**
125
+ * Mount the actions here instead of in a container created next to the input.
126
+ * Use it when your markup already has a slot for them.
127
+ */
128
+ container?: HTMLElement;
129
+ /** Called whenever the password's visibility changes. */
130
+ onRevealChange?: (revealed: boolean) => void;
131
+ }
132
+ interface InputInstance {
133
+ readonly revealed: boolean;
134
+ /** Show or hide the password. Toggles when the argument is omitted. */
135
+ reveal(next?: boolean): void;
136
+ /** Re-read the field and re-render the actions. Call after changing it yourself. */
137
+ refresh(): void;
138
+ update(options: Partial<InputOptions>): void;
139
+ destroy(): void;
140
+ }
141
+ /**
142
+ * Wire a field's in-field actions.
143
+ *
144
+ * @param input The field itself. A `type="password"` field gets the reveal for free.
145
+ */
146
+ declare function createInput(input: HTMLInputElement, options?: InputOptions): InputInstance;
147
+
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 };
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { createMarquee } from './chunk-55J5JEWX.js';
1
+ export { createInput, createMarquee, createSearch } from './chunk-OKNWKBQI.js';
@@ -1,6 +1,6 @@
1
1
  import { RefObject } from 'react';
2
- import { MarqueeOptions } from '../index.js';
3
- export { MarqueeInstance } from '../index.js';
2
+ import { MarqueeOptions, InputOptions, SearchMatch, SearchOptions } from '../index.js';
3
+ export { FuseConstructor, InputAction, InputIcon, MarqueeInstance } from '../index.js';
4
4
 
5
5
  interface UseMarqueeResult {
6
6
  /** Attach to the clipping viewport. */
@@ -42,4 +42,54 @@ interface UseMarqueeResult {
42
42
  */
43
43
  declare function useMarquee(options?: MarqueeOptions): UseMarqueeResult;
44
44
 
45
- export { MarqueeOptions, type UseMarqueeResult, useMarquee };
45
+ interface UseInputResult {
46
+ /** Attach to the `<input>`. */
47
+ inputRef: RefObject<HTMLInputElement | null>;
48
+ /** Attach to your own actions container, or leave it and one is created for you. */
49
+ actionsRef: RefObject<HTMLElement | null>;
50
+ /** True while a password is readable. */
51
+ revealed: boolean;
52
+ reveal: (next?: boolean) => void;
53
+ /** Re-render the actions after you change the field yourself. */
54
+ refresh: () => void;
55
+ }
56
+ /**
57
+ * In-field actions for an input, with the password reveal wired for free.
58
+ *
59
+ * ```tsx
60
+ * const { inputRef, revealed } = useInput();
61
+ * return <input ref={inputRef} type="password" autoComplete="current-password" />;
62
+ * ```
63
+ *
64
+ * The toggle is a real `<button type="button">`, so it never submits the form, and the
65
+ * caret survives the type switch.
66
+ */
67
+ declare function useInput(options?: InputOptions): UseInputResult;
68
+
69
+ interface UseSearchResult<T> {
70
+ /** Attach to the search field, or drive it yourself with `search`. */
71
+ inputRef: RefObject<HTMLInputElement | null>;
72
+ query: string;
73
+ results: SearchMatch<T>[];
74
+ /** Debounced. */
75
+ search: (query: string) => void;
76
+ /** Skips the debounce. */
77
+ searchNow: (query: string) => void;
78
+ clear: () => void;
79
+ }
80
+ /**
81
+ * Search-as-you-type over a list.
82
+ *
83
+ * Pass `fuse` (Fuse.js's constructor) for fuzzy matching, `matcher` to replace the
84
+ * engine, or neither for the built-in accent-insensitive substring search.
85
+ *
86
+ * ```tsx
87
+ * import Fuse from "fuse.js";
88
+ *
89
+ * const { inputRef, results } = useSearch({ items: docs, keys: ["title", "body"], fuse: Fuse });
90
+ * return <input ref={inputRef} type="search" placeholder="Search" />;
91
+ * ```
92
+ */
93
+ declare function useSearch<T>(options?: SearchOptions<T>): UseSearchResult<T>;
94
+
95
+ export { InputOptions, MarqueeOptions, SearchMatch, SearchOptions, type UseInputResult, type UseMarqueeResult, type UseSearchResult, useInput, useMarquee, useSearch };
@@ -1,5 +1,5 @@
1
- import { createMarquee } from '../chunk-55J5JEWX.js';
2
- import { useRef, useState, useEffect, useLayoutEffect } from 'react';
1
+ import { createMarquee, createInput, createSearch } from '../chunk-OKNWKBQI.js';
2
+ import { useRef, useState, useMemo, useEffect, useLayoutEffect } from 'react';
3
3
 
4
4
  var useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
5
5
  function useMarquee(options = {}) {
@@ -51,5 +51,83 @@ function useMarquee(options = {}) {
51
51
  measure: () => instanceRef.current?.measure()
52
52
  };
53
53
  }
54
+ var useIsomorphicLayoutEffect2 = typeof window === "undefined" ? useEffect : useLayoutEffect;
55
+ function useInput(options = {}) {
56
+ const inputRef = useRef(null);
57
+ const actionsRef = useRef(null);
58
+ const instanceRef = useRef(null);
59
+ const optionsRef = useRef(options);
60
+ optionsRef.current = options;
61
+ const [revealed, setRevealed] = useState(false);
62
+ useIsomorphicLayoutEffect2(() => {
63
+ const input = inputRef.current;
64
+ if (!input) return;
65
+ const instance = createInput(input, {
66
+ ...optionsRef.current,
67
+ container: actionsRef.current ?? optionsRef.current.container,
68
+ onRevealChange: (next) => {
69
+ setRevealed(next);
70
+ optionsRef.current.onRevealChange?.(next);
71
+ }
72
+ });
73
+ instanceRef.current = instance;
74
+ return () => {
75
+ instance.destroy();
76
+ instanceRef.current = null;
77
+ };
78
+ }, []);
79
+ useIsomorphicLayoutEffect2(() => {
80
+ instanceRef.current?.update(options);
81
+ }, [options.reveal, options.actions, options.position]);
82
+ return {
83
+ inputRef,
84
+ actionsRef,
85
+ revealed,
86
+ reveal: (next) => instanceRef.current?.reveal(next),
87
+ refresh: () => instanceRef.current?.refresh()
88
+ };
89
+ }
90
+ function useSearch(options = {}) {
91
+ const inputRef = useRef(null);
92
+ const instanceRef = useRef(null);
93
+ const optionsRef = useRef(options);
94
+ optionsRef.current = options;
95
+ const [query, setQuery] = useState("");
96
+ const [results, setResults] = useState([]);
97
+ const instance = useMemo(() => {
98
+ const created = createSearch({
99
+ ...optionsRef.current,
100
+ onResults: (next, nextQuery) => {
101
+ setResults(next);
102
+ setQuery(nextQuery);
103
+ optionsRef.current.onResults?.(next, nextQuery);
104
+ }
105
+ });
106
+ instanceRef.current = created;
107
+ return created;
108
+ }, []);
109
+ useEffect(() => () => instance.destroy(), [instance]);
110
+ useEffect(() => {
111
+ instance.setItems(options.items ?? []);
112
+ }, [instance, options.items]);
113
+ useEffect(() => {
114
+ const input = inputRef.current;
115
+ if (!input) return;
116
+ return instance.attach(input);
117
+ }, [instance]);
118
+ return {
119
+ inputRef,
120
+ query,
121
+ results,
122
+ search: (next) => instance.search(next),
123
+ searchNow: (next) => {
124
+ instance.searchNow(next);
125
+ },
126
+ clear: () => {
127
+ if (inputRef.current) inputRef.current.value = "";
128
+ instance.searchNow("");
129
+ }
130
+ };
131
+ }
54
132
 
55
- export { useMarquee };
133
+ export { useInput, useMarquee, useSearch };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/primitives",
3
- "version": "0.1.2",
3
+ "version": "0.3.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",
@@ -26,16 +26,21 @@
26
26
  "files": [
27
27
  "dist",
28
28
  "src",
29
+ "recipes",
29
30
  "registry.json",
30
31
  "README.md",
31
32
  "LICENSE"
32
33
  ],
33
34
  "peerDependencies": {
34
- "react": ">=18"
35
+ "react": ">=18",
36
+ "fuse.js": ">=6"
35
37
  },
36
38
  "peerDependenciesMeta": {
37
39
  "react": {
38
40
  "optional": true
41
+ },
42
+ "fuse.js": {
43
+ "optional": true
39
44
  }
40
45
  },
41
46
  "devDependencies": {
@@ -43,7 +48,8 @@
43
48
  "@types/react": "^19.2.0",
44
49
  "react": "^19.2.0",
45
50
  "tsup": "^8.0.0",
46
- "typescript": "^5.0.0"
51
+ "typescript": "^5.0.0",
52
+ "fuse.js": "^7.0.0"
47
53
  },
48
54
  "engines": {
49
55
  "node": ">=18"
@@ -56,7 +62,11 @@
56
62
  "unstyled",
57
63
  "react",
58
64
  "vanilla",
59
- "enigma"
65
+ "enigma",
66
+ "input",
67
+ "password",
68
+ "search",
69
+ "fuse"
60
70
  ],
61
71
  "license": "Apache-2.0",
62
72
  "author": "FJRG2007 <fjrg2007@tpeoficial.com>",
@@ -0,0 +1,32 @@
1
+ /* A starting point for the field, yours to edit. The primitive creates the action
2
+ buttons and publishes their state; it decides nothing about how they look. */
3
+
4
+ .enigma-field { display: grid; gap: 0.375rem; }
5
+ .enigma-field__label { font-size: 0.75rem; color: #a3a3a3; }
6
+
7
+ .enigma-field__box {
8
+ display: flex; align-items: center; gap: 0.375rem;
9
+ padding: 0 0.75rem;
10
+ background: #171717; border: 1px solid #404040; border-radius: 0.5rem;
11
+ }
12
+ .enigma-field__box:focus-within { border-color: #a3a3a3; }
13
+
14
+ .enigma-field__input {
15
+ flex: 1; min-width: 0; padding: 0.625rem 0;
16
+ font-size: 0.875rem; color: #f5f5f5;
17
+ background: transparent; border: 0; outline: none;
18
+ }
19
+
20
+ /* The engine's buttons. Styled through its hooks, never through a className. */
21
+ [data-enigma-input-actions] { display: inline-flex; gap: 0.125rem; }
22
+ [data-enigma-input-actions][data-position="start"] { order: -1; }
23
+
24
+ [data-enigma-input-action] {
25
+ display: grid; place-items: center;
26
+ width: 1.75rem; height: 1.75rem;
27
+ color: #a3a3a3; background: none; border: 0; border-radius: 0.375rem;
28
+ cursor: pointer; font-size: 0.9375rem;
29
+ }
30
+ [data-enigma-input-action]:hover { color: #f5f5f5; background: #262626; }
31
+ [data-enigma-input-action][aria-pressed="true"] { color: #fbbf24; }
32
+ [data-enigma-input-action]:disabled { opacity: 0.5; cursor: default; }
@@ -0,0 +1,28 @@
1
+ import "./input.css";
2
+ import type { InputHTMLAttributes } from "react";
3
+ import { useInput } from "@enigmax/primitives/react";
4
+
5
+ /**
6
+ * A styled field, yours to edit.
7
+ *
8
+ * The reveal toggle, its accessible name, its pressed state and the caret handling all
9
+ * come from the primitive. input.css beside this file is a starting point.
10
+ */
11
+ export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
12
+ label?: string;
13
+ /** Remove the automatic password reveal. */
14
+ reveal?: boolean;
15
+ }
16
+
17
+ export function Field({ label, reveal = true, className = "", ...props }: FieldProps) {
18
+ const { inputRef } = useInput({ reveal });
19
+
20
+ return (
21
+ <label className="enigma-field">
22
+ {label && <span className="enigma-field__label">{label}</span>}
23
+ <span className="enigma-field__box">
24
+ <input ref={inputRef} className={`enigma-field__input ${className}`.trim()} {...props} />
25
+ </span>
26
+ </label>
27
+ );
28
+ }
@@ -0,0 +1,44 @@
1
+ import type { InputHTMLAttributes } from "react";
2
+ import { useInput } from "@enigmax/primitives/react";
3
+
4
+ /**
5
+ * A styled field, yours to edit.
6
+ *
7
+ * The reveal toggle, its accessible name, its pressed state and the caret handling all
8
+ * come from the primitive. Every class below is a suggestion.
9
+ *
10
+ * The action buttons are created by the engine, so they are styled through the
11
+ * data-* hooks rather than a className prop - hence the arbitrary-variant selectors.
12
+ */
13
+ export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
14
+ label?: string;
15
+ /** Remove the automatic password reveal. */
16
+ reveal?: boolean;
17
+ }
18
+
19
+ export function Field({ label, reveal = true, className = "", ...props }: FieldProps) {
20
+ const { inputRef } = useInput({ reveal });
21
+
22
+ return (
23
+ <label className="grid gap-1.5">
24
+ {label && <span className="text-xs text-neutral-400">{label}</span>}
25
+ <span
26
+ className="
27
+ flex items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-900 px-3
28
+ focus-within:border-neutral-400
29
+ [&_[data-enigma-input-actions]]:inline-flex
30
+ [&_[data-enigma-input-action]]:grid [&_[data-enigma-input-action]]:size-7 [&_[data-enigma-input-action]]:place-items-center
31
+ [&_[data-enigma-input-action]]:rounded-md [&_[data-enigma-input-action]]:text-neutral-400
32
+ [&_[data-enigma-input-action]:hover]:bg-neutral-800 [&_[data-enigma-input-action]:hover]:text-neutral-100
33
+ [&_[data-enigma-input-action][aria-pressed=true]]:text-amber-400
34
+ "
35
+ >
36
+ <input
37
+ ref={inputRef}
38
+ className={`min-w-0 flex-1 bg-transparent py-2.5 text-sm text-neutral-100 outline-none placeholder:text-neutral-500 ${className}`}
39
+ {...props}
40
+ />
41
+ </span>
42
+ </label>
43
+ );
44
+ }
@@ -0,0 +1,22 @@
1
+ /* A starting point for the marquee, yours to edit. The primitive ships no styles;
2
+ it sets only what the behaviour needs (overflow, touch-action, user-select,
3
+ will-change, transform) and publishes its state as data-* attributes. */
4
+
5
+ .enigma-marquee { position: relative; width: 100%; cursor: grab; }
6
+ .enigma-marquee[data-grabbing] { cursor: grabbing; }
7
+
8
+ /* Fade both ends so items enter and leave instead of being cut. */
9
+ .enigma-marquee.is-faded {
10
+ -webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
11
+ mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
12
+ }
13
+
14
+ /* Never set `transform` or `translate` on the track: the engine owns it, and in
15
+ Tailwind v4 a translate-* utility composes with it rather than replacing it. */
16
+ .enigma-marquee__track { display: flex; }
17
+ .enigma-marquee__copy { display: flex; flex-shrink: 0; align-items: center; gap: 2.5rem; padding-right: 2.5rem; }
18
+ .enigma-marquee__item { flex-shrink: 0; }
19
+
20
+ /* State the engine publishes, for you to style. */
21
+ .enigma-marquee[data-hovering] .enigma-marquee__item { opacity: 1; }
22
+ .enigma-marquee[data-reduced-motion="true"] { /* autoplay is off; the drag still works */ }
@@ -0,0 +1,52 @@
1
+ import "./marquee.css";
2
+ import type { ReactNode, Ref } from "react";
3
+ import { useMarquee } from "@enigmax/primitives/react";
4
+
5
+ /**
6
+ * A styled marquee, yours to edit.
7
+ *
8
+ * The behaviour comes from the primitive, which ships no styles at all; marquee.css
9
+ * beside this file is a starting point you are meant to change.
10
+ */
11
+ export interface MarqueeProps<T> {
12
+ items: T[];
13
+ /** Pixels per second. Never a duration - the row would speed up as items are added. */
14
+ speed?: number;
15
+ /** Speed multiplier while a mouse rests on the row. 0 pauses it. */
16
+ hoverScale?: number;
17
+ reverse?: boolean;
18
+ /** Fade both ends so items enter and leave instead of being cut. */
19
+ fade?: boolean;
20
+ className?: string;
21
+ children: (item: T, index: number) => ReactNode;
22
+ }
23
+
24
+ export function Marquee<T>({
25
+ items,
26
+ speed = 70,
27
+ hoverScale = 1,
28
+ reverse = false,
29
+ fade = true,
30
+ className = "",
31
+ children
32
+ }: MarqueeProps<T>) {
33
+ const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hoverScale, reverse });
34
+
35
+ return (
36
+ <div
37
+ ref={laneRef as Ref<HTMLDivElement>}
38
+ className={["enigma-marquee", fade ? "is-faded" : "", className].filter(Boolean).join(" ")}
39
+ data-grabbing={dragging ? "" : undefined}
40
+ >
41
+ <div ref={trackRef as Ref<HTMLDivElement>} className="enigma-marquee__track">
42
+ {Array.from({ length: copies }, (_, copy) => (
43
+ <div key={copy} aria-hidden={copy > 0} className="enigma-marquee__copy">
44
+ {items.map((item, index) => (
45
+ <div key={index} className="enigma-marquee__item">{children(item, index)}</div>
46
+ ))}
47
+ </div>
48
+ ))}
49
+ </div>
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,61 @@
1
+ import type { ReactNode, Ref } from "react";
2
+ import { useMarquee } from "@enigmax/primitives/react";
3
+
4
+ /**
5
+ * A styled marquee, yours to edit.
6
+ *
7
+ * The behaviour comes from the primitive, which ships no styles at all; every class
8
+ * below is a suggestion you are meant to change.
9
+ *
10
+ * TAILWIND v4 WARNING: never put a `translate-*` utility on the track. v4 writes those
11
+ * to the CSS `translate` property, which COMPOSES with `transform` rather than replacing
12
+ * it, so the class and the engine's transform add up and the row drifts. Anything you
13
+ * need to offset goes on the lane or on an inner element, never on the moved one.
14
+ */
15
+ export interface MarqueeProps<T> {
16
+ items: T[];
17
+ /** Pixels per second. Never a duration - the row would speed up as items are added. */
18
+ speed?: number;
19
+ /** Speed multiplier while a mouse rests on the row. 0 pauses it. */
20
+ hoverScale?: number;
21
+ reverse?: boolean;
22
+ /** Fade both ends so items enter and leave instead of being cut. */
23
+ fade?: boolean;
24
+ className?: string;
25
+ children: (item: T, index: number) => ReactNode;
26
+ }
27
+
28
+ export function Marquee<T>({
29
+ items,
30
+ speed = 70,
31
+ hoverScale = 1,
32
+ reverse = false,
33
+ fade = true,
34
+ className = "",
35
+ children
36
+ }: MarqueeProps<T>) {
37
+ const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hoverScale, reverse });
38
+
39
+ return (
40
+ <div
41
+ ref={laneRef as Ref<HTMLDivElement>}
42
+ className={[
43
+ "relative w-full",
44
+ dragging ? "cursor-grabbing" : "cursor-grab",
45
+ fade ? "[mask-image:linear-gradient(90deg,transparent,#000_6%,#000_94%,transparent)]" : "",
46
+ className
47
+ ].filter(Boolean).join(" ")}
48
+ >
49
+ {/* No translate-* utility here. See the note at the top of this file. */}
50
+ <div ref={trackRef as Ref<HTMLDivElement>} className="flex">
51
+ {Array.from({ length: copies }, (_, copy) => (
52
+ <div key={copy} aria-hidden={copy > 0} className="flex shrink-0 items-center gap-10 pr-10">
53
+ {items.map((item, index) => (
54
+ <div key={index} className="shrink-0">{children(item, index)}</div>
55
+ ))}
56
+ </div>
57
+ ))}
58
+ </div>
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,73 @@
1
+ import Fuse from "fuse.js";
2
+ import type { ReactNode } from "react";
3
+ import { useSearch } from "@enigmax/primitives/react";
4
+
5
+ /**
6
+ * A styled search field with its result list, yours to edit.
7
+ *
8
+ * Debouncing, ranking, cancellation and the Escape-to-clear all come from the primitive.
9
+ * Drop the Fuse import and the `fuse` prop to fall back to the built-in
10
+ * accent-insensitive substring matcher and no dependency at all.
11
+ */
12
+ export interface SearchProps<T> {
13
+ items: T[];
14
+ /** Fields to search. Dotted paths work: "author.name". */
15
+ keys: string[];
16
+ placeholder?: string;
17
+ /** Fuzzy matching. Omit for the zero-dependency substring matcher. */
18
+ fuzzy?: boolean;
19
+ emptyMessage?: string;
20
+ children: (item: T, score: number) => ReactNode;
21
+ }
22
+
23
+ export function Search<T>({
24
+ items,
25
+ keys,
26
+ placeholder = "Search",
27
+ fuzzy = true,
28
+ emptyMessage = "No matches",
29
+ children
30
+ }: SearchProps<T>) {
31
+ const { inputRef, results, query } = useSearch<T>({
32
+ items,
33
+ keys,
34
+ fuse: fuzzy ? (Fuse as never) : undefined,
35
+ debounce: 120
36
+ });
37
+
38
+ return (
39
+ <div className="grid gap-2.5">
40
+ <input
41
+ ref={inputRef}
42
+ type="search"
43
+ aria-label={placeholder}
44
+ placeholder={placeholder}
45
+ className="
46
+ rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2.5
47
+ text-sm text-neutral-100 outline-none
48
+ placeholder:text-neutral-500 focus:border-neutral-400
49
+ "
50
+ />
51
+
52
+ {query && (
53
+ <p className="font-mono text-[11px] text-neutral-500">
54
+ {results.length} result{results.length === 1 ? "" : "s"} for &quot;{query}&quot;
55
+ </p>
56
+ )}
57
+
58
+ <ul className="grid list-none gap-1.5 p-0">
59
+ {results.map((result, index) => (
60
+ <li
61
+ key={index}
62
+ className="rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm text-neutral-100"
63
+ >
64
+ {children(result.item, result.score)}
65
+ </li>
66
+ ))}
67
+ {query && results.length === 0 && (
68
+ <li className="text-sm text-neutral-500">{emptyMessage}</li>
69
+ )}
70
+ </ul>
71
+ </div>
72
+ );
73
+ }