Files
site/apps/web/public/wesp/js/setup-touch-keyboard.js

205 lines
5.8 KiB
JavaScript

/**
* On-screen keyboard for setup wizard on touch kiosks (no system IME).
* Uses wesp-setup CSS variables for light/dark theme.
*/
const LAYOUTS = {
hostname: [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
[
"a",
"s",
"d",
"f",
"g",
"h",
"j",
"k",
"l",
{ key: "backspace", label: "⌫", span: 2 },
],
["z", "x", "c", "v", "b", "n", "m", "_", "-", "."],
],
url: [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
[
"a",
"s",
"d",
"f",
"g",
"h",
"j",
"k",
"l",
{ key: "backspace", label: "⌫", span: 2 },
],
["z", "x", "c", "v", "b", "n", "m", "_", "-", "."],
[
{ key: "http://", label: "http://", span: 3 },
{ key: ":", label: ":" },
{ key: "/", label: "/" },
{ key: "backspace", label: "⌫", span: 2 },
],
],
};
const HOSTNAME_CHAR = /[a-z0-9._-]/;
const URL_CHAR = /[a-z0-9.:/_-]/;
function allowedChar(ch, layout) {
if (layout === "url") return URL_CHAR.test(ch);
return HOSTNAME_CHAR.test(ch);
}
function normalizeInsert(ch, layout) {
const lower = ch.toLowerCase();
if (ch.length === 1 && !allowedChar(lower, layout)) return "";
return lower;
}
export class SetupTouchKeyboard {
#panel;
#root = null;
#grid = null;
#activeInput = null;
#activeLayout = "hostname";
#bindings = new Map();
#docClickHandler = null;
#maxLength = 200;
constructor(panel) {
this.#panel = panel;
}
attach(input, { layout = "hostname", maxLength = 200 } = {}) {
if (!input || input.disabled) return;
this.#bindings.set(input, { layout, maxLength });
input.readOnly = true;
input.inputMode = "none";
input.autocomplete = "off";
input.autocorrect = "off";
input.spellcheck = false;
input.classList.add("wesp-setup__input--touch");
const onFocus = () => this.#showFor(input);
input.addEventListener("focus", onFocus);
input.addEventListener("click", onFocus);
}
mount() {
if (this.#root) return;
this.#root = document.createElement("div");
this.#root.className = "wesp-setup__keyboard";
this.#root.setAttribute("role", "group");
this.#root.setAttribute("aria-label", "Экранная клавиатура");
this.#grid = document.createElement("div");
this.#grid.className = "wesp-setup__keyboard-grid";
this.#root.appendChild(this.#grid);
const footer = this.#panel.querySelector(".wesp-setup__footer");
if (footer) {
this.#panel.insertBefore(this.#root, footer);
} else {
this.#panel.appendChild(this.#root);
}
this.#docClickHandler = (e) => {
if (!this.#root?.classList.contains("is-visible")) return;
const t = e.target;
if (this.#root.contains(t)) return;
if (this.#activeInput && (t === this.#activeInput || this.#activeInput.contains?.(t))) return;
for (const input of this.#bindings.keys()) {
if (t === input || input.contains?.(t)) return;
}
this.hide();
};
document.addEventListener("pointerdown", this.#docClickHandler, true);
}
hide() {
this.#root?.classList.remove("is-visible");
this.#activeInput = null;
}
destroy() {
if (this.#docClickHandler) {
document.removeEventListener("pointerdown", this.#docClickHandler, true);
this.#docClickHandler = null;
}
this.#root?.remove();
this.#root = null;
this.#grid = null;
this.#bindings.clear();
this.#activeInput = null;
}
#showFor(input) {
const binding = this.#bindings.get(input);
if (!binding) return;
this.mount();
this.#activeInput = input;
this.#activeLayout = binding.layout;
this.#maxLength = binding.maxLength;
this.#renderKeys(binding.layout);
this.#root.classList.add("is-visible");
window.requestAnimationFrame(() => {
input.scrollIntoView({ block: "nearest", behavior: "smooth" });
});
}
#renderKeys(layoutName) {
const rows = LAYOUTS[layoutName] || LAYOUTS.hostname;
this.#grid.replaceChildren();
rows.forEach((row) => {
row.forEach((item) => {
const spec = typeof item === "string" ? { key: item, label: item } : item;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "wesp-setup__keyboard-key";
if (spec.key === "backspace") btn.classList.add("wesp-setup__keyboard-key--wide");
if (spec.key === "http://") btn.classList.add("wesp-setup__keyboard-key--action");
btn.textContent = spec.label || spec.key;
const span = spec.span || 1;
if (span > 1) btn.style.gridColumn = `span ${span}`;
btn.addEventListener("pointerdown", (e) => {
e.preventDefault();
this.#onKey(spec.key);
});
this.#grid.appendChild(btn);
});
});
}
#onKey(key) {
const input = this.#activeInput;
if (!input) return;
if (key === "backspace") {
input.value = input.value.slice(0, -1);
input.dispatchEvent(new Event("input", { bubbles: true }));
return;
}
let insert = "";
if (key === "http://") {
if (this.#activeLayout === "url") insert = "http://";
} else {
insert = normalizeInsert(key, this.#activeLayout);
}
if (!insert) return;
if (input.value.length >= this.#maxLength) return;
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? start;
input.value = input.value.slice(0, start) + insert + input.value.slice(end);
const pos = start + insert.length;
try {
input.setSelectionRange(pos, pos);
} catch {
/* readonly selection may fail in some browsers */
}
input.dispatchEvent(new Event("input", { bubbles: true }));
}
}