Added New Field OTP
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
---
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
import { cn } from "../utils/cn";
|
||||
|
||||
interface Props extends HTMLAttributes<"div"> {
|
||||
id?: string;
|
||||
name: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
error?: string; // SSR error (optional)
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
value?: string; // initial value (e.g., from server)
|
||||
length?: number; // number of boxes
|
||||
placeholder?: string | string[];
|
||||
onlyDigits?: boolean; // restrict to 0-9
|
||||
size?: "sm" | "md" | "lg"; // box sizes
|
||||
autoFocus?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const p = Astro.props as Props;
|
||||
|
||||
const fieldId = p.id ?? p.name;
|
||||
const descId = p.description ? `${fieldId}-desc` : undefined;
|
||||
const errId = `${fieldId}-err`;
|
||||
|
||||
const len = Math.max(1, p.length ?? 6);
|
||||
const initial = (p.value ?? "").slice(0, len);
|
||||
const boxesSSR = Array.from({ length: len }, (_, i) => initial[i] ?? "");
|
||||
|
||||
const sizeCls =
|
||||
p.size === "sm"
|
||||
? "wr:h-10 wr:w-10 wr:text-base"
|
||||
: p.size === "lg"
|
||||
? "wr:h-14 wr:w-14 wr:text-xl"
|
||||
: "wr:h-12 wr:w-12 wr:text-lg";
|
||||
|
||||
const boxBase =
|
||||
"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";
|
||||
---
|
||||
|
||||
<div class={cn("wr:space-y-1.5", p.class)} data-field={p.name}>
|
||||
{
|
||||
p.label && (
|
||||
<label
|
||||
for={fieldId}
|
||||
class="wr:block wr:text-sm wr:font-medium wr:mb-1.5 wr:text-foreground"
|
||||
>
|
||||
{p.label} {p.required && <span class="wr:text-danger">*</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Hidden form value (drives validation & submission) -->
|
||||
<input
|
||||
id={fieldId}
|
||||
type="hidden"
|
||||
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" : ""}
|
||||
>
|
||||
{
|
||||
boxesSSR.map((ch, i) => (
|
||||
<input
|
||||
aria-label={`Code digit ${i + 1}`}
|
||||
inputmode={p.onlyDigits === false ? "text" : "numeric"}
|
||||
pattern={p.onlyDigits === false ? undefined : "[0-9]*"}
|
||||
maxlength="1"
|
||||
class={cn(boxBase, sizeCls)}
|
||||
placeholder={
|
||||
Array.isArray(p.placeholder)
|
||||
? (p.placeholder[i] ?? "")
|
||||
: (p.placeholder ?? "")
|
||||
}
|
||||
value={ch}
|
||||
data-otp-box
|
||||
data-idx={i}
|
||||
disabled={p.disabled}
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
p.description && (
|
||||
<p id={descId} class="wr:mt-1 wr:text-sm wr:text-muted-foreground">
|
||||
{p.description}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
<p id={errId} class="wr:mt-1 wr:text-xs wr:text-danger" hidden={!p.error}>
|
||||
{p.error ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
// Find all OTP groups on the page (component is safe to repeat)
|
||||
document.querySelectorAll("[data-otp-group]").forEach((group) => {
|
||||
if (group.__otpInit) return;
|
||||
group.__otpInit = true;
|
||||
|
||||
const hidden = group.parentElement.querySelector(
|
||||
'input[type="hidden"][data-control]',
|
||||
);
|
||||
const boxes = Array.from(
|
||||
group.querySelectorAll("input[data-otp-box]"),
|
||||
);
|
||||
const onlyDigits = !!group.getAttribute("data-only-digits");
|
||||
const autoFocus = !!group.getAttribute("data-autofocus");
|
||||
|
||||
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 }));
|
||||
};
|
||||
|
||||
// Keep boxes styled when form sets aria-invalid on the hidden input
|
||||
if (hidden) {
|
||||
const applyInvalid = () => {
|
||||
const invalid =
|
||||
hidden.getAttribute("aria-invalid") === "true";
|
||||
boxes.forEach((b) => {
|
||||
b.classList.toggle("wr:border-danger", invalid);
|
||||
b.classList.toggle(
|
||||
"wr:focus-visible:ring-danger/40",
|
||||
invalid,
|
||||
);
|
||||
});
|
||||
};
|
||||
applyInvalid();
|
||||
const mo = new MutationObserver(applyInvalid);
|
||||
mo.observe(hidden, {
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-invalid"],
|
||||
});
|
||||
}
|
||||
|
||||
// Helpers
|
||||
const clampIdx = (i) => Math.max(0, Math.min(i, boxes.length - 1));
|
||||
const focusIdx = (i, select = true) => {
|
||||
const el = boxes[clampIdx(i)];
|
||||
el?.focus();
|
||||
if (select) el?.select?.();
|
||||
};
|
||||
|
||||
function handleInput(e) {
|
||||
const el = e.target;
|
||||
const idx = Number(el.getAttribute("data-idx") || "0");
|
||||
|
||||
let v = el.value || "";
|
||||
// Normalize: keep only first acceptable char
|
||||
if (v.length > 1) v = v.slice(-1);
|
||||
if (onlyDigits) v = v.replace(/\D/g, "");
|
||||
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();
|
||||
focusIdx(idx - 1);
|
||||
}
|
||||
if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
focusIdx(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());
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e) {
|
||||
e.preventDefault();
|
||||
const startBox = e.target.closest("input[data-otp-box]");
|
||||
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));
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user