Auto-format token and join-code inputs with dashes while typing

Shared wireCodeMask helper: uppercase, dash every 4 chars, raw-length
cap, caret preserved, backspace over a dash deletes the char before it.
This commit is contained in:
2026-07-20 10:46:13 +07:00
parent 36c4e4f306
commit 65480fd892
3 changed files with 28 additions and 2 deletions
+22
View File
@@ -1,4 +1,5 @@
// Tiny DOM helpers — no framework, just ergonomic element creation. // Tiny DOM helpers — no framework, just ergonomic element creation.
import { groupCode, normalizeCode } from './format.js';
// el('div', { class: 'x', onClick: fn }, child, child, ...) // el('div', { class: 'x', onClick: fn }, child, child, ...)
// Attrs: class, dataset(obj), style(obj), html(innerHTML), on<Event>(fn), // Attrs: class, dataset(obj), style(obj), html(innerHTML), on<Event>(fn),
@@ -75,6 +76,27 @@ export function emptyState(title, subtitle, action) {
); );
} }
// Live code mask for token/join-code inputs: uppercase + a dash after every 4
// characters as you type, capped at rawMax raw characters, keeping the caret
// anchored to the same raw character. Backspacing onto a dash deletes the
// character before it (otherwise the dash would just reappear and the key
// would look dead).
export function wireCodeMask(input, rawMax) {
input.addEventListener('keydown', (e) => {
const pos = input.selectionStart;
if (e.key === 'Backspace' && pos === input.selectionEnd && input.value[pos - 1] === '-') {
input.setSelectionRange(pos - 1, pos - 1);
}
});
input.addEventListener('input', () => {
const rawBefore = Math.min(rawMax, normalizeCode(input.value.slice(0, input.selectionStart)).length);
const raw = normalizeCode(input.value).slice(0, rawMax);
input.value = groupCode(raw, 4);
const caret = rawBefore + (rawBefore > 0 ? Math.floor((rawBefore - 1) / 4) : 0);
input.setSelectionRange(caret, caret);
});
}
let toastHost = null; let toastHost = null;
export function toast(message, type = 'error', ms = 4200) { export function toast(message, type = 'error', ms = 4200) {
if (!toastHost) { if (!toastHost) {
+3 -1
View File
@@ -2,7 +2,7 @@
// Two panels: create a new account (one-time token reveal) or log in with an // Two panels: create a new account (one-time token reveal) or log in with an
// existing account number. // existing account number.
import { api } from '../api.js'; import { api } from '../api.js';
import { el, mount, toast } from '../dom.js'; import { el, mount, toast, wireCodeMask } from '../dom.js';
import { groupCode, normalizeCode } from '../format.js'; import { groupCode, normalizeCode } from '../format.js';
export function renderAuth(container, ctx) { export function renderAuth(container, ctx) {
@@ -123,8 +123,10 @@ export function renderAuth(container, ctx) {
autocapitalize: 'characters', autocapitalize: 'characters',
spellcheck: 'false', spellcheck: 'false',
placeholder: 'XXXX-XXXX-XXXX-XXXX', placeholder: 'XXXX-XXXX-XXXX-XXXX',
maxlength: '19',
'aria-label': 'Account number', 'aria-label': 'Account number',
}); });
wireCodeMask(input, 16);
const submitBtn = el('button', { class: 'btn btn-primary btn-block', type: 'submit' }, 'Log in'); const submitBtn = el('button', { class: 'btn btn-primary btn-block', type: 'submit' }, 'Log in');
async function onSubmit(e) { async function onSubmit(e) {
+3 -1
View File
@@ -1,6 +1,6 @@
// Trip list dashboard: cards, a "new trip" form, and a "join a trip" form. // Trip list dashboard: cards, a "new trip" form, and a "join a trip" form.
import { api } from '../api.js'; import { api } from '../api.js';
import { el, mount, clear, loading, errorBox, emptyState, toast } from '../dom.js'; import { el, mount, clear, loading, errorBox, emptyState, toast, wireCodeMask } from '../dom.js';
import { formatRange, ymd, parseYMD, pluralize, normalizeCode } from '../format.js'; import { formatRange, ymd, parseYMD, pluralize, normalizeCode } from '../format.js';
import { enableTripReorder } from './dragdrop.js'; import { enableTripReorder } from './dragdrop.js';
@@ -201,8 +201,10 @@ export function renderTrips(container, ctx) {
autocapitalize: 'characters', autocapitalize: 'characters',
spellcheck: 'false', spellcheck: 'false',
placeholder: 'XXXX-XXXX', placeholder: 'XXXX-XXXX',
maxlength: '9',
'aria-label': 'Join code', 'aria-label': 'Join code',
}); });
wireCodeMask(codeInput, 8);
const errorEl = el('p', { class: 'form-error' }); const errorEl = el('p', { class: 'form-error' });
const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Join trip'); const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Join trip');