@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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 }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user