orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1// Six boxes for the six digit code, which is what every code entry worth using
2// does now: one character per field, focus follows typing, and it submits itself
3// the moment the last one is filled.
4//
5// Progressive enhancement rather than markup. The template renders an ordinary
6// single field and hides the boxes, so a browser with no JavaScript still has a
7// working sign in, and this swaps them over on load.
8
9const LEN = 6;
10
11export function otp() {
12 const form = document.querySelector("[data-otp-form]");
13 if (!form) return;
14
15 const wrap = form.querySelector("[data-otp]");
16 const fallback = form.querySelector("[data-otp-fallback]");
17 if (!wrap || !fallback) return;
18
19 const boxes = Array.from(wrap.querySelectorAll("input"));
20 if (boxes.length !== LEN) return;
21
22 // type=hidden rather than hidden=true, so a `required` field the browser
23 // cannot see never blocks the submit with a validation message pointing at
24 // nothing.
25 fallback.type = "hidden";
26 fallback.required = false;
27 wrap.hidden = false;
28
29 const button = form.querySelector("[type=submit]");
30 const label = button ? button.textContent : "";
31 let submitted = false;
32
33 const value = () => boxes.map((b) => b.value).join("");
34
35 function maybeSubmit() {
36 if (submitted || value().length !== LEN) return;
37 fallback.value = value();
38 for (const b of boxes) b.blur();
39 form.requestSubmit();
40 }
41
42 // The sixth digit submits on its own, but the request takes long enough that
43 // people reach for the button anyway, and the second POST carries a code the
44 // first one already burned, so they get an error for signing in correctly.
45 form.addEventListener("submit", (e) => {
46 if (submitted) {
47 e.preventDefault();
48 return;
49 }
50 submitted = true;
51 if (button) {
52 button.disabled = true;
53 button.textContent = "Signing in…";
54 }
55 });
56
57 // Coming back to this page from the history cache restores it exactly as it
58 // was left, disabled button and all, so a second attempt would have nothing
59 // to press.
60 window.addEventListener("pageshow", (e) => {
61 if (!e.persisted) return;
62 submitted = false;
63 if (button) {
64 button.disabled = false;
65 button.textContent = label;
66 }
67 });
68
69 function fill(from, digits) {
70 let i = from;
71 for (const d of digits) {
72 if (i >= LEN) break;
73 boxes[i].value = d;
74 i++;
75 }
76 // Land on the first empty box, or the last one when the code is complete.
77 const next = boxes.findIndex((b) => b.value === "");
78 boxes[next === -1 ? LEN - 1 : next].focus();
79 maybeSubmit();
80 }
81
82 boxes.forEach((box, i) => {
83 box.addEventListener("input", () => {
84 // A phone's autofill drops the whole code into the first box, so this is
85 // the paste path as much as the typing one.
86 const digits = box.value.replace(/\D/g, "");
87 box.value = "";
88 if (!digits) return;
89 fill(i, digits);
90 });
91
92 box.addEventListener("keydown", (e) => {
93 if (e.key === "Backspace" && box.value === "" && i > 0) {
94 e.preventDefault();
95 boxes[i - 1].value = "";
96 boxes[i - 1].focus();
97 return;
98 }
99 if (e.key === "ArrowLeft" && i > 0) {
100 e.preventDefault();
101 boxes[i - 1].focus();
102 }
103 if (e.key === "ArrowRight" && i < LEN - 1) {
104 e.preventDefault();
105 boxes[i + 1].focus();
106 }
107 });
108
109 box.addEventListener("paste", (e) => {
110 e.preventDefault();
111 const digits = (e.clipboardData || window.clipboardData)
112 .getData("text")
113 .replace(/\D/g, "");
114 if (digits) fill(i, digits);
115 });
116
117 // Selecting the contents means typing over a digit replaces it rather than
118 // being ignored, since maxlength is already reached.
119 box.addEventListener("focus", () => box.select());
120 });
121
122 boxes[0].focus();
123}