@enigmax/primitives 0.1.2 → 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,315 @@
1
+ /**
2
+ * Input affordances: the buttons that live inside a field, with no styling of their own.
3
+ *
4
+ * A password field gets its reveal toggle automatically, because a field the visitor
5
+ * cannot read back is the single most common cause of a failed sign-in. Everything about
6
+ * it is replaceable - the icon, the label, the position, the container, whether it exists
7
+ * at all - and the same mechanism takes any other action you want in there.
8
+ *
9
+ * The details below are the ones the hand-rolled version gets wrong; each is commented
10
+ * with the failure it prevents.
11
+ */
12
+
13
+ /** What an action renders. A string is parsed as HTML, so an inline SVG works. */
14
+ export type InputIcon = string | Node;
15
+
16
+ export interface InputActionState {
17
+ /** The field the action belongs to. */
18
+ input: HTMLInputElement;
19
+ /** True while the password is readable. Always false for a non-password field. */
20
+ revealed: boolean;
21
+ /** The field cannot be acted on: disabled or read-only. */
22
+ locked: boolean;
23
+ }
24
+
25
+ export interface InputAction {
26
+ /** Stable id. Used for `data-action` and to replace a built-in (e.g. "reveal"). */
27
+ name: string;
28
+ /** Accessible name. Becomes both `aria-label` and `title`. */
29
+ label: string | ((state: InputActionState) => string);
30
+ icon: InputIcon | ((state: InputActionState) => InputIcon);
31
+ /** Runs on click and on Enter/Space, because it is a real button. */
32
+ onSelect: (state: InputActionState) => void;
33
+ /** Renders `aria-pressed`. Omit for actions that are not a toggle. */
34
+ pressed?: (state: InputActionState) => boolean;
35
+ /** Hide the action without removing it, e.g. a clear button on an empty field. */
36
+ visible?: (state: InputActionState) => boolean;
37
+ }
38
+
39
+ export interface InputOptions {
40
+ /**
41
+ * The password reveal. `false` removes it. An object overrides parts of it - the
42
+ * icons, the labels - without giving up the caret and focus handling.
43
+ */
44
+ reveal?: boolean | {
45
+ /** Shown while the password is hidden; selecting it reveals. */
46
+ showIcon?: InputIcon;
47
+ /** Shown while the password is readable. */
48
+ hideIcon?: InputIcon;
49
+ showLabel?: string;
50
+ hideLabel?: string;
51
+ };
52
+ /** Extra actions, or a replacement for a built-in when `name` matches. */
53
+ actions?: InputAction[];
54
+ /** Which side the actions mount on. Position them yourself with CSS. */
55
+ position?: "start" | "end";
56
+ /**
57
+ * Mount the actions here instead of in a container created next to the input.
58
+ * Use it when your markup already has a slot for them.
59
+ */
60
+ container?: HTMLElement;
61
+ /** Called whenever the password's visibility changes. */
62
+ onRevealChange?: (revealed: boolean) => void;
63
+ }
64
+
65
+ export interface InputInstance {
66
+ readonly revealed: boolean;
67
+ /** Show or hide the password. Toggles when the argument is omitted. */
68
+ reveal(next?: boolean): void;
69
+ /** Re-read the field and re-render the actions. Call after changing it yourself. */
70
+ refresh(): void;
71
+ update(options: Partial<InputOptions>): void;
72
+ destroy(): void;
73
+ }
74
+
75
+ /** Neutral 1em glyphs that inherit `color`. Replace them with anything. */
76
+ const EYE = '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>';
77
+ const EYE_OFF = '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.6 5.2A9.8 9.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.2 6.2A17.7 17.7 0 0 0 2 12s3.5 7 10 7a9.6 9.6 0 0 0 4.2-.9"/><path d="m2 2 20 20"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>';
78
+
79
+ function resolve<T>(value: T | ((state: InputActionState) => T), state: InputActionState): T {
80
+ return typeof value === "function" ? (value as (state: InputActionState) => T)(state) : value;
81
+ }
82
+
83
+ function paint(target: HTMLElement, icon: InputIcon): void {
84
+ if (typeof icon === "string") target.innerHTML = icon;
85
+ else {
86
+ target.replaceChildren(icon);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Wire a field's in-field actions.
92
+ *
93
+ * @param input The field itself. A `type="password"` field gets the reveal for free.
94
+ */
95
+ export function createInput(input: HTMLInputElement, options: InputOptions = {}): InputInstance {
96
+ let opts: InputOptions = { ...options };
97
+ let revealed = false;
98
+ let destroyed = false;
99
+ let restoreTimer: ReturnType<typeof setTimeout> | null = null;
100
+
101
+ const owned = !opts.container;
102
+ const container = opts.container ?? document.createElement("span");
103
+ const buttons = new Map<string, HTMLButtonElement>();
104
+
105
+ function state(): InputActionState {
106
+ return { input, revealed, locked: input.disabled || input.readOnly };
107
+ }
108
+
109
+ function revealConfig() {
110
+ return typeof opts.reveal === "object" ? opts.reveal : {};
111
+ }
112
+
113
+ /** The built-in, presented as an ordinary action so it can be replaced by name. */
114
+ function revealAction(): InputAction {
115
+ const config = revealConfig();
116
+ return {
117
+ name: "reveal",
118
+ label: (current) => (current.revealed ? config.hideLabel ?? "Hide password" : config.showLabel ?? "Show password"),
119
+ icon: (current) => (current.revealed ? config.hideIcon ?? EYE_OFF : config.showIcon ?? EYE),
120
+ pressed: (current) => current.revealed,
121
+ onSelect: () => setRevealed(!revealed)
122
+ };
123
+ }
124
+
125
+ /** A password field is one whose type is password OR one already revealed by us. */
126
+ function isPasswordField(): boolean {
127
+ return input.type === "password" || (revealed && input.dataset.enigmaInputReveal === "on");
128
+ }
129
+
130
+ function activeActions(): InputAction[] {
131
+ const custom = opts.actions ?? [];
132
+ const wantsReveal = opts.reveal !== false && isPasswordField();
133
+ const builtIn = wantsReveal && !custom.some((action) => action.name === "reveal") ? [revealAction()] : [];
134
+ return [...builtIn, ...custom];
135
+ }
136
+
137
+ function setRevealed(next: boolean): void {
138
+ if (!isPasswordField() && next) return;
139
+ // Captured BEFORE the switch. Assigning `type` inside a click handler resets the
140
+ // caret to 0 in Chromium - reproduced in twenty lines with no library, and it does
141
+ // NOT happen when the same assignment runs outside an event - so anything read
142
+ // afterwards is already the clobbered value.
143
+ const selection = readSelection();
144
+
145
+ revealed = next;
146
+ input.type = next ? "text" : "password";
147
+ input.dataset.enigmaInputReveal = next ? "on" : "off";
148
+
149
+ render();
150
+
151
+ // After the render, because rewriting the buttons moves it again.
152
+ if (selection) {
153
+ applySelection(selection);
154
+ // And once more on the next MACROTASK. Chromium clobbers the caret exactly
155
+ // there - not synchronously and not in a microtask, both of which still read
156
+ // the right value - so a restore that only runs inline silently loses. The
157
+ // field is re-checked for focus first, so a visitor who clicked elsewhere in
158
+ // the meantime is not dragged back.
159
+ if (restoreTimer !== null) clearTimeout(restoreTimer);
160
+ restoreTimer = setTimeout(() => {
161
+ restoreTimer = null;
162
+ if (!destroyed && document.activeElement === input) applySelection(selection);
163
+ }, 0);
164
+ }
165
+ opts.onRevealChange?.(next);
166
+ }
167
+
168
+ /** Read the field's caret, if it has one and owns the focus. */
169
+ function readSelection(): [number, number] | null {
170
+ if (document.activeElement !== input) return null;
171
+ try {
172
+ const { selectionStart, selectionEnd } = input;
173
+ return selectionStart === null || selectionEnd === null ? null : [selectionStart, selectionEnd];
174
+ } catch {
175
+ return null; // selection is not supported on this input type
176
+ }
177
+ }
178
+
179
+ function applySelection(selection: [number, number]): void {
180
+ try { input.setSelectionRange(selection[0], selection[1]); } catch { /* unsupported */ }
181
+ }
182
+
183
+ function render(): void {
184
+ if (destroyed) return;
185
+ // Rewriting the buttons resets the caret of a focused field - measured, and it
186
+ // happens asynchronously too, because the MutationObserver below re-renders after
187
+ // the type switch. Preserving it HERE covers every trigger: the observer, an
188
+ // update(), a caller's refresh(). Doing it only in setRevealed left the reveal
189
+ // sending the cursor to position 0 one task later.
190
+ const selection = readSelection();
191
+ const current = state();
192
+ const actions = activeActions();
193
+ const seen = new Set<string>();
194
+
195
+ for (const action of actions) {
196
+ seen.add(action.name);
197
+ const visible = action.visible ? action.visible(current) : true;
198
+ let button = buttons.get(action.name);
199
+
200
+ if (!button) {
201
+ button = document.createElement("button");
202
+ // Without this a button inside a form SUBMITS it, so revealing a password
203
+ // posts the half-filled form. It is the single most common bug here.
204
+ button.type = "button";
205
+ button.dataset.enigmaInputAction = action.name;
206
+ buttons.set(action.name, button);
207
+ container.append(button);
208
+ }
209
+
210
+ if (!visible) {
211
+ // Removed rather than `hidden`: the hidden attribute works through a UA
212
+ // `display: none`, and ANY author rule that sets `display` on the button
213
+ // beats it - which a theme styling these buttons always does.
214
+ button.remove();
215
+ buttons.delete(action.name);
216
+ continue;
217
+ }
218
+ if (!button.isConnected) container.append(button);
219
+
220
+ const label = resolve(action.label, current);
221
+ button.setAttribute("aria-label", label);
222
+ button.title = label;
223
+ button.disabled = current.locked;
224
+ if (action.pressed) button.setAttribute("aria-pressed", String(action.pressed(current)));
225
+ else button.removeAttribute("aria-pressed");
226
+ paint(button, resolve(action.icon, current));
227
+ }
228
+
229
+ for (const [name, button] of [...buttons]) {
230
+ if (seen.has(name)) continue;
231
+ button.remove();
232
+ buttons.delete(name);
233
+ }
234
+
235
+ container.hidden = buttons.size === 0;
236
+
237
+ if (selection) {
238
+ try { input.setSelectionRange(selection[0], selection[1]); } catch { /* unsupported */ }
239
+ }
240
+ }
241
+
242
+ /**
243
+ * A press on an action must not pull focus out of the field: the visitor is mid-word
244
+ * and expects to keep typing. Preventing the default on mousedown keeps focus where
245
+ * it is, which also means the caret survives on its own. Keyboard users still reach
246
+ * the button with Tab and activate it with Space, and focus correctly stays there.
247
+ */
248
+ function onMouseDown(event: MouseEvent): void {
249
+ if (!(event.target as HTMLElement | null)?.closest("[data-enigma-input-action]")) return;
250
+ event.preventDefault();
251
+ }
252
+
253
+ function onClick(event: MouseEvent): void {
254
+ const target = (event.target as HTMLElement | null)?.closest<HTMLElement>("[data-enigma-input-action]");
255
+ if (!target) return;
256
+ const action = activeActions().find((candidate) => candidate.name === target.dataset.enigmaInputAction);
257
+ if (!action) return;
258
+ event.preventDefault();
259
+ action.onSelect(state());
260
+ }
261
+
262
+ /** The field's own type can change from outside; the reveal has to follow. */
263
+ const observer = typeof MutationObserver === "function"
264
+ ? new MutationObserver(() => {
265
+ if (!isPasswordField() && revealed) revealed = false;
266
+ render();
267
+ })
268
+ : null;
269
+
270
+ function mount(): void {
271
+ input.dataset.enigmaInput = "";
272
+ container.dataset.enigmaInputActions = "";
273
+ container.dataset.position = opts.position ?? "end";
274
+ if (owned && !container.isConnected) {
275
+ if ((opts.position ?? "end") === "start") input.before(container);
276
+ else input.after(container);
277
+ }
278
+ container.addEventListener("mousedown", onMouseDown);
279
+ container.addEventListener("click", onClick);
280
+ observer?.observe(input, { attributes: true, attributeFilter: ["type", "disabled", "readonly"] });
281
+ render();
282
+ }
283
+
284
+ mount();
285
+
286
+ return {
287
+ get revealed() { return revealed; },
288
+ reveal(next?: boolean) { setRevealed(next ?? !revealed); },
289
+ refresh: render,
290
+ update(next: Partial<InputOptions>) {
291
+ const moved = next.position !== undefined && next.position !== opts.position;
292
+ opts = { ...opts, ...next };
293
+ if (moved && owned) {
294
+ container.dataset.position = opts.position ?? "end";
295
+ if (opts.position === "start") input.before(container);
296
+ else input.after(container);
297
+ }
298
+ render();
299
+ },
300
+ destroy() {
301
+ destroyed = true;
302
+ if (restoreTimer !== null) clearTimeout(restoreTimer);
303
+ observer?.disconnect();
304
+ container.removeEventListener("mousedown", onMouseDown);
305
+ container.removeEventListener("click", onClick);
306
+ for (const button of buttons.values()) button.remove();
307
+ buttons.clear();
308
+ if (owned) container.remove();
309
+ delete input.dataset.enigmaInput;
310
+ delete input.dataset.enigmaInputReveal;
311
+ // A field left as text would leak the password on the next render.
312
+ if (revealed) input.type = "password";
313
+ }
314
+ };
315
+ }
@@ -0,0 +1,225 @@
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
+
12
+ export interface SearchMatch<T> {
13
+ item: T;
14
+ /** Lower is better, matching Fuse's convention. 0 is an exact hit. */
15
+ score: number;
16
+ /** Which key matched, when the matcher reports it. */
17
+ key?: string;
18
+ }
19
+
20
+ /** The shape of Fuse's constructor, declared here so the package need not depend on it. */
21
+ export interface FuseLike<T> {
22
+ search(query: string): { item: T; score?: number; matches?: { key?: string; }[]; }[];
23
+ }
24
+ export type FuseConstructor = new <T>(items: readonly T[], options?: Record<string, unknown>) => FuseLike<T>;
25
+
26
+ export interface SearchOptions<T> {
27
+ items?: readonly T[];
28
+ /** Fields to search. Dotted paths work with the built-in matcher and with Fuse. */
29
+ keys?: string[];
30
+ /**
31
+ * Pass Fuse.js's constructor to get fuzzy matching. Omit it for the built-in
32
+ * substring matcher. Ignored when `matcher` is set.
33
+ */
34
+ fuse?: FuseConstructor;
35
+ /** Handed to Fuse verbatim, so any Fuse option behaves exactly as documented there. */
36
+ fuseOptions?: Record<string, unknown>;
37
+ /** Replaces the engine entirely. Return the results in the order you want them. */
38
+ matcher?: (query: string, items: readonly T[]) => SearchMatch<T>[];
39
+ /** ms to wait after the last keystroke. 0 searches on every one. */
40
+ debounce?: number;
41
+ /** Queries shorter than this return nothing rather than everything. */
42
+ minLength?: number;
43
+ /** Cap the result list. */
44
+ limit?: number;
45
+ /** What an empty query returns. "none" (default) or "all". */
46
+ empty?: "none" | "all";
47
+ onResults?: (results: SearchMatch<T>[], query: string) => void;
48
+ }
49
+
50
+ export interface SearchInstance<T> {
51
+ readonly query: string;
52
+ readonly results: SearchMatch<T>[];
53
+ /** Debounced. */
54
+ search(query: string): void;
55
+ /** Skips the debounce, e.g. on Enter. */
56
+ searchNow(query: string): SearchMatch<T>[];
57
+ setItems(items: readonly T[]): void;
58
+ update(options: Partial<SearchOptions<T>>): void;
59
+ /** Bind to a field: input events search, Escape clears. Returns an unbind. */
60
+ attach(input: HTMLInputElement): () => void;
61
+ subscribe(listener: (results: SearchMatch<T>[], query: string) => void): () => void;
62
+ destroy(): void;
63
+ }
64
+
65
+ const DEFAULT_DEBOUNCE = 120;
66
+
67
+ /** "Café" must match "cafe" - a search that fails on an accent reads as broken. */
68
+ function fold(value: string): string {
69
+ return value.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
70
+ }
71
+
72
+ function readPath(item: unknown, path: string): string {
73
+ let cursor: unknown = item;
74
+ for (const step of path.split(".")) {
75
+ if (cursor == null || typeof cursor !== "object") return "";
76
+ cursor = (cursor as Record<string, unknown>)[step];
77
+ }
78
+ return cursor == null ? "" : String(cursor);
79
+ }
80
+
81
+ /**
82
+ * The zero-dependency fallback: accent-insensitive substring, ranked by where the match
83
+ * lands and which key it landed in, so a title hit outranks a body hit.
84
+ */
85
+ function substringMatcher<T>(query: string, items: readonly T[], keys: string[]): SearchMatch<T>[] {
86
+ const needle = fold(query);
87
+ const results: SearchMatch<T>[] = [];
88
+
89
+ for (const item of items) {
90
+ const fields = keys.length ? keys : [""];
91
+ let best: SearchMatch<T> | null = null;
92
+
93
+ for (let index = 0; index < fields.length; index++) {
94
+ const key = fields[index];
95
+ const haystack = fold(key ? readPath(item, key) : String(item));
96
+ const at = haystack.indexOf(needle);
97
+ if (at < 0) continue;
98
+ // Earlier in the string and earlier in the key list is a better hit; a whole
99
+ // field that IS the query scores 0.
100
+ const score = haystack === needle ? 0 : (at + 1) / (haystack.length + 1) + index * 0.01;
101
+ if (!best || score < best.score) best = { item, score, key: key || undefined };
102
+ }
103
+ if (best) results.push(best);
104
+ }
105
+
106
+ return results.sort((left, right) => left.score - right.score);
107
+ }
108
+
109
+ export function createSearch<T>(options: SearchOptions<T> = {}): SearchInstance<T> {
110
+ let opts: SearchOptions<T> = { ...options };
111
+ let items: readonly T[] = opts.items ?? [];
112
+ let query = "";
113
+ let results: SearchMatch<T>[] = [];
114
+ let timer: ReturnType<typeof setTimeout> | null = null;
115
+ let engine: FuseLike<T> | null = null;
116
+ let destroyed = false;
117
+ const listeners = new Set<(results: SearchMatch<T>[], query: string) => void>();
118
+
119
+ function buildEngine(): void {
120
+ engine = null;
121
+ if (opts.matcher || !opts.fuse) return;
122
+ // Fuse indexes on construction, so it is rebuilt when the items or keys change
123
+ // and never per keystroke.
124
+ engine = new opts.fuse(items, { keys: opts.keys ?? [], includeScore: true, includeMatches: true, threshold: 0.4, ...opts.fuseOptions });
125
+ }
126
+
127
+ function run(next: string): SearchMatch<T>[] {
128
+ const trimmed = next.trim();
129
+ const minLength = opts.minLength ?? 1;
130
+
131
+ let found: SearchMatch<T>[];
132
+ // The limit applies to this branch too: a caller asking for 10 rows means 10 rows,
133
+ // including the "show everything" state, which is the longest list of them all.
134
+ if (trimmed.length < minLength) {
135
+ found = (opts.empty ?? "none") === "all" ? items.map((item) => ({ item, score: 0 })) : [];
136
+ } else if (opts.matcher) {
137
+ found = opts.matcher(trimmed, items);
138
+ } else if (engine) {
139
+ found = engine.search(trimmed).map((hit) => ({
140
+ item: hit.item,
141
+ score: hit.score ?? 0,
142
+ key: hit.matches?.[0]?.key
143
+ }));
144
+ } else {
145
+ found = substringMatcher(trimmed, items, opts.keys ?? []);
146
+ }
147
+
148
+ return opts.limit != null ? found.slice(0, opts.limit) : found;
149
+ }
150
+
151
+ function publish(next: string): SearchMatch<T>[] {
152
+ query = next;
153
+ results = run(next);
154
+ opts.onResults?.(results, query);
155
+ for (const listener of listeners) listener(results, query);
156
+ return results;
157
+ }
158
+
159
+ function clearTimer(): void {
160
+ if (timer === null) return;
161
+ clearTimeout(timer);
162
+ timer = null;
163
+ }
164
+
165
+ buildEngine();
166
+
167
+ return {
168
+ get query() { return query; },
169
+ get results() { return results; },
170
+ search(next: string) {
171
+ if (destroyed) return;
172
+ clearTimer();
173
+ const wait = opts.debounce ?? DEFAULT_DEBOUNCE;
174
+ // A pending search is dropped rather than queued, so a fast typist gets one
175
+ // pass over the data instead of one per keystroke.
176
+ if (wait <= 0) { publish(next); return; }
177
+ timer = setTimeout(() => { timer = null; publish(next); }, wait);
178
+ },
179
+ searchNow(next: string) {
180
+ clearTimer();
181
+ return publish(next);
182
+ },
183
+ setItems(next: readonly T[]) {
184
+ items = next;
185
+ buildEngine();
186
+ // The visible results are stale the moment the data changes under them.
187
+ if (query) publish(query);
188
+ },
189
+ update(next: Partial<SearchOptions<T>>) {
190
+ opts = { ...opts, ...next };
191
+ if (next.items) items = next.items;
192
+ buildEngine();
193
+ if (query) publish(query);
194
+ },
195
+ attach(input: HTMLInputElement) {
196
+ const onInput = () => this.search(input.value);
197
+ const onKeyDown = (event: KeyboardEvent) => {
198
+ if (event.key !== "Escape" || !input.value) return;
199
+ // Escape clears the field first and only then reaches the dialog around
200
+ // it, which is what a visitor expects from a search box.
201
+ event.stopPropagation();
202
+ input.value = "";
203
+ this.searchNow("");
204
+ };
205
+ input.addEventListener("input", onInput);
206
+ input.addEventListener("keydown", onKeyDown);
207
+ input.dataset.enigmaSearch = "";
208
+ return () => {
209
+ input.removeEventListener("input", onInput);
210
+ input.removeEventListener("keydown", onKeyDown);
211
+ delete input.dataset.enigmaSearch;
212
+ };
213
+ },
214
+ subscribe(listener) {
215
+ listeners.add(listener);
216
+ return () => { listeners.delete(listener); };
217
+ },
218
+ destroy() {
219
+ destroyed = true;
220
+ clearTimer();
221
+ listeners.clear();
222
+ engine = null;
223
+ }
224
+ };
225
+ }
package/src/index.ts CHANGED
@@ -1 +1,3 @@
1
1
  export { createMarquee, type MarqueeOptions, type MarqueeInstance } from "@/core/marquee";
2
+ export { createInput, type InputOptions, type InputInstance, type InputAction, type InputIcon, type InputActionState } from "@/core/input";
3
+ export { createSearch, type SearchOptions, type SearchInstance, type SearchMatch, type FuseConstructor, type FuseLike } from "@/core/search";
@@ -1,2 +1,6 @@
1
1
  export { useMarquee, type UseMarqueeResult } from "@/react/use-marquee";
2
2
  export { type MarqueeOptions, type MarqueeInstance } from "@/core/marquee";
3
+ export { useInput, type UseInputResult } from "@/react/use-input";
4
+ export { useSearch, type UseSearchResult } from "@/react/use-search";
5
+ export { type InputOptions, type InputAction, type InputIcon } from "@/core/input";
6
+ export { type SearchOptions, type SearchMatch, type FuseConstructor } from "@/core/search";
@@ -0,0 +1,70 @@
1
+ import { createInput, type InputOptions, type InputInstance } from "@/core/input";
2
+ import { useRef, useState, useEffect, useLayoutEffect, type RefObject } from "react";
3
+
4
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
5
+
6
+ export interface UseInputResult {
7
+ /** Attach to the `<input>`. */
8
+ inputRef: RefObject<HTMLInputElement | null>;
9
+ /** Attach to your own actions container, or leave it and one is created for you. */
10
+ actionsRef: RefObject<HTMLElement | null>;
11
+ /** True while a password is readable. */
12
+ revealed: boolean;
13
+ reveal: (next?: boolean) => void;
14
+ /** Re-render the actions after you change the field yourself. */
15
+ refresh: () => void;
16
+ }
17
+
18
+ /**
19
+ * In-field actions for an input, with the password reveal wired for free.
20
+ *
21
+ * ```tsx
22
+ * const { inputRef, revealed } = useInput();
23
+ * return <input ref={inputRef} type="password" autoComplete="current-password" />;
24
+ * ```
25
+ *
26
+ * The toggle is a real `<button type="button">`, so it never submits the form, and the
27
+ * caret survives the type switch.
28
+ */
29
+ export function useInput(options: InputOptions = {}): UseInputResult {
30
+ const inputRef = useRef<HTMLInputElement | null>(null);
31
+ const actionsRef = useRef<HTMLElement | null>(null);
32
+ const instanceRef = useRef<InputInstance | null>(null);
33
+ const optionsRef = useRef(options);
34
+ optionsRef.current = options;
35
+
36
+ const [revealed, setRevealed] = useState(false);
37
+
38
+ useIsomorphicLayoutEffect(() => {
39
+ const input = inputRef.current;
40
+ if (!input) return;
41
+
42
+ const instance = createInput(input, {
43
+ ...optionsRef.current,
44
+ container: actionsRef.current ?? optionsRef.current.container,
45
+ onRevealChange: (next) => {
46
+ setRevealed(next);
47
+ optionsRef.current.onRevealChange?.(next);
48
+ }
49
+ });
50
+ instanceRef.current = instance;
51
+ return () => {
52
+ instance.destroy();
53
+ instanceRef.current = null;
54
+ };
55
+ // Created once; option changes go through update() so the field is not rebuilt
56
+ // on every render, which would drop the caret mid-typing.
57
+ }, []);
58
+
59
+ useIsomorphicLayoutEffect(() => {
60
+ instanceRef.current?.update(options);
61
+ }, [options.reveal, options.actions, options.position]);
62
+
63
+ return {
64
+ inputRef,
65
+ actionsRef,
66
+ revealed,
67
+ reveal: (next?: boolean) => instanceRef.current?.reveal(next),
68
+ refresh: () => instanceRef.current?.refresh()
69
+ };
70
+ }