Files
Astro-Component/src/components/PasswordField.astro
T
2025-08-22 15:23:39 +05:30

304 lines
9.7 KiB
Plaintext

---
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:sm:py-3 wr:px-4 wr:block wr:w-full wr:border-border wr:rounded-lg wr:sm:text-sm wr:focus:border-border wr:focus:ring-blue-500 wr:disabled:opacity-50 wr:disabled:pointer-events-none wr:dark:bg-input wr:dark:border-border wr:dark:text-neutral-400 wr:dark:placeholder-neutral-500 wr:dark:focus:ring-neutral-600",
sizeKls,
"wr:pr-10", // space for eye button
p.error && "wr:border-danger wr:focus-visible: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 wr:hover: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 wr:dark: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>