Update OTP Field with Resend OTP

This commit is contained in:
2025-08-21 14:44:39 +05:30
parent 4388d1e6d3
commit 861ae1c609
2 changed files with 326 additions and 269 deletions
+324 -267
View File
@@ -1,21 +1,22 @@
--- ---
import type { HTMLAttributes } from "astro/types"; import type { HTMLAttributes } from "astro/types";
import { cn } from "../utils/cn";
type Size = "sm" | "md" | "lg";
interface Props extends HTMLAttributes<"div"> { interface Props extends HTMLAttributes<"div"> {
id?: string; id?: string;
name: string; // hidden input name (what Zod receives) name: string;
label?: string; label?: string;
description?: string; description?: string;
error?: string; error?: string; // SSR error (optional)
length?: number; // number of boxes required?: boolean;
placeholderChar?: string; // shown in each box
autoFocus?: boolean;
disabled?: boolean; disabled?: boolean;
size?: Size; value?: string; // initial value (e.g., from server)
length?: number; // number of boxes
// Resend (optional) placeholder?: string | string[];
onlyDigits?: boolean; // restrict to 0-9
size?: "sm" | "md" | "lg"; // box sizes
autoFocus?: boolean;
class?: string;
resendEnabled?: boolean; // show/hide resend UI resendEnabled?: boolean; // show/hide resend UI
resendLabel?: string; // button label resendLabel?: string; // button label
resendCooldown?: number; // seconds resendCooldown?: number; // seconds
@@ -25,316 +26,372 @@ interface Props extends HTMLAttributes<"div"> {
const p = Astro.props as Props; const p = Astro.props as Props;
const fieldId = p.id ?? p.name; const fieldId = p.id ?? p.name;
const descId = p.description ? `${fieldId}-desc` : undefined;
const errId = `${fieldId}-err`; const errId = `${fieldId}-err`;
const LEN = Math.max(4, Math.min(p.length ?? 6, 10)); const len = Math.max(1, p.length ?? 6);
const SIZE = p.size ?? "md"; const initial = (p.value ?? "").slice(0, len);
const boxesSSR = Array.from({ length: len }, (_, i) => initial[i] ?? "");
const boxCls = const sizeCls =
SIZE === "sm" p.size === "sm"
? "wr:h-10 wr:w-10 wr:text-base" ? "wr:h-10 wr:w-10 wr:text-base"
: SIZE === "lg" : p.size === "lg"
? "wr:h-14 wr:w-14 wr:text-xl" ? "wr:h-14 wr:w-14 wr:text-xl"
: "wr:h-12 wr:w-12 wr:text-lg"; : "wr:h-12 wr:w-12 wr:text-lg";
const ringErr = "wr:border-danger wr:focus-visible:ring-danger/40"; const boxBase =
const ringOk = "wr:border-border wr:focus-visible:ring-primary/40"; "wr:rounded-lg wr:border wr:border-gray-200 wr:bg-background wr:text-center wr:tracking-widest " +
"wr:focus-visible:outline-none wr:focus-visible:ring-2 wr:focus-visible:ring-primary/40 " +
"wr:disabled:opacity-50 wr:disabled:pointer-events-none " +
"wr:dark:bg-neutral-900 wr:dark:border-neutral-700 wr:dark:text-neutral-100";
const groupCls = "wr:flex wr:items-center wr:gap-2 wr:select-none";
const showResend = !!p.resendEnabled;
const resendLabel = p.resendLabel ?? "Resend code"; const resendLabel = p.resendLabel ?? "Resend code";
const resendCooldown = Math.max(5, p.resendCooldown ?? 30);
const containerId = `otp-${Math.random().toString(36).slice(2)}`;
--- ---
<div <div
id={containerId} class="wr:flex wr:items-center wr:justify-between wr:flex-col wr:md:flex-row"
class="wr:space-y-2"
data-field={p.name}
aria-live="polite"
> >
{ <div class={cn("wr:space-y-1.5", p.class)} data-field={p.name}>
p.label && ( {
<label p.label && (
for={fieldId} <label
class="wr:block wr:text-sm wr:font-medium wr:text-foreground" for={fieldId}
> class="wr:block wr:text-sm wr:font-medium wr:mb-1.5 wr:text-foreground"
{p.label} >
</label> {p.label}{" "}
) {p.required && <span class="wr:text-danger">*</span>}
} </label>
)
}
<!-- Inputs row + optional resend button --> <!-- Hidden form value (drives validation & submission) -->
<div <input
class="wr:flex wr:flex-wrap wr:items-center wr:gap-2 wr:justify-between" id={fieldId}
> type="hidden"
<div class="wr:flex wr:flex-wrap wr:gap-2 wr:items-center"> name={p.name}
value={initial}
data-control
aria-invalid={!!p.error}
aria-describedby={[descId, p.error ? errId : undefined]
.filter(Boolean)
.join(" ") || undefined}
/>
<!-- Visible boxes -->
<div
class={groupCls}
role="group"
aria-labelledby={p.label ? fieldId : undefined}
aria-describedby={[descId, p.error ? errId : undefined]
.filter(Boolean)
.join(" ") || undefined}
data-otp-group
data-name={p.name}
data-length={len}
data-only-digits={p.onlyDigits !== false ? "1" : ""}
data-autofocus={p.autoFocus ? "1" : ""}
data-placeholder={Array.isArray(p.placeholder)
? ""
: (p.placeholder ?? "")}
data-disabled={p.disabled ? "1" : ""}
>
{ {
Array.from({ length: LEN }).map((_, i) => ( boxesSSR.map((ch, i) => (
<input <input
type="text" aria-label={`Code digit ${i + 1}`}
inputmode="numeric" inputmode={p.onlyDigits === false ? "text" : "numeric"}
pattern="[0-9]*" pattern={p.onlyDigits === false ? undefined : "[0-9]*"}
maxlength="1" maxlength="1"
class={[ class={cn(boxBase, sizeCls)}
"wr:text-center wr:rounded-lg wr:border wr:bg-background wr:text-foreground wr:shadow-xs", placeholder={
"wr:focus-visible:outline-none wr:focus-visible:ring-2", Array.isArray(p.placeholder)
p.error ? ringErr : ringOk, ? (p.placeholder[i] ?? "")
"wr:transition wr:duration-150 wr:placeholder:text-muted-foreground/50", : (p.placeholder ?? "")
boxCls, }
].join(" ")} value={ch}
aria-label={`OTP digit ${i + 1}`} data-otp-box
placeholder={p.placeholderChar ?? "•"}
data-otp-cell
data-idx={i} data-idx={i}
disabled={p.disabled} disabled={p.disabled}
autocomplete="one-time-code"
/> />
)) ))
} }
</div> </div>
{ {
p.resendEnabled && ( p.description && (
<div class="wr:mt-3 wr:flex wr:items-center wr:justify-between wr:text-sm"> <p
<button id={descId}
type="button" class="wr:mt-1 wr:text-sm wr:text-muted-foreground"
data-otp-resend >
class="wr:text-primary wr:hover:underline wr:disabled:opacity-50" {p.description}
aria-live="polite" </p>
>
{resendLabel ?? "Resend code"}
</button>
<span
data-otp-timer
class="wr:text-muted-foreground wr:ml-2"
/>
</div>
) )
} }
<p
id={errId}
class="wr:mt-1 wr:text-xs wr:text-danger"
hidden={!p.error}
>
{p.error ?? ""}
</p>
</div> </div>
<!-- Hidden field that your form/Zod consumes -->
<input type="hidden" id={fieldId} name={p.name} value="" data-otp-hidden />
{ {
p.description && ( p.resendEnabled && (
<p class="wr:text-sm wr:text-muted-foreground">{p.description}</p> <div class="wr:mt-3 wr:flex wr:items-center wr:justify-between wr:text-sm">
<button
type="button"
data-otp-resend
class="wr:text-primary wr:hover:underline wr:disabled:opacity-50"
aria-live="polite"
>
{resendLabel ?? "Resend code"}
</button>
<span data-otp-timer class="wr:text-muted-foreground wr:ml-2" />
</div>
) )
} }
<p id={errId} class="wr:mt-1 wr:text-xs wr:text-danger" hidden>
{p.error ?? ""}
</p>
</div> </div>
<script <script
is:inline is:inline
define:vars={{ define:vars={{
containerId,
fieldId,
LEN,
autoFocus: p.autoFocus ?? false,
onResend: p.onResend ?? "", onResend: p.onResend ?? "",
showResend, showResend: p.resendEnabled ?? false,
resendCooldown, resendCooldown: p.resendCooldown ?? 0,
}} }}
> >
(() => { (() => {
const host = document.getElementById(containerId); // Find all OTP groups on the page (component is safe to repeat)
if (!host) return; document.querySelectorAll("[data-otp-group]").forEach((group) => {
if (group.__otpInit) return;
group.__otpInit = true;
const cells = Array.from( const hidden = group.parentElement.querySelector(
host.querySelectorAll<HTMLInputElement>("[data-otp-cell]"), 'input[type="hidden"][data-control]',
); );
const hidden = const boxes = Array.from(
host.querySelector<HTMLInputElement>("[data-otp-hidden]"); group.querySelectorAll("input[data-otp-box]"),
const err = host.querySelector<HTMLElement>('[id$="-err"]'); );
const onlyDigits = !!group.getAttribute("data-only-digits");
const autoFocus = !!group.getAttribute("data-autofocus");
let values = Array(LEN).fill(""); const toValue = () => boxes.map((b) => b.value || "").join("");
const setHidden = (v, bubble = true) => {
if (!hidden) return;
hidden.value = v;
// bubble an input event so your per-field validation runs
if (bubble)
hidden.dispatchEvent(new Event("input", { bubbles: true }));
};
function val() { // Keep boxes styled when form sets aria-invalid on the hidden input
return values.join(""); if (hidden) {
} const applyInvalid = () => {
function renderHidden() { const invalid =
if (!hidden) return; hidden.getAttribute("aria-invalid") === "true";
hidden.value = val(); boxes.forEach((b) => {
// Fire an input event so your form validator (validateOn=input) can react b.classList.toggle("wr:border-danger", invalid);
hidden.dispatchEvent(new Event("input", { bubbles: true })); b.classList.toggle(
} "wr:focus-visible:ring-danger/40",
function focusAt(i) { invalid,
const next = cells[i]; );
next?.focus(); });
next?.select?.(); };
} applyInvalid();
function clearError() { const mo = new MutationObserver(applyInvalid);
if (!err) return; mo.observe(hidden, {
err.textContent = ""; attributes: true,
err.hidden = true; attributeFilter: ["aria-invalid"],
} });
}
// Fill a cell and move // Helpers
function setAt(i, d) { const clampIdx = (i) => Math.max(0, Math.min(i, boxes.length - 1));
if (i < 0 || i >= LEN) return; const focusIdx = (i, select = true) => {
values[i] = (d || "").replace(/\D/g, "").slice(0, 1); const el = boxes[clampIdx(i)];
cells[i].value = values[i]; el?.focus();
} if (select) el?.select?.();
};
// Paste handler function handleInput(e) {
function pasteInto(startIdx, text) { const el = e.target;
const digits = (text || "") const idx = Number(el.getAttribute("data-idx") || "0");
.replace(/\D/g, "")
.slice(0, LEN - startIdx)
.split("");
digits.forEach((ch, j) => setAt(startIdx + j, ch));
const last = Math.min(LEN - 1, startIdx + digits.length);
renderHidden();
focusAt(Math.min(last + 1, LEN - 1));
}
// Init let v = el.value || "";
cells.forEach((cell, idx) => { // Normalize: keep only first acceptable char
cell.addEventListener("keydown", (e) => { if (v.length > 1) v = v.slice(-1);
const key = e.key; if (onlyDigits) v = v.replace(/\D/g, "");
if (key === "Backspace") { el.value = v;
// Move forward on valid single char
if (v) focusIdx(idx + 1);
setHidden(toValue());
}
function handleKeydown(e) {
const el = e.target;
const idx = Number(el.getAttribute("data-idx") || "0");
if (e.key === "ArrowLeft") {
e.preventDefault(); e.preventDefault();
if (values[idx]) { focusIdx(idx - 1);
setAt(idx, ""); }
renderHidden(); if (e.key === "ArrowRight") {
} else if (idx > 0) { e.preventDefault();
setAt(idx - 1, ""); focusIdx(idx + 1);
renderHidden(); }
focusAt(idx - 1); if (e.key === "Home") {
e.preventDefault();
focusIdx(0);
}
if (e.key === "End") {
e.preventDefault();
focusIdx(boxes.length - 1);
}
if (e.key === "Backspace") {
// If box has a value, clear it; else move back and clear previous
if (el.value) {
el.value = "";
setHidden(toValue());
} else {
const j = Math.max(0, idx - 1);
const prev = boxes[j];
if (prev) {
prev.value = "";
prev.focus();
setHidden(toValue());
}
} }
return;
}
if (key === "ArrowLeft") {
e.preventDefault(); e.preventDefault();
focusAt(Math.max(0, idx - 1));
return;
} }
if (key === "ArrowRight") { }
e.preventDefault();
focusAt(Math.min(LEN - 1, idx + 1));
return;
}
if (key.length === 1 && /\d/.test(key)) {
e.preventDefault();
setAt(idx, key);
renderHidden();
if (idx < LEN - 1) focusAt(idx + 1);
else cells[idx].blur();
return;
}
if (key === " ") {
e.preventDefault();
return;
}
});
cell.addEventListener("input", (e) => { function handlePaste(e) {
const v = e.target.value || "";
const d = v.replace(/\D/g, "");
if (!d) {
values[idx] = "";
cells[idx].value = "";
renderHidden();
return;
}
values[idx] = d[0];
cells[idx].value = d[0];
renderHidden();
if (idx < LEN - 1) focusAt(idx + 1);
});
cell.addEventListener("paste", (e) => {
e.preventDefault(); e.preventDefault();
const text = e.clipboardData?.getData("text") || ""; const startBox = e.target.closest("input[data-otp-box]");
pasteInto(idx, text); const startIdx = Number(
startBox?.getAttribute("data-idx") || "0",
);
let text = (e.clipboardData?.getData("text") || "").trim();
if (onlyDigits) text = text.replace(/\D/g, "");
if (!text) return;
let j = startIdx;
for (const ch of text.slice(0, boxes.length - startIdx)) {
boxes[j].value = ch.slice(0, 1);
j++;
if (j >= boxes.length) break;
}
setHidden(toValue());
focusIdx(j < boxes.length ? j : boxes.length - 1);
}
boxes.forEach((b, i) => {
b.addEventListener("input", handleInput);
b.addEventListener("keydown", handleKeydown);
b.addEventListener("paste", handlePaste);
b.addEventListener("focus", () => b.select?.());
});
// Initial sync
setHidden(toValue(), false);
// Optional autofocus
if (autoFocus) {
const firstEmpty = boxes.findIndex((b) => !b.value);
focusIdx(
firstEmpty === -1 ? boxes.length - 1 : firstEmpty,
true,
);
}
// Respect disabled on the group
if (group.getAttribute("data-disabled")) {
boxes.forEach((b) => (b.disabled = true));
}
// Resend
const btnSel = "[data-otp-resend]";
const timerEl = document.querySelector("[data-otp-timer]");
let cooldownTimer = 0;
let remaining = 0;
const baseLabel =
document.querySelector(btnSel)?.textContent?.trim() || "Resend";
function setResendState({ disabled, label }) {
const btn = document.querySelector(btnSel);
if (!btn) return;
btn.disabled = !!disabled;
if (label != null) btn.textContent = label;
}
function tick() {
if (!timerEl) return;
if (remaining <= 0) {
timerEl.textContent = "";
setResendState({ disabled: false, label: baseLabel });
cooldownTimer && clearInterval(cooldownTimer);
cooldownTimer = 0;
return;
}
const m = Math.floor(remaining / 60);
const s = remaining % 60;
timerEl.textContent = `Available in ${m}:${String(s).padStart(2, "0")}`;
remaining -= 1;
}
function startCooldown(seconds) {
remaining = Number(seconds) || 60;
setResendState({ disabled: true, label: "Sending…" });
// small delay to show “Sending…”, then start the countdown
setTimeout(() => {
setResendState({ disabled: true, label: "Resend" });
tick();
cooldownTimer && clearInterval(cooldownTimer);
cooldownTimer = window.setInterval(tick, 1000);
}, 500);
}
// Delegate click so it works even if button is conditionally rendered
document.addEventListener("click", async (e) => {
const btn = e.target?.closest?.(btnSel);
if (!btn || !document.contains(btn)) return;
// Do nothing if already cooling down
if (btn.disabled) return;
// Optional user hook (string name -> function on window)
let ok = true;
try {
const fnName = onResend; // e.g. "submit"
const fn = fnName ? window[fnName] : null;
if (typeof fn === "function") {
// you can pass context if you like
const value = Array.from(
document.querySelectorAll("[data-otp-slot]"),
)
.map((i) => i.value || "")
.join("");
const res = await fn({ document, length, value });
if (res === false) ok = false; // let user cancel cooldown
} else {
// fire a custom event as a fallback
document.dispatchEvent(
new CustomEvent("wr:otp-resend"),
);
}
} catch {
ok = false;
}
if (ok) startCooldown(resendCooldown); // <-- uses injected var properly
}); });
}); });
if (autoFocus) focusAt(0);
// Resend
const btnSel = "[data-otp-resend]";
const timerEl = host.querySelector("[data-otp-timer]");
let cooldownTimer = 0;
let remaining = 0;
const baseLabel =
host.querySelector(btnSel)?.textContent?.trim() || "Resend";
function setResendState({ disabled, label }) {
const btn = host.querySelector(btnSel);
if (!btn) return;
btn.disabled = !!disabled;
if (label != null) btn.textContent = label;
}
function tick() {
if (!timerEl) return;
if (remaining <= 0) {
timerEl.textContent = "";
setResendState({ disabled: false, label: baseLabel });
cooldownTimer && clearInterval(cooldownTimer);
cooldownTimer = 0;
return;
}
const m = Math.floor(remaining / 60);
const s = remaining % 60;
timerEl.textContent = `Available in ${m}:${String(s).padStart(2, "0")}`;
remaining -= 1;
}
function startCooldown(seconds) {
remaining = Number(seconds) || 60;
setResendState({ disabled: true, label: "Sending…" });
// small delay to show “Sending…”, then start the countdown
setTimeout(() => {
setResendState({ disabled: true, label: "Resend" });
tick();
cooldownTimer && clearInterval(cooldownTimer);
cooldownTimer = window.setInterval(tick, 1000);
}, 500);
}
// Delegate click so it works even if button is conditionally rendered
host.addEventListener("click", async (e) => {
const btn = e.target?.closest?.(btnSel);
if (!btn || !host.contains(btn)) return;
// Do nothing if already cooling down
if (btn.disabled) return;
// Optional user hook (string name -> function on window)
let ok = true;
try {
const fnName = onResend; // e.g. "submit"
const fn = fnName ? window[fnName] : null;
if (typeof fn === "function") {
// you can pass context if you like
const value = Array.from(
host.querySelectorAll("[data-otp-slot]"),
)
.map((i) => i.value || "")
.join("");
const res = await fn({ host, length, value });
if (res === false) ok = false; // let user cancel cooldown
} else {
// fire a custom event as a fallback
host.dispatchEvent(new CustomEvent("wr:otp-resend"));
}
} catch {
ok = false;
}
if (ok) startCooldown(resendCooldown); // <-- uses injected var properly
});
// Keep hidden up to date initially
renderHidden();
// Clear form-level error when typing
host.addEventListener("input", () => clearError());
})(); })();
</script> </script>
+2 -2
View File
@@ -10,11 +10,11 @@ import ComponentLayout from "../layouts/ComponentLayout.astro";
label="Verification code" label="Verification code"
description="Enter the 6-digit code we sent to your email." description="Enter the 6-digit code we sent to your email."
length={6} length={6}
placeholderChar="-" placeholder="-"
size="md" size="md"
autoFocus={true} autoFocus={true}
resendEnabled={true} resendEnabled={true}
resendCooldown={120} resendCooldown={10}
resendLabel="Resend" resendLabel="Resend"
onResend="submit" onResend="submit"
/> />