@timeax/form-palette 0.0.1
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/.scaffold-cache.json +537 -0
- package/package.json +42 -0
- package/src/.scaffold-cache.json +544 -0
- package/src/adapters/axios.ts +117 -0
- package/src/adapters/index.ts +91 -0
- package/src/adapters/inertia.ts +187 -0
- package/src/core/adapter-registry.ts +87 -0
- package/src/core/bound/bind-host.ts +14 -0
- package/src/core/bound/observe-bound-field.ts +172 -0
- package/src/core/bound/wait-for-bound-field.ts +57 -0
- package/src/core/context.ts +23 -0
- package/src/core/core-provider.tsx +818 -0
- package/src/core/core-root.tsx +72 -0
- package/src/core/core-shell.tsx +44 -0
- package/src/core/errors/error-strip.tsx +71 -0
- package/src/core/errors/index.ts +2 -0
- package/src/core/errors/map-error-bag.ts +51 -0
- package/src/core/errors/map-zod.ts +39 -0
- package/src/core/hooks/use-button.ts +220 -0
- package/src/core/hooks/use-core-context.ts +20 -0
- package/src/core/hooks/use-core-utility.ts +0 -0
- package/src/core/hooks/use-core.ts +13 -0
- package/src/core/hooks/use-field.ts +497 -0
- package/src/core/hooks/use-optional-field.ts +28 -0
- package/src/core/index.ts +0 -0
- package/src/core/registry/binder-registry.ts +82 -0
- package/src/core/registry/field-registry.ts +187 -0
- package/src/core/test.tsx +17 -0
- package/src/global.d.ts +14 -0
- package/src/index.ts +68 -0
- package/src/input/index.ts +4 -0
- package/src/input/input-field.tsx +854 -0
- package/src/input/input-layout-graph.ts +230 -0
- package/src/input/input-props.ts +190 -0
- package/src/lib/get-global-countries.ts +87 -0
- package/src/lib/utils.ts +6 -0
- package/src/presets/index.ts +0 -0
- package/src/presets/shadcn-preset.ts +0 -0
- package/src/presets/shadcn-variants/checkbox.tsx +849 -0
- package/src/presets/shadcn-variants/chips.tsx +756 -0
- package/src/presets/shadcn-variants/color.tsx +284 -0
- package/src/presets/shadcn-variants/custom.tsx +227 -0
- package/src/presets/shadcn-variants/date.tsx +796 -0
- package/src/presets/shadcn-variants/file.tsx +764 -0
- package/src/presets/shadcn-variants/keyvalue.tsx +556 -0
- package/src/presets/shadcn-variants/multiselect.tsx +1132 -0
- package/src/presets/shadcn-variants/number.tsx +176 -0
- package/src/presets/shadcn-variants/password.tsx +737 -0
- package/src/presets/shadcn-variants/phone.tsx +628 -0
- package/src/presets/shadcn-variants/radio.tsx +578 -0
- package/src/presets/shadcn-variants/select.tsx +956 -0
- package/src/presets/shadcn-variants/slider.tsx +622 -0
- package/src/presets/shadcn-variants/text.tsx +343 -0
- package/src/presets/shadcn-variants/textarea.tsx +66 -0
- package/src/presets/shadcn-variants/toggle.tsx +218 -0
- package/src/presets/shadcn-variants/treeselect.tsx +784 -0
- package/src/presets/ui/badge.tsx +46 -0
- package/src/presets/ui/button.tsx +60 -0
- package/src/presets/ui/calendar.tsx +214 -0
- package/src/presets/ui/checkbox.tsx +115 -0
- package/src/presets/ui/custom.tsx +0 -0
- package/src/presets/ui/dialog.tsx +141 -0
- package/src/presets/ui/field.tsx +246 -0
- package/src/presets/ui/input-mask.tsx +739 -0
- package/src/presets/ui/input-otp.tsx +77 -0
- package/src/presets/ui/input.tsx +1011 -0
- package/src/presets/ui/label.tsx +22 -0
- package/src/presets/ui/number.tsx +1370 -0
- package/src/presets/ui/popover.tsx +46 -0
- package/src/presets/ui/radio-group.tsx +43 -0
- package/src/presets/ui/scroll-area.tsx +56 -0
- package/src/presets/ui/select.tsx +190 -0
- package/src/presets/ui/separator.tsx +28 -0
- package/src/presets/ui/slider.tsx +61 -0
- package/src/presets/ui/switch.tsx +32 -0
- package/src/presets/ui/textarea.tsx +634 -0
- package/src/presets/ui/time-dropdowns.tsx +350 -0
- package/src/schema/adapter.ts +217 -0
- package/src/schema/core.ts +429 -0
- package/src/schema/field-map.ts +0 -0
- package/src/schema/field.ts +224 -0
- package/src/schema/index.ts +0 -0
- package/src/schema/input-field.ts +260 -0
- package/src/schema/presets.ts +0 -0
- package/src/schema/variant.ts +216 -0
- package/src/variants/core/checkbox.tsx +54 -0
- package/src/variants/core/chips.tsx +22 -0
- package/src/variants/core/color.tsx +16 -0
- package/src/variants/core/custom.tsx +18 -0
- package/src/variants/core/date.tsx +25 -0
- package/src/variants/core/file.tsx +9 -0
- package/src/variants/core/keyvalue.tsx +12 -0
- package/src/variants/core/multiselect.tsx +28 -0
- package/src/variants/core/number.tsx +115 -0
- package/src/variants/core/password.tsx +35 -0
- package/src/variants/core/phone.tsx +16 -0
- package/src/variants/core/radio.tsx +38 -0
- package/src/variants/core/select.tsx +15 -0
- package/src/variants/core/slider.tsx +55 -0
- package/src/variants/core/text.tsx +114 -0
- package/src/variants/core/textarea.tsx +22 -0
- package/src/variants/core/toggle.tsx +50 -0
- package/src/variants/core/treeselect.tsx +11 -0
- package/src/variants/helpers/selection-summary.tsx +236 -0
- package/src/variants/index.ts +75 -0
- package/src/variants/registry.ts +38 -0
- package/src/variants/select-shared.ts +0 -0
- package/src/variants/shared.ts +126 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
// src/presets/shadcn-variants/password.tsx
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import type { VariantBaseProps, ChangeDetail } from "@/variants/shared";
|
|
6
|
+
import type { ShadcnTextVariantProps } from "@/presets/shadcn-variants/text";
|
|
7
|
+
import { Input } from "@/presets/ui/input";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
import { Eye, EyeOff, Check } from "lucide-react";
|
|
10
|
+
|
|
11
|
+
type BaseProps = VariantBaseProps<string | undefined>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Options for the built-in password strength meter.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: Score is always in the range 0–4 (inclusive).
|
|
17
|
+
*/
|
|
18
|
+
export interface StrengthOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Custom scoring function.
|
|
21
|
+
* Return a number in the range 0–4 (inclusive) where 0 = weakest, 4 = strongest.
|
|
22
|
+
*/
|
|
23
|
+
calc?: (value: string) => number;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Labels for each score bucket (index 0..4).
|
|
27
|
+
* Defaults to: ["Very weak", "Weak", "Okay", "Good", "Strong"]
|
|
28
|
+
*/
|
|
29
|
+
labels?: [string, string, string, string, string];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Thresholds for score steps using a 0–100 bar.
|
|
33
|
+
* Defaults to [0, 25, 50, 75, 100] mapping to scores 0..4 respectively.
|
|
34
|
+
*/
|
|
35
|
+
thresholds?: [number, number, number, number, number];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Minimum score required to consider the password acceptable (0–4).
|
|
39
|
+
* This is purely visual unless you enforce it in validate/onChange.
|
|
40
|
+
* Default: 2
|
|
41
|
+
*/
|
|
42
|
+
minScore?: number | 2;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether to show the textual label next to/under the bar.
|
|
46
|
+
* Default: true
|
|
47
|
+
*/
|
|
48
|
+
showLabel?: boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Where to render the meter.
|
|
52
|
+
* - "inline" → compact row under the input
|
|
53
|
+
* - "block" → stacked with more spacing
|
|
54
|
+
* Default: "inline"
|
|
55
|
+
*/
|
|
56
|
+
display?: "inline" | "block";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Heuristic length/charset score: fast, dependency-free. Returns 0..4. */
|
|
60
|
+
function defaultScore(pw: string): number {
|
|
61
|
+
if (!pw) return 0;
|
|
62
|
+
let score = 0;
|
|
63
|
+
|
|
64
|
+
// length
|
|
65
|
+
if (pw.length >= 8) score++;
|
|
66
|
+
if (pw.length >= 12) score++;
|
|
67
|
+
|
|
68
|
+
// diversity
|
|
69
|
+
const hasLower = /[a-z]/.test(pw);
|
|
70
|
+
const hasUpper = /[A-Z]/.test(pw);
|
|
71
|
+
const hasDigit = /\d/.test(pw);
|
|
72
|
+
const hasSymbol = /[^A-Za-z0-9]/.test(pw);
|
|
73
|
+
|
|
74
|
+
const variety = [hasLower, hasUpper, hasDigit, hasSymbol].filter(Boolean)
|
|
75
|
+
.length;
|
|
76
|
+
if (variety >= 2) score++;
|
|
77
|
+
if (variety >= 3) score++;
|
|
78
|
+
|
|
79
|
+
// Cap at 4
|
|
80
|
+
return Math.max(0, Math.min(4, score));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const DEFAULT_LABELS: [string, string, string, string, string] = [
|
|
84
|
+
"Very weak",
|
|
85
|
+
"Weak",
|
|
86
|
+
"Okay",
|
|
87
|
+
"Good",
|
|
88
|
+
"Strong",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const DEFAULT_THRESHOLDS: [number, number, number, number, number] = [
|
|
92
|
+
0, 25, 50, 75, 100,
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
function normalizeStrengthOptions(
|
|
96
|
+
raw: boolean | StrengthOptions | undefined,
|
|
97
|
+
): StrengthOptions | null {
|
|
98
|
+
if (!raw) return null;
|
|
99
|
+
|
|
100
|
+
const base: StrengthOptions = {
|
|
101
|
+
calc: defaultScore,
|
|
102
|
+
labels: DEFAULT_LABELS,
|
|
103
|
+
thresholds: DEFAULT_THRESHOLDS,
|
|
104
|
+
minScore: 2,
|
|
105
|
+
showLabel: true,
|
|
106
|
+
display: "inline",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (raw === true) {
|
|
110
|
+
return base;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
...base,
|
|
115
|
+
...raw,
|
|
116
|
+
labels: raw.labels ?? base.labels,
|
|
117
|
+
thresholds: raw.thresholds ?? base.thresholds,
|
|
118
|
+
minScore: raw.minScore ?? base.minScore,
|
|
119
|
+
showLabel: raw.showLabel ?? base.showLabel,
|
|
120
|
+
display: raw.display ?? base.display,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─────────────────────────────────────────────
|
|
125
|
+
// Definition map / rules
|
|
126
|
+
// ─────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
export interface PasswordRuleConfig {
|
|
129
|
+
/**
|
|
130
|
+
* Pattern used to decide if the rule passes.
|
|
131
|
+
*/
|
|
132
|
+
pattern: RegExp;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* If true, the rule is considered optional (recommendation).
|
|
136
|
+
* Default: false unless the rule name is not prefixed with "!".
|
|
137
|
+
*/
|
|
138
|
+
optional?: boolean;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Weight in the scoring (relative importance).
|
|
142
|
+
* Default: 1, doubled if the use key is prefixed with "!".
|
|
143
|
+
*/
|
|
144
|
+
weight?: number;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Short label for the rule (e.g. "At least 8 characters").
|
|
148
|
+
* Defaults to the map key if omitted.
|
|
149
|
+
*/
|
|
150
|
+
label?: string;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Longer description, used in detailed rule view.
|
|
154
|
+
*/
|
|
155
|
+
description?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A definition entry can be:
|
|
160
|
+
* - string → treated as a regex source
|
|
161
|
+
* - RegExp → used directly
|
|
162
|
+
* - full config
|
|
163
|
+
*/
|
|
164
|
+
export type PasswordRuleDefinition =
|
|
165
|
+
| string
|
|
166
|
+
| RegExp
|
|
167
|
+
| PasswordRuleConfig;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Map of alias/keys → definition entries.
|
|
171
|
+
*/
|
|
172
|
+
export type PasswordDefinitionMap = Record<string, PasswordRuleDefinition>;
|
|
173
|
+
|
|
174
|
+
// Default rule definitions used by the meter.
|
|
175
|
+
const DEFAULT_RULE_DEFINITIONS: PasswordDefinitionMap = {
|
|
176
|
+
"length-8": {
|
|
177
|
+
pattern: /.{8,}/,
|
|
178
|
+
label: "8+ chars",
|
|
179
|
+
description: "Use at least 8 characters.",
|
|
180
|
+
},
|
|
181
|
+
"length-12": {
|
|
182
|
+
pattern: /.{12,}/,
|
|
183
|
+
optional: true,
|
|
184
|
+
label: "12+ chars",
|
|
185
|
+
description: "Use 12 or more characters for stronger security.",
|
|
186
|
+
},
|
|
187
|
+
lower: {
|
|
188
|
+
pattern: /[a-z]/,
|
|
189
|
+
label: "Lowercase",
|
|
190
|
+
description: "Include at least one lowercase letter (a–z).",
|
|
191
|
+
},
|
|
192
|
+
upper: {
|
|
193
|
+
pattern: /[A-Z]/,
|
|
194
|
+
label: "Uppercase",
|
|
195
|
+
description: "Include at least one uppercase letter (A–Z).",
|
|
196
|
+
},
|
|
197
|
+
digit: {
|
|
198
|
+
pattern: /\d/,
|
|
199
|
+
label: "Number",
|
|
200
|
+
description: "Include at least one digit (0–9).",
|
|
201
|
+
},
|
|
202
|
+
symbol: {
|
|
203
|
+
pattern: /[^A-Za-z0-9]/,
|
|
204
|
+
label: "Symbol",
|
|
205
|
+
description: "Include at least one symbol (e.g. !, @, #, ?).",
|
|
206
|
+
},
|
|
207
|
+
"no-space": {
|
|
208
|
+
pattern: /^\S+$/,
|
|
209
|
+
optional: true,
|
|
210
|
+
label: "No spaces",
|
|
211
|
+
description: "Avoid spaces in your password.",
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Merge default → global → local rule definitions.
|
|
217
|
+
*
|
|
218
|
+
* - DEFAULT_RULE_DEFINITIONS
|
|
219
|
+
* - window["form-palette"]?.ruleDefinition
|
|
220
|
+
* - props.ruleDefinitions
|
|
221
|
+
*/
|
|
222
|
+
function getMergedRuleDefinitions(
|
|
223
|
+
local?: PasswordDefinitionMap,
|
|
224
|
+
): PasswordDefinitionMap {
|
|
225
|
+
let merged: PasswordDefinitionMap = { ...DEFAULT_RULE_DEFINITIONS };
|
|
226
|
+
|
|
227
|
+
if (typeof window !== "undefined") {
|
|
228
|
+
const fp = (window as any)["form-palette"];
|
|
229
|
+
const globalDefs = fp?.ruleDefinition as
|
|
230
|
+
| PasswordDefinitionMap
|
|
231
|
+
| undefined;
|
|
232
|
+
|
|
233
|
+
if (globalDefs && typeof globalDefs === "object") {
|
|
234
|
+
merged = { ...merged, ...globalDefs };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (local && typeof local === "object") {
|
|
239
|
+
merged = { ...merged, ...local };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return merged;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Internal normalized state for a single rule.
|
|
247
|
+
*/
|
|
248
|
+
interface NormalizedRuleState {
|
|
249
|
+
key: string;
|
|
250
|
+
label: string;
|
|
251
|
+
description?: string;
|
|
252
|
+
optional: boolean;
|
|
253
|
+
required: boolean;
|
|
254
|
+
weight: number;
|
|
255
|
+
passed: boolean;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Props passed to custom meter renderers.
|
|
260
|
+
*/
|
|
261
|
+
export interface PasswordMeterRenderProps {
|
|
262
|
+
/** Raw password value. */
|
|
263
|
+
value: string;
|
|
264
|
+
/** Bucket score 0..4 based on percent + thresholds. */
|
|
265
|
+
score: number;
|
|
266
|
+
/** 0–100 progress used for the bar. */
|
|
267
|
+
percent: number;
|
|
268
|
+
/** Human label for the current score. */
|
|
269
|
+
label: string;
|
|
270
|
+
/** Whether score >= minScore. */
|
|
271
|
+
passed: boolean;
|
|
272
|
+
/** Effective minScore after normalization. */
|
|
273
|
+
minScore: number;
|
|
274
|
+
/** Effective thresholds used for bucketing. */
|
|
275
|
+
thresholds: [number, number, number, number, number];
|
|
276
|
+
/** Effective labels used. */
|
|
277
|
+
labels: [string, string, string, string, string];
|
|
278
|
+
/** Rule-level details when using a definition map. */
|
|
279
|
+
rules: NormalizedRuleState[];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Password-only props (on top of Shadcn text UI props & VariantBaseProps).
|
|
284
|
+
*
|
|
285
|
+
* This is what the form runtime sees as VariantPropsFor<"password">.
|
|
286
|
+
*/
|
|
287
|
+
export interface PasswordVariantProps {
|
|
288
|
+
/** Maximum number of characters permitted. */
|
|
289
|
+
maxLength?: number;
|
|
290
|
+
/** Browser autocomplete hint (e.g., "current-password", "new-password"). */
|
|
291
|
+
autoComplete?: string;
|
|
292
|
+
|
|
293
|
+
/** Show an eye button to toggle between obscured/plain text. (default: true) */
|
|
294
|
+
revealToggle?: boolean;
|
|
295
|
+
/** Start in the revealed (plain text) state. */
|
|
296
|
+
defaultRevealed?: boolean;
|
|
297
|
+
/** Called whenever the reveal state changes. */
|
|
298
|
+
onRevealChange?(revealed: boolean): void;
|
|
299
|
+
/** Override the icons used for hide/show. */
|
|
300
|
+
renderToggleIcon?(revealed: boolean): React.ReactNode;
|
|
301
|
+
/** Accessible label for the toggle button. */
|
|
302
|
+
toggleAriaLabel?(revealed: boolean): string;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Extra className for the reveal toggle button.
|
|
306
|
+
*/
|
|
307
|
+
toggleButtonClassName?: string;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Enable the built-in strength meter (boolean or options).
|
|
311
|
+
*
|
|
312
|
+
* - false / undefined → no built-in meter is shown
|
|
313
|
+
* - true → use defaults
|
|
314
|
+
* - object → merge with defaults
|
|
315
|
+
*/
|
|
316
|
+
strengthMeter?: boolean | StrengthOptions;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Optional rule definition map.
|
|
320
|
+
*/
|
|
321
|
+
ruleDefinitions?: PasswordDefinitionMap;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Selection of rule aliases to apply.
|
|
325
|
+
*
|
|
326
|
+
* - "length" → use ruleDefinitions["length"] with default importance
|
|
327
|
+
* - "!length" → same rule but treated as more important
|
|
328
|
+
*/
|
|
329
|
+
ruleUses?: string[];
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Built-in meter style:
|
|
333
|
+
* - "simple" → single bar + label
|
|
334
|
+
* - "rules" → bar + per-rule checklist
|
|
335
|
+
* Default: "simple"
|
|
336
|
+
*/
|
|
337
|
+
meterStyle?: "simple" | "rules";
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Optional custom meter renderer.
|
|
341
|
+
*/
|
|
342
|
+
renderMeter?(props: PasswordMeterRenderProps): React.ReactNode;
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* ClassNames for the meter and rules UI.
|
|
346
|
+
*/
|
|
347
|
+
meterWrapperClassName?: string;
|
|
348
|
+
meterContainerClassName?: string;
|
|
349
|
+
meterBarClassName?: string;
|
|
350
|
+
meterLabelClassName?: string;
|
|
351
|
+
|
|
352
|
+
rulesWrapperClassName?: string;
|
|
353
|
+
rulesHeadingClassName?: string;
|
|
354
|
+
rulesListClassName?: string;
|
|
355
|
+
ruleItemClassName?: string;
|
|
356
|
+
ruleIconClassName?: string;
|
|
357
|
+
ruleLabelClassName?: string;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Extra className for the outer field wrapper.
|
|
361
|
+
*/
|
|
362
|
+
className?: string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// We still *type* against ShadcnTextVariantProps so password can reuse
|
|
366
|
+
// all the visual/text props. We take control of type, value, onValue & trailingControl.
|
|
367
|
+
type TextUiProps = Omit<
|
|
368
|
+
ShadcnTextVariantProps,
|
|
369
|
+
"type" | "inputMode" | "leadingControl" | "trailingControl" | "value" | "onValue"
|
|
370
|
+
>;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Full props for the Shadcn-based password variant.
|
|
374
|
+
*/
|
|
375
|
+
export type ShadcnPasswordVariantProps = TextUiProps &
|
|
376
|
+
PasswordVariantProps &
|
|
377
|
+
Pick<BaseProps, "value" | "onValue" | "error">;
|
|
378
|
+
|
|
379
|
+
// ─────────────────────────────────────────────
|
|
380
|
+
// Rule normalization & scoring
|
|
381
|
+
// ─────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
function normalizeRules(
|
|
384
|
+
value: string,
|
|
385
|
+
definitions?: PasswordDefinitionMap,
|
|
386
|
+
uses?: string[],
|
|
387
|
+
): { rules: NormalizedRuleState[]; percent: number | null } {
|
|
388
|
+
if (!definitions || Object.keys(definitions).length === 0) {
|
|
389
|
+
return { rules: [], percent: null };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const useList =
|
|
393
|
+
uses && uses.length ? uses : Object.keys(definitions);
|
|
394
|
+
|
|
395
|
+
const rules: NormalizedRuleState[] = [];
|
|
396
|
+
let totalWeight = 0;
|
|
397
|
+
let passedWeight = 0;
|
|
398
|
+
|
|
399
|
+
for (const rawKey of useList) {
|
|
400
|
+
if (!rawKey) continue;
|
|
401
|
+
|
|
402
|
+
const important = rawKey.startsWith("!");
|
|
403
|
+
const key = important ? rawKey.slice(1) : rawKey;
|
|
404
|
+
const def = definitions[key];
|
|
405
|
+
if (!def) continue;
|
|
406
|
+
|
|
407
|
+
let pattern: RegExp;
|
|
408
|
+
let optional = !important;
|
|
409
|
+
let weight = important ? 2 : 1;
|
|
410
|
+
let label = key;
|
|
411
|
+
let description: string | undefined;
|
|
412
|
+
|
|
413
|
+
if (typeof def === "string") {
|
|
414
|
+
pattern = new RegExp(def);
|
|
415
|
+
} else if (def instanceof RegExp) {
|
|
416
|
+
pattern = def;
|
|
417
|
+
} else {
|
|
418
|
+
pattern = def.pattern;
|
|
419
|
+
if (def.optional !== undefined) optional = def.optional;
|
|
420
|
+
if (def.weight !== undefined) weight = def.weight;
|
|
421
|
+
if (def.label) label = def.label;
|
|
422
|
+
if (def.description) description = def.description;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const passed = pattern.test(value);
|
|
426
|
+
totalWeight += weight;
|
|
427
|
+
if (passed) passedWeight += weight;
|
|
428
|
+
|
|
429
|
+
rules.push({
|
|
430
|
+
key,
|
|
431
|
+
label,
|
|
432
|
+
description,
|
|
433
|
+
optional,
|
|
434
|
+
required: !optional,
|
|
435
|
+
weight,
|
|
436
|
+
passed,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (totalWeight === 0) {
|
|
441
|
+
return { rules, percent: null };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const percent = (passedWeight / totalWeight) * 100;
|
|
445
|
+
return { rules, percent };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function clampScore(x: number): number {
|
|
449
|
+
if (Number.isNaN(x)) return 0;
|
|
450
|
+
return Math.max(0, Math.min(4, x));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function computeMeterState(
|
|
454
|
+
value: string,
|
|
455
|
+
strength: StrengthOptions,
|
|
456
|
+
definitions?: PasswordDefinitionMap,
|
|
457
|
+
uses?: string[],
|
|
458
|
+
): PasswordMeterRenderProps {
|
|
459
|
+
const { rules, percent: rulesPercent } = normalizeRules(
|
|
460
|
+
value,
|
|
461
|
+
definitions,
|
|
462
|
+
uses,
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const labels = strength.labels ?? DEFAULT_LABELS;
|
|
466
|
+
const thresholds = strength.thresholds ?? DEFAULT_THRESHOLDS;
|
|
467
|
+
const minScore = (strength.minScore ?? 2) as number;
|
|
468
|
+
|
|
469
|
+
let percent: number;
|
|
470
|
+
let score: number;
|
|
471
|
+
|
|
472
|
+
if (rulesPercent != null) {
|
|
473
|
+
percent = rulesPercent;
|
|
474
|
+
} else {
|
|
475
|
+
const rawScore = clampScore(
|
|
476
|
+
strength.calc ? strength.calc(value) : defaultScore(value),
|
|
477
|
+
);
|
|
478
|
+
percent = (rawScore / 4) * 100;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
let bucketIndex = 0;
|
|
482
|
+
for (let i = 0; i < thresholds.length; i++) {
|
|
483
|
+
if (percent >= thresholds[i]) {
|
|
484
|
+
bucketIndex = i;
|
|
485
|
+
} else {
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
score = bucketIndex;
|
|
490
|
+
|
|
491
|
+
const label =
|
|
492
|
+
labels[score] ??
|
|
493
|
+
labels[labels.length - 1] ??
|
|
494
|
+
DEFAULT_LABELS[DEFAULT_LABELS.length - 1];
|
|
495
|
+
|
|
496
|
+
const passed = score >= minScore;
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
value,
|
|
500
|
+
score,
|
|
501
|
+
percent,
|
|
502
|
+
label,
|
|
503
|
+
passed,
|
|
504
|
+
minScore,
|
|
505
|
+
thresholds: thresholds,
|
|
506
|
+
labels,
|
|
507
|
+
rules,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function meterColor(score: number): string {
|
|
512
|
+
if (score <= 1) return "bg-destructive";
|
|
513
|
+
if (score === 2) return "bg-orange-500";
|
|
514
|
+
if (score === 3) return "bg-amber-500";
|
|
515
|
+
return "bg-emerald-500";
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ─────────────────────────────────────────────
|
|
519
|
+
// Main variant component
|
|
520
|
+
// ─────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
export const ShadcnPasswordVariant = React.forwardRef<
|
|
523
|
+
HTMLInputElement,
|
|
524
|
+
ShadcnPasswordVariantProps
|
|
525
|
+
>(function ShadcnPasswordVariant(props, ref) {
|
|
526
|
+
const {
|
|
527
|
+
// base variant bits
|
|
528
|
+
value,
|
|
529
|
+
onValue,
|
|
530
|
+
error,
|
|
531
|
+
|
|
532
|
+
// password base props
|
|
533
|
+
maxLength,
|
|
534
|
+
autoComplete,
|
|
535
|
+
revealToggle = true,
|
|
536
|
+
defaultRevealed = false,
|
|
537
|
+
onRevealChange,
|
|
538
|
+
renderToggleIcon,
|
|
539
|
+
toggleAriaLabel,
|
|
540
|
+
toggleButtonClassName,
|
|
541
|
+
|
|
542
|
+
// strength / rules
|
|
543
|
+
strengthMeter,
|
|
544
|
+
ruleDefinitions,
|
|
545
|
+
ruleUses,
|
|
546
|
+
meterStyle = "simple",
|
|
547
|
+
renderMeter,
|
|
548
|
+
meterWrapperClassName,
|
|
549
|
+
meterContainerClassName,
|
|
550
|
+
meterBarClassName,
|
|
551
|
+
meterLabelClassName,
|
|
552
|
+
rulesWrapperClassName,
|
|
553
|
+
rulesHeadingClassName,
|
|
554
|
+
rulesListClassName,
|
|
555
|
+
ruleItemClassName,
|
|
556
|
+
ruleIconClassName,
|
|
557
|
+
ruleLabelClassName,
|
|
558
|
+
|
|
559
|
+
className,
|
|
560
|
+
|
|
561
|
+
// everything else from Shadcn text UI
|
|
562
|
+
...restTextProps
|
|
563
|
+
} = props;
|
|
564
|
+
|
|
565
|
+
const [revealed, setRevealed] = React.useState<boolean>(
|
|
566
|
+
Boolean(defaultRevealed),
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const normalizedStrength = React.useMemo(
|
|
570
|
+
() => normalizeStrengthOptions(strengthMeter),
|
|
571
|
+
[strengthMeter],
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
const effectiveRuleDefinitions = React.useMemo(
|
|
575
|
+
() => getMergedRuleDefinitions(ruleDefinitions),
|
|
576
|
+
[ruleDefinitions],
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
const meterState = React.useMemo<PasswordMeterRenderProps | null>(() => {
|
|
580
|
+
if (!normalizedStrength) return null;
|
|
581
|
+
const v = value ?? "";
|
|
582
|
+
return computeMeterState(
|
|
583
|
+
v,
|
|
584
|
+
normalizedStrength,
|
|
585
|
+
effectiveRuleDefinitions,
|
|
586
|
+
ruleUses,
|
|
587
|
+
);
|
|
588
|
+
}, [normalizedStrength, value, ruleUses, effectiveRuleDefinitions]);
|
|
589
|
+
|
|
590
|
+
const handleToggleReveal = React.useCallback(() => {
|
|
591
|
+
setRevealed((prev) => {
|
|
592
|
+
const next = !prev;
|
|
593
|
+
onRevealChange?.(next);
|
|
594
|
+
return next;
|
|
595
|
+
});
|
|
596
|
+
}, [onRevealChange]);
|
|
597
|
+
|
|
598
|
+
const handleChange = React.useCallback(
|
|
599
|
+
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
600
|
+
const next = event.target.value ?? "";
|
|
601
|
+
const detail: ChangeDetail<PasswordMeterRenderProps | undefined> = {
|
|
602
|
+
source: "variant",
|
|
603
|
+
raw: next,
|
|
604
|
+
nativeEvent: event,
|
|
605
|
+
meta: meterState ?? undefined,
|
|
606
|
+
};
|
|
607
|
+
onValue?.(next, detail);
|
|
608
|
+
},
|
|
609
|
+
[onValue, meterState],
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
const toggleLabel =
|
|
613
|
+
toggleAriaLabel?.(revealed) ??
|
|
614
|
+
(revealed ? "Hide password" : "Show password");
|
|
615
|
+
|
|
616
|
+
const trailingControl =
|
|
617
|
+
revealToggle === false ? undefined : (
|
|
618
|
+
<button
|
|
619
|
+
type="button"
|
|
620
|
+
onClick={handleToggleReveal}
|
|
621
|
+
aria-label={toggleLabel}
|
|
622
|
+
tabIndex={-1}
|
|
623
|
+
className={cn(
|
|
624
|
+
"inline-flex h-full items-center justify-center px-3 text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/50 focus-visible:outline-none focus-visible:bg-muted/50",
|
|
625
|
+
toggleButtonClassName,
|
|
626
|
+
)}
|
|
627
|
+
data-slot="password-toggle"
|
|
628
|
+
>
|
|
629
|
+
{renderToggleIcon ? (
|
|
630
|
+
renderToggleIcon(revealed)
|
|
631
|
+
) : revealed ? (
|
|
632
|
+
<EyeOff className="h-4 w-4" />
|
|
633
|
+
) : (
|
|
634
|
+
<Eye className="h-4 w-4" />
|
|
635
|
+
)}
|
|
636
|
+
</button>
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
const meterNode =
|
|
640
|
+
normalizedStrength && meterState
|
|
641
|
+
? renderMeter?.(meterState) ??
|
|
642
|
+
(strengthMeter && (
|
|
643
|
+
<div
|
|
644
|
+
className={cn(
|
|
645
|
+
normalizedStrength.display === "block"
|
|
646
|
+
? "mt-2 space-y-2"
|
|
647
|
+
: "mt-1.5 flex flex-col gap-0",
|
|
648
|
+
meterWrapperClassName,
|
|
649
|
+
)}
|
|
650
|
+
data-slot="password-meter"
|
|
651
|
+
>
|
|
652
|
+
{/* Progress Bar Row */}
|
|
653
|
+
<div
|
|
654
|
+
className={cn(
|
|
655
|
+
"flex w-full items-center gap-3",
|
|
656
|
+
meterContainerClassName,
|
|
657
|
+
)}
|
|
658
|
+
>
|
|
659
|
+
<div className="flex-1">
|
|
660
|
+
{/* Reduced height from h-2 to h-1 */}
|
|
661
|
+
<div className="h-1 w-full overflow-hidden rounded-full bg-secondary">
|
|
662
|
+
<div
|
|
663
|
+
className={cn(
|
|
664
|
+
"h-full transition-all duration-300 ease-out",
|
|
665
|
+
meterColor(meterState.score),
|
|
666
|
+
meterBarClassName,
|
|
667
|
+
)}
|
|
668
|
+
style={{ width: `${meterState.percent}%` }}
|
|
669
|
+
/>
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
|
|
673
|
+
{normalizedStrength.showLabel !== false && (
|
|
674
|
+
<div
|
|
675
|
+
className={cn(
|
|
676
|
+
"min-w-[4rem] text-right text-[10px] font-medium uppercase tracking-wider text-muted-foreground",
|
|
677
|
+
meterLabelClassName,
|
|
678
|
+
)}
|
|
679
|
+
>
|
|
680
|
+
{meterState.label}
|
|
681
|
+
</div>
|
|
682
|
+
)}
|
|
683
|
+
</div>
|
|
684
|
+
|
|
685
|
+
{/* New Modern Chips for Rules */}
|
|
686
|
+
{meterStyle === "rules" &&
|
|
687
|
+
meterState.rules.length > 0 && (
|
|
688
|
+
<div
|
|
689
|
+
className={cn(
|
|
690
|
+
"flex flex-wrap gap-1.5 pt-1",
|
|
691
|
+
rulesWrapperClassName,
|
|
692
|
+
)}
|
|
693
|
+
>
|
|
694
|
+
{meterState.rules.map((rule) => (
|
|
695
|
+
<span
|
|
696
|
+
key={rule.key}
|
|
697
|
+
className={cn(
|
|
698
|
+
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium transition-colors duration-200",
|
|
699
|
+
rule.passed
|
|
700
|
+
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:border-emerald-400/20 dark:bg-emerald-400/10 dark:text-emerald-400"
|
|
701
|
+
: "border-transparent bg-secondary text-muted-foreground",
|
|
702
|
+
ruleItemClassName
|
|
703
|
+
)}
|
|
704
|
+
>
|
|
705
|
+
{rule.passed && (
|
|
706
|
+
<Check className="h-3 w-3" strokeWidth={3} />
|
|
707
|
+
)}
|
|
708
|
+
{rule.label}
|
|
709
|
+
</span>
|
|
710
|
+
))}
|
|
711
|
+
</div>
|
|
712
|
+
)}
|
|
713
|
+
</div>
|
|
714
|
+
))
|
|
715
|
+
: null;
|
|
716
|
+
|
|
717
|
+
return (
|
|
718
|
+
<div className={cn("group/password w-full", className)} data-slot="password-field">
|
|
719
|
+
<Input
|
|
720
|
+
ref={ref}
|
|
721
|
+
{...restTextProps}
|
|
722
|
+
type={revealed ? "text" : "password"}
|
|
723
|
+
value={value ?? ""}
|
|
724
|
+
onChange={handleChange}
|
|
725
|
+
maxLength={maxLength}
|
|
726
|
+
autoComplete={autoComplete}
|
|
727
|
+
trailingControl={trailingControl}
|
|
728
|
+
aria-invalid={error ? "true" : undefined}
|
|
729
|
+
/>
|
|
730
|
+
{meterNode}
|
|
731
|
+
</div>
|
|
732
|
+
);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
ShadcnPasswordVariant.displayName = "ShadcnPasswordVariant";
|
|
736
|
+
|
|
737
|
+
export default ShadcnPasswordVariant;
|