@exegia/corpora-ui 0.5.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.
Files changed (71) hide show
  1. package/README.md +21 -0
  2. package/dist-lib/components/blocks/auth/auth-shell.d.ts +56 -0
  3. package/dist-lib/components/blocks/auth/code-auth-block.d.ts +22 -0
  4. package/dist-lib/components/blocks/auth/forgot-password-block.d.ts +15 -0
  5. package/dist-lib/components/blocks/auth/login-block.d.ts +25 -0
  6. package/dist-lib/components/blocks/auth/signup-block.d.ts +26 -0
  7. package/dist-lib/components/composed/password-input.d.ts +30 -0
  8. package/dist-lib/components/composed/social-providers.d.ts +22 -0
  9. package/dist-lib/components/docs/code-block.d.ts +4 -0
  10. package/dist-lib/components/docs/component-preview.d.ts +10 -0
  11. package/dist-lib/components/docs/demo-controls.d.ts +28 -0
  12. package/dist-lib/components/docs/props-table.d.ts +4 -0
  13. package/dist-lib/components/theme-provider.d.ts +15 -0
  14. package/dist-lib/components/ui/button.d.ts +28 -0
  15. package/dist-lib/components/ui/card.d.ts +16 -0
  16. package/dist-lib/components/ui/checkbox.d.ts +7 -0
  17. package/dist-lib/components/ui/field.d.ts +10 -0
  18. package/dist-lib/components/ui/frame.d.ts +7 -0
  19. package/dist-lib/components/ui/glasscn/glass-button.d.ts +6 -0
  20. package/dist-lib/components/ui/glasscn/liquid-glass.d.ts +31 -0
  21. package/dist-lib/components/ui/input-group.d.ts +13 -0
  22. package/dist-lib/components/ui/input.d.ts +9 -0
  23. package/dist-lib/components/ui/label.d.ts +11 -0
  24. package/dist-lib/components/ui/otp-field.d.ts +11 -0
  25. package/dist-lib/components/ui/separator.d.ts +4 -0
  26. package/dist-lib/components/ui/spinner.d.ts +3 -0
  27. package/dist-lib/components/ui/textarea.d.ts +8 -0
  28. package/dist-lib/index.d.ts +29 -0
  29. package/dist-lib/index.js +1617 -0
  30. package/dist-lib/index.js.map +1 -0
  31. package/dist-lib/lib/auth-accent.d.ts +33 -0
  32. package/dist-lib/lib/glass-variants.d.ts +6 -0
  33. package/dist-lib/lib/sound.d.ts +13 -0
  34. package/dist-lib/lib/utils.d.ts +2 -0
  35. package/dist-lib/lib/utils.test.d.ts +1 -0
  36. package/package.json +80 -0
  37. package/src/components/blocks/.gitkeep +0 -0
  38. package/src/components/blocks/auth/auth-shell.tsx +266 -0
  39. package/src/components/blocks/auth/code-auth-block.tsx +190 -0
  40. package/src/components/blocks/auth/forgot-password-block.tsx +123 -0
  41. package/src/components/blocks/auth/login-block.tsx +239 -0
  42. package/src/components/blocks/auth/signup-block.tsx +260 -0
  43. package/src/components/composed/.gitkeep +0 -0
  44. package/src/components/composed/password-input.tsx +179 -0
  45. package/src/components/composed/social-providers.tsx +89 -0
  46. package/src/components/docs/code-block.tsx +8 -0
  47. package/src/components/docs/component-preview.tsx +28 -0
  48. package/src/components/docs/demo-controls.tsx +118 -0
  49. package/src/components/docs/props-table.tsx +35 -0
  50. package/src/components/theme-provider.tsx +230 -0
  51. package/src/components/ui/button.tsx +199 -0
  52. package/src/components/ui/card.tsx +253 -0
  53. package/src/components/ui/checkbox.tsx +83 -0
  54. package/src/components/ui/field.tsx +89 -0
  55. package/src/components/ui/frame.tsx +87 -0
  56. package/src/components/ui/glasscn/glass-button.tsx +25 -0
  57. package/src/components/ui/glasscn/liquid-glass.tsx +276 -0
  58. package/src/components/ui/input-group.tsx +105 -0
  59. package/src/components/ui/input.tsx +70 -0
  60. package/src/components/ui/label.tsx +36 -0
  61. package/src/components/ui/otp-field.tsx +88 -0
  62. package/src/components/ui/separator.tsx +23 -0
  63. package/src/components/ui/spinner.tsx +17 -0
  64. package/src/components/ui/textarea.tsx +58 -0
  65. package/src/index.css +171 -0
  66. package/src/index.ts +37 -0
  67. package/src/lib/auth-accent.ts +49 -0
  68. package/src/lib/glass-variants.ts +57 -0
  69. package/src/lib/sound.ts +39 -0
  70. package/src/lib/utils.test.ts +22 -0
  71. package/src/lib/utils.ts +6 -0
@@ -0,0 +1,239 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { PasswordInput } from "@/components/composed/password-input";
6
+ import {
7
+ SocialProviders,
8
+ type SocialProvider,
9
+ } from "@/components/composed/social-providers";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Checkbox } from "@/components/ui/checkbox";
12
+ import { Field, FieldLabel } from "@/components/ui/field";
13
+ import { Input } from "@/components/ui/input";
14
+ import { Label } from "@/components/ui/label";
15
+ import { type AuthAccent, authAccentActionStyles } from "@/lib/auth-accent";
16
+ import { cn } from "@/lib/utils";
17
+ import {
18
+ AuthCard,
19
+ AuthError,
20
+ AuthSeparator,
21
+ AuthSuccess,
22
+ MorphStep,
23
+ Reveal,
24
+ type AuthStatus,
25
+ } from "./auth-shell";
26
+
27
+ export interface LoginBlockProps {
28
+ title?: string;
29
+ description?: string;
30
+ /** Brand mark rendered above the title. Omit for no logo row at all. */
31
+ logo?: React.ReactNode;
32
+ /** Brand accent for the primary action. Omit to keep the default primary. */
33
+ accent?: AuthAccent;
34
+ /** Social providers to offer; empty array hides the social section. */
35
+ providers?: SocialProvider[];
36
+ showRememberMe?: boolean;
37
+ showForgotPassword?: boolean;
38
+ /** Reject (or throw) to show the error state with the error's message. */
39
+ onSubmit?: (data: {
40
+ email: string;
41
+ password: string;
42
+ remember: boolean;
43
+ }) => Promise<void> | void;
44
+ onProviderSelect?: (provider: SocialProvider) => Promise<void> | void;
45
+ onForgotPassword?: () => void;
46
+ onSignup?: () => void;
47
+ }
48
+
49
+ export function LoginBlock({
50
+ title = "Login to your account",
51
+ description = "Enter your email and password to login",
52
+ logo,
53
+ accent,
54
+ providers = ["google", "apple", "github"],
55
+ showRememberMe = true,
56
+ showForgotPassword = true,
57
+ onSubmit,
58
+ onProviderSelect,
59
+ onForgotPassword,
60
+ onSignup,
61
+ }: LoginBlockProps) {
62
+ const emailId = React.useId();
63
+ const rememberId = React.useId();
64
+ const [status, setStatus] = React.useState<AuthStatus>("idle");
65
+ const [error, setError] = React.useState<string | null>(null);
66
+ const [loadingProvider, setLoadingProvider] =
67
+ React.useState<SocialProvider | null>(null);
68
+ const [remember, setRemember] = React.useState(false);
69
+ const [email, setEmail] = React.useState("");
70
+ const [emailValid, setEmailValid] = React.useState(false);
71
+ const [password, setPassword] = React.useState("");
72
+
73
+ const busy = status === "loading" || loadingProvider !== null;
74
+
75
+ // Progressive disclosure: each step unlocks the next. The password field is
76
+ // gated on the email input's own constraint validation (required + type
77
+ // email), the submit button on the password being non-empty — a login form
78
+ // verifies a credential rather than enforcing policy, so strength rules here
79
+ // would lock out anyone whose password predates them. The submit gate
80
+ // re-checks emailValid so a stale password (typed, then the email was edited
81
+ // back into an invalid state) can't leave the button on its own.
82
+ const passwordValid = password.trim().length > 0;
83
+ const canSubmit = emailValid && passwordValid;
84
+
85
+ async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
86
+ event.preventDefault();
87
+ // Enter in any field submits the form even while the button is hidden.
88
+ if (!canSubmit) return;
89
+ const form = new FormData(event.currentTarget);
90
+ setError(null);
91
+ setStatus("loading");
92
+ try {
93
+ await onSubmit?.({
94
+ email: String(form.get("email") ?? ""),
95
+ password: String(form.get("password") ?? ""),
96
+ remember,
97
+ });
98
+ setStatus("success");
99
+ } catch (cause) {
100
+ setStatus("idle");
101
+ setError(cause instanceof Error ? cause.message : "Unable to login.");
102
+ }
103
+ }
104
+
105
+ async function handleProvider(provider: SocialProvider) {
106
+ setError(null);
107
+ setLoadingProvider(provider);
108
+ try {
109
+ await onProviderSelect?.(provider);
110
+ setStatus("success");
111
+ } catch (cause) {
112
+ setError(
113
+ cause instanceof Error ? cause.message : "Unable to login.",
114
+ );
115
+ } finally {
116
+ setLoadingProvider(null);
117
+ }
118
+ }
119
+
120
+ return (
121
+ <AuthCard
122
+ title={title}
123
+ description={description}
124
+ logo={logo}
125
+ accent={accent}
126
+ footer={
127
+ status !== "success" && (
128
+ <>
129
+ Don&apos;t have an account?{" "}
130
+ <Button variant="link" onClick={onSignup}>
131
+ Sign up
132
+ </Button>
133
+ </>
134
+ )
135
+ }
136
+ >
137
+ <MorphStep step={status === "success" ? "success" : "form"}>
138
+ {status === "success" ? (
139
+ <AuthSuccess
140
+ title="Welcome back"
141
+ description="You are now logged in."
142
+ />
143
+ ) : (
144
+ <div className="flex flex-col gap-4">
145
+ <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
146
+ <Field name="email">
147
+ <FieldLabel htmlFor={emailId}>Email</FieldLabel>
148
+ <Input
149
+ id={emailId}
150
+ aria-label="Email"
151
+ name="email"
152
+ type="email"
153
+ autoComplete="email"
154
+ placeholder="you@example.com"
155
+ required
156
+ disabled={busy}
157
+ value={email}
158
+ onChange={(event) => {
159
+ setEmail(event.currentTarget.value);
160
+ setEmailValid(event.currentTarget.validity.valid);
161
+ }}
162
+ />
163
+ </Field>
164
+ <Reveal show={emailValid}>
165
+ <Field name="password">
166
+ <FieldLabel htmlFor="password">Password</FieldLabel>
167
+ <PasswordInput
168
+ id="password"
169
+ name="password"
170
+ autoComplete="current-password"
171
+ required
172
+ disabled={busy}
173
+ value={password}
174
+ onChange={(event) => setPassword(event.target.value)}
175
+ />
176
+ {showForgotPassword && (
177
+ // self-end: Field root is items-start, so this aligns the
178
+ // link with the input's trailing edge. sm:text-xs is
179
+ // required — the cva's sm:text-sm survives tailwind-merge
180
+ // and would otherwise win at the breakpoint.
181
+ <Button
182
+ variant="link"
183
+ type="button"
184
+ className="self-end font-normal text-muted-foreground text-xs hover:text-foreground sm:text-xs"
185
+ onClick={onForgotPassword}
186
+ >
187
+ Forgot password?
188
+ </Button>
189
+ )}
190
+ </Field>
191
+ </Reveal>
192
+ {showRememberMe && (
193
+ <div className="flex items-center gap-2">
194
+ <Checkbox
195
+ id={rememberId}
196
+ checked={remember}
197
+ onCheckedChange={(checked) => setRemember(checked)}
198
+ disabled={busy}
199
+ />
200
+ <Label htmlFor={rememberId} className="font-normal">
201
+ Remember me
202
+ </Label>
203
+ </div>
204
+ )}
205
+ <AuthError message={error} />
206
+ <Reveal show={canSubmit}>
207
+ <Button
208
+ className={cn("w-full", accent && authAccentActionStyles)}
209
+ type="submit"
210
+ loading={status === "loading"}
211
+ disabled={busy}
212
+ >
213
+ Login
214
+ </Button>
215
+ </Reveal>
216
+ </form>
217
+ {providers.length > 0 && (
218
+ // The social providers retire once the email path is filled in
219
+ // and valid — the user has committed to a path, so the
220
+ // alternatives are just noise. They come back if a field is
221
+ // invalidated again. gap-4 on the wrapper reproduces the spacing
222
+ // the two children had as direct flex items of the column above.
223
+ <Reveal show={!canSubmit} className="flex flex-col gap-4">
224
+ <AuthSeparator label="Or continue with" />
225
+ <SocialProviders
226
+ providers={providers}
227
+ action="login"
228
+ loadingProvider={loadingProvider}
229
+ disabled={status === "loading"}
230
+ onSelect={handleProvider}
231
+ />
232
+ </Reveal>
233
+ )}
234
+ </div>
235
+ )}
236
+ </MorphStep>
237
+ </AuthCard>
238
+ );
239
+ }
@@ -0,0 +1,260 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import {
6
+ PasswordInput,
7
+ getPasswordStrength,
8
+ passwordRequirements,
9
+ } from "@/components/composed/password-input";
10
+ import {
11
+ SocialProviders,
12
+ type SocialProvider,
13
+ } from "@/components/composed/social-providers";
14
+ import { Button } from "@/components/ui/button";
15
+ import { Checkbox } from "@/components/ui/checkbox";
16
+ import { Field, FieldLabel } from "@/components/ui/field";
17
+ import { Input } from "@/components/ui/input";
18
+ import { Label } from "@/components/ui/label";
19
+ import { type AuthAccent, authAccentActionStyles } from "@/lib/auth-accent";
20
+ import { cn } from "@/lib/utils";
21
+ import {
22
+ AuthCard,
23
+ AuthError,
24
+ AuthSeparator,
25
+ AuthSuccess,
26
+ MorphStep,
27
+ Reveal,
28
+ type AuthStatus,
29
+ } from "./auth-shell";
30
+
31
+ export interface SignupBlockProps {
32
+ title?: string;
33
+ description?: string;
34
+ /** Brand mark rendered above the title. Omit for no logo row at all. */
35
+ logo?: React.ReactNode;
36
+ /** Brand accent for the primary action. Omit to keep the default primary. */
37
+ accent?: AuthAccent;
38
+ providers?: SocialProvider[];
39
+ showNameField?: boolean;
40
+ /** Require the terms checkbox before submitting. */
41
+ showTerms?: boolean;
42
+ /** Block submission until every password requirement is met. */
43
+ enforceStrongPassword?: boolean;
44
+ onSubmit?: (data: {
45
+ name: string;
46
+ email: string;
47
+ password: string;
48
+ }) => Promise<void> | void;
49
+ onProviderSelect?: (provider: SocialProvider) => Promise<void> | void;
50
+ onLogin?: () => void;
51
+ onTerms?: () => void;
52
+ }
53
+
54
+ export function SignupBlock({
55
+ title = "Create your account",
56
+ description = "Start exploring manuscripts in minutes",
57
+ logo,
58
+ accent,
59
+ providers = ["google", "apple", "github"],
60
+ showNameField = true,
61
+ showTerms = true,
62
+ enforceStrongPassword = true,
63
+ onSubmit,
64
+ onProviderSelect,
65
+ onLogin,
66
+ onTerms,
67
+ }: SignupBlockProps) {
68
+ const nameId = React.useId();
69
+ const emailId = React.useId();
70
+ const termsId = React.useId();
71
+ const [status, setStatus] = React.useState<AuthStatus>("idle");
72
+ const [error, setError] = React.useState<string | null>(null);
73
+ const [loadingProvider, setLoadingProvider] =
74
+ React.useState<SocialProvider | null>(null);
75
+ const [password, setPassword] = React.useState("");
76
+ const [terms, setTerms] = React.useState(false);
77
+ // Tracked from each input's own constraint validation rather than a regex.
78
+ // The values stay uncontrolled — only validity is needed, and the form reads
79
+ // values from FormData on submit.
80
+ const [emailValid, setEmailValid] = React.useState(false);
81
+ const [nameValid, setNameValid] = React.useState(false);
82
+
83
+ const busy = status === "loading" || loadingProvider !== null;
84
+ const strongEnough =
85
+ !enforceStrongPassword ||
86
+ getPasswordStrength(password) === passwordRequirements.length;
87
+
88
+ // The social providers retire once the email path is filled in and valid —
89
+ // the user has committed to a path, so the alternatives are just noise. It
90
+ // comes back if they invalidate a field again.
91
+ const passwordValid = password.trim().length > 0 && strongEnough;
92
+ const emailPathComplete =
93
+ (!showNameField || nameValid) && emailValid && passwordValid;
94
+
95
+ async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
96
+ event.preventDefault();
97
+ setError(null);
98
+ if (!strongEnough) {
99
+ setError("Please meet all password requirements.");
100
+ return;
101
+ }
102
+ if (showTerms && !terms) {
103
+ setError("Please accept the terms to continue.");
104
+ return;
105
+ }
106
+ const form = new FormData(event.currentTarget);
107
+ setStatus("loading");
108
+ try {
109
+ await onSubmit?.({
110
+ name: String(form.get("name") ?? ""),
111
+ email: String(form.get("email") ?? ""),
112
+ password,
113
+ });
114
+ setStatus("success");
115
+ } catch (cause) {
116
+ setStatus("idle");
117
+ setError(
118
+ cause instanceof Error ? cause.message : "Unable to create account.",
119
+ );
120
+ }
121
+ }
122
+
123
+ async function handleProvider(provider: SocialProvider) {
124
+ setError(null);
125
+ setLoadingProvider(provider);
126
+ try {
127
+ await onProviderSelect?.(provider);
128
+ setStatus("success");
129
+ } catch (cause) {
130
+ setError(
131
+ cause instanceof Error ? cause.message : "Unable to create account.",
132
+ );
133
+ } finally {
134
+ setLoadingProvider(null);
135
+ }
136
+ }
137
+
138
+ return (
139
+ <AuthCard
140
+ title={title}
141
+ description={description}
142
+ logo={logo}
143
+ accent={accent}
144
+ footer={
145
+ status !== "success" && (
146
+ <>
147
+ Already have an account?{" "}
148
+ <Button variant="link" onClick={onLogin}>
149
+ Login
150
+ </Button>
151
+ </>
152
+ )
153
+ }
154
+ >
155
+ <MorphStep step={status === "success" ? "success" : "form"}>
156
+ {status === "success" ? (
157
+ <AuthSuccess
158
+ title="Account created"
159
+ description="Check your inbox to confirm your email address."
160
+ />
161
+ ) : (
162
+ <div className="flex flex-col gap-4">
163
+ {providers.length > 0 && (
164
+ // gap-4 on the wrapper reproduces the spacing the two children
165
+ // had as direct flex items of the column above.
166
+ <Reveal show={!emailPathComplete} className="flex flex-col gap-4">
167
+ <SocialProviders
168
+ providers={providers}
169
+ action="signup"
170
+ loadingProvider={loadingProvider}
171
+ disabled={status === "loading"}
172
+ onSelect={handleProvider}
173
+ />
174
+ <AuthSeparator label="Or with email" />
175
+ </Reveal>
176
+ )}
177
+ <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
178
+ {showNameField && (
179
+ <Field name="name">
180
+ <FieldLabel htmlFor={nameId}>Name</FieldLabel>
181
+ <Input
182
+ aria-label="Name"
183
+ id={nameId}
184
+ name="name"
185
+ autoComplete="name"
186
+ placeholder="Your name"
187
+ required
188
+ disabled={busy}
189
+ onChange={(event) =>
190
+ setNameValid(event.currentTarget.validity.valid)
191
+ }
192
+ />
193
+ </Field>
194
+ )}
195
+ <Field name="email">
196
+ <FieldLabel htmlFor={emailId}>Email</FieldLabel>
197
+ <Input
198
+ id={emailId}
199
+ name="email"
200
+ type="email"
201
+ autoComplete="email"
202
+ placeholder="you@example.com"
203
+ required
204
+ disabled={busy}
205
+ onChange={(event) =>
206
+ setEmailValid(event.currentTarget.validity.valid)
207
+ }
208
+ />
209
+ </Field>
210
+ <Reveal show={emailValid}>
211
+ <Field name="password">
212
+ <FieldLabel>Password</FieldLabel>
213
+ <PasswordInput
214
+ name="password"
215
+ autoComplete="new-password"
216
+ placeholder="Create a password"
217
+ showStrength
218
+ required
219
+ disabled={busy}
220
+ value={password}
221
+ onChange={(event) => setPassword(event.target.value)}
222
+ />
223
+ </Field>
224
+ </Reveal>
225
+ {showTerms && (
226
+ <div className="flex items-center gap-2">
227
+ <Checkbox
228
+ id={termsId}
229
+ checked={terms}
230
+ onCheckedChange={(checked) => setTerms(checked === true)}
231
+ disabled={busy}
232
+ />
233
+ <Label htmlFor={termsId} className="font-normal">
234
+ I agree to the{" "}
235
+ <Button
236
+ variant="link"
237
+ type="button"
238
+ onClick={onTerms}
239
+ >
240
+ terms
241
+ </Button>
242
+ </Label>
243
+ </div>
244
+ )}
245
+ <AuthError message={error} />
246
+ <Button
247
+ className={cn("w-full", accent && authAccentActionStyles)}
248
+ type="submit"
249
+ loading={status === "loading"}
250
+ disabled={busy}
251
+ >
252
+ Create account
253
+ </Button>
254
+ </form>
255
+ </div>
256
+ )}
257
+ </MorphStep>
258
+ </AuthCard>
259
+ );
260
+ }
File without changes
@@ -0,0 +1,179 @@
1
+ "use client";
2
+
3
+ import { CheckIcon, EyeIcon, EyeOffIcon, XIcon } from "lucide-react";
4
+ import { AnimatePresence, motion } from "motion/react";
5
+ import * as React from "react";
6
+
7
+ import { Button } from "@/components/ui/button";
8
+ import {
9
+ InputGroup,
10
+ InputGroupAddon,
11
+ InputGroupInput,
12
+ } from "@/components/ui/input-group";
13
+ import { playCue } from "@/lib/sound";
14
+ import { cn } from "@/lib/utils";
15
+
16
+ // transitions.dev motion scale
17
+ const EASE = [0.22, 1, 0.36, 1] as const;
18
+
19
+ export const passwordRequirements = [
20
+ { regex: /.{8,}/, text: "At least 8 characters" },
21
+ { regex: /[0-9]/, text: "At least 1 number" },
22
+ { regex: /[a-z]/, text: "At least 1 lowercase letter" },
23
+ { regex: /[A-Z]/, text: "At least 1 uppercase letter" },
24
+ ] as const;
25
+
26
+ export function getPasswordStrength(password: string): number {
27
+ return passwordRequirements.filter((req) => req.regex.test(password)).length;
28
+ }
29
+
30
+ const STRENGTH_COLORS = [
31
+ "bg-border",
32
+ "bg-red-500",
33
+ "bg-orange-500",
34
+ "bg-amber-500",
35
+ "bg-emerald-500",
36
+ ] as const;
37
+
38
+ export interface PasswordInputProps
39
+ extends Omit<React.ComponentProps<typeof InputGroupInput>, "type" | "size"> {
40
+ /** Render the eye toggle button. */
41
+ visibilityToggle?: boolean;
42
+ /** Render the animated strength meter and requirement checklist. */
43
+ showStrength?: boolean;
44
+ /** Play reveal/hide cues on the visibility toggle. Inert until bindSounds(). */
45
+ sound?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Password field with visibility toggle and optional strength meter.
50
+ * Composition of InputGroup + Button (coss particles p-input-9 and
51
+ * p-input-group-26).
52
+ */
53
+ export function PasswordInput({
54
+ visibilityToggle = true,
55
+ showStrength = false,
56
+ sound = true,
57
+ className,
58
+ value: valueProp,
59
+ defaultValue,
60
+ onChange,
61
+ ...props
62
+ }: PasswordInputProps) {
63
+ const [visible, setVisible] = React.useState(false);
64
+ const [uncontrolled, setUncontrolled] = React.useState(
65
+ String(defaultValue ?? ""),
66
+ );
67
+ const value = valueProp !== undefined ? String(valueProp) : uncontrolled;
68
+ const score = getPasswordStrength(value);
69
+
70
+ return (
71
+ <div className={cn("flex w-full flex-col gap-3", className)}>
72
+ <InputGroup>
73
+ <InputGroupInput
74
+ placeholder="Enter your password"
75
+ {...props}
76
+ type={visible ? "text" : "password"}
77
+ value={value}
78
+ onChange={(event) => {
79
+ setUncontrolled(event.target.value);
80
+ onChange?.(event);
81
+ }}
82
+ />
83
+ {visibilityToggle && (
84
+ <InputGroupAddon align="inline-end">
85
+ <Button
86
+ aria-label={visible ? "Hide password" : "Show password"}
87
+ onClick={() => {
88
+ // Semantic cues from the cuelume palette: bloom = reveal,
89
+ // droplet = dismiss. Press/release stays off to avoid layering.
90
+ if (sound) playCue(visible ? "droplet" : "bloom");
91
+ setVisible((v) => !v);
92
+ }}
93
+ size="icon-xs"
94
+ variant="ghost"
95
+ sound={false}
96
+ >
97
+ <AnimatePresence mode="wait" initial={false}>
98
+ <motion.span
99
+ key={visible ? "off" : "on"}
100
+ className="inline-flex"
101
+ initial={{ opacity: 0, scale: 0.8, filter: "blur(2px)" }}
102
+ animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
103
+ exit={{ opacity: 0, scale: 0.8, filter: "blur(2px)" }}
104
+ transition={{ duration: 0.15, ease: EASE }}
105
+ >
106
+ {visible ? (
107
+ <EyeOffIcon aria-hidden="true" />
108
+ ) : (
109
+ <EyeIcon aria-hidden="true" />
110
+ )}
111
+ </motion.span>
112
+ </AnimatePresence>
113
+ </Button>
114
+ </InputGroupAddon>
115
+ )}
116
+ </InputGroup>
117
+
118
+ <AnimatePresence initial={false}>
119
+ {showStrength && (
120
+ <motion.div
121
+ className="flex flex-col gap-2 overflow-hidden"
122
+ initial={{ height: 0, opacity: 0 }}
123
+ animate={{ height: "auto", opacity: 1 }}
124
+ exit={{ height: 0, opacity: 0 }}
125
+ transition={{ duration: 0.25, ease: EASE }}
126
+ >
127
+ <div
128
+ aria-label="Password strength"
129
+ aria-valuemax={4}
130
+ aria-valuemin={0}
131
+ aria-valuenow={score}
132
+ className="h-1 w-full overflow-hidden rounded-full bg-border"
133
+ role="progressbar"
134
+ >
135
+ <motion.div
136
+ className={cn("h-full rounded-full", STRENGTH_COLORS[score])}
137
+ animate={{ width: `${(score / 4) * 100}%` }}
138
+ transition={{ duration: 0.4, ease: EASE }}
139
+ />
140
+ </div>
141
+ <ul
142
+ aria-label="Password requirements"
143
+ className="flex flex-col gap-1"
144
+ >
145
+ {passwordRequirements.map((req) => {
146
+ const met = req.regex.test(value);
147
+ return (
148
+ <li className="flex items-center gap-2 text-xs" key={req.text}>
149
+ {met ? (
150
+ <CheckIcon
151
+ aria-hidden="true"
152
+ className="size-3.5 text-emerald-500"
153
+ />
154
+ ) : (
155
+ <XIcon
156
+ aria-hidden="true"
157
+ className="size-3.5 text-muted-foreground/80"
158
+ />
159
+ )}
160
+ <span
161
+ className={
162
+ met ? "text-emerald-600" : "text-muted-foreground"
163
+ }
164
+ >
165
+ {req.text}
166
+ <span className="sr-only">
167
+ {met ? " - met" : " - not met"}
168
+ </span>
169
+ </span>
170
+ </li>
171
+ );
172
+ })}
173
+ </ul>
174
+ </motion.div>
175
+ )}
176
+ </AnimatePresence>
177
+ </div>
178
+ );
179
+ }