/** * 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 })); } }