@enigmax/primitives 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-WCB7V7XO.js → chunk-42Y7OOJM.js} +183 -3
- package/dist/index.d.ts +86 -1
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +115 -4
- package/dist/react/index.js +251 -3
- package/package.json +9 -6
- package/recipes/input/styles.css +54 -0
- package/recipes/input/tailwind.tsx +84 -0
- package/registry.json +49 -19
- package/src/core/input.ts +26 -3
- package/src/core/password.ts +252 -0
- package/src/index.ts +10 -0
- package/src/react/index.ts +20 -0
- package/src/react/input.tsx +429 -0
- package/recipes/input.css +0 -32
- package/recipes/input.css.tsx +0 -28
- package/recipes/input.tailwind.tsx +0 -44
package/dist/react/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
import { generatePassword, estimatePasswordStrength, INPUT_ICON_PATHS, createMarquee, createInput, createButton } from '../chunk-42Y7OOJM.js';
|
|
3
|
+
export { estimatePasswordStrength, generatePassword } from '../chunk-42Y7OOJM.js';
|
|
2
4
|
import { createSearch } from '../chunk-UZFEEFMF.js';
|
|
3
|
-
import { useRef, useState,
|
|
5
|
+
import { forwardRef, 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,250 @@ function useButton(options = {}) {
|
|
|
182
185
|
}
|
|
183
186
|
};
|
|
184
187
|
}
|
|
188
|
+
var ICON_PROPS = {
|
|
189
|
+
viewBox: "0 0 24 24",
|
|
190
|
+
width: "1em",
|
|
191
|
+
height: "1em",
|
|
192
|
+
fill: "none",
|
|
193
|
+
stroke: "currentColor",
|
|
194
|
+
strokeWidth: 2,
|
|
195
|
+
strokeLinecap: "round",
|
|
196
|
+
strokeLinejoin: "round",
|
|
197
|
+
"aria-hidden": true
|
|
198
|
+
};
|
|
199
|
+
function Icon({ paths }) {
|
|
200
|
+
return /* @__PURE__ */ jsx("svg", { ...ICON_PROPS, children: paths.map((path) => /* @__PURE__ */ jsx("path", { d: path }, path)) });
|
|
201
|
+
}
|
|
202
|
+
function writeValue(input, next) {
|
|
203
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
204
|
+
if (setter) setter.call(input, next);
|
|
205
|
+
else input.value = next;
|
|
206
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
207
|
+
}
|
|
208
|
+
var Input = forwardRef(function Input2({
|
|
209
|
+
reveal,
|
|
210
|
+
revealLabels,
|
|
211
|
+
generate = false,
|
|
212
|
+
generateLabel = "Generate a password",
|
|
213
|
+
revealOnGenerate = true,
|
|
214
|
+
copyOnGenerate = false,
|
|
215
|
+
onGenerate,
|
|
216
|
+
actions = [],
|
|
217
|
+
position = "end",
|
|
218
|
+
strength = false,
|
|
219
|
+
onStrengthChange,
|
|
220
|
+
breach,
|
|
221
|
+
breachDelay = 500,
|
|
222
|
+
onBreachChange,
|
|
223
|
+
wrapperProps,
|
|
224
|
+
fieldProps,
|
|
225
|
+
classNames,
|
|
226
|
+
children,
|
|
227
|
+
type = "text",
|
|
228
|
+
onChange,
|
|
229
|
+
...props
|
|
230
|
+
}, forwardedRef) {
|
|
231
|
+
const innerRef = useRef(null);
|
|
232
|
+
const [revealed, setRevealed] = useState(false);
|
|
233
|
+
const describedBy = useId();
|
|
234
|
+
const isPassword = type === "password";
|
|
235
|
+
const showReveal = reveal ?? isPassword;
|
|
236
|
+
const showGenerate = generate !== false && isPassword;
|
|
237
|
+
const controlled = props.value !== void 0;
|
|
238
|
+
const [ownValue, setOwnValue] = useState(String(props.defaultValue ?? ""));
|
|
239
|
+
const value = controlled ? String(props.value ?? "") : ownValue;
|
|
240
|
+
const handleChange = useCallback((event) => {
|
|
241
|
+
if (!controlled) setOwnValue(event.target.value);
|
|
242
|
+
onChange?.(event);
|
|
243
|
+
}, [controlled, onChange]);
|
|
244
|
+
const caret = useRef(null);
|
|
245
|
+
const toggleReveal = useCallback(() => {
|
|
246
|
+
const input = innerRef.current;
|
|
247
|
+
if (input && typeof document !== "undefined" && document.activeElement === input) {
|
|
248
|
+
try {
|
|
249
|
+
const { selectionStart, selectionEnd } = input;
|
|
250
|
+
caret.current = selectionStart === null || selectionEnd === null ? null : [selectionStart, selectionEnd];
|
|
251
|
+
} catch {
|
|
252
|
+
caret.current = null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
setRevealed((current) => !current);
|
|
256
|
+
}, []);
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
const selection = caret.current;
|
|
259
|
+
if (!selection) return;
|
|
260
|
+
caret.current = null;
|
|
261
|
+
const restore = () => {
|
|
262
|
+
const input = innerRef.current;
|
|
263
|
+
if (!input || document.activeElement !== input) return;
|
|
264
|
+
try {
|
|
265
|
+
input.setSelectionRange(selection[0], selection[1]);
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
restore();
|
|
270
|
+
const timer = setTimeout(restore, 0);
|
|
271
|
+
return () => clearTimeout(timer);
|
|
272
|
+
}, [revealed]);
|
|
273
|
+
const handleGenerate = useCallback(() => {
|
|
274
|
+
const input = innerRef.current;
|
|
275
|
+
if (!input) return;
|
|
276
|
+
const password = generatePassword(typeof generate === "object" ? generate : {});
|
|
277
|
+
writeValue(input, password);
|
|
278
|
+
if (revealOnGenerate) setRevealed(true);
|
|
279
|
+
if (copyOnGenerate) void navigator.clipboard?.writeText(password).catch(() => {
|
|
280
|
+
});
|
|
281
|
+
input.focus();
|
|
282
|
+
onGenerate?.(password);
|
|
283
|
+
}, [generate, revealOnGenerate, copyOnGenerate, onGenerate]);
|
|
284
|
+
const estimateOptions = typeof strength === "object" ? strength : void 0;
|
|
285
|
+
const userInputs = estimateOptions?.userInputs;
|
|
286
|
+
const report = useMemo(
|
|
287
|
+
() => strength === false ? null : estimatePasswordStrength(value, { userInputs }),
|
|
288
|
+
// The array is compared by identity, so a literal would re-estimate every render.
|
|
289
|
+
[strength, value, userInputs]
|
|
290
|
+
);
|
|
291
|
+
const strengthListener = useRef(onStrengthChange);
|
|
292
|
+
strengthListener.current = onStrengthChange;
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
if (report) strengthListener.current?.(report);
|
|
295
|
+
}, [report]);
|
|
296
|
+
const [breachState, setBreachState] = useState({ status: "idle", count: 0, error: null });
|
|
297
|
+
const breachListener = useRef(onBreachChange);
|
|
298
|
+
breachListener.current = onBreachChange;
|
|
299
|
+
useEffect(() => {
|
|
300
|
+
if (!breach || !value) {
|
|
301
|
+
setBreachState({ status: "idle", count: 0, error: null });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const controller = new AbortController();
|
|
305
|
+
const timer = setTimeout(() => {
|
|
306
|
+
setBreachState({ status: "checking", count: 0, error: null });
|
|
307
|
+
breach(value, { signal: controller.signal }).then((result) => {
|
|
308
|
+
if (controller.signal.aborted) return;
|
|
309
|
+
setBreachState({ status: result.breached ? "breached" : "safe", count: result.count, error: null });
|
|
310
|
+
}).catch((error) => {
|
|
311
|
+
if (controller.signal.aborted) return;
|
|
312
|
+
setBreachState({ status: "error", count: 0, error });
|
|
313
|
+
});
|
|
314
|
+
}, breachDelay);
|
|
315
|
+
return () => {
|
|
316
|
+
clearTimeout(timer);
|
|
317
|
+
controller.abort();
|
|
318
|
+
};
|
|
319
|
+
}, [breach, value, breachDelay]);
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
breachListener.current?.(breachState);
|
|
322
|
+
}, [breachState]);
|
|
323
|
+
const builtIn = [];
|
|
324
|
+
if (showGenerate) {
|
|
325
|
+
builtIn.push({
|
|
326
|
+
name: "generate",
|
|
327
|
+
label: generateLabel,
|
|
328
|
+
icon: /* @__PURE__ */ jsx(Icon, { paths: INPUT_ICON_PATHS.generate }),
|
|
329
|
+
onSelect: handleGenerate
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
if (showReveal) {
|
|
333
|
+
builtIn.push({
|
|
334
|
+
name: "reveal",
|
|
335
|
+
label: revealed ? revealLabels?.hide ?? "Hide password" : revealLabels?.show ?? "Show password",
|
|
336
|
+
icon: /* @__PURE__ */ jsx(Icon, { paths: revealed ? INPUT_ICON_PATHS.eyeOff : INPUT_ICON_PATHS.eye }),
|
|
337
|
+
pressed: revealed,
|
|
338
|
+
onSelect: toggleReveal
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const overridden = new Set(actions.map((action) => action.name));
|
|
342
|
+
const rendered = [...builtIn.filter((action) => !overridden.has(action.name)), ...actions].filter((action) => action.visible !== false);
|
|
343
|
+
const locked = props.disabled === true || props.readOnly === true;
|
|
344
|
+
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(
|
|
345
|
+
"button",
|
|
346
|
+
{
|
|
347
|
+
type: "button",
|
|
348
|
+
"data-enigma-input-action": action.name,
|
|
349
|
+
className: classNames?.action,
|
|
350
|
+
"aria-label": action.label,
|
|
351
|
+
title: action.label,
|
|
352
|
+
"aria-pressed": action.pressed,
|
|
353
|
+
disabled: locked,
|
|
354
|
+
onMouseDown: (event) => event.preventDefault(),
|
|
355
|
+
onClick: action.onSelect,
|
|
356
|
+
children: action.icon
|
|
357
|
+
},
|
|
358
|
+
action.name
|
|
359
|
+
)) });
|
|
360
|
+
return /* @__PURE__ */ jsxs(
|
|
361
|
+
"div",
|
|
362
|
+
{
|
|
363
|
+
...wrapperProps,
|
|
364
|
+
"data-enigma-input-root": "",
|
|
365
|
+
"data-revealed": revealed ? "" : void 0,
|
|
366
|
+
"data-breached": breachState.status === "breached" ? "" : void 0,
|
|
367
|
+
"data-score": report && !report.empty ? report.score : void 0,
|
|
368
|
+
children: [
|
|
369
|
+
/* @__PURE__ */ jsxs("div", { ...fieldProps, "data-enigma-input-field": "", children: [
|
|
370
|
+
position === "start" && buttons,
|
|
371
|
+
/* @__PURE__ */ jsx(
|
|
372
|
+
"input",
|
|
373
|
+
{
|
|
374
|
+
...props,
|
|
375
|
+
ref: (node) => {
|
|
376
|
+
innerRef.current = node;
|
|
377
|
+
if (typeof forwardedRef === "function") forwardedRef(node);
|
|
378
|
+
else if (forwardedRef) forwardedRef.current = node;
|
|
379
|
+
},
|
|
380
|
+
type: revealed && isPassword ? "text" : type,
|
|
381
|
+
onChange: handleChange,
|
|
382
|
+
"data-enigma-input": "",
|
|
383
|
+
"aria-describedby": report ? describedBy : props["aria-describedby"]
|
|
384
|
+
}
|
|
385
|
+
),
|
|
386
|
+
position === "end" && buttons
|
|
387
|
+
] }),
|
|
388
|
+
report && /* @__PURE__ */ jsx(PasswordStrength, { id: describedBy, report, classNames: classNames?.strength }),
|
|
389
|
+
children
|
|
390
|
+
]
|
|
391
|
+
}
|
|
392
|
+
);
|
|
393
|
+
});
|
|
394
|
+
var SCORE_LABELS = ["Very weak", "Weak", "Fair", "Strong", "Very strong"];
|
|
395
|
+
function PasswordStrength({
|
|
396
|
+
value = "",
|
|
397
|
+
report,
|
|
398
|
+
userInputs,
|
|
399
|
+
segments = 5,
|
|
400
|
+
labels = SCORE_LABELS,
|
|
401
|
+
showWarning = true,
|
|
402
|
+
classNames,
|
|
403
|
+
...props
|
|
404
|
+
}) {
|
|
405
|
+
const computed = useMemo(
|
|
406
|
+
() => report ?? estimatePasswordStrength(value, { userInputs }),
|
|
407
|
+
[report, value, userInputs]
|
|
408
|
+
);
|
|
409
|
+
const score = computed.score;
|
|
410
|
+
return /* @__PURE__ */ jsxs(
|
|
411
|
+
"div",
|
|
412
|
+
{
|
|
413
|
+
...props,
|
|
414
|
+
"data-enigma-password-strength": "",
|
|
415
|
+
"data-score": computed.empty ? void 0 : score,
|
|
416
|
+
"data-empty": computed.empty ? "" : void 0,
|
|
417
|
+
children: [
|
|
418
|
+
/* @__PURE__ */ jsx("div", { "data-enigma-password-strength-track": "", "aria-hidden": "true", className: classNames?.track, children: Array.from({ length: segments }, (unused, index) => /* @__PURE__ */ jsx(
|
|
419
|
+
"span",
|
|
420
|
+
{
|
|
421
|
+
"data-enigma-password-strength-segment": "",
|
|
422
|
+
className: classNames?.segment,
|
|
423
|
+
"data-filled": !computed.empty && index <= score ? "" : void 0
|
|
424
|
+
},
|
|
425
|
+
index
|
|
426
|
+
)) }),
|
|
427
|
+
/* @__PURE__ */ jsx("p", { "data-enigma-password-strength-label": "", role: "status", "aria-live": "polite", className: classNames?.label, children: computed.empty ? "" : labels[score] ?? "" }),
|
|
428
|
+
showWarning && !computed.empty && computed.warnings.length > 0 && /* @__PURE__ */ jsx("p", { "data-enigma-password-strength-warning": "", className: classNames?.warning, children: computed.warnings[0] })
|
|
429
|
+
]
|
|
430
|
+
}
|
|
431
|
+
);
|
|
432
|
+
}
|
|
185
433
|
|
|
186
|
-
export { useButton, useInput, useMarquee, useSearch };
|
|
434
|
+
export { Input, PasswordStrength, useButton, useInput, useMarquee, useSearch };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enigmax/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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
|
-
"
|
|
40
|
-
"
|
|
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
|
+
}
|
package/registry.json
CHANGED
|
@@ -92,8 +92,8 @@
|
|
|
92
92
|
},
|
|
93
93
|
{
|
|
94
94
|
"name": "input",
|
|
95
|
-
"title": "Input
|
|
96
|
-
"description": "
|
|
95
|
+
"title": "Input, with what a password field needs",
|
|
96
|
+
"description": "A field you pass props to, with no styles of its own. A password gets its reveal toggle automatically; a generator, a strength meter and a breach check are one prop each and off until you ask. React renders every part of it, so nothing fights your tree.",
|
|
97
97
|
"targets": [
|
|
98
98
|
"vanilla",
|
|
99
99
|
"astro",
|
|
@@ -112,13 +112,23 @@
|
|
|
112
112
|
"createInput"
|
|
113
113
|
],
|
|
114
114
|
"react": [
|
|
115
|
-
"
|
|
115
|
+
"Input",
|
|
116
|
+
"PasswordStrength"
|
|
116
117
|
]
|
|
117
118
|
},
|
|
118
119
|
"files": [
|
|
119
120
|
{
|
|
120
121
|
"path": "src/core/input.ts",
|
|
121
|
-
"dest": "input.ts",
|
|
122
|
+
"dest": "input/input.ts",
|
|
123
|
+
"targets": [
|
|
124
|
+
"vanilla",
|
|
125
|
+
"astro",
|
|
126
|
+
"react"
|
|
127
|
+
]
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"path": "src/core/password.ts",
|
|
131
|
+
"dest": "input/password.ts",
|
|
122
132
|
"targets": [
|
|
123
133
|
"vanilla",
|
|
124
134
|
"astro",
|
|
@@ -127,7 +137,7 @@
|
|
|
127
137
|
},
|
|
128
138
|
{
|
|
129
139
|
"path": "src/react/use-input.ts",
|
|
130
|
-
"dest": "use-input.ts",
|
|
140
|
+
"dest": "input/use-input.ts",
|
|
131
141
|
"targets": [
|
|
132
142
|
"react"
|
|
133
143
|
],
|
|
@@ -136,40 +146,60 @@
|
|
|
136
146
|
}
|
|
137
147
|
},
|
|
138
148
|
{
|
|
139
|
-
"path": "recipes/input.
|
|
140
|
-
"dest": "
|
|
149
|
+
"path": "recipes/input/styles.css",
|
|
150
|
+
"dest": "input/styles.css",
|
|
141
151
|
"targets": [
|
|
142
|
-
"react"
|
|
152
|
+
"react",
|
|
153
|
+
"vanilla",
|
|
154
|
+
"astro"
|
|
143
155
|
],
|
|
144
|
-
"style": "
|
|
156
|
+
"style": "css"
|
|
145
157
|
},
|
|
146
158
|
{
|
|
147
|
-
"path": "
|
|
148
|
-
"dest": "
|
|
159
|
+
"path": "src/react/input.tsx",
|
|
160
|
+
"dest": "input/index.tsx",
|
|
161
|
+
"main": true,
|
|
149
162
|
"targets": [
|
|
150
163
|
"react"
|
|
151
164
|
],
|
|
152
|
-
"
|
|
165
|
+
"rewrite": {
|
|
166
|
+
"@/core/input": "./input",
|
|
167
|
+
"@/core/password": "./password"
|
|
168
|
+
}
|
|
153
169
|
},
|
|
154
170
|
{
|
|
155
|
-
"path": "recipes/input.
|
|
156
|
-
"dest": "input.
|
|
171
|
+
"path": "recipes/input/tailwind.tsx",
|
|
172
|
+
"dest": "input/PasswordField.tsx",
|
|
157
173
|
"targets": [
|
|
158
|
-
"react"
|
|
159
|
-
"vanilla",
|
|
160
|
-
"astro"
|
|
174
|
+
"react"
|
|
161
175
|
],
|
|
162
|
-
"style": "
|
|
176
|
+
"style": "tailwind",
|
|
177
|
+
"rewrite": {
|
|
178
|
+
"@enigmax/primitives/react": "./index"
|
|
179
|
+
}
|
|
163
180
|
}
|
|
164
181
|
],
|
|
165
182
|
"styles": false,
|
|
166
183
|
"themeHooks": [
|
|
184
|
+
"[data-enigma-input-root]",
|
|
185
|
+
"[data-enigma-input-root][data-revealed]",
|
|
186
|
+
"[data-enigma-input-root][data-breached]",
|
|
187
|
+
"[data-enigma-input-root][data-score]",
|
|
188
|
+
"[data-enigma-input-field]",
|
|
167
189
|
"[data-enigma-input]",
|
|
168
190
|
"[data-enigma-input][data-enigma-input-reveal=on]",
|
|
169
191
|
"[data-enigma-input-actions]",
|
|
170
192
|
"[data-enigma-input-actions][data-position=start]",
|
|
171
193
|
"[data-enigma-input-action]",
|
|
172
|
-
"[data-enigma-input-action=reveal]"
|
|
194
|
+
"[data-enigma-input-action=reveal]",
|
|
195
|
+
"[data-enigma-input-action=generate]",
|
|
196
|
+
"[data-enigma-password-strength]",
|
|
197
|
+
"[data-enigma-password-strength][data-empty]",
|
|
198
|
+
"[data-enigma-password-strength-track]",
|
|
199
|
+
"[data-enigma-password-strength-segment]",
|
|
200
|
+
"[data-enigma-password-strength-segment][data-filled]",
|
|
201
|
+
"[data-enigma-password-strength-label]",
|
|
202
|
+
"[data-enigma-password-strength-warning]"
|
|
173
203
|
],
|
|
174
204
|
"docs": "docs/notes/primitives.md#input",
|
|
175
205
|
"recipes": [
|
package/src/core/input.ts
CHANGED
|
@@ -72,9 +72,32 @@ export interface InputInstance {
|
|
|
72
72
|
destroy(): void;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The built-in glyphs, as path data.
|
|
77
|
+
*
|
|
78
|
+
* Path data rather than markup because there are two renderers: this file writes an SVG
|
|
79
|
+
* string into a button it created, and the React component builds elements. Keeping the
|
|
80
|
+
* shapes here means one definition, and a theme that replaces an icon replaces it in both.
|
|
81
|
+
* Everything is stroked with `currentColor` at 1em, so an icon inherits the field's text.
|
|
82
|
+
*/
|
|
83
|
+
export const INPUT_ICON_PATHS = {
|
|
84
|
+
eye: ["M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z", "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"],
|
|
85
|
+
eyeOff: [
|
|
86
|
+
"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",
|
|
87
|
+
"m2 2 20 20",
|
|
88
|
+
"M9.9 9.9a3 3 0 0 0 4.2 4.2"
|
|
89
|
+
],
|
|
90
|
+
generate: ["m12 3 1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9Z", "M19 15l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8Z"]
|
|
91
|
+
} as const;
|
|
92
|
+
|
|
93
|
+
/** The same shapes as a standalone SVG string, for the DOM renderer below. */
|
|
94
|
+
export function iconMarkup(paths: readonly string[]): string {
|
|
95
|
+
const body = paths.map((path) => `<path d="${path}"/>`).join("");
|
|
96
|
+
return `<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">${body}</svg>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const EYE = iconMarkup(INPUT_ICON_PATHS.eye);
|
|
100
|
+
const EYE_OFF = iconMarkup(INPUT_ICON_PATHS.eyeOff);
|
|
78
101
|
|
|
79
102
|
function resolve<T>(value: T | ((state: InputActionState) => T), state: InputActionState): T {
|
|
80
103
|
return typeof value === "function" ? (value as (state: InputActionState) => T)(state) : value;
|