@enigmax/primitives 0.6.0 → 0.8.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.
@@ -1,6 +1,9 @@
1
- import { createMarquee, createInput, createButton } from '../chunk-WCB7V7XO.js';
1
+ "use client";
2
+ import { generatePassword, estimatePasswordStrength, INPUT_ICON_PATHS, createButton, createMarquee, createInput } from '../chunk-42Y7OOJM.js';
3
+ export { estimatePasswordStrength, generatePassword } from '../chunk-42Y7OOJM.js';
2
4
  import { createSearch } from '../chunk-UZFEEFMF.js';
3
- import { useRef, useState, useMemo, useEffect, useCallback, useLayoutEffect } from 'react';
5
+ import { forwardRef, createElement, useRef, useState, useId, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
6
+ import { jsx, jsxs } from 'react/jsx-runtime';
4
7
 
5
8
  var useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
6
9
  function useMarquee(options = {}) {
@@ -182,5 +185,275 @@ function useButton(options = {}) {
182
185
  }
183
186
  };
184
187
  }
188
+ var Button = forwardRef(function Button2({
189
+ as,
190
+ children,
191
+ pending,
192
+ href,
193
+ disabled,
194
+ loading,
195
+ cooldown,
196
+ shortcut,
197
+ onPress,
198
+ onChange,
199
+ type = "button",
200
+ ...rest
201
+ }, ref) {
202
+ const button = useButton({ href, disabled, loading, cooldown, shortcut, onPress, onChange });
203
+ const state = button;
204
+ const Tag = as ?? state.element;
205
+ const native = Tag === "button" ? { type } : {};
206
+ const label = typeof children === "function" ? children(state) : children;
207
+ return createElement(
208
+ Tag,
209
+ { ref, ...button.props, ...native, ...rest },
210
+ state.loading && pending !== void 0 ? pending : label
211
+ );
212
+ });
213
+ var ICON_PROPS = {
214
+ viewBox: "0 0 24 24",
215
+ width: "1em",
216
+ height: "1em",
217
+ fill: "none",
218
+ stroke: "currentColor",
219
+ strokeWidth: 2,
220
+ strokeLinecap: "round",
221
+ strokeLinejoin: "round",
222
+ "aria-hidden": true
223
+ };
224
+ function Icon({ paths }) {
225
+ return /* @__PURE__ */ jsx("svg", { ...ICON_PROPS, children: paths.map((path) => /* @__PURE__ */ jsx("path", { d: path }, path)) });
226
+ }
227
+ function writeValue(input, next) {
228
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
229
+ if (setter) setter.call(input, next);
230
+ else input.value = next;
231
+ input.dispatchEvent(new Event("input", { bubbles: true }));
232
+ }
233
+ var Input = forwardRef(function Input2({
234
+ reveal,
235
+ revealLabels,
236
+ generate = false,
237
+ generateLabel = "Generate a password",
238
+ revealOnGenerate = true,
239
+ copyOnGenerate = false,
240
+ onGenerate,
241
+ actions = [],
242
+ position = "end",
243
+ strength = false,
244
+ onStrengthChange,
245
+ breach,
246
+ breachDelay = 500,
247
+ onBreachChange,
248
+ wrapperProps,
249
+ fieldProps,
250
+ classNames,
251
+ children,
252
+ type = "text",
253
+ onChange,
254
+ ...props
255
+ }, forwardedRef) {
256
+ const innerRef = useRef(null);
257
+ const [revealed, setRevealed] = useState(false);
258
+ const describedBy = useId();
259
+ const isPassword = type === "password";
260
+ const showReveal = reveal ?? isPassword;
261
+ const showGenerate = generate !== false && isPassword;
262
+ const controlled = props.value !== void 0;
263
+ const [ownValue, setOwnValue] = useState(String(props.defaultValue ?? ""));
264
+ const value = controlled ? String(props.value ?? "") : ownValue;
265
+ const handleChange = useCallback((event) => {
266
+ if (!controlled) setOwnValue(event.target.value);
267
+ onChange?.(event);
268
+ }, [controlled, onChange]);
269
+ const caret = useRef(null);
270
+ const toggleReveal = useCallback(() => {
271
+ const input = innerRef.current;
272
+ if (input && typeof document !== "undefined" && document.activeElement === input) {
273
+ try {
274
+ const { selectionStart, selectionEnd } = input;
275
+ caret.current = selectionStart === null || selectionEnd === null ? null : [selectionStart, selectionEnd];
276
+ } catch {
277
+ caret.current = null;
278
+ }
279
+ }
280
+ setRevealed((current) => !current);
281
+ }, []);
282
+ useEffect(() => {
283
+ const selection = caret.current;
284
+ if (!selection) return;
285
+ caret.current = null;
286
+ const restore = () => {
287
+ const input = innerRef.current;
288
+ if (!input || document.activeElement !== input) return;
289
+ try {
290
+ input.setSelectionRange(selection[0], selection[1]);
291
+ } catch {
292
+ }
293
+ };
294
+ restore();
295
+ const timer = setTimeout(restore, 0);
296
+ return () => clearTimeout(timer);
297
+ }, [revealed]);
298
+ const handleGenerate = useCallback(() => {
299
+ const input = innerRef.current;
300
+ if (!input) return;
301
+ const password = generatePassword(typeof generate === "object" ? generate : {});
302
+ writeValue(input, password);
303
+ if (revealOnGenerate) setRevealed(true);
304
+ if (copyOnGenerate) void navigator.clipboard?.writeText(password).catch(() => {
305
+ });
306
+ input.focus();
307
+ onGenerate?.(password);
308
+ }, [generate, revealOnGenerate, copyOnGenerate, onGenerate]);
309
+ const estimateOptions = typeof strength === "object" ? strength : void 0;
310
+ const userInputs = estimateOptions?.userInputs;
311
+ const report = useMemo(
312
+ () => strength === false ? null : estimatePasswordStrength(value, { userInputs }),
313
+ // The array is compared by identity, so a literal would re-estimate every render.
314
+ [strength, value, userInputs]
315
+ );
316
+ const strengthListener = useRef(onStrengthChange);
317
+ strengthListener.current = onStrengthChange;
318
+ useEffect(() => {
319
+ if (report) strengthListener.current?.(report);
320
+ }, [report]);
321
+ const [breachState, setBreachState] = useState({ status: "idle", count: 0, error: null });
322
+ const breachListener = useRef(onBreachChange);
323
+ breachListener.current = onBreachChange;
324
+ useEffect(() => {
325
+ if (!breach || !value) {
326
+ setBreachState({ status: "idle", count: 0, error: null });
327
+ return;
328
+ }
329
+ const controller = new AbortController();
330
+ const timer = setTimeout(() => {
331
+ setBreachState({ status: "checking", count: 0, error: null });
332
+ breach(value, { signal: controller.signal }).then((result) => {
333
+ if (controller.signal.aborted) return;
334
+ setBreachState({ status: result.breached ? "breached" : "safe", count: result.count, error: null });
335
+ }).catch((error) => {
336
+ if (controller.signal.aborted) return;
337
+ setBreachState({ status: "error", count: 0, error });
338
+ });
339
+ }, breachDelay);
340
+ return () => {
341
+ clearTimeout(timer);
342
+ controller.abort();
343
+ };
344
+ }, [breach, value, breachDelay]);
345
+ useEffect(() => {
346
+ breachListener.current?.(breachState);
347
+ }, [breachState]);
348
+ const builtIn = [];
349
+ if (showGenerate) {
350
+ builtIn.push({
351
+ name: "generate",
352
+ label: generateLabel,
353
+ icon: /* @__PURE__ */ jsx(Icon, { paths: INPUT_ICON_PATHS.generate }),
354
+ onSelect: handleGenerate
355
+ });
356
+ }
357
+ if (showReveal) {
358
+ builtIn.push({
359
+ name: "reveal",
360
+ label: revealed ? revealLabels?.hide ?? "Hide password" : revealLabels?.show ?? "Show password",
361
+ icon: /* @__PURE__ */ jsx(Icon, { paths: revealed ? INPUT_ICON_PATHS.eyeOff : INPUT_ICON_PATHS.eye }),
362
+ pressed: revealed,
363
+ onSelect: toggleReveal
364
+ });
365
+ }
366
+ const overridden = new Set(actions.map((action) => action.name));
367
+ const rendered = [...builtIn.filter((action) => !overridden.has(action.name)), ...actions].filter((action) => action.visible !== false);
368
+ const locked = props.disabled === true || props.readOnly === true;
369
+ 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(
370
+ "button",
371
+ {
372
+ type: "button",
373
+ "data-enigma-input-action": action.name,
374
+ className: classNames?.action,
375
+ "aria-label": action.label,
376
+ title: action.label,
377
+ "aria-pressed": action.pressed,
378
+ disabled: locked,
379
+ onMouseDown: (event) => event.preventDefault(),
380
+ onClick: action.onSelect,
381
+ children: action.icon
382
+ },
383
+ action.name
384
+ )) });
385
+ return /* @__PURE__ */ jsxs(
386
+ "div",
387
+ {
388
+ ...wrapperProps,
389
+ "data-enigma-input-root": "",
390
+ "data-revealed": revealed ? "" : void 0,
391
+ "data-breached": breachState.status === "breached" ? "" : void 0,
392
+ "data-score": report && !report.empty ? report.score : void 0,
393
+ children: [
394
+ /* @__PURE__ */ jsxs("div", { ...fieldProps, "data-enigma-input-field": "", children: [
395
+ position === "start" && buttons,
396
+ /* @__PURE__ */ jsx(
397
+ "input",
398
+ {
399
+ ...props,
400
+ ref: (node) => {
401
+ innerRef.current = node;
402
+ if (typeof forwardedRef === "function") forwardedRef(node);
403
+ else if (forwardedRef) forwardedRef.current = node;
404
+ },
405
+ type: revealed && isPassword ? "text" : type,
406
+ onChange: handleChange,
407
+ "data-enigma-input": "",
408
+ "aria-describedby": report ? describedBy : props["aria-describedby"]
409
+ }
410
+ ),
411
+ position === "end" && buttons
412
+ ] }),
413
+ report && /* @__PURE__ */ jsx(PasswordStrength, { id: describedBy, report, classNames: classNames?.strength }),
414
+ children
415
+ ]
416
+ }
417
+ );
418
+ });
419
+ var SCORE_LABELS = ["Very weak", "Weak", "Fair", "Strong", "Very strong"];
420
+ function PasswordStrength({
421
+ value = "",
422
+ report,
423
+ userInputs,
424
+ segments = 5,
425
+ labels = SCORE_LABELS,
426
+ showWarning = true,
427
+ classNames,
428
+ ...props
429
+ }) {
430
+ const computed = useMemo(
431
+ () => report ?? estimatePasswordStrength(value, { userInputs }),
432
+ [report, value, userInputs]
433
+ );
434
+ const score = computed.score;
435
+ return /* @__PURE__ */ jsxs(
436
+ "div",
437
+ {
438
+ ...props,
439
+ "data-enigma-password-strength": "",
440
+ "data-score": computed.empty ? void 0 : score,
441
+ "data-empty": computed.empty ? "" : void 0,
442
+ children: [
443
+ /* @__PURE__ */ jsx("div", { "data-enigma-password-strength-track": "", "aria-hidden": "true", className: classNames?.track, children: Array.from({ length: segments }, (unused, index) => /* @__PURE__ */ jsx(
444
+ "span",
445
+ {
446
+ "data-enigma-password-strength-segment": "",
447
+ className: classNames?.segment,
448
+ "data-filled": !computed.empty && index <= score ? "" : void 0
449
+ },
450
+ index
451
+ )) }),
452
+ /* @__PURE__ */ jsx("p", { "data-enigma-password-strength-label": "", role: "status", "aria-live": "polite", className: classNames?.label, children: computed.empty ? "" : labels[score] ?? "" }),
453
+ showWarning && !computed.empty && computed.warnings.length > 0 && /* @__PURE__ */ jsx("p", { "data-enigma-password-strength-warning": "", className: classNames?.warning, children: computed.warnings[0] })
454
+ ]
455
+ }
456
+ );
457
+ }
185
458
 
186
- export { useButton, useInput, useMarquee, useSearch };
459
+ export { Button, Input, PasswordStrength, useButton, useInput, useMarquee, useSearch };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/primitives",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",
@@ -23,7 +23,8 @@
23
23
  "scripts": {
24
24
  "build": "tsup",
25
25
  "typecheck": "tsc --noEmit",
26
- "test": "playwright test",
26
+ "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",
27
28
  "test:install": "playwright install chromium",
28
29
  "prepublishOnly": "npm run typecheck && npm run build"
29
30
  },
@@ -36,8 +37,8 @@
36
37
  "LICENSE"
37
38
  ],
38
39
  "peerDependencies": {
39
- "react": ">=18",
40
- "fuse.js": ">=6"
40
+ "fuse.js": ">=6",
41
+ "react": ">=18"
41
42
  },
42
43
  "peerDependenciesMeta": {
43
44
  "react": {
@@ -50,10 +51,12 @@
50
51
  "devDependencies": {
51
52
  "@playwright/test": "^1.48.0",
52
53
  "@types/react": "^19.2.0",
54
+ "@types/react-dom": "^19.2.4",
55
+ "fuse.js": "^7.0.0",
53
56
  "react": "^19.2.0",
57
+ "react-dom": "^19.2.8",
54
58
  "tsup": "^8.0.0",
55
- "typescript": "^5.0.0",
56
- "fuse.js": "^7.0.0"
59
+ "typescript": "^5.0.0"
57
60
  },
58
61
  "engines": {
59
62
  "node": ">=18"
@@ -0,0 +1,54 @@
1
+ /* A starting point for the field, yours to edit. The primitive renders the structure and
2
+ publishes its state through data-* attributes; every colour below is this file's. */
3
+
4
+ [data-enigma-input-root] { display: grid; gap: 0.375rem; }
5
+
6
+ [data-enigma-input-field] {
7
+ display: flex; align-items: center; gap: 0.375rem;
8
+ padding: 0 0.75rem;
9
+ background: #171717; border: 1px solid #404040; border-radius: 0.5rem;
10
+ }
11
+ [data-enigma-input-field]:focus-within { border-color: #a3a3a3; }
12
+
13
+ [data-enigma-input] {
14
+ flex: 1; min-width: 0; padding: 0.625rem 0;
15
+ font-size: 0.875rem; color: #f5f5f5;
16
+ background: transparent; border: 0; outline: none;
17
+ }
18
+
19
+ /* A breached password is worth showing on the field itself, not only in the message. */
20
+ [data-enigma-input-root][data-breached] [data-enigma-input-field] { border-color: #dc2626; }
21
+
22
+ [data-enigma-input-actions] { display: inline-flex; gap: 0.125rem; }
23
+ [data-enigma-input-actions][data-position="start"] { order: -1; }
24
+
25
+ [data-enigma-input-action] {
26
+ display: grid; place-items: center;
27
+ width: 1.75rem; height: 1.75rem;
28
+ color: #a3a3a3; background: none; border: 0; border-radius: 0.375rem;
29
+ cursor: pointer; font-size: 0.9375rem;
30
+ }
31
+ [data-enigma-input-action]:hover { color: #f5f5f5; background: #262626; }
32
+ [data-enigma-input-action][aria-pressed="true"] { color: #fbbf24; }
33
+ [data-enigma-input-action]:disabled { opacity: 0.5; cursor: default; }
34
+
35
+ /* The meter. Five bars, one per score, red through green - the palette lives here because
36
+ a headless package has no business choosing what "strong" looks like on your brand. */
37
+ [data-enigma-password-strength] { display: grid; gap: 0.25rem; }
38
+ [data-enigma-password-strength-track] { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.25rem; }
39
+
40
+ [data-enigma-password-strength-segment] {
41
+ height: 0.25rem; border-radius: 999px; background: #262626;
42
+ transition: background-color 120ms ease-out;
43
+ }
44
+
45
+ [data-score="0"] [data-enigma-password-strength-segment][data-filled] { background: #dc2626; }
46
+ [data-score="1"] [data-enigma-password-strength-segment][data-filled] { background: #ea580c; }
47
+ [data-score="2"] [data-enigma-password-strength-segment][data-filled] { background: #eab308; }
48
+ [data-score="3"] [data-enigma-password-strength-segment][data-filled] { background: #84cc16; }
49
+ [data-score="4"] [data-enigma-password-strength-segment][data-filled] { background: #16a34a; }
50
+
51
+ [data-enigma-password-strength-label] { margin: 0; font-size: 0.75rem; color: #a3a3a3; }
52
+ [data-enigma-password-strength-warning] { margin: 0; font-size: 0.75rem; color: #fbbf24; }
53
+ /* An empty field is not a bad password: say nothing until there is something to say. */
54
+ [data-enigma-password-strength][data-empty] [data-enigma-password-strength-label] { visibility: hidden; }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Input, type BreachChecker, type BreachState } from "@enigmax/primitives/react";
5
+
6
+ /**
7
+ * A registration password field, styled with Tailwind. Yours to edit.
8
+ *
9
+ * The primitive renders the structure and publishes its state on `data-*`; every colour
10
+ * below is this file's. The meter's five bars read the score from the ROOT, which is why
11
+ * they colour themselves through a group variant rather than five separate components.
12
+ */
13
+
14
+ interface PasswordFieldProps {
15
+ value: string;
16
+ onChange: (value: string) => void;
17
+ /** What the visitor has already typed elsewhere, so the meter can spot it in there. */
18
+ userInputs?: string[];
19
+ /**
20
+ * Check the password against a breach corpus. `enigma add password-breach` gives you
21
+ * `checkPasswordBreach` from @enigmax/utils, which asks Have I Been Pwned without
22
+ * sending the password anywhere. Leave it out and the field simply does not check.
23
+ */
24
+ breach?: BreachChecker;
25
+ /** Your own message, from your own validation. */
26
+ error?: string;
27
+ }
28
+
29
+ const SEGMENT = [
30
+ "h-1 rounded-full bg-neutral-800 transition-colors",
31
+ "group-data-[score=0]/field:data-[filled]:bg-red-600",
32
+ "group-data-[score=1]/field:data-[filled]:bg-orange-600",
33
+ "group-data-[score=2]/field:data-[filled]:bg-yellow-500",
34
+ "group-data-[score=3]/field:data-[filled]:bg-lime-500",
35
+ "group-data-[score=4]/field:data-[filled]:bg-green-600"
36
+ ].join(" ");
37
+
38
+ export function PasswordField({ value, onChange, userInputs, breach: check, error }: PasswordFieldProps) {
39
+ const [breach, setBreach] = useState<BreachState>({ status: "idle", count: 0, error: null });
40
+
41
+ return (
42
+ <div className="grid gap-1.5">
43
+ <label htmlFor="password" className="text-xs text-neutral-400">Password</label>
44
+
45
+ <Input
46
+ id="password"
47
+ type="password"
48
+ autoComplete="new-password"
49
+ value={value}
50
+ onChange={(event) => onChange(event.target.value)}
51
+ generate={{ length: 20 }}
52
+ strength={{ userInputs }}
53
+ // The check is a prop, so the network request is a decision this file makes
54
+ // rather than one the field takes on its own.
55
+ breach={check}
56
+ onBreachChange={setBreach}
57
+ wrapperProps={{ className: "group/field grid gap-1.5" }}
58
+ fieldProps={{
59
+ className: "flex items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-900 px-3 focus-within:border-neutral-400 group-data-[breached]/field:border-red-600"
60
+ }}
61
+ className="min-w-0 flex-1 border-0 bg-transparent py-2.5 text-sm text-neutral-100 outline-none"
62
+ classNames={{
63
+ actions: "inline-flex gap-0.5",
64
+ action: "grid h-7 w-7 place-items-center rounded-md text-neutral-400 hover:bg-neutral-800 hover:text-neutral-100 aria-pressed:text-amber-400 disabled:opacity-50",
65
+ strength: {
66
+ track: "grid grid-cols-5 gap-1",
67
+ segment: SEGMENT,
68
+ label: "m-0 text-xs text-neutral-400",
69
+ warning: "m-0 text-xs text-amber-400"
70
+ }
71
+ }}
72
+ >
73
+ {/* Whatever a breach means here is this form's decision, so this form makes
74
+ it. The field reports the count and stops. */}
75
+ {breach.status === "breached" && (
76
+ <p className="m-0 text-xs text-red-500">
77
+ This password has appeared in {breach.count.toLocaleString()} breaches. Please pick another.
78
+ </p>
79
+ )}
80
+ {error && <p className="m-0 text-xs text-red-500">{error}</p>}
81
+ </Input>
82
+ </div>
83
+ );
84
+ }
@@ -1,4 +1,4 @@
1
- import "./marquee.css";
1
+ import "./styles.css";
2
2
  import type { ReactNode, Ref } from "react";
3
3
  import { useMarquee, type MarqueeHover } from "@enigmax/primitives/react";
4
4