Added Password Field with Rules
This commit is contained in:
@@ -520,6 +520,50 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
||||
});
|
||||
}
|
||||
|
||||
function enforcePasswordUiRules(formEl) {
|
||||
let allValid = true;
|
||||
|
||||
formEl.querySelectorAll("[data-pw-root]").forEach((pw) => {
|
||||
// if the PasswordField marked itself valid, accept it
|
||||
if (pw.dataset.pwValid === "1") return;
|
||||
|
||||
// otherwise recompute from its declared rules (in case user edited quickly)
|
||||
const name = pw.getAttribute("data-pw-name");
|
||||
const input = pw.querySelector("input[name]");
|
||||
const val = (input && input.value) || "";
|
||||
|
||||
let rules = {};
|
||||
try {
|
||||
rules = JSON.parse(
|
||||
pw.getAttribute("data-pw-rules") || "{}",
|
||||
);
|
||||
} catch {}
|
||||
|
||||
const ok =
|
||||
(!rules.lower || /[a-z]/.test(val)) &&
|
||||
(!rules.upper || /[A-Z]/.test(val)) &&
|
||||
(!rules.number || /[0-9]/.test(val)) &&
|
||||
(!rules.symbol || /[^A-Za-z0-9]/.test(val)) &&
|
||||
(!rules.minLength ||
|
||||
val.length >= Number(rules.minLength || 0));
|
||||
|
||||
if (!ok) {
|
||||
allValid = false;
|
||||
// show a field error on the password field
|
||||
const fieldRoot =
|
||||
formEl.querySelector(
|
||||
`[data-field="${CSS.escape(name)}"]`,
|
||||
) || pw;
|
||||
setError(
|
||||
fieldRoot,
|
||||
"Password does not meet all requirements.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return allValid;
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
const schema = getSchema();
|
||||
const onSubmitName = form.dataset.onsubmit;
|
||||
@@ -528,16 +572,19 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
if (schema) {
|
||||
const data = collectData(form);
|
||||
console.log(data);
|
||||
const result = schema.safeParse(data);
|
||||
console.log(result);
|
||||
if (!result.success) {
|
||||
showErrors(form, result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setFormError("");
|
||||
form.querySelectorAll("[data-field]").forEach(clearState);
|
||||
|
||||
if (!enforcePasswordUiRules(form)) {
|
||||
return; // block submit until all enabled rules pass
|
||||
}
|
||||
|
||||
if (!onSubmitName) {
|
||||
form.submit();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
---
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
import { cn } from "../utils/cn";
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
interface Rules {
|
||||
lower?: boolean;
|
||||
upper?: boolean;
|
||||
number?: boolean;
|
||||
symbol?: boolean;
|
||||
minLength?: number; // default 8
|
||||
}
|
||||
|
||||
interface Props extends HTMLAttributes<"div"> {
|
||||
id?: string;
|
||||
name: string; // required (used by form validator)
|
||||
label?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
value?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
class?: string;
|
||||
|
||||
showMeter?: boolean; // default true
|
||||
showChecklist?: boolean; // default true
|
||||
rules?: Rules; // enable/disable rules + custom minLength
|
||||
error?: string; // SSR error (form client will overwrite)
|
||||
}
|
||||
|
||||
const p = Astro.props as Props;
|
||||
|
||||
const fieldId = p.id ?? p.name;
|
||||
const descId = p.description ? `${fieldId}-desc` : undefined;
|
||||
const errId = `${fieldId}-err`;
|
||||
|
||||
const sizeKls =
|
||||
p.size === "sm"
|
||||
? "wr:h-10 wr:px-3 wr:text-sm"
|
||||
: p.size === "lg"
|
||||
? "wr:h-12 wr:px-4 wr:text-base"
|
||||
: "wr:h-11 wr:px-3 wr:text-sm";
|
||||
|
||||
const inputCls = cn(
|
||||
"wr:py-2.5 wr:px-4 wr:block wr:w-full wr:rounded-lg wr:border wr:border-gray-200 wr:sm:text-sm",
|
||||
"focus:wr:border-primary focus:wr:ring-primary/40",
|
||||
"wr:disabled:opacity-50 wr:disabled:pointer-events-none",
|
||||
"dark:wr:bg-neutral-900 dark:wr:border-neutral-700 dark:wr:text-neutral-400 dark:wr:placeholder-neutral-500 dark:focus:wr:ring-neutral-600",
|
||||
sizeKls,
|
||||
"wr:pr-10", // space for eye button
|
||||
p.error && "wr:border-danger wr:focus-visible:wr:ring-danger/40",
|
||||
);
|
||||
|
||||
const containerId = `pf-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
const rules = {
|
||||
lower: true,
|
||||
upper: true,
|
||||
number: true,
|
||||
symbol: true,
|
||||
minLength: 8,
|
||||
...(p.rules ?? {}),
|
||||
};
|
||||
|
||||
const showMeter = p.showMeter ?? true;
|
||||
const showChecklist = p.showChecklist ?? true;
|
||||
---
|
||||
|
||||
<div
|
||||
id={containerId}
|
||||
class={cn("wr:space-y-2", p.class)}
|
||||
data-field={p.name}
|
||||
data-pw-root
|
||||
data-pw-name={p.name}
|
||||
data-pw-rules={JSON.stringify(rules)}
|
||||
>
|
||||
{
|
||||
p.label && (
|
||||
<label
|
||||
for={fieldId}
|
||||
class="wr:block wr:text-sm wr:font-medium wr:mb-1 wr:text-foreground"
|
||||
>
|
||||
{p.label} {p.required && <span class="wr:text-danger">*</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="wr:relative wr:overflow-visible wr:flex-1">
|
||||
<input
|
||||
id={fieldId}
|
||||
name={p.name}
|
||||
type="password"
|
||||
data-control
|
||||
class={inputCls}
|
||||
placeholder={p.placeholder}
|
||||
required={p.required}
|
||||
disabled={p.disabled}
|
||||
value={typeof p.value === "string" ? p.value : undefined}
|
||||
aria-invalid={!!p.error}
|
||||
aria-describedby={[descId, p.error ? errId : undefined]
|
||||
.filter(Boolean)
|
||||
.join(" ") || undefined}
|
||||
/>
|
||||
|
||||
<!-- eye toggle (keeps old inline behavior) -->
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle password"
|
||||
class="wr:absolute wr:inset-y-0 wr:right-0 wr:pr-3 wr:flex wr:items-center wr:text-muted-foreground hover:wr:text-foreground wr:z-20"
|
||||
onclick={`const i = this.previousElementSibling; i.type = i.type === 'password' ? 'text' : 'password';`}
|
||||
tabindex="-1"
|
||||
>
|
||||
<Icon name="mdi:eye" class="wr:size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{
|
||||
showMeter && (
|
||||
<div
|
||||
data-meter
|
||||
class="wr:h-1.5 wr:w-full wr:rounded-full wr:bg-muted wr:overflow-hidden"
|
||||
>
|
||||
<div
|
||||
data-meter-bar
|
||||
class="wr:h-full wr:w-0 wr:rounded-full wr:transition-[width,background-color] wr:duration-200"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
showChecklist && (
|
||||
<ul data-checklist class="wr:mt-1 wr:space-y-1.5">
|
||||
{rules.lower && (
|
||||
<li
|
||||
data-rule="lower"
|
||||
class="wr:text-xs wr:flex wr:items-center wr:gap-2 wr:text-muted-foreground"
|
||||
>
|
||||
<Icon
|
||||
data-ic
|
||||
class="wr:size-4"
|
||||
name="mdi:close-circle"
|
||||
/>
|
||||
At least one lowercase letter
|
||||
</li>
|
||||
)}
|
||||
{rules.upper && (
|
||||
<li
|
||||
data-rule="upper"
|
||||
class="wr:text-xs wr:flex wr:items-center wr:gap-2 wr:text-muted-foreground"
|
||||
>
|
||||
<Icon
|
||||
data-ic
|
||||
class="wr:size-4"
|
||||
name="mdi:close-circle"
|
||||
/>
|
||||
At least one uppercase letter
|
||||
</li>
|
||||
)}
|
||||
{rules.number && (
|
||||
<li
|
||||
data-rule="number"
|
||||
class="wr:text-xs wr:flex wr:items-center wr:gap-2 wr:text-muted-foreground"
|
||||
>
|
||||
<Icon
|
||||
data-ic
|
||||
class="wr:size-4"
|
||||
name="mdi:close-circle"
|
||||
/>
|
||||
At least one number
|
||||
</li>
|
||||
)}
|
||||
{rules.symbol && (
|
||||
<li
|
||||
data-rule="symbol"
|
||||
class="wr:text-xs wr:flex wr:items-center wr:gap-2 wr:text-muted-foreground"
|
||||
>
|
||||
<Icon
|
||||
data-ic
|
||||
class="wr:size-4"
|
||||
name="mdi:close-circle"
|
||||
/>
|
||||
At least one symbol
|
||||
</li>
|
||||
)}
|
||||
{!!rules.minLength && (
|
||||
<li
|
||||
data-rule="length"
|
||||
class="wr:text-xs wr:flex wr:items-center wr:gap-2 wr:text-muted-foreground"
|
||||
>
|
||||
<Icon
|
||||
data-ic
|
||||
class="wr:size-4"
|
||||
name="mdi:close-circle"
|
||||
/>
|
||||
At least {rules.minLength} characters
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
p.description && (
|
||||
<p
|
||||
id={descId}
|
||||
class="wr:text-sm wr:text-gray-500 dark:wr:text-neutral-500"
|
||||
>
|
||||
{p.description}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- error slot that your Form script updates -->
|
||||
<p id={errId} class="wr:text-xs wr:text-danger" hidden>{p.error ?? ""}</p>
|
||||
</div>
|
||||
|
||||
<script is:inline define:vars={{ containerId, rules, showMeter, showChecklist }}
|
||||
>
|
||||
(() => {
|
||||
const root = document.getElementById(containerId);
|
||||
if (!root) return;
|
||||
|
||||
const input = root.querySelector(
|
||||
"input[type='password'], input[type='text'][name]",
|
||||
);
|
||||
|
||||
const meterBar = showMeter
|
||||
? root.querySelector("[data-meter-bar]")
|
||||
: null;
|
||||
const items = showChecklist
|
||||
? root.querySelectorAll("[data-checklist] [data-rule]")
|
||||
: [];
|
||||
|
||||
const ruleEnabled = {
|
||||
lower: !!rules.lower,
|
||||
upper: !!rules.upper,
|
||||
number: !!rules.number,
|
||||
symbol: !!rules.symbol,
|
||||
length: !!rules.minLength,
|
||||
};
|
||||
|
||||
const totalRules =
|
||||
Object.values(ruleEnabled).filter(Boolean).length || 1;
|
||||
|
||||
function evalPass(v = "") {
|
||||
const res = {
|
||||
lower: /[a-z]/.test(v),
|
||||
upper: /[A-Z]/.test(v),
|
||||
number: /[0-9]/.test(v),
|
||||
symbol: /[^A-Za-z0-9]/.test(v),
|
||||
length: rules.minLength ? v.length >= rules.minLength : true,
|
||||
};
|
||||
let score = 0;
|
||||
for (const k in res) {
|
||||
if (ruleEnabled[k]) score += res[k] ? 1 : 0;
|
||||
}
|
||||
return { res, score };
|
||||
}
|
||||
|
||||
function colorForScore(scoreRatio) {
|
||||
// 0..1 -> choose danger / warning / success
|
||||
if (scoreRatio < 0.34) return "var(--danger)"; // red-ish
|
||||
if (scoreRatio < 0.67) return "var(--warning)"; // amber-ish
|
||||
return "var(--success)"; // green-ish
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
const val = input.value || "";
|
||||
const { res, score } = evalPass(val);
|
||||
const ratio = Math.max(0, Math.min(1, score / totalRules));
|
||||
|
||||
// meter
|
||||
if (meterBar) {
|
||||
meterBar.style.width = `${Math.round(ratio * 100)}%`;
|
||||
meterBar.style.backgroundColor = `hsl(${colorForScore(ratio)})`;
|
||||
}
|
||||
|
||||
// checklist
|
||||
items.forEach((li) => {
|
||||
const key = li.getAttribute("data-rule");
|
||||
const ok = !!res[key];
|
||||
const ic = li.querySelector("[data-ic]");
|
||||
li.classList.toggle("wr:text-success", ok);
|
||||
li.classList.toggle("wr:text-muted-foreground", !ok);
|
||||
if (ic) {
|
||||
ic.setAttribute(
|
||||
"name",
|
||||
ok ? "mdi:check-circle" : "mdi:close-circle",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const okAll = Object.entries(ruleEnabled).every(
|
||||
([k, enabled]) => !enabled || res[k],
|
||||
);
|
||||
// expose validity for the form
|
||||
root.dataset.pwValid = okAll ? "1" : "";
|
||||
}
|
||||
|
||||
// init + live updates
|
||||
updateUI();
|
||||
input?.addEventListener("input", updateUI);
|
||||
})();
|
||||
</script>
|
||||
@@ -38,6 +38,9 @@ import Base from "./Base.astro";
|
||||
<li class="list-item">
|
||||
<a href="/forms4">Forms 4</a>
|
||||
</li>
|
||||
<li class="list-item">
|
||||
<a href="/PassForm">PassForm</a>
|
||||
</li>
|
||||
<li class="list-item">
|
||||
<a href="/theme">Theme</a>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
import Form from "../components/Form.astro";
|
||||
import Field from "../components/Field.astro";
|
||||
import ComponentLayout from "../layouts/ComponentLayout.astro";
|
||||
import PasswordField from "../components/PasswordField.astro";
|
||||
---
|
||||
|
||||
<ComponentLayout class="wr:p-4">
|
||||
<Form
|
||||
title="Create account"
|
||||
schemaName="schema"
|
||||
onSubmit="submit"
|
||||
class="wr:max-w-md"
|
||||
>
|
||||
<Field
|
||||
name="email"
|
||||
label="Email"
|
||||
kind="email"
|
||||
placeholder="you@site.com"
|
||||
/>
|
||||
|
||||
<PasswordField
|
||||
name="password"
|
||||
label="Password"
|
||||
placeholder="••••••••"
|
||||
rules={{
|
||||
lower: true,
|
||||
upper: true,
|
||||
number: true,
|
||||
symbol: true,
|
||||
minLength: 8,
|
||||
}}
|
||||
showMeter={true}
|
||||
showChecklist={true}
|
||||
/>
|
||||
|
||||
<!-- any other fields... -->
|
||||
</Form>
|
||||
</ComponentLayout>
|
||||
|
||||
<script>
|
||||
import { z } from "zod";
|
||||
|
||||
window.schema = z.object({
|
||||
email: z.string().email("Provide a valid email"),
|
||||
password: z.string().min(8, "At least 8 characters"),
|
||||
});
|
||||
|
||||
window.submit = async (data: any, form: any) => {
|
||||
console.log("Validated data:", data);
|
||||
};
|
||||
</script>
|
||||
Reference in New Issue
Block a user