feat(auth): draw code fields as one box per character

The emailed code and the device code are now typed into a row of boxes,
with a dash drawn between the device code's two halves. Typing moves to
the next box, backspace on an empty box goes back, the arrow keys move,
and a pasted or autofilled code, dashes and all, is spread across them.

It is an enhancement of the field and not a replacement for it. The
server still sends the single plain input, which works on its own with
script off; a short inline script draws the boxes over it and keeps it
in step. The real input is still the one that submits and the one whose
validity dims the button, so nothing else had to learn about boxes.
Pressing the button early moves to the first empty box, since the
browser's own message would point at a field nobody can see.
This commit is contained in:
Wanjohi
2026-10-02 06:00:08 +03:00
parent 6d654f666f
commit 39e4bebf64
3 changed files with 215 additions and 21 deletions
+50
View File
@@ -486,6 +486,56 @@ form:has([data-component='input']:invalid) [data-component='button']:hover {
font-variant-numeric: tabular-nums;
}
/* A code field drawn as one box per character, by \`segments.ts\`. The real
field is still the one that submits and the one the button's dimming reads,
so it is hidden from sight and not removed. */
[data-component='segments'] {
position: relative;
width: 100%;
}
[data-component='segments'][data-enhanced] > [data-component='input'] {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
[data-component='segments'] [data-slot='boxes'] {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
}
[data-component='input'][data-variant='box'] {
flex: 1 1 0%;
min-width: 0;
padding: 0.9rem 0;
text-align: center;
font-family: var(--font-mona);
font-size: 1.5rem;
line-height: 2rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
text-transform: uppercase;
}
/* A box selects its character on focus so typing replaces it; that is
behaviour, not something to show. */
[data-component='input'][data-variant='box']::selection {
background: transparent;
}
[data-component='segments'] [data-slot='separator'] {
flex: none;
color: var(--color-muted-foreground2);
font-family: var(--font-mona);
font-size: 1.5rem;
font-weight: 700;
user-select: none;
}
/* A wrong answer shakes the field it came from, once. Every error is a new
page, so an animation that runs on load runs exactly when one arrives —
no script needed to trigger it. */
+36 -21
View File
@@ -17,6 +17,7 @@
/** @jsxImportSource hono/jsx */
import { Layout } from './base.js';
import segments from './segments.js';
import type {
Alert,
ChooseScreen,
@@ -52,7 +53,10 @@ export interface HtmlRendererOptions {
theme?: Theme;
}
/** The default renderer: server-rendered HTML, no client-side script. */
/**
* The default renderer: server-rendered HTML that works with script off. The
* one script, `segments.ts`, only redraws a code field as boxes.
*/
export function HtmlRenderer(options?: HtmlRendererOptions): Renderer {
const theme = options?.theme;
@@ -243,27 +247,38 @@ function Input(props: { field: Field }) {
const sizes = Array.from({ length: groups }, (_, i) =>
Math.min(field.group ?? field.length, field.length - i * (field.group ?? field.length))
);
// One box per character once `segments.ts` has run; this single field
// until then, and for good if it never does.
return (
<input
data-component="input"
data-variant="code"
type="text"
name={field.name}
aria-label={field.label}
placeholder={field.label}
minLength={field.length}
maxLength={field.length + groups - 1}
// Whole and well-formed, or `:invalid` — which is what the stylesheet
// dims the button on. `minLength` alone is not checked until edited.
pattern={sizes.map((n) => `${char}{${n}}`).join('[\\- ]?')}
size={field.length + groups - 1}
required
spellcheck={false}
autocapitalize="characters"
inputmode={field.numeric ? 'numeric' : 'text'}
autocomplete={field.autocomplete}
autofocus={field.autofocus}
/>
<>
<div
data-component="segments"
data-length={field.length}
data-group={field.group}
data-numeric={field.numeric ? 'true' : 'false'}>
<input
data-component="input"
data-variant="code"
type="text"
name={field.name}
aria-label={field.label}
placeholder={field.label}
minLength={field.length}
maxLength={field.length + groups - 1}
// Whole and well-formed, or `:invalid` — which is what the stylesheet
// dims the button on. `minLength` alone is not checked until edited.
pattern={sizes.map((n) => `${char}{${n}}`).join('[\\- ]?')}
size={field.length + groups - 1}
required
spellcheck={false}
autocapitalize="characters"
inputmode={field.numeric ? 'numeric' : 'text'}
autocomplete={field.autocomplete}
autofocus={field.autofocus}
/>
</div>
<script dangerouslySetInnerHTML={{ __html: segments }} />
</>
);
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Turns a code field into one box per character.
*
* An enhancement, not the field. The server sends a single plain input that
* is complete on its own — it validates, submits and autofills — and this
* draws boxes over it when script runs. With script off, or if this throws,
* the person gets the plain field and loses nothing but the look.
*
* The real input stays the one that submits and the one the stylesheet reads
* validity from, so the button still dims on the same rule. The boxes carry no
* `name` and are never sent.
*
* Runs once per field: it is emitted directly after the input's wrapper and
* enhances `document.currentScript`'s previous sibling, so two code fields on
* one page each get their own copy and neither looks the other up.
*
* Plain ES2017 in a string, for the same reason `css.ts` is a string: nothing
* bundles this page.
*/
export default `(() => {
const root = document.currentScript && document.currentScript.previousElementSibling;
if (!root || root.getAttribute('data-component') !== 'segments') return;
const real = root.querySelector('input');
const length = Number(root.dataset.length);
const group = Number(root.dataset.group) || length;
const numeric = root.dataset.numeric === 'true';
if (!real || !length) return;
// What one box accepts. A pasted \`BCDF-3467\` is cleaned to its characters
// here, so the dash is something the boxes draw and never something typed.
const clean = (text) => {
const kept = numeric ? text.replace(/[^0-9]/g, '') : text.replace(/[^0-9a-zA-Z]/g, '');
return numeric ? kept : kept.toUpperCase();
};
const boxes = [];
const row = document.createElement('div');
row.setAttribute('data-slot', 'boxes');
for (let i = 0; i < length; i++) {
if (i > 0 && i % group === 0) {
const dash = document.createElement('span');
dash.setAttribute('data-slot', 'separator');
dash.setAttribute('aria-hidden', 'true');
dash.textContent = '-';
row.appendChild(dash);
}
const box = document.createElement('input');
box.type = 'text';
box.setAttribute('data-component', 'input');
box.setAttribute('data-variant', 'box');
box.setAttribute('aria-label', real.getAttribute('aria-label') + ', character ' + (i + 1) + ' of ' + length);
box.inputMode = numeric ? 'numeric' : 'text';
box.autocapitalize = 'characters';
box.spellcheck = false;
// Only the first box offers the one-time-code autofill, and it may be
// handed the whole code at once — which \`spread\` below deals out.
box.autocomplete = i === 0 ? real.autocomplete || 'one-time-code' : 'off';
boxes.push(box);
row.appendChild(box);
}
const sync = () => {
real.value = boxes.map((b) => b.value).join('');
// The button dims on the real field's validity, so it must hear this.
real.dispatchEvent(new Event('input', { bubbles: true }));
};
// The first empty box, or the last one if the code is complete.
const next = () => {
const empty = boxes.findIndex((b) => !b.value);
return boxes[empty === -1 ? length - 1 : empty];
};
// Put \`text\` into the boxes from \`from\` onwards and move to what is next.
const spread = (from, text) => {
const chars = clean(text).split('');
for (let i = from; i < length && chars.length; i++) boxes[i].value = chars.shift();
sync();
next().focus();
};
boxes.forEach((box, i) => {
// Typing into a filled box replaces it, rather than leaving two
// characters for \`spread\` to guess the order of.
box.addEventListener('focus', () => box.select());
box.addEventListener('input', () => {
const text = box.value;
box.value = '';
if (text) spread(i, text);
else sync();
});
box.addEventListener('paste', (event) => {
event.preventDefault();
spread(i, (event.clipboardData || window.clipboardData).getData('text'));
});
box.addEventListener('keydown', (event) => {
if (event.key === 'Backspace' && !box.value && i > 0) {
event.preventDefault();
boxes[i - 1].value = '';
boxes[i - 1].focus();
sync();
} else if (event.key === 'ArrowLeft' && i > 0) {
event.preventDefault();
boxes[i - 1].focus();
} else if (event.key === 'ArrowRight' && i < length - 1) {
event.preventDefault();
boxes[i + 1].focus();
}
});
});
// The real field is hidden from here on, so the browser's own message
// would point at nothing. Pressing the button early moves to the first
// empty box instead, which says what is missing just as plainly.
real.addEventListener('invalid', (event) => {
event.preventDefault();
next().focus();
});
real.tabIndex = -1;
real.setAttribute('aria-hidden', 'true');
real.autocomplete = 'off';
root.setAttribute('data-enhanced', '');
root.appendChild(row);
const prefilled = clean(real.value).split('');
boxes.forEach((b) => (b.value = prefilled.shift() || ''));
sync();
if (real.autofocus) next().focus();
})();`;