Files
TREK/client/src/native/keyboardFields.ts
T
Maurice d06168d94a feat(native): the app answers a finger like a native app, not like a web page
After a tap, WebKit and Chrome keep :hover on what was tapped and send a
made-up mouseover, so buttons and rows stayed tinted until the next tap.
While a finger drives the app <html> carries data-touch-input: a PostCSS
step guards every :hover rule with it (no specificity change), and the
made-up mouseover and mouseout stop before any handler. A trackpad or mouse
clears the mark and gets hover back; a browser never sets it.

A control without a press of its own dims the moment a finger lands on it,
after a 60 ms wait so a scroll does not flash rows, and only the innermost
pressed control. A focus: ring no longer stays round a tapped button.

Fields get a fitting return key ("done", "go" in a form, "search" on the
search fields), and addresses, codes and numbers are no longer capitalised
or corrected. Ticking off packing items and tasks, a picker chip, a select
option, a picked date and dragging a plan row now give the haptics a native
list gives.

Confirmations ask through the system where the binary has the new
@capacitor/action-sheet and @capacitor/dialog plugins: a sheet with the
action in red when it destroys something, an alert otherwise, also for
"discard changes" in a sheet. An older app, a browser and a question with a
list under it keep the HTML dialog, as does a system that could not ask.

iOS no longer lifts a page preview on a long press on a link.
2026-11-09 08:54:51 +01:00

118 lines
3.8 KiB
TypeScript

/**
* The keyboard a field gets in the app: what its return key says, and whether
* it capitalises and corrects what is typed.
*
* Left alone, every field in a web view gets the return key of a text editor
* and the autocorrect of a letter, so an email address starts with a capital,
* a booking code is "corrected" into a word, and Enter on a search field reads
* "return". Before a field is focused (on the touch that focuses it, or on the
* focus itself when code moves it there) this fills in what the field does not
* say for itself:
*
* - enterkeyhint: "go" in a form, which Enter submits, "done" anywhere else.
* Search fields say "search" in their own markup.
* - autocapitalize, autocorrect and spellcheck off for addresses, codes,
* numbers and names a machine reads (email, URL, password, tel, number;
* autocomplete such as username or one-time-code; a name or id such as
* "code", "token" or "host").
*
* An attribute the field sets itself always wins, and textareas, which take
* Enter as a new line, keep the return key. Only the app calls this.
*/
const SKIP_TYPES = new Set([
'button',
'checkbox',
'color',
'date',
'datetime-local',
'file',
'hidden',
'image',
'month',
'radio',
'range',
'reset',
'submit',
'time',
'week',
]);
const MACHINE_TYPES = new Set(['email', 'url', 'password', 'tel', 'number']);
const MACHINE_MODES = new Set(['email', 'url', 'numeric', 'decimal', 'tel']);
const MACHINE_AUTOCOMPLETE =
/\b(username|email|url|one-time-code|current-password|new-password|tel|postal-code|cc-[a-z-]+)\b/;
const MACHINE_WORDS = new Set([
'code',
'token',
'key',
'apikey',
'secret',
'host',
'hostname',
'url',
'uri',
'domain',
'slug',
'username',
'email',
'mail',
'iban',
'bic',
'pin',
'otp',
'totp',
'ref',
'reference',
'server',
'port',
'ip',
]);
/** The words of an id or name: "bookingCode", "booking_code" and "booking-code" all give booking, code. */
function words(...values: (string | null)[]): string[] {
return values
.filter((v): v is string => !!v)
.flatMap((v) =>
v
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.toLowerCase()
.split(/[^a-z0-9]+/)
)
.filter(Boolean);
}
function readByMachine(el: HTMLInputElement): boolean {
if (MACHINE_TYPES.has(el.type)) return true;
if (MACHINE_MODES.has((el.getAttribute('inputmode') ?? '').toLowerCase())) return true;
if (MACHINE_AUTOCOMPLETE.test((el.getAttribute('autocomplete') ?? '').toLowerCase())) return true;
return words(el.name, el.id).some((w) => MACHINE_WORDS.has(w));
}
function setIfAbsent(el: Element, name: string, value: string) {
if (!el.hasAttribute(name)) el.setAttribute(name, value);
}
export function prepareField(target: EventTarget | null): void {
if (!(target instanceof HTMLInputElement) || SKIP_TYPES.has(target.type) || target.readOnly) return;
const el = target;
const search = el.type === 'search' || el.getAttribute('role') === 'searchbox';
setIfAbsent(el, 'enterkeyhint', search ? 'search' : el.form ? 'go' : 'done');
if (search || readByMachine(el)) {
setIfAbsent(el, 'autocapitalize', 'none');
setIfAbsent(el, 'autocorrect', 'off');
setIfAbsent(el, 'spellcheck', 'false');
}
}
export function prepareKeyboardFields(doc: Document = document): () => void {
// On the touch, before the focus: the keyboard is set up as the field takes it.
const onTouch = (e: Event) => prepareField(e.target instanceof Element ? e.target.closest('input') : null);
const onFocus = (e: FocusEvent) => prepareField(e.target);
doc.addEventListener('touchstart', onTouch, { capture: true, passive: true });
doc.addEventListener('focusin', onFocus, true);
return () => {
doc.removeEventListener('touchstart', onTouch, { capture: true });
doc.removeEventListener('focusin', onFocus, true);
};
}