Update OTP Field with Resend OTP
This commit is contained in:
+324
-267
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user