304 lines
9.7 KiB
Plaintext
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>
|