mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 14:12:39 +02:00
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:
@@ -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. */
|
||||
|
||||
@@ -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 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
})();`;
|
||||
Reference in New Issue
Block a user