@enigmax/primitives 0.9.0 → 0.11.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,466 @@
1
+ import { generatePassword, estimatePasswordStrength, INPUT_ICON_PATHS, createButton, createMarquee, createInput } from './chunk-42Y7OOJM.js';
2
+ import { createSearch } from './chunk-UZFEEFMF.js';
3
+ import { forwardRef, createElement, useRef, useState, useId, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
4
+ import { jsx, jsxs } from 'react/jsx-runtime';
5
+
6
+ var useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
7
+ function useMarquee(options = {}) {
8
+ const laneRef = useRef(null);
9
+ const trackRef = useRef(null);
10
+ const instanceRef = useRef(null);
11
+ const optionsRef = useRef(options);
12
+ optionsRef.current = options;
13
+ const [copies, setCopies] = useState(2);
14
+ const [dragging, setDragging] = useState(false);
15
+ const [period, setPeriod] = useState(0);
16
+ useIsomorphicLayoutEffect(() => {
17
+ const lane = laneRef.current;
18
+ const track = trackRef.current;
19
+ if (!lane || !track) return;
20
+ const instance = createMarquee(lane, track, {
21
+ ...optionsRef.current,
22
+ // React owns the DOM; cloning behind its back would be undone on render.
23
+ copies: "external",
24
+ onCopyCountChange: setCopies,
25
+ onMeasure: (measured) => setPeriod(measured),
26
+ onItemClick: (event) => optionsRef.current.onItemClick?.(event)
27
+ });
28
+ instanceRef.current = instance;
29
+ const syncDragging = () => setDragging(instance.dragging);
30
+ lane.addEventListener("pointerdown", syncDragging);
31
+ window.addEventListener("pointerup", syncDragging);
32
+ window.addEventListener("pointercancel", syncDragging);
33
+ return () => {
34
+ lane.removeEventListener("pointerdown", syncDragging);
35
+ window.removeEventListener("pointerup", syncDragging);
36
+ window.removeEventListener("pointercancel", syncDragging);
37
+ instance.destroy();
38
+ instanceRef.current = null;
39
+ };
40
+ }, []);
41
+ useIsomorphicLayoutEffect(() => {
42
+ instanceRef.current?.update(options);
43
+ }, [options.speed, options.reverse, options.vertical, options.draggable, options.hover, options.decay]);
44
+ useIsomorphicLayoutEffect(() => {
45
+ instanceRef.current?.measure();
46
+ }, [copies]);
47
+ return {
48
+ laneRef,
49
+ trackRef,
50
+ copies,
51
+ dragging,
52
+ period,
53
+ measure: () => instanceRef.current?.measure()
54
+ };
55
+ }
56
+ var useIsomorphicLayoutEffect2 = typeof window === "undefined" ? useEffect : useLayoutEffect;
57
+ function useInput(options = {}) {
58
+ const inputRef = useRef(null);
59
+ const actionsRef = useRef(null);
60
+ const instanceRef = useRef(null);
61
+ const optionsRef = useRef(options);
62
+ optionsRef.current = options;
63
+ const [revealed, setRevealed] = useState(false);
64
+ useIsomorphicLayoutEffect2(() => {
65
+ const input = inputRef.current;
66
+ if (!input) return;
67
+ const instance = createInput(input, {
68
+ ...optionsRef.current,
69
+ container: actionsRef.current ?? optionsRef.current.container,
70
+ onRevealChange: (next) => {
71
+ setRevealed(next);
72
+ optionsRef.current.onRevealChange?.(next);
73
+ }
74
+ });
75
+ instanceRef.current = instance;
76
+ return () => {
77
+ instance.destroy();
78
+ instanceRef.current = null;
79
+ };
80
+ }, []);
81
+ useIsomorphicLayoutEffect2(() => {
82
+ instanceRef.current?.update(options);
83
+ }, [options.reveal, options.actions, options.position]);
84
+ return {
85
+ inputRef,
86
+ actionsRef,
87
+ revealed,
88
+ reveal: (next) => instanceRef.current?.reveal(next),
89
+ refresh: () => instanceRef.current?.refresh()
90
+ };
91
+ }
92
+ function useSearch(options = {}) {
93
+ const inputRef = useRef(null);
94
+ const instanceRef = useRef(null);
95
+ const optionsRef = useRef(options);
96
+ optionsRef.current = options;
97
+ const [query, setQuery] = useState("");
98
+ const [results, setResults] = useState([]);
99
+ const instance = useMemo(() => {
100
+ const created = createSearch({
101
+ ...optionsRef.current,
102
+ onResults: (next, nextQuery) => {
103
+ setResults(next);
104
+ setQuery(nextQuery);
105
+ optionsRef.current.onResults?.(next, nextQuery);
106
+ }
107
+ });
108
+ instanceRef.current = created;
109
+ return created;
110
+ }, []);
111
+ useEffect(() => () => instance.destroy(), [instance]);
112
+ useEffect(() => {
113
+ instance.setItems(options.items ?? []);
114
+ }, [instance, options.items]);
115
+ useEffect(() => {
116
+ const input = inputRef.current;
117
+ if (!input) return;
118
+ return instance.attach(input);
119
+ }, [instance]);
120
+ return {
121
+ inputRef,
122
+ query,
123
+ results,
124
+ search: (next) => instance.search(next),
125
+ searchNow: (next) => {
126
+ instance.searchNow(next);
127
+ },
128
+ clear: () => {
129
+ if (inputRef.current) inputRef.current.value = "";
130
+ instance.searchNow("");
131
+ }
132
+ };
133
+ }
134
+ function useButton(options = {}) {
135
+ const optionsRef = useRef(options);
136
+ optionsRef.current = options;
137
+ const instance = useMemo(() => createButton({
138
+ ...optionsRef.current,
139
+ onPress: (event) => optionsRef.current.onPress?.(event),
140
+ onChange: (next) => optionsRef.current.onChange?.(next)
141
+ // Built once: recreating it would drop a running cooldown on every render.
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps
143
+ }), []);
144
+ const [state, setState] = useState(() => instance.state);
145
+ useEffect(() => {
146
+ const unsubscribe = instance.subscribe(setState);
147
+ setState(instance.state);
148
+ return () => {
149
+ unsubscribe();
150
+ instance.destroy();
151
+ };
152
+ }, [instance]);
153
+ useEffect(() => {
154
+ instance.update({
155
+ href: options.href,
156
+ disabled: options.disabled,
157
+ loading: options.loading,
158
+ cooldown: options.cooldown,
159
+ shortcut: options.shortcut
160
+ });
161
+ }, [instance, options.href, options.disabled, options.loading, options.cooldown, options.shortcut]);
162
+ const press = useCallback(() => {
163
+ void instance.press();
164
+ }, [instance]);
165
+ return {
166
+ ...state,
167
+ press,
168
+ reset: () => instance.reset(),
169
+ props: {
170
+ onClick: (event) => {
171
+ if (!state.available) {
172
+ event.preventDefault();
173
+ return;
174
+ }
175
+ void instance.press();
176
+ },
177
+ "aria-disabled": !state.available,
178
+ "aria-busy": state.loading,
179
+ ...state.loading ? { "data-loading": "" } : {},
180
+ ...state.cooldown > 0 ? { "data-cooldown": "" } : {},
181
+ ...state.element === "button" ? { disabled: state.disabled } : { href: options.href },
182
+ ...state.shortcut ? { title: `Shortcut: ${state.shortcut.toUpperCase()}` } : {}
183
+ }
184
+ };
185
+ }
186
+
187
+ // src/react/link.ts
188
+ var linkComponent = null;
189
+ function setLinkComponent(component) {
190
+ linkComponent = component;
191
+ }
192
+ function getLinkComponent() {
193
+ return linkComponent ?? "a";
194
+ }
195
+ var Button = forwardRef(function Button2({
196
+ as,
197
+ children,
198
+ pending,
199
+ href,
200
+ disabled,
201
+ loading,
202
+ cooldown,
203
+ shortcut,
204
+ onPress,
205
+ onChange,
206
+ type = "button",
207
+ ...rest
208
+ }, ref) {
209
+ const button = useButton({ href, disabled, loading, cooldown, shortcut, onPress, onChange });
210
+ const state = button;
211
+ const Tag = as ?? (state.element === "a" ? getLinkComponent() : "button");
212
+ const native = Tag === "button" ? { type } : {};
213
+ const label = typeof children === "function" ? children(state) : children;
214
+ return createElement(
215
+ Tag,
216
+ { ref, ...button.props, ...native, ...rest },
217
+ state.loading && pending !== void 0 ? pending : label
218
+ );
219
+ });
220
+ var ICON_PROPS = {
221
+ viewBox: "0 0 24 24",
222
+ width: "1em",
223
+ height: "1em",
224
+ fill: "none",
225
+ stroke: "currentColor",
226
+ strokeWidth: 2,
227
+ strokeLinecap: "round",
228
+ strokeLinejoin: "round",
229
+ "aria-hidden": true
230
+ };
231
+ function Icon({ paths }) {
232
+ return /* @__PURE__ */ jsx("svg", { ...ICON_PROPS, children: paths.map((path) => /* @__PURE__ */ jsx("path", { d: path }, path)) });
233
+ }
234
+ function writeValue(input, next) {
235
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
236
+ if (setter) setter.call(input, next);
237
+ else input.value = next;
238
+ input.dispatchEvent(new Event("input", { bubbles: true }));
239
+ }
240
+ var Input = forwardRef(function Input2({
241
+ reveal,
242
+ revealLabels,
243
+ generate = false,
244
+ generateLabel = "Generate a password",
245
+ revealOnGenerate = true,
246
+ copyOnGenerate = false,
247
+ onGenerate,
248
+ actions = [],
249
+ position = "end",
250
+ strength = false,
251
+ onStrengthChange,
252
+ breach,
253
+ breachDelay = 500,
254
+ onBreachChange,
255
+ wrapperProps,
256
+ fieldProps,
257
+ classNames,
258
+ children,
259
+ type = "text",
260
+ onChange,
261
+ ...props
262
+ }, forwardedRef) {
263
+ const innerRef = useRef(null);
264
+ const [revealed, setRevealed] = useState(false);
265
+ const describedBy = useId();
266
+ const isPassword = type === "password";
267
+ const showReveal = reveal ?? isPassword;
268
+ const showGenerate = generate !== false && isPassword;
269
+ const controlled = props.value !== void 0;
270
+ const [ownValue, setOwnValue] = useState(String(props.defaultValue ?? ""));
271
+ const value = controlled ? String(props.value ?? "") : ownValue;
272
+ const handleChange = useCallback((event) => {
273
+ if (!controlled) setOwnValue(event.target.value);
274
+ onChange?.(event);
275
+ }, [controlled, onChange]);
276
+ const caret = useRef(null);
277
+ const toggleReveal = useCallback(() => {
278
+ const input = innerRef.current;
279
+ if (input && typeof document !== "undefined" && document.activeElement === input) {
280
+ try {
281
+ const { selectionStart, selectionEnd } = input;
282
+ caret.current = selectionStart === null || selectionEnd === null ? null : [selectionStart, selectionEnd];
283
+ } catch {
284
+ caret.current = null;
285
+ }
286
+ }
287
+ setRevealed((current) => !current);
288
+ }, []);
289
+ useEffect(() => {
290
+ const selection = caret.current;
291
+ if (!selection) return;
292
+ caret.current = null;
293
+ const restore = () => {
294
+ const input = innerRef.current;
295
+ if (!input || document.activeElement !== input) return;
296
+ try {
297
+ input.setSelectionRange(selection[0], selection[1]);
298
+ } catch {
299
+ }
300
+ };
301
+ restore();
302
+ const timer = setTimeout(restore, 0);
303
+ return () => clearTimeout(timer);
304
+ }, [revealed]);
305
+ const handleGenerate = useCallback(() => {
306
+ const input = innerRef.current;
307
+ if (!input) return;
308
+ const password = generatePassword(typeof generate === "object" ? generate : {});
309
+ writeValue(input, password);
310
+ if (revealOnGenerate) setRevealed(true);
311
+ if (copyOnGenerate) void navigator.clipboard?.writeText(password).catch(() => {
312
+ });
313
+ input.focus();
314
+ onGenerate?.(password);
315
+ }, [generate, revealOnGenerate, copyOnGenerate, onGenerate]);
316
+ const estimateOptions = typeof strength === "object" ? strength : void 0;
317
+ const userInputs = estimateOptions?.userInputs;
318
+ const report = useMemo(
319
+ () => strength === false ? null : estimatePasswordStrength(value, { userInputs }),
320
+ // The array is compared by identity, so a literal would re-estimate every render.
321
+ [strength, value, userInputs]
322
+ );
323
+ const strengthListener = useRef(onStrengthChange);
324
+ strengthListener.current = onStrengthChange;
325
+ useEffect(() => {
326
+ if (report) strengthListener.current?.(report);
327
+ }, [report]);
328
+ const [breachState, setBreachState] = useState({ status: "idle", count: 0, error: null });
329
+ const breachListener = useRef(onBreachChange);
330
+ breachListener.current = onBreachChange;
331
+ useEffect(() => {
332
+ if (!breach || !value) {
333
+ setBreachState({ status: "idle", count: 0, error: null });
334
+ return;
335
+ }
336
+ const controller = new AbortController();
337
+ const timer = setTimeout(() => {
338
+ setBreachState({ status: "checking", count: 0, error: null });
339
+ breach(value, { signal: controller.signal }).then((result) => {
340
+ if (controller.signal.aborted) return;
341
+ setBreachState({ status: result.breached ? "breached" : "safe", count: result.count, error: null });
342
+ }).catch((error) => {
343
+ if (controller.signal.aborted) return;
344
+ setBreachState({ status: "error", count: 0, error });
345
+ });
346
+ }, breachDelay);
347
+ return () => {
348
+ clearTimeout(timer);
349
+ controller.abort();
350
+ };
351
+ }, [breach, value, breachDelay]);
352
+ useEffect(() => {
353
+ breachListener.current?.(breachState);
354
+ }, [breachState]);
355
+ const builtIn = [];
356
+ if (showGenerate) {
357
+ builtIn.push({
358
+ name: "generate",
359
+ label: generateLabel,
360
+ icon: /* @__PURE__ */ jsx(Icon, { paths: INPUT_ICON_PATHS.generate }),
361
+ onSelect: handleGenerate
362
+ });
363
+ }
364
+ if (showReveal) {
365
+ builtIn.push({
366
+ name: "reveal",
367
+ label: revealed ? revealLabels?.hide ?? "Hide password" : revealLabels?.show ?? "Show password",
368
+ icon: /* @__PURE__ */ jsx(Icon, { paths: revealed ? INPUT_ICON_PATHS.eyeOff : INPUT_ICON_PATHS.eye }),
369
+ pressed: revealed,
370
+ onSelect: toggleReveal
371
+ });
372
+ }
373
+ const overridden = new Set(actions.map((action) => action.name));
374
+ const rendered = [...builtIn.filter((action) => !overridden.has(action.name)), ...actions].filter((action) => action.visible !== false);
375
+ const locked = props.disabled === true || props.readOnly === true;
376
+ const buttons = rendered.length === 0 ? null : /* @__PURE__ */ jsx("span", { "data-enigma-input-actions": "", "data-position": position, className: classNames?.actions, children: rendered.map((action) => /* @__PURE__ */ jsx(
377
+ "button",
378
+ {
379
+ type: "button",
380
+ "data-enigma-input-action": action.name,
381
+ className: classNames?.action,
382
+ "aria-label": action.label,
383
+ title: action.label,
384
+ "aria-pressed": action.pressed,
385
+ disabled: locked,
386
+ onMouseDown: (event) => event.preventDefault(),
387
+ onClick: action.onSelect,
388
+ children: action.icon
389
+ },
390
+ action.name
391
+ )) });
392
+ return /* @__PURE__ */ jsxs(
393
+ "div",
394
+ {
395
+ ...wrapperProps,
396
+ "data-enigma-input-root": "",
397
+ "data-revealed": revealed ? "" : void 0,
398
+ "data-breached": breachState.status === "breached" ? "" : void 0,
399
+ "data-score": report && !report.empty ? report.score : void 0,
400
+ children: [
401
+ /* @__PURE__ */ jsxs("div", { ...fieldProps, "data-enigma-input-field": "", children: [
402
+ position === "start" && buttons,
403
+ /* @__PURE__ */ jsx(
404
+ "input",
405
+ {
406
+ ...props,
407
+ ref: (node) => {
408
+ innerRef.current = node;
409
+ if (typeof forwardedRef === "function") forwardedRef(node);
410
+ else if (forwardedRef) forwardedRef.current = node;
411
+ },
412
+ type: revealed && isPassword ? "text" : type,
413
+ onChange: handleChange,
414
+ "data-enigma-input": "",
415
+ "aria-describedby": report ? describedBy : props["aria-describedby"]
416
+ }
417
+ ),
418
+ position === "end" && buttons
419
+ ] }),
420
+ report && /* @__PURE__ */ jsx(PasswordStrength, { id: describedBy, report, classNames: classNames?.strength }),
421
+ children
422
+ ]
423
+ }
424
+ );
425
+ });
426
+ var SCORE_LABELS = ["Very weak", "Weak", "Fair", "Strong", "Very strong"];
427
+ function PasswordStrength({
428
+ value = "",
429
+ report,
430
+ userInputs,
431
+ segments = 5,
432
+ labels = SCORE_LABELS,
433
+ showWarning = true,
434
+ classNames,
435
+ ...props
436
+ }) {
437
+ const computed = useMemo(
438
+ () => report ?? estimatePasswordStrength(value, { userInputs }),
439
+ [report, value, userInputs]
440
+ );
441
+ const score = computed.score;
442
+ return /* @__PURE__ */ jsxs(
443
+ "div",
444
+ {
445
+ ...props,
446
+ "data-enigma-password-strength": "",
447
+ "data-score": computed.empty ? void 0 : score,
448
+ "data-empty": computed.empty ? "" : void 0,
449
+ children: [
450
+ /* @__PURE__ */ jsx("div", { "data-enigma-password-strength-track": "", "aria-hidden": "true", className: classNames?.track, children: Array.from({ length: segments }, (unused, index) => /* @__PURE__ */ jsx(
451
+ "span",
452
+ {
453
+ "data-enigma-password-strength-segment": "",
454
+ className: classNames?.segment,
455
+ "data-filled": !computed.empty && index <= score ? "" : void 0
456
+ },
457
+ index
458
+ )) }),
459
+ /* @__PURE__ */ jsx("p", { "data-enigma-password-strength-label": "", role: "status", "aria-live": "polite", className: classNames?.label, children: computed.empty ? "" : labels[score] ?? "" }),
460
+ showWarning && !computed.empty && computed.warnings.length > 0 && /* @__PURE__ */ jsx("p", { "data-enigma-password-strength-warning": "", className: classNames?.warning, children: computed.warnings[0] })
461
+ ]
462
+ }
463
+ );
464
+ }
465
+
466
+ export { Button, Input, PasswordStrength, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useSearch };
@@ -0,0 +1,26 @@
1
+ import * as react from 'react';
2
+ import { ButtonProps } from '../react/index.js';
3
+ export { BreachChecker, BreachState, BreachStatus, FieldAction, Input, InputProps, PasswordStrength, PasswordStrengthProps, UseButtonResult, UseInputResult, UseMarqueeResult, UseSearchResult, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useSearch } from '../react/index.js';
4
+ export { ButtonOptions, ButtonState, EstimateOptions, GeneratePasswordOptions, InputAction, InputIcon, InputOptions, MarqueeHover, MarqueeInstance, MarqueeOptions, PasswordScore, PasswordStrengthReport, estimatePasswordStrength, generatePassword } from '../index.js';
5
+ export { F as FuseConstructor, b as SearchMatch, c as SearchOptions } from '../search-CsO3L1Lw.js';
6
+
7
+ /**
8
+ * The Next.js entry. Same components, wired to `next/link`.
9
+ *
10
+ * ```tsx
11
+ * import { Button } from "@enigmax/primitives/next";
12
+ *
13
+ * <Button href="/settings">Settings</Button> // renders next/link
14
+ * ```
15
+ *
16
+ * The import path is the configuration. There is nothing to register and nothing to pass at
17
+ * the call site, and a project that is not on Next never resolves this file - which is the
18
+ * whole reason the main entry cannot simply `import Link from "next/link"` itself: that
19
+ * import is a build error for every consumer on Vite, Astro, Remix or plain React.
20
+ *
21
+ * Everything else is re-exported unchanged, so this can be the only import path in a Next
22
+ * app rather than a second one to remember.
23
+ */
24
+ declare const Button: react.ForwardRefExoticComponent<ButtonProps & react.RefAttributes<HTMLElement>>;
25
+
26
+ export { Button, ButtonProps };
@@ -0,0 +1,13 @@
1
+ "use client";
2
+ import { Button } from '../chunk-L4KA342C.js';
3
+ export { Input, PasswordStrength, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useSearch } from '../chunk-L4KA342C.js';
4
+ export { estimatePasswordStrength, generatePassword } from '../chunk-42Y7OOJM.js';
5
+ import '../chunk-UZFEEFMF.js';
6
+ import Link from 'next/link';
7
+ import { forwardRef, createElement } from 'react';
8
+
9
+ var Button2 = forwardRef(function Button3(props, ref) {
10
+ return createElement(Button, { ...props, as: props.as ?? (props.href ? Link : void 0), ref });
11
+ });
12
+
13
+ export { Button2 as Button };
@@ -1,10 +1,45 @@
1
1
  import * as react from 'react';
2
- import { RefObject, ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
3
- import { MarqueeOptions, InputOptions, ButtonState, ButtonOptions, GeneratePasswordOptions, EstimateOptions, PasswordStrengthReport } from '../index.js';
2
+ import { ComponentPropsWithoutRef, ElementType, ReactNode, RefObject } from 'react';
3
+ import { ButtonOptions, ButtonState, MarqueeOptions, InputOptions, GeneratePasswordOptions, EstimateOptions, PasswordStrengthReport } from '../index.js';
4
4
  export { InputAction, InputIcon, MarqueeHover, MarqueeInstance, PasswordScore, estimatePasswordStrength, generatePassword } from '../index.js';
5
5
  import { b as SearchMatch, c as SearchOptions } from '../search-CsO3L1Lw.js';
6
6
  export { F as FuseConstructor } from '../search-CsO3L1Lw.js';
7
7
 
8
+ /**
9
+ * `<Button>` - the component, for the ninety percent.
10
+ *
11
+ * ```tsx
12
+ * <Button onPress={save}>Save</Button>
13
+ * ```
14
+ *
15
+ * That is the whole thing: disabled while the work runs, `aria-busy`, `data-loading`, and a
16
+ * cooldown afterwards if you asked for one. `useButton` is still there for a button whose
17
+ * markup is nothing like a button - a card, a table row - but reaching for it to render an
18
+ * ordinary one means writing the same six lines at every call site, which is how they drift.
19
+ */
20
+ interface ButtonProps extends ButtonOptions, Omit<ComponentPropsWithoutRef<"button">, "onClick" | "onChange" | "children" | "type"> {
21
+ /**
22
+ * Override what to render. You rarely need it: an href already renders whatever
23
+ * `setLinkComponent` registered - your router's Link - and a button otherwise.
24
+ */
25
+ as?: ElementType;
26
+ /**
27
+ * The label, or a function of the state for one that changes with it.
28
+ *
29
+ * ```tsx
30
+ * <Button onPress={send} cooldown={30_000}>
31
+ * {({ loading, cooldown }) => loading ? "Sending" : cooldown ? `Wait ${Math.ceil(cooldown / 1000)}s` : "Resend"}
32
+ * </Button>
33
+ * ```
34
+ */
35
+ children?: ReactNode | ((state: ButtonState) => ReactNode);
36
+ /** Replaces the label while async work runs - a spinner, or just different words. */
37
+ pending?: ReactNode;
38
+ /** `type` on a real button. Ignored on a link, which has none. */
39
+ type?: "button" | "submit" | "reset";
40
+ }
41
+ declare const Button: react.ForwardRefExoticComponent<ButtonProps & react.RefAttributes<HTMLElement>>;
42
+
8
43
  interface UseMarqueeResult {
9
44
  /** Attach to the clipping viewport. */
10
45
  laneRef: RefObject<HTMLElement | null>;
@@ -129,41 +164,6 @@ interface UseButtonResult extends ButtonState {
129
164
  */
130
165
  declare function useButton(options?: ButtonOptions): UseButtonResult;
131
166
 
132
- /**
133
- * `<Button>` - the component, for the ninety percent.
134
- *
135
- * ```tsx
136
- * <Button onPress={save}>Save</Button>
137
- * ```
138
- *
139
- * That is the whole thing: disabled while the work runs, `aria-busy`, `data-loading`, and a
140
- * cooldown afterwards if you asked for one. `useButton` is still there for a button whose
141
- * markup is nothing like a button - a card, a table row - but reaching for it to render an
142
- * ordinary one means writing the same six lines at every call site, which is how they drift.
143
- */
144
- interface ButtonProps extends ButtonOptions, Omit<ComponentPropsWithoutRef<"button">, "onClick" | "onChange" | "children" | "type"> {
145
- /**
146
- * Override what to render. You rarely need it: an href already renders whatever
147
- * `setLinkComponent` registered - your router's Link - and a button otherwise.
148
- */
149
- as?: ElementType;
150
- /**
151
- * The label, or a function of the state for one that changes with it.
152
- *
153
- * ```tsx
154
- * <Button onPress={send} cooldown={30_000}>
155
- * {({ loading, cooldown }) => loading ? "Sending" : cooldown ? `Wait ${Math.ceil(cooldown / 1000)}s` : "Resend"}
156
- * </Button>
157
- * ```
158
- */
159
- children?: ReactNode | ((state: ButtonState) => ReactNode);
160
- /** Replaces the label while async work runs - a spinner, or just different words. */
161
- pending?: ReactNode;
162
- /** `type` on a real button. Ignored on a link, which has none. */
163
- type?: "button" | "submit" | "reset";
164
- }
165
- declare const Button: react.ForwardRefExoticComponent<ButtonProps & react.RefAttributes<HTMLElement>>;
166
-
167
167
  /** Register the router's link. Pass null to go back to a plain anchor. */
168
168
  declare function setLinkComponent(component: ElementType | null): void;
169
169
  /** What an href should render as right now. */
@@ -1,468 +1,4 @@
1
1
  "use client";
2
- import { generatePassword, estimatePasswordStrength, INPUT_ICON_PATHS, createButton, createMarquee, createInput } from '../chunk-42Y7OOJM.js';
2
+ export { Button, Input, PasswordStrength, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useSearch } from '../chunk-L4KA342C.js';
3
3
  export { estimatePasswordStrength, generatePassword } from '../chunk-42Y7OOJM.js';
4
- import { createSearch } from '../chunk-UZFEEFMF.js';
5
- import { forwardRef, createElement, useRef, useState, useId, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
6
- import { jsx, jsxs } from 'react/jsx-runtime';
7
-
8
- var useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
9
- function useMarquee(options = {}) {
10
- const laneRef = useRef(null);
11
- const trackRef = useRef(null);
12
- const instanceRef = useRef(null);
13
- const optionsRef = useRef(options);
14
- optionsRef.current = options;
15
- const [copies, setCopies] = useState(2);
16
- const [dragging, setDragging] = useState(false);
17
- const [period, setPeriod] = useState(0);
18
- useIsomorphicLayoutEffect(() => {
19
- const lane = laneRef.current;
20
- const track = trackRef.current;
21
- if (!lane || !track) return;
22
- const instance = createMarquee(lane, track, {
23
- ...optionsRef.current,
24
- // React owns the DOM; cloning behind its back would be undone on render.
25
- copies: "external",
26
- onCopyCountChange: setCopies,
27
- onMeasure: (measured) => setPeriod(measured),
28
- onItemClick: (event) => optionsRef.current.onItemClick?.(event)
29
- });
30
- instanceRef.current = instance;
31
- const syncDragging = () => setDragging(instance.dragging);
32
- lane.addEventListener("pointerdown", syncDragging);
33
- window.addEventListener("pointerup", syncDragging);
34
- window.addEventListener("pointercancel", syncDragging);
35
- return () => {
36
- lane.removeEventListener("pointerdown", syncDragging);
37
- window.removeEventListener("pointerup", syncDragging);
38
- window.removeEventListener("pointercancel", syncDragging);
39
- instance.destroy();
40
- instanceRef.current = null;
41
- };
42
- }, []);
43
- useIsomorphicLayoutEffect(() => {
44
- instanceRef.current?.update(options);
45
- }, [options.speed, options.reverse, options.vertical, options.draggable, options.hover, options.decay]);
46
- useIsomorphicLayoutEffect(() => {
47
- instanceRef.current?.measure();
48
- }, [copies]);
49
- return {
50
- laneRef,
51
- trackRef,
52
- copies,
53
- dragging,
54
- period,
55
- measure: () => instanceRef.current?.measure()
56
- };
57
- }
58
- var useIsomorphicLayoutEffect2 = typeof window === "undefined" ? useEffect : useLayoutEffect;
59
- function useInput(options = {}) {
60
- const inputRef = useRef(null);
61
- const actionsRef = useRef(null);
62
- const instanceRef = useRef(null);
63
- const optionsRef = useRef(options);
64
- optionsRef.current = options;
65
- const [revealed, setRevealed] = useState(false);
66
- useIsomorphicLayoutEffect2(() => {
67
- const input = inputRef.current;
68
- if (!input) return;
69
- const instance = createInput(input, {
70
- ...optionsRef.current,
71
- container: actionsRef.current ?? optionsRef.current.container,
72
- onRevealChange: (next) => {
73
- setRevealed(next);
74
- optionsRef.current.onRevealChange?.(next);
75
- }
76
- });
77
- instanceRef.current = instance;
78
- return () => {
79
- instance.destroy();
80
- instanceRef.current = null;
81
- };
82
- }, []);
83
- useIsomorphicLayoutEffect2(() => {
84
- instanceRef.current?.update(options);
85
- }, [options.reveal, options.actions, options.position]);
86
- return {
87
- inputRef,
88
- actionsRef,
89
- revealed,
90
- reveal: (next) => instanceRef.current?.reveal(next),
91
- refresh: () => instanceRef.current?.refresh()
92
- };
93
- }
94
- function useSearch(options = {}) {
95
- const inputRef = useRef(null);
96
- const instanceRef = useRef(null);
97
- const optionsRef = useRef(options);
98
- optionsRef.current = options;
99
- const [query, setQuery] = useState("");
100
- const [results, setResults] = useState([]);
101
- const instance = useMemo(() => {
102
- const created = createSearch({
103
- ...optionsRef.current,
104
- onResults: (next, nextQuery) => {
105
- setResults(next);
106
- setQuery(nextQuery);
107
- optionsRef.current.onResults?.(next, nextQuery);
108
- }
109
- });
110
- instanceRef.current = created;
111
- return created;
112
- }, []);
113
- useEffect(() => () => instance.destroy(), [instance]);
114
- useEffect(() => {
115
- instance.setItems(options.items ?? []);
116
- }, [instance, options.items]);
117
- useEffect(() => {
118
- const input = inputRef.current;
119
- if (!input) return;
120
- return instance.attach(input);
121
- }, [instance]);
122
- return {
123
- inputRef,
124
- query,
125
- results,
126
- search: (next) => instance.search(next),
127
- searchNow: (next) => {
128
- instance.searchNow(next);
129
- },
130
- clear: () => {
131
- if (inputRef.current) inputRef.current.value = "";
132
- instance.searchNow("");
133
- }
134
- };
135
- }
136
- function useButton(options = {}) {
137
- const optionsRef = useRef(options);
138
- optionsRef.current = options;
139
- const instance = useMemo(() => createButton({
140
- ...optionsRef.current,
141
- onPress: (event) => optionsRef.current.onPress?.(event),
142
- onChange: (next) => optionsRef.current.onChange?.(next)
143
- // Built once: recreating it would drop a running cooldown on every render.
144
- // eslint-disable-next-line react-hooks/exhaustive-deps
145
- }), []);
146
- const [state, setState] = useState(() => instance.state);
147
- useEffect(() => {
148
- const unsubscribe = instance.subscribe(setState);
149
- setState(instance.state);
150
- return () => {
151
- unsubscribe();
152
- instance.destroy();
153
- };
154
- }, [instance]);
155
- useEffect(() => {
156
- instance.update({
157
- href: options.href,
158
- disabled: options.disabled,
159
- loading: options.loading,
160
- cooldown: options.cooldown,
161
- shortcut: options.shortcut
162
- });
163
- }, [instance, options.href, options.disabled, options.loading, options.cooldown, options.shortcut]);
164
- const press = useCallback(() => {
165
- void instance.press();
166
- }, [instance]);
167
- return {
168
- ...state,
169
- press,
170
- reset: () => instance.reset(),
171
- props: {
172
- onClick: (event) => {
173
- if (!state.available) {
174
- event.preventDefault();
175
- return;
176
- }
177
- void instance.press();
178
- },
179
- "aria-disabled": !state.available,
180
- "aria-busy": state.loading,
181
- ...state.loading ? { "data-loading": "" } : {},
182
- ...state.cooldown > 0 ? { "data-cooldown": "" } : {},
183
- ...state.element === "button" ? { disabled: state.disabled } : { href: options.href },
184
- ...state.shortcut ? { title: `Shortcut: ${state.shortcut.toUpperCase()}` } : {}
185
- }
186
- };
187
- }
188
-
189
- // src/react/link.ts
190
- var linkComponent = null;
191
- function setLinkComponent(component) {
192
- linkComponent = component;
193
- }
194
- function getLinkComponent() {
195
- return linkComponent ?? "a";
196
- }
197
- var Button = forwardRef(function Button2({
198
- as,
199
- children,
200
- pending,
201
- href,
202
- disabled,
203
- loading,
204
- cooldown,
205
- shortcut,
206
- onPress,
207
- onChange,
208
- type = "button",
209
- ...rest
210
- }, ref) {
211
- const button = useButton({ href, disabled, loading, cooldown, shortcut, onPress, onChange });
212
- const state = button;
213
- const Tag = as ?? (state.element === "a" ? getLinkComponent() : "button");
214
- const native = Tag === "button" ? { type } : {};
215
- const label = typeof children === "function" ? children(state) : children;
216
- return createElement(
217
- Tag,
218
- { ref, ...button.props, ...native, ...rest },
219
- state.loading && pending !== void 0 ? pending : label
220
- );
221
- });
222
- var ICON_PROPS = {
223
- viewBox: "0 0 24 24",
224
- width: "1em",
225
- height: "1em",
226
- fill: "none",
227
- stroke: "currentColor",
228
- strokeWidth: 2,
229
- strokeLinecap: "round",
230
- strokeLinejoin: "round",
231
- "aria-hidden": true
232
- };
233
- function Icon({ paths }) {
234
- return /* @__PURE__ */ jsx("svg", { ...ICON_PROPS, children: paths.map((path) => /* @__PURE__ */ jsx("path", { d: path }, path)) });
235
- }
236
- function writeValue(input, next) {
237
- const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
238
- if (setter) setter.call(input, next);
239
- else input.value = next;
240
- input.dispatchEvent(new Event("input", { bubbles: true }));
241
- }
242
- var Input = forwardRef(function Input2({
243
- reveal,
244
- revealLabels,
245
- generate = false,
246
- generateLabel = "Generate a password",
247
- revealOnGenerate = true,
248
- copyOnGenerate = false,
249
- onGenerate,
250
- actions = [],
251
- position = "end",
252
- strength = false,
253
- onStrengthChange,
254
- breach,
255
- breachDelay = 500,
256
- onBreachChange,
257
- wrapperProps,
258
- fieldProps,
259
- classNames,
260
- children,
261
- type = "text",
262
- onChange,
263
- ...props
264
- }, forwardedRef) {
265
- const innerRef = useRef(null);
266
- const [revealed, setRevealed] = useState(false);
267
- const describedBy = useId();
268
- const isPassword = type === "password";
269
- const showReveal = reveal ?? isPassword;
270
- const showGenerate = generate !== false && isPassword;
271
- const controlled = props.value !== void 0;
272
- const [ownValue, setOwnValue] = useState(String(props.defaultValue ?? ""));
273
- const value = controlled ? String(props.value ?? "") : ownValue;
274
- const handleChange = useCallback((event) => {
275
- if (!controlled) setOwnValue(event.target.value);
276
- onChange?.(event);
277
- }, [controlled, onChange]);
278
- const caret = useRef(null);
279
- const toggleReveal = useCallback(() => {
280
- const input = innerRef.current;
281
- if (input && typeof document !== "undefined" && document.activeElement === input) {
282
- try {
283
- const { selectionStart, selectionEnd } = input;
284
- caret.current = selectionStart === null || selectionEnd === null ? null : [selectionStart, selectionEnd];
285
- } catch {
286
- caret.current = null;
287
- }
288
- }
289
- setRevealed((current) => !current);
290
- }, []);
291
- useEffect(() => {
292
- const selection = caret.current;
293
- if (!selection) return;
294
- caret.current = null;
295
- const restore = () => {
296
- const input = innerRef.current;
297
- if (!input || document.activeElement !== input) return;
298
- try {
299
- input.setSelectionRange(selection[0], selection[1]);
300
- } catch {
301
- }
302
- };
303
- restore();
304
- const timer = setTimeout(restore, 0);
305
- return () => clearTimeout(timer);
306
- }, [revealed]);
307
- const handleGenerate = useCallback(() => {
308
- const input = innerRef.current;
309
- if (!input) return;
310
- const password = generatePassword(typeof generate === "object" ? generate : {});
311
- writeValue(input, password);
312
- if (revealOnGenerate) setRevealed(true);
313
- if (copyOnGenerate) void navigator.clipboard?.writeText(password).catch(() => {
314
- });
315
- input.focus();
316
- onGenerate?.(password);
317
- }, [generate, revealOnGenerate, copyOnGenerate, onGenerate]);
318
- const estimateOptions = typeof strength === "object" ? strength : void 0;
319
- const userInputs = estimateOptions?.userInputs;
320
- const report = useMemo(
321
- () => strength === false ? null : estimatePasswordStrength(value, { userInputs }),
322
- // The array is compared by identity, so a literal would re-estimate every render.
323
- [strength, value, userInputs]
324
- );
325
- const strengthListener = useRef(onStrengthChange);
326
- strengthListener.current = onStrengthChange;
327
- useEffect(() => {
328
- if (report) strengthListener.current?.(report);
329
- }, [report]);
330
- const [breachState, setBreachState] = useState({ status: "idle", count: 0, error: null });
331
- const breachListener = useRef(onBreachChange);
332
- breachListener.current = onBreachChange;
333
- useEffect(() => {
334
- if (!breach || !value) {
335
- setBreachState({ status: "idle", count: 0, error: null });
336
- return;
337
- }
338
- const controller = new AbortController();
339
- const timer = setTimeout(() => {
340
- setBreachState({ status: "checking", count: 0, error: null });
341
- breach(value, { signal: controller.signal }).then((result) => {
342
- if (controller.signal.aborted) return;
343
- setBreachState({ status: result.breached ? "breached" : "safe", count: result.count, error: null });
344
- }).catch((error) => {
345
- if (controller.signal.aborted) return;
346
- setBreachState({ status: "error", count: 0, error });
347
- });
348
- }, breachDelay);
349
- return () => {
350
- clearTimeout(timer);
351
- controller.abort();
352
- };
353
- }, [breach, value, breachDelay]);
354
- useEffect(() => {
355
- breachListener.current?.(breachState);
356
- }, [breachState]);
357
- const builtIn = [];
358
- if (showGenerate) {
359
- builtIn.push({
360
- name: "generate",
361
- label: generateLabel,
362
- icon: /* @__PURE__ */ jsx(Icon, { paths: INPUT_ICON_PATHS.generate }),
363
- onSelect: handleGenerate
364
- });
365
- }
366
- if (showReveal) {
367
- builtIn.push({
368
- name: "reveal",
369
- label: revealed ? revealLabels?.hide ?? "Hide password" : revealLabels?.show ?? "Show password",
370
- icon: /* @__PURE__ */ jsx(Icon, { paths: revealed ? INPUT_ICON_PATHS.eyeOff : INPUT_ICON_PATHS.eye }),
371
- pressed: revealed,
372
- onSelect: toggleReveal
373
- });
374
- }
375
- const overridden = new Set(actions.map((action) => action.name));
376
- const rendered = [...builtIn.filter((action) => !overridden.has(action.name)), ...actions].filter((action) => action.visible !== false);
377
- const locked = props.disabled === true || props.readOnly === true;
378
- const buttons = rendered.length === 0 ? null : /* @__PURE__ */ jsx("span", { "data-enigma-input-actions": "", "data-position": position, className: classNames?.actions, children: rendered.map((action) => /* @__PURE__ */ jsx(
379
- "button",
380
- {
381
- type: "button",
382
- "data-enigma-input-action": action.name,
383
- className: classNames?.action,
384
- "aria-label": action.label,
385
- title: action.label,
386
- "aria-pressed": action.pressed,
387
- disabled: locked,
388
- onMouseDown: (event) => event.preventDefault(),
389
- onClick: action.onSelect,
390
- children: action.icon
391
- },
392
- action.name
393
- )) });
394
- return /* @__PURE__ */ jsxs(
395
- "div",
396
- {
397
- ...wrapperProps,
398
- "data-enigma-input-root": "",
399
- "data-revealed": revealed ? "" : void 0,
400
- "data-breached": breachState.status === "breached" ? "" : void 0,
401
- "data-score": report && !report.empty ? report.score : void 0,
402
- children: [
403
- /* @__PURE__ */ jsxs("div", { ...fieldProps, "data-enigma-input-field": "", children: [
404
- position === "start" && buttons,
405
- /* @__PURE__ */ jsx(
406
- "input",
407
- {
408
- ...props,
409
- ref: (node) => {
410
- innerRef.current = node;
411
- if (typeof forwardedRef === "function") forwardedRef(node);
412
- else if (forwardedRef) forwardedRef.current = node;
413
- },
414
- type: revealed && isPassword ? "text" : type,
415
- onChange: handleChange,
416
- "data-enigma-input": "",
417
- "aria-describedby": report ? describedBy : props["aria-describedby"]
418
- }
419
- ),
420
- position === "end" && buttons
421
- ] }),
422
- report && /* @__PURE__ */ jsx(PasswordStrength, { id: describedBy, report, classNames: classNames?.strength }),
423
- children
424
- ]
425
- }
426
- );
427
- });
428
- var SCORE_LABELS = ["Very weak", "Weak", "Fair", "Strong", "Very strong"];
429
- function PasswordStrength({
430
- value = "",
431
- report,
432
- userInputs,
433
- segments = 5,
434
- labels = SCORE_LABELS,
435
- showWarning = true,
436
- classNames,
437
- ...props
438
- }) {
439
- const computed = useMemo(
440
- () => report ?? estimatePasswordStrength(value, { userInputs }),
441
- [report, value, userInputs]
442
- );
443
- const score = computed.score;
444
- return /* @__PURE__ */ jsxs(
445
- "div",
446
- {
447
- ...props,
448
- "data-enigma-password-strength": "",
449
- "data-score": computed.empty ? void 0 : score,
450
- "data-empty": computed.empty ? "" : void 0,
451
- children: [
452
- /* @__PURE__ */ jsx("div", { "data-enigma-password-strength-track": "", "aria-hidden": "true", className: classNames?.track, children: Array.from({ length: segments }, (unused, index) => /* @__PURE__ */ jsx(
453
- "span",
454
- {
455
- "data-enigma-password-strength-segment": "",
456
- className: classNames?.segment,
457
- "data-filled": !computed.empty && index <= score ? "" : void 0
458
- },
459
- index
460
- )) }),
461
- /* @__PURE__ */ jsx("p", { "data-enigma-password-strength-label": "", role: "status", "aria-live": "polite", className: classNames?.label, children: computed.empty ? "" : labels[score] ?? "" }),
462
- showWarning && !computed.empty && computed.warnings.length > 0 && /* @__PURE__ */ jsx("p", { "data-enigma-password-strength-warning": "", className: classNames?.warning, children: computed.warnings[0] })
463
- ]
464
- }
465
- );
466
- }
467
-
468
- export { Button, Input, PasswordStrength, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useSearch };
4
+ import '../chunk-UZFEEFMF.js';
@@ -0,0 +1,9 @@
1
+ import * as react from 'react';
2
+ import { ButtonProps } from '../react/index.js';
3
+ export { BreachChecker, BreachState, BreachStatus, FieldAction, Input, InputProps, PasswordStrength, PasswordStrengthProps, UseButtonResult, UseInputResult, UseMarqueeResult, UseSearchResult, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useSearch } from '../react/index.js';
4
+ export { ButtonOptions, ButtonState, EstimateOptions, GeneratePasswordOptions, InputAction, InputIcon, InputOptions, MarqueeHover, MarqueeInstance, MarqueeOptions, PasswordScore, PasswordStrengthReport, estimatePasswordStrength, generatePassword } from '../index.js';
5
+ export { F as FuseConstructor, b as SearchMatch, c as SearchOptions } from '../search-CsO3L1Lw.js';
6
+
7
+ declare const Button: react.ForwardRefExoticComponent<ButtonProps & react.RefAttributes<HTMLElement>>;
8
+
9
+ export { Button, ButtonProps };
@@ -0,0 +1,16 @@
1
+ "use client";
2
+ import { Button } from '../chunk-L4KA342C.js';
3
+ export { Input, PasswordStrength, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useSearch } from '../chunk-L4KA342C.js';
4
+ export { estimatePasswordStrength, generatePassword } from '../chunk-42Y7OOJM.js';
5
+ import '../chunk-UZFEEFMF.js';
6
+ import { Link } from 'react-router';
7
+ import { forwardRef, createElement } from 'react';
8
+
9
+ var RouterLink = forwardRef(function RouterLink2({ href = "", ...props }, ref) {
10
+ return createElement(Link, { ...props, to: href, ref });
11
+ });
12
+ var Button2 = forwardRef(function Button3(props, ref) {
13
+ return createElement(Button, { ...props, as: props.as ?? (props.href ? RouterLink : void 0), ref });
14
+ });
15
+
16
+ export { Button2 as Button };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/primitives",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
@@ -18,13 +18,21 @@
18
18
  "./search": {
19
19
  "types": "./dist/search/index.d.ts",
20
20
  "import": "./dist/search/index.js"
21
+ },
22
+ "./next": {
23
+ "types": "./dist/next/index.d.ts",
24
+ "import": "./dist/next/index.js"
25
+ },
26
+ "./react-router": {
27
+ "types": "./dist/react-router/index.d.ts",
28
+ "import": "./dist/react-router/index.js"
21
29
  }
22
30
  },
23
31
  "scripts": {
24
32
  "build": "tsup",
25
33
  "typecheck": "tsc --noEmit",
26
34
  "test": "npm run test:fixtures && playwright test",
27
- "test:fixtures": "esbuild test/fixture/react.tsx --bundle --format=esm --jsx=automatic --outfile=test/fixture/build/react.js --tsconfig=tsconfig.json",
35
+ "test:fixtures": "esbuild test/fixture/react.tsx --bundle --format=esm --jsx=automatic --outfile=test/fixture/build/react.js --tsconfig=tsconfig.json --alias:next/link=./test/fixture/next-link-stub.tsx --alias:react-router=./test/fixture/react-router-stub.tsx",
28
36
  "test:install": "playwright install chromium",
29
37
  "prepublishOnly": "npm run typecheck && npm run build"
30
38
  },
@@ -38,7 +46,9 @@
38
46
  ],
39
47
  "peerDependencies": {
40
48
  "fuse.js": ">=6",
41
- "react": ">=18"
49
+ "next": ">=13",
50
+ "react": ">=18",
51
+ "react-router": ">=7"
42
52
  },
43
53
  "peerDependenciesMeta": {
44
54
  "react": {
@@ -46,6 +56,12 @@
46
56
  },
47
57
  "fuse.js": {
48
58
  "optional": true
59
+ },
60
+ "next": {
61
+ "optional": true
62
+ },
63
+ "react-router": {
64
+ "optional": true
49
65
  }
50
66
  },
51
67
  "devDependencies": {
@@ -53,8 +69,10 @@
53
69
  "@types/react": "^19.2.0",
54
70
  "@types/react-dom": "^19.2.4",
55
71
  "fuse.js": "^7.0.0",
72
+ "next": "^15.5.23",
56
73
  "react": "^19.2.0",
57
74
  "react-dom": "^19.2.8",
75
+ "react-router": "^7.18.2",
58
76
  "tsup": "^8.0.0",
59
77
  "typescript": "^5.0.0"
60
78
  },
@@ -0,0 +1,32 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { forwardRef, createElement } from "react";
5
+ import { Button as BaseButton, type ButtonProps } from "@/react/button";
6
+
7
+ /**
8
+ * The Next.js entry. Same components, wired to `next/link`.
9
+ *
10
+ * ```tsx
11
+ * import { Button } from "@enigmax/primitives/next";
12
+ *
13
+ * <Button href="/settings">Settings</Button> // renders next/link
14
+ * ```
15
+ *
16
+ * The import path is the configuration. There is nothing to register and nothing to pass at
17
+ * the call site, and a project that is not on Next never resolves this file - which is the
18
+ * whole reason the main entry cannot simply `import Link from "next/link"` itself: that
19
+ * import is a build error for every consumer on Vite, Astro, Remix or plain React.
20
+ *
21
+ * Everything else is re-exported unchanged, so this can be the only import path in a Next
22
+ * app rather than a second one to remember.
23
+ */
24
+
25
+ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button(props, ref) {
26
+ // Only an href makes it a link; a plain button must not be handed a router component.
27
+ return createElement(BaseButton, { ...props, as: props.as ?? (props.href ? Link : undefined), ref });
28
+ });
29
+
30
+ // `export *` skips names this module exports itself, so the Button above is the one a
31
+ // consumer gets and everything else comes straight from the React entry.
32
+ export * from "@/react/index";
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { Link } from "react-router";
4
+ import { forwardRef, createElement } from "react";
5
+ import { Button as BaseButton, type ButtonProps } from "@/react/button";
6
+
7
+ /**
8
+ * The React Router entry. Same components, wired to its `Link`.
9
+ *
10
+ * ```tsx
11
+ * import { Button } from "@enigmax/primitives/react-router";
12
+ *
13
+ * <Button href="/settings">Settings</Button> // client navigation
14
+ * ```
15
+ *
16
+ * Same deal as the Next entry: the import path is the configuration, and a subpath is only
17
+ * ever resolved by a project that HAS the router - which is what lets a framework-agnostic
18
+ * package reference one at all.
19
+ *
20
+ * React Router names its target `to`, not `href`, so the prop is translated below. That
21
+ * translation is the whole reason this is a file rather than a line in the docs.
22
+ *
23
+ * React Aria solves the same problem the other way, with a `RouterProvider` taking the
24
+ * router's `navigate` (react-spectrum.adobe.com/react-aria/routing.html). One mechanism
25
+ * covers every router and it always renders a real anchor, but it is still a line of setup
26
+ * in the app. `setLinkComponent` is this package's version of that, for a router with no
27
+ * entry of its own here.
28
+ */
29
+
30
+ /** Bridges the two names, so a call site writes `href` whatever the router underneath is. */
31
+ const RouterLink = forwardRef<HTMLAnchorElement, { href?: string; }>(function RouterLink({ href = "", ...props }, ref) {
32
+ return createElement(Link, { ...props, to: href, ref });
33
+ });
34
+
35
+ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button(props, ref) {
36
+ // Only an href makes it a link; a plain button must not be handed a router component.
37
+ return createElement(BaseButton, { ...props, as: props.as ?? (props.href ? RouterLink : undefined), ref });
38
+ });
39
+
40
+ export * from "@/react/index";