@enigmax/primitives 0.17.0 → 0.18.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/button-CaXaqG_K.d.ts +63 -0
- package/dist/chunk-2QFTRNAZ.js +88 -0
- package/dist/chunk-6BGBYUSZ.js +114 -0
- package/dist/chunk-AU3H5WIY.js +107 -0
- package/dist/chunk-F25CGNQC.js +19 -0
- package/dist/chunk-HC2ME5PU.js +168 -0
- package/dist/chunk-HS3X3XCW.js +43 -0
- package/dist/chunk-IIT6U7LM.js +249 -0
- package/dist/chunk-IXVMRVD4.js +96 -0
- package/dist/chunk-KJINGUQN.js +188 -0
- package/dist/chunk-LKA2UG4P.js +542 -0
- package/dist/chunk-MMQPZGSU.js +161 -0
- package/dist/chunk-OCMI7R6H.js +79 -0
- package/dist/chunk-QYMUIW5I.js +28 -0
- package/dist/chunk-S653GLSF.js +17 -0
- package/dist/chunk-SNYUBXWQ.js +149 -0
- package/dist/chunk-U3V4EHOB.js +41 -0
- package/dist/chunk-UOSSNUSC.js +309 -0
- package/dist/chunk-WSB655JB.js +47 -0
- package/dist/chunk-XNNQRA35.js +31 -0
- package/dist/chunk-XQHCZAPJ.js +102 -0
- package/dist/chunk-ZCUFYBPB.js +154 -0
- package/dist/chunk-ZWR2EXHQ.js +55 -0
- package/dist/flags-BBJc9unY.d.ts +133 -0
- package/dist/index-D-ARvWpQ.d.ts +306 -0
- package/dist/index-DNXPtY9X.d.ts +144 -0
- package/dist/index.d.ts +16 -602
- package/dist/index.js +9 -1
- package/dist/input-BwXjFenq.d.ts +77 -0
- package/dist/marquee-CJ3Uwy3E.d.ts +81 -0
- package/dist/network-D2LsBG_k.d.ts +39 -0
- package/dist/next/index.d.ts +20 -3
- package/dist/next/index.js +23 -3
- package/dist/notifications-BpVV6sel.d.ts +70 -0
- package/dist/password-3DRQYAYQ.js +2 -0
- package/dist/password-C8lG4Zm9.d.ts +71 -0
- package/dist/password-FB2CUEKJ.js +1 -0
- package/dist/react/button.d.ts +75 -0
- package/dist/react/button.js +4 -0
- package/dist/react/flag.d.ts +37 -0
- package/dist/react/flag.js +3 -0
- package/dist/react/index.d.ts +28 -312
- package/dist/react/index.js +22 -2
- package/dist/react/input.d.ts +4 -0
- package/dist/react/input.js +3 -0
- package/dist/react/marquee.d.ts +44 -0
- package/dist/react/marquee.js +3 -0
- package/dist/react/network.d.ts +20 -0
- package/dist/react/network.js +3 -0
- package/dist/react/notifications.d.ts +17 -0
- package/dist/react/notifications.js +3 -0
- package/dist/react/palette.d.ts +3 -0
- package/dist/react/palette.js +4 -0
- package/dist/react/relative-time.d.ts +21 -0
- package/dist/react/relative-time.js +3 -0
- package/dist/react/search.d.ts +30 -0
- package/dist/react/search.js +3 -0
- package/dist/react/slot.d.ts +47 -0
- package/dist/react/slot.js +2 -0
- package/dist/react/toast.d.ts +40 -0
- package/dist/react/toast.js +4 -0
- package/dist/react-router/index.d.ts +20 -3
- package/dist/react-router/index.js +23 -3
- package/dist/relative-time-YpRTG7YH.d.ts +106 -0
- package/dist/search-P4OHCRXD.js +50 -0
- package/package.json +52 -2
- package/registry.json +175 -18
- package/src/core/flags.ts +88 -42
- package/src/core/input-icons.ts +34 -0
- package/src/core/input.ts +5 -21
- package/src/core/palette.ts +0 -0
- package/src/index.ts +2 -2
- package/src/react/button.tsx +70 -4
- package/src/react/flag.tsx +18 -6
- package/src/react/index.ts +40 -5
- package/src/react/input/icon.tsx +17 -0
- package/src/react/input/index.tsx +307 -0
- package/src/react/input/password.tsx +174 -0
- package/src/react/input/search.tsx +82 -0
- package/src/react/input/types.ts +146 -0
- package/src/react/input/write-value.ts +18 -0
- package/src/react/palette/context.ts +60 -0
- package/src/react/palette/index.tsx +66 -0
- package/src/react/palette/root.tsx +621 -0
- package/src/react/slot.tsx +91 -0
- package/src/react/use-button.ts +3 -1
- package/dist/chunk-4GLYRF4B.js +0 -1259
- package/dist/chunk-JFNND6P4.js +0 -749
- package/src/react/input.tsx +0 -429
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Icon } from "@/react/input/icon";
|
|
4
|
+
import { INPUT_ICON_PATHS } from "@/core/input-icons";
|
|
5
|
+
import { writeValue } from "@/react/input/write-value";
|
|
6
|
+
import type { AnyInputProps, InputProps, FieldAction, BreachState } from "@/react/input/types";
|
|
7
|
+
import { forwardRef, lazy, Suspense, useCallback, useEffect, useId, useRef, useState, type ChangeEvent, type ReactNode } from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `<Input>` - one field for every `type`, with the affordances each type needs and none of
|
|
11
|
+
* the styling.
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* <Input type="email" required />
|
|
15
|
+
* <Input type="password" generate strength breach={checkPasswordBreach} />
|
|
16
|
+
* <Input type="search" items={docs} keys={["title"]} renderResults={...} />
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* ONE component keyed on `type`, because that is what HTML is: `type` is an attribute of a
|
|
20
|
+
* single element. What differs per type is which PROPS exist, and that is a discriminated
|
|
21
|
+
* union - `strength` on a text field is a compile error rather than a prop that silently
|
|
22
|
+
* does nothing. Widgets that are not one `<input>` stay their own components; the search
|
|
23
|
+
* PALETTE is a dialog, so it lives in `SearchPalette` rather than behind a prop here.
|
|
24
|
+
*
|
|
25
|
+
* WHAT LOADS. The field, its buttons and the reveal are this module and nothing else. The
|
|
26
|
+
* password estimator, the breach watcher and the search engine each live in their own chunk
|
|
27
|
+
* and are imported the moment the type that needs them is used - so a form of text and email
|
|
28
|
+
* fields ships none of them, and a page with one password field does not pay for search.
|
|
29
|
+
* The generator is loaded on the first press of its button, because until then it is a
|
|
30
|
+
* function nobody has called.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const PasswordExtras = lazy(() => import("@/react/input/password").then((module) => ({ default: module.PasswordExtras })));
|
|
34
|
+
const SearchExtras = lazy(() => import("@/react/input/search").then((module) => ({ default: module.SearchExtras })));
|
|
35
|
+
|
|
36
|
+
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, forwardedRef) {
|
|
37
|
+
const {
|
|
38
|
+
reveal,
|
|
39
|
+
revealLabels,
|
|
40
|
+
generate = false,
|
|
41
|
+
generateLabel = "Generate a password",
|
|
42
|
+
revealOnGenerate = true,
|
|
43
|
+
copyOnGenerate = false,
|
|
44
|
+
onGenerate,
|
|
45
|
+
actions = [],
|
|
46
|
+
position = "end",
|
|
47
|
+
strength = false,
|
|
48
|
+
onStrengthChange,
|
|
49
|
+
breach,
|
|
50
|
+
breachDelay = 500,
|
|
51
|
+
onBreachChange,
|
|
52
|
+
items,
|
|
53
|
+
keys,
|
|
54
|
+
delay,
|
|
55
|
+
limit,
|
|
56
|
+
fuse,
|
|
57
|
+
fuseOptions,
|
|
58
|
+
matcher,
|
|
59
|
+
onResults,
|
|
60
|
+
renderResults,
|
|
61
|
+
clearable,
|
|
62
|
+
clearLabel = "Clear",
|
|
63
|
+
wrapperProps,
|
|
64
|
+
fieldProps,
|
|
65
|
+
classNames,
|
|
66
|
+
children,
|
|
67
|
+
type = "text",
|
|
68
|
+
onChange,
|
|
69
|
+
...rest
|
|
70
|
+
} = props as AnyInputProps;
|
|
71
|
+
|
|
72
|
+
const innerRef = useRef<HTMLInputElement | null>(null);
|
|
73
|
+
const [element, setElement] = useState<HTMLInputElement | null>(null);
|
|
74
|
+
const [revealed, setRevealed] = useState(false);
|
|
75
|
+
const describedBy = useId();
|
|
76
|
+
|
|
77
|
+
const isPassword = type === "password";
|
|
78
|
+
const isSearch = type === "search";
|
|
79
|
+
const showReveal = reveal ?? isPassword;
|
|
80
|
+
const showGenerate = generate !== false && isPassword;
|
|
81
|
+
const showClear = (clearable ?? isSearch) && isSearch;
|
|
82
|
+
|
|
83
|
+
// Tracked whether the field is controlled or not, because the meter, the breach check
|
|
84
|
+
// and the clear button need the current value, and an uncontrolled field never reports
|
|
85
|
+
// it as a prop.
|
|
86
|
+
const controlled = rest.value !== undefined;
|
|
87
|
+
const [ownValue, setOwnValue] = useState(String(rest.defaultValue ?? ""));
|
|
88
|
+
const value = controlled ? String(rest.value ?? "") : ownValue;
|
|
89
|
+
|
|
90
|
+
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
|
91
|
+
if (!controlled) setOwnValue(event.target.value);
|
|
92
|
+
onChange?.(event);
|
|
93
|
+
}, [controlled, onChange]);
|
|
94
|
+
|
|
95
|
+
/* -------- the reveal, and the caret it would otherwise eat -------- */
|
|
96
|
+
|
|
97
|
+
// Captured BEFORE the type switch. Assigning `type` while an input is focused resets
|
|
98
|
+
// the caret to 0 in Chromium, and it does it one MACROTASK later - so anything read
|
|
99
|
+
// after the switch is already the clobbered value.
|
|
100
|
+
const caret = useRef<[number, number] | null>(null);
|
|
101
|
+
|
|
102
|
+
const toggleReveal = useCallback(() => {
|
|
103
|
+
const input = innerRef.current;
|
|
104
|
+
if (input && typeof document !== "undefined" && document.activeElement === input) {
|
|
105
|
+
try {
|
|
106
|
+
const { selectionStart, selectionEnd } = input;
|
|
107
|
+
caret.current = selectionStart === null || selectionEnd === null ? null : [selectionStart, selectionEnd];
|
|
108
|
+
} catch {
|
|
109
|
+
caret.current = null; // selection is not supported on every input type
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
setRevealed((current) => !current);
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const selection = caret.current;
|
|
117
|
+
if (!selection) return;
|
|
118
|
+
caret.current = null;
|
|
119
|
+
|
|
120
|
+
const restore = (): void => {
|
|
121
|
+
const input = innerRef.current;
|
|
122
|
+
if (!input || document.activeElement !== input) return;
|
|
123
|
+
try { input.setSelectionRange(selection[0], selection[1]); } catch { /* unsupported */ }
|
|
124
|
+
};
|
|
125
|
+
restore();
|
|
126
|
+
// And again on the next macrotask, which is where Chromium actually clobbers it.
|
|
127
|
+
// A restore that only runs inline silently loses. Focus is re-checked first, so a
|
|
128
|
+
// visitor who clicked elsewhere meanwhile is not dragged back.
|
|
129
|
+
const timer = setTimeout(restore, 0);
|
|
130
|
+
return () => clearTimeout(timer);
|
|
131
|
+
}, [revealed]);
|
|
132
|
+
|
|
133
|
+
/* -------- the generator, loaded when it is first pressed -------- */
|
|
134
|
+
|
|
135
|
+
const handleGenerate = useCallback(async () => {
|
|
136
|
+
const input = innerRef.current;
|
|
137
|
+
if (!input) return;
|
|
138
|
+
// Imported here rather than at the top: a form with a password field that nobody
|
|
139
|
+
// ever asks to generate has no reason to carry a CSPRNG alphabet around.
|
|
140
|
+
const { generatePassword } = await import("@/core/password");
|
|
141
|
+
const password = generatePassword(typeof generate === "object" ? generate : {});
|
|
142
|
+
writeValue(input, password);
|
|
143
|
+
if (revealOnGenerate) setRevealed(true);
|
|
144
|
+
if (copyOnGenerate) void navigator.clipboard?.writeText(password).catch(() => {
|
|
145
|
+
// Denied permission or an insecure context. The password is in the field, which
|
|
146
|
+
// is the part that matters, so there is nothing to report.
|
|
147
|
+
});
|
|
148
|
+
input.focus();
|
|
149
|
+
onGenerate?.(password);
|
|
150
|
+
}, [generate, revealOnGenerate, copyOnGenerate, onGenerate]);
|
|
151
|
+
|
|
152
|
+
const handleClear = useCallback(() => {
|
|
153
|
+
const input = innerRef.current;
|
|
154
|
+
if (!input) return;
|
|
155
|
+
writeValue(input, "");
|
|
156
|
+
input.focus();
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
/* -------- what the per-type chunks report back -------- */
|
|
160
|
+
|
|
161
|
+
const [score, setScore] = useState<number | null>(null);
|
|
162
|
+
const [breached, setBreached] = useState(false);
|
|
163
|
+
|
|
164
|
+
const handleBreach = useCallback((state: BreachState) => {
|
|
165
|
+
setBreached(state.status === "breached");
|
|
166
|
+
onBreachChange?.(state);
|
|
167
|
+
}, [onBreachChange]);
|
|
168
|
+
|
|
169
|
+
/* -------- rendering -------- */
|
|
170
|
+
|
|
171
|
+
const builtIn: FieldAction[] = [];
|
|
172
|
+
if (showGenerate) {
|
|
173
|
+
builtIn.push({
|
|
174
|
+
name: "generate",
|
|
175
|
+
label: generateLabel,
|
|
176
|
+
icon: <Icon paths={INPUT_ICON_PATHS.generate} />,
|
|
177
|
+
onSelect: () => { void handleGenerate(); }
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (showReveal) {
|
|
181
|
+
builtIn.push({
|
|
182
|
+
name: "reveal",
|
|
183
|
+
label: revealed ? revealLabels?.hide ?? "Hide password" : revealLabels?.show ?? "Show password",
|
|
184
|
+
icon: <Icon paths={revealed ? INPUT_ICON_PATHS.eyeOff : INPUT_ICON_PATHS.eye} />,
|
|
185
|
+
pressed: revealed,
|
|
186
|
+
onSelect: toggleReveal
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (showClear) {
|
|
190
|
+
builtIn.push({
|
|
191
|
+
name: "clear",
|
|
192
|
+
label: clearLabel,
|
|
193
|
+
icon: <Icon paths={INPUT_ICON_PATHS.clear} />,
|
|
194
|
+
// Nothing to clear is nothing to press: the button would be a target that does
|
|
195
|
+
// not respond, which reads as broken rather than as unavailable.
|
|
196
|
+
visible: value.length > 0,
|
|
197
|
+
onSelect: handleClear
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const overridden = new Set(actions.map((action) => action.name));
|
|
202
|
+
const rendered = [...builtIn.filter((action) => !overridden.has(action.name)), ...actions]
|
|
203
|
+
.filter((action) => action.visible !== false);
|
|
204
|
+
|
|
205
|
+
const locked = rest.disabled === true || rest.readOnly === true;
|
|
206
|
+
const buttons = rendered.length === 0 ? null : (
|
|
207
|
+
<span data-enigma-input-actions="" data-position={position} className={classNames?.actions}>
|
|
208
|
+
{rendered.map((action) => (
|
|
209
|
+
<button
|
|
210
|
+
key={action.name}
|
|
211
|
+
type="button"
|
|
212
|
+
data-enigma-input-action={action.name}
|
|
213
|
+
className={classNames?.action}
|
|
214
|
+
aria-label={action.label}
|
|
215
|
+
title={action.label}
|
|
216
|
+
aria-pressed={action.pressed}
|
|
217
|
+
disabled={locked}
|
|
218
|
+
// Keeps focus in the field: the visitor is mid-word and expects to keep
|
|
219
|
+
// typing, and a caret that never left needs no restoring.
|
|
220
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
221
|
+
onClick={action.onSelect}
|
|
222
|
+
>
|
|
223
|
+
{action.icon}
|
|
224
|
+
</button>
|
|
225
|
+
))}
|
|
226
|
+
</span>
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const wantsPasswordChunk = isPassword && (strength !== false || Boolean(breach));
|
|
230
|
+
const wantsSearchChunk = isSearch && Boolean(items || matcher);
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div
|
|
234
|
+
{...wrapperProps}
|
|
235
|
+
data-enigma-input-root=""
|
|
236
|
+
data-revealed={revealed ? "" : undefined}
|
|
237
|
+
data-breached={breached ? "" : undefined}
|
|
238
|
+
data-score={score ?? undefined}
|
|
239
|
+
>
|
|
240
|
+
<div {...fieldProps} data-enigma-input-field="">
|
|
241
|
+
{position === "start" && buttons}
|
|
242
|
+
<input
|
|
243
|
+
{...rest}
|
|
244
|
+
ref={(node) => {
|
|
245
|
+
innerRef.current = node;
|
|
246
|
+
setElement(node);
|
|
247
|
+
if (typeof forwardedRef === "function") forwardedRef(node);
|
|
248
|
+
else if (forwardedRef) forwardedRef.current = node;
|
|
249
|
+
}}
|
|
250
|
+
// Revealing a password is a type switch, which is what the caret dance
|
|
251
|
+
// above exists for.
|
|
252
|
+
type={revealed && isPassword ? "text" : type}
|
|
253
|
+
onChange={handleChange}
|
|
254
|
+
data-enigma-input=""
|
|
255
|
+
aria-describedby={score !== null ? describedBy : rest["aria-describedby"]}
|
|
256
|
+
/>
|
|
257
|
+
{position === "end" && buttons}
|
|
258
|
+
</div>
|
|
259
|
+
{/* No fallback: the field is already on screen and usable, and a spinner where a
|
|
260
|
+
meter is about to appear moves the layout twice for nothing. */}
|
|
261
|
+
{wantsPasswordChunk && (
|
|
262
|
+
<Suspense fallback={null}>
|
|
263
|
+
<PasswordExtras
|
|
264
|
+
id={describedBy}
|
|
265
|
+
value={value}
|
|
266
|
+
strength={strength}
|
|
267
|
+
onStrengthChange={onStrengthChange}
|
|
268
|
+
onScore={setScore}
|
|
269
|
+
breach={breach}
|
|
270
|
+
breachDelay={breachDelay}
|
|
271
|
+
onBreachChange={handleBreach}
|
|
272
|
+
classNames={classNames?.strength}
|
|
273
|
+
/>
|
|
274
|
+
</Suspense>
|
|
275
|
+
)}
|
|
276
|
+
{wantsSearchChunk && (
|
|
277
|
+
<Suspense fallback={null}>
|
|
278
|
+
<SearchExtras
|
|
279
|
+
input={element}
|
|
280
|
+
items={items}
|
|
281
|
+
keys={keys}
|
|
282
|
+
delay={delay}
|
|
283
|
+
limit={limit}
|
|
284
|
+
fuse={fuse}
|
|
285
|
+
fuseOptions={fuseOptions}
|
|
286
|
+
matcher={matcher}
|
|
287
|
+
onResults={onResults}
|
|
288
|
+
renderResults={renderResults}
|
|
289
|
+
/>
|
|
290
|
+
</Suspense>
|
|
291
|
+
)}
|
|
292
|
+
{children}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}) as <Item = unknown>(props: InputProps<Item> & { ref?: React.Ref<HTMLInputElement>; }) => ReactNode;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Types only. A VALUE re-export here (`PasswordStrength`, say) would be a static edge into
|
|
299
|
+
* the password chunk, and a static edge is exactly what stops it being a chunk: the bundler
|
|
300
|
+
* would fold it back into whatever imports `Input`. The entry re-exports it instead, where
|
|
301
|
+
* an unused export is dropped.
|
|
302
|
+
*/
|
|
303
|
+
export type {
|
|
304
|
+
InputProps, InputBaseProps, InputType, FieldAction,
|
|
305
|
+
BreachChecker, BreachState, BreachStatus,
|
|
306
|
+
PasswordOnlyProps, SearchOnlyProps, PlainOnlyProps
|
|
307
|
+
} from "@/react/input/types";
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { BreachChecker, BreachState, PasswordStrengthClassNames } from "@/react/input/types";
|
|
4
|
+
import { useEffect, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
|
5
|
+
import { estimatePasswordStrength, type EstimateOptions, type PasswordStrengthReport, type PasswordScore } from "@/core/password";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Everything only a password field needs: the estimator, the meter and the breach watcher.
|
|
9
|
+
*
|
|
10
|
+
* Its own chunk. `<Input>` imports it dynamically the moment a password field asks for
|
|
11
|
+
* `strength` or `breach`, so a form of text and email fields never downloads a word list, an
|
|
12
|
+
* entropy table or an abort controller it has no use for. Nothing here is imported by the
|
|
13
|
+
* field statically - that would fold the chunk straight back into it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const SCORE_LABELS = ["Very weak", "Weak", "Fair", "Strong", "Very strong"] as const;
|
|
17
|
+
|
|
18
|
+
export interface PasswordExtrasProps {
|
|
19
|
+
/** Id the field points `aria-describedby` at. */
|
|
20
|
+
id: string;
|
|
21
|
+
value: string;
|
|
22
|
+
strength: boolean | EstimateOptions;
|
|
23
|
+
onStrengthChange?: (report: PasswordStrengthReport) => void;
|
|
24
|
+
/** Reports the score up so the ROOT can carry `data-score` for a theme to style. */
|
|
25
|
+
onScore: (score: number | null) => void;
|
|
26
|
+
breach?: BreachChecker;
|
|
27
|
+
breachDelay: number;
|
|
28
|
+
onBreachChange: (state: BreachState) => void;
|
|
29
|
+
classNames?: PasswordStrengthClassNames;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function PasswordExtras({
|
|
33
|
+
id,
|
|
34
|
+
value,
|
|
35
|
+
strength,
|
|
36
|
+
onStrengthChange,
|
|
37
|
+
onScore,
|
|
38
|
+
breach,
|
|
39
|
+
breachDelay,
|
|
40
|
+
onBreachChange,
|
|
41
|
+
classNames
|
|
42
|
+
}: PasswordExtrasProps): ReactNode {
|
|
43
|
+
const estimateOptions = typeof strength === "object" ? strength : undefined;
|
|
44
|
+
const userInputs = estimateOptions?.userInputs;
|
|
45
|
+
const report = useMemo(
|
|
46
|
+
() => (strength === false ? null : estimatePasswordStrength(value, { userInputs })),
|
|
47
|
+
// The array is compared by identity, so a literal would re-estimate every render.
|
|
48
|
+
[strength, value, userInputs]
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const strengthListener = useRef(onStrengthChange);
|
|
52
|
+
strengthListener.current = onStrengthChange;
|
|
53
|
+
const scoreListener = useRef(onScore);
|
|
54
|
+
scoreListener.current = onScore;
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!report) return;
|
|
58
|
+
strengthListener.current?.(report);
|
|
59
|
+
scoreListener.current(report.empty ? null : report.score);
|
|
60
|
+
}, [report]);
|
|
61
|
+
|
|
62
|
+
/* -------- the breach check -------- */
|
|
63
|
+
|
|
64
|
+
const [breachState, setBreachState] = useState<BreachState>({ status: "idle", count: 0, error: null });
|
|
65
|
+
const breachListener = useRef(onBreachChange);
|
|
66
|
+
breachListener.current = onBreachChange;
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!breach || !value) {
|
|
70
|
+
setBreachState({ status: "idle", count: 0, error: null });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
setBreachState({ status: "checking", count: 0, error: null });
|
|
76
|
+
breach(value, { signal: controller.signal })
|
|
77
|
+
.then((result) => {
|
|
78
|
+
if (controller.signal.aborted) return;
|
|
79
|
+
setBreachState({ status: result.breached ? "breached" : "safe", count: result.count, error: null });
|
|
80
|
+
})
|
|
81
|
+
.catch((error: unknown) => {
|
|
82
|
+
// An abort is this effect cleaning up after itself, not a failure.
|
|
83
|
+
if (controller.signal.aborted) return;
|
|
84
|
+
setBreachState({ status: "error", count: 0, error });
|
|
85
|
+
});
|
|
86
|
+
}, breachDelay);
|
|
87
|
+
|
|
88
|
+
return () => {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
// Cancels the request in flight, so the answer to a password that is three
|
|
91
|
+
// keystrokes old can never overwrite the answer to the current one.
|
|
92
|
+
controller.abort();
|
|
93
|
+
};
|
|
94
|
+
}, [breach, value, breachDelay]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
breachListener.current(breachState);
|
|
98
|
+
}, [breachState]);
|
|
99
|
+
|
|
100
|
+
if (!report) return null;
|
|
101
|
+
return <PasswordStrength id={id} report={report} classNames={classNames} />;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface PasswordStrengthProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
|
105
|
+
/** The password to score. Ignored when `report` is given. */
|
|
106
|
+
value?: string;
|
|
107
|
+
/** A report you already have, e.g. from `<Input onStrengthChange>`. */
|
|
108
|
+
report?: PasswordStrengthReport;
|
|
109
|
+
userInputs?: string[];
|
|
110
|
+
/** Bars to draw. Five, so each score has one of its own. */
|
|
111
|
+
segments?: number;
|
|
112
|
+
/** Your own wording, worst first. */
|
|
113
|
+
labels?: readonly string[];
|
|
114
|
+
/** Show the top warning under the bars. On by default; it is the useful half. */
|
|
115
|
+
showWarning?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Classes for the inner parts. The score is on the ROOT, so a segment colours itself
|
|
118
|
+
* with a group variant - `group-data-[score=0]/strength:bg-red-600` and so on.
|
|
119
|
+
*/
|
|
120
|
+
classNames?: PasswordStrengthClassNames;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The bars under a password field.
|
|
125
|
+
*
|
|
126
|
+
* Structure and state only - `data-score` on the root and `data-filled` per segment are
|
|
127
|
+
* where the colours attach. The component picks no colours, because red-through-green is a
|
|
128
|
+
* palette decision and this package does not own one.
|
|
129
|
+
*/
|
|
130
|
+
export function PasswordStrength({
|
|
131
|
+
value = "",
|
|
132
|
+
report,
|
|
133
|
+
userInputs,
|
|
134
|
+
segments = 5,
|
|
135
|
+
labels = SCORE_LABELS,
|
|
136
|
+
showWarning = true,
|
|
137
|
+
classNames,
|
|
138
|
+
...props
|
|
139
|
+
}: PasswordStrengthProps): ReactNode {
|
|
140
|
+
const computed = useMemo(
|
|
141
|
+
() => report ?? estimatePasswordStrength(value, { userInputs }),
|
|
142
|
+
[report, value, userInputs]
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const score: PasswordScore = computed.score;
|
|
146
|
+
return (
|
|
147
|
+
<div
|
|
148
|
+
{...props}
|
|
149
|
+
data-enigma-password-strength=""
|
|
150
|
+
data-score={computed.empty ? undefined : score}
|
|
151
|
+
data-empty={computed.empty ? "" : undefined}
|
|
152
|
+
>
|
|
153
|
+
<div data-enigma-password-strength-track="" aria-hidden="true" className={classNames?.track}>
|
|
154
|
+
{Array.from({ length: segments }, (unused, index) => (
|
|
155
|
+
<span
|
|
156
|
+
key={index}
|
|
157
|
+
data-enigma-password-strength-segment=""
|
|
158
|
+
className={classNames?.segment}
|
|
159
|
+
// Score 0 still fills one bar: an empty track next to a filled
|
|
160
|
+
// field reads as "not measured", not as "this is a bad password".
|
|
161
|
+
data-filled={!computed.empty && index <= score ? "" : undefined}
|
|
162
|
+
/>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
{/* Announced when the band changes, which is rarely enough not to chatter. */}
|
|
166
|
+
<p data-enigma-password-strength-label="" role="status" aria-live="polite" className={classNames?.label}>
|
|
167
|
+
{computed.empty ? "" : labels[score] ?? ""}
|
|
168
|
+
</p>
|
|
169
|
+
{showWarning && !computed.empty && computed.warnings.length > 0 && (
|
|
170
|
+
<p data-enigma-password-strength-warning="" className={classNames?.warning}>{computed.warnings[0]}</p>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import { createSearch, type SearchInstance, type SearchMatch, type SearchOptions } from "@/core/search";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The search wiring for `<Input type="search">`: debouncing, ranking, cancellation, and the
|
|
8
|
+
* results the field reports.
|
|
9
|
+
*
|
|
10
|
+
* Its own chunk, imported the moment a search field is given something to search - so a
|
|
11
|
+
* password form never downloads a matcher, and a project that passes Fuse's constructor
|
|
12
|
+
* only pays for it on the page that has the field.
|
|
13
|
+
*
|
|
14
|
+
* The engine is attached to the field the base component rendered rather than rendering one
|
|
15
|
+
* of its own: two owners of one input is the thing that makes a wrapper feel wrong, and the
|
|
16
|
+
* field has to stay a plain `<input>` the consumer can pass any native prop to.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface SearchExtrasProps<Item = unknown> {
|
|
20
|
+
/** The field itself. Null until it mounts, which is one render. */
|
|
21
|
+
input: HTMLInputElement | null;
|
|
22
|
+
items?: Item[];
|
|
23
|
+
keys?: SearchOptions<Item>["keys"];
|
|
24
|
+
delay?: number;
|
|
25
|
+
limit?: number;
|
|
26
|
+
fuse?: SearchOptions<Item>["fuse"];
|
|
27
|
+
fuseOptions?: SearchOptions<Item>["fuseOptions"];
|
|
28
|
+
matcher?: SearchOptions<Item>["matcher"];
|
|
29
|
+
onResults?: (matches: SearchMatch<Item>[], query: string) => void;
|
|
30
|
+
renderResults?: (matches: SearchMatch<Item>[], query: string) => ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function SearchExtras<Item>({
|
|
34
|
+
input,
|
|
35
|
+
items,
|
|
36
|
+
keys,
|
|
37
|
+
delay = 150,
|
|
38
|
+
limit,
|
|
39
|
+
fuse,
|
|
40
|
+
fuseOptions,
|
|
41
|
+
matcher,
|
|
42
|
+
onResults,
|
|
43
|
+
renderResults
|
|
44
|
+
}: SearchExtrasProps<Item>): ReactNode {
|
|
45
|
+
const [results, setResults] = useState<SearchMatch<Item>[]>([]);
|
|
46
|
+
const [query, setQuery] = useState("");
|
|
47
|
+
|
|
48
|
+
const listener = useRef(onResults);
|
|
49
|
+
listener.current = onResults;
|
|
50
|
+
|
|
51
|
+
// Built once. The engine indexes on construction, so rebuilding it per render would
|
|
52
|
+
// re-index the whole list on every keystroke - the defect this exists to avoid.
|
|
53
|
+
const instance = useMemo<SearchInstance<Item>>(() => createSearch<Item>({
|
|
54
|
+
items,
|
|
55
|
+
keys,
|
|
56
|
+
fuse,
|
|
57
|
+
fuseOptions,
|
|
58
|
+
matcher,
|
|
59
|
+
debounce: delay,
|
|
60
|
+
limit,
|
|
61
|
+
onResults: (next, nextQuery) => {
|
|
62
|
+
setResults(next);
|
|
63
|
+
setQuery(nextQuery);
|
|
64
|
+
listener.current?.(next, nextQuery);
|
|
65
|
+
}
|
|
66
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
67
|
+
}), []);
|
|
68
|
+
|
|
69
|
+
useEffect(() => () => instance.destroy(), [instance]);
|
|
70
|
+
|
|
71
|
+
// New data must re-index and re-run the visible query, or the list keeps showing
|
|
72
|
+
// matches against items that are gone.
|
|
73
|
+
useEffect(() => { instance.setItems(items ?? []); }, [instance, items]);
|
|
74
|
+
useEffect(() => { instance.update({ keys, fuse, fuseOptions, matcher, debounce: delay, limit }); }, [instance, keys, fuse, fuseOptions, matcher, delay, limit]);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!input) return;
|
|
78
|
+
return instance.attach(input);
|
|
79
|
+
}, [instance, input]);
|
|
80
|
+
|
|
81
|
+
return renderResults ? <>{renderResults(results, query)}</> : null;
|
|
82
|
+
}
|