webOS: open the keyboard only on OK; hovering a tab does not switch views
Text fields are read-only while the focus passes over them (webOS opens its keyboard for every editable field that gets focus); OK or a click starts typing, Enter, Back or up/down ends it. The pointer focuses a tab without switching to it; a click does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
// Green Yellow Blue PageUp PageDown 1-7
|
||||
// type TEXT type into the focused field
|
||||
// sleep SECONDS
|
||||
// point X Y / click X Y the Magic Remote's pointer: move there / click there
|
||||
// shot NAME OUTDIR/NAME.png
|
||||
// log print the app's console and the focused element
|
||||
|
||||
@@ -71,6 +72,12 @@ for (const raw of readFileSync(scenePath, "utf8").split("\n")) {
|
||||
case "type":
|
||||
await page.keyboard.type(line.slice(5));
|
||||
break;
|
||||
case "point":
|
||||
await page.mouse.move(Number(rest[0]), Number(rest[1]));
|
||||
break;
|
||||
case "click":
|
||||
await page.mouse.click(Number(rest[0]), Number(rest[1]));
|
||||
break;
|
||||
case "sleep":
|
||||
await new Promise((r) => setTimeout(r, parseFloat(rest[0]) * 1000));
|
||||
break;
|
||||
|
||||
+4
-2
@@ -4,7 +4,7 @@
|
||||
import { app } from "./app.ts";
|
||||
import { applyTheme } from "./theme.ts";
|
||||
import { h, replace } from "./ui/dom.ts";
|
||||
import { current, focus, focusFirst, initFocus, Keys } from "./ui/focus.ts";
|
||||
import { current, focus, focusFirst, initFocus, Keys, pointerFocus } from "./ui/focus.ts";
|
||||
import { button, confirmDialog, toast } from "./ui/widgets.ts";
|
||||
import { CalendarView } from "./views/calendar.ts";
|
||||
import { DownloadsView, watchDownloads } from "./views/downloads.ts";
|
||||
@@ -57,8 +57,10 @@ function start(): void {
|
||||
views.forEach(([name, view], i) => {
|
||||
const tab = button(`${i + 1} ${name}`, () => show(entry, true), "tab");
|
||||
const entry: Entry = { name, view, tab };
|
||||
// Moving along the tabs switches views once the focus rests there.
|
||||
// Moving along the tabs with the arrows switches views once the focus
|
||||
// rests there; the pointer only switches on a click.
|
||||
tab.addEventListener("focus", () => {
|
||||
if (pointerFocus) return;
|
||||
clearTimeout(switchTimer);
|
||||
switchTimer = window.setTimeout(() => {
|
||||
if (document.activeElement === tab) show(entry, false);
|
||||
|
||||
+65
-9
@@ -7,6 +7,11 @@
|
||||
// with up and down) by setting onKey; returning true stops the navigation.
|
||||
// A group (class "fgroup") remembers its last focused element, so moving back
|
||||
// into a group continues where one left it.
|
||||
//
|
||||
// Text fields are read-only while the focus merely passes over them: webOS
|
||||
// opens its on-screen keyboard for every editable field that gets focus. OK
|
||||
// (or a pointer click) starts editing and so opens the keyboard; Enter, Back
|
||||
// or moving up or down ends it.
|
||||
|
||||
export const Keys = {
|
||||
left: 37,
|
||||
@@ -38,6 +43,10 @@ interface Layer {
|
||||
}
|
||||
|
||||
const layers: Layer[] = [];
|
||||
|
||||
// pointerFocus tells whether the last focus came from the Magic Remote's
|
||||
// pointer hovering (not from the arrow keys).
|
||||
export let pointerFocus = false;
|
||||
let globalKeys: (ev: KeyboardEvent) => boolean = () => false;
|
||||
|
||||
export function initFocus(root: HTMLElement, back: () => void, keys: (ev: KeyboardEvent) => boolean): void {
|
||||
@@ -45,10 +54,21 @@ export function initFocus(root: HTMLElement, back: () => void, keys: (ev: Keyboa
|
||||
layers.push({ root, back, restore: null });
|
||||
globalKeys = keys;
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
document.addEventListener("click", (ev) => {
|
||||
const el = ev.target as Element | null;
|
||||
if (isTextInput(el) && el.classList.contains("f") && el.readOnly) startEditing(el);
|
||||
});
|
||||
document.addEventListener("focusout", (ev) => {
|
||||
if (isTextInput(ev.target as Element)) (ev.target as HTMLInputElement).readOnly = true;
|
||||
});
|
||||
// The Magic Remote's pointer: hovering focuses, like moving there.
|
||||
document.addEventListener("mouseover", (ev) => {
|
||||
const el = (ev.target as Element | null)?.closest?.(".f") as HTMLElement | null;
|
||||
if (el && inLayer(el) && el !== document.activeElement && !isTextInput(document.activeElement)) focus(el, false);
|
||||
if (el && inLayer(el) && el !== document.activeElement && !editing(document.activeElement)) {
|
||||
pointerFocus = true;
|
||||
focus(el, false);
|
||||
pointerFocus = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,7 +98,8 @@ export function current(): HTMLElement | null {
|
||||
}
|
||||
|
||||
export function focus(el: HTMLElement, scroll = true): void {
|
||||
if (!el.hasAttribute("tabindex") && !isTextInput(el)) el.tabIndex = -1;
|
||||
if (isTextInput(el)) el.readOnly = true; // no keyboard until OK
|
||||
else if (!el.hasAttribute("tabindex")) el.tabIndex = -1;
|
||||
el.focus({ preventScroll: true });
|
||||
if (scroll) el.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
const g = el.closest(".fgroup") as (HTMLElement & { lastFocus?: HTMLElement }) | null;
|
||||
@@ -112,13 +133,36 @@ function inLayer(el: Element): boolean {
|
||||
return top().root.contains(el);
|
||||
}
|
||||
|
||||
function isTextInput(el: Element | null): el is HTMLInputElement {
|
||||
export function isTextInput(el: Element | null): el is HTMLInputElement {
|
||||
return !!el && el.tagName === "INPUT" && ["text", "search", "password", "url", ""].includes((el as HTMLInputElement).type);
|
||||
}
|
||||
|
||||
// editing: a text field that takes typing (its keyboard is open).
|
||||
function editing(el: Element | null): el is HTMLInputElement {
|
||||
return isTextInput(el) && !el.readOnly;
|
||||
}
|
||||
|
||||
// startEditing makes the field editable and refocuses it, which opens the
|
||||
// on-screen keyboard, with the cursor at the end.
|
||||
function startEditing(input: HTMLInputElement): void {
|
||||
input.blur(); // (leaving makes it read-only: before it becomes editable)
|
||||
input.readOnly = false;
|
||||
input.focus({ preventScroll: true });
|
||||
const end = input.value.length;
|
||||
try {
|
||||
input.setSelectionRange(end, end);
|
||||
} catch {
|
||||
/* some input types have no selection */
|
||||
}
|
||||
}
|
||||
|
||||
function stopEditing(input: HTMLInputElement): void {
|
||||
input.readOnly = true;
|
||||
}
|
||||
|
||||
export function isBackKey(ev: KeyboardEvent): boolean {
|
||||
if (ev.keyCode === Keys.back || ev.keyCode === Keys.escape) return true;
|
||||
return ev.keyCode === Keys.backspace && !isTextInput(document.activeElement);
|
||||
return ev.keyCode === Keys.backspace && !editing(document.activeElement);
|
||||
}
|
||||
|
||||
function onKeyDown(ev: KeyboardEvent): void {
|
||||
@@ -127,17 +171,29 @@ function onKeyDown(ev: KeyboardEvent): void {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
const typing = editing(document.activeElement) ? (document.activeElement as HTMLInputElement) : null;
|
||||
if (typing && (ev.keyCode === Keys.enter || isBackKey(ev))) {
|
||||
// Done typing: the field stays focused (Enter still reaches the view,
|
||||
// e.g. the search box moves on to the results).
|
||||
stopEditing(typing);
|
||||
if (ev.keyCode !== Keys.enter) ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!typing && ev.keyCode === Keys.enter && isTextInput(el)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
startEditing(el);
|
||||
return;
|
||||
}
|
||||
if (isBackKey(ev)) {
|
||||
if (isTextInput(document.activeElement) && ev.keyCode === Keys.escape && document.activeElement.value) {
|
||||
return; // let Escape do nothing special in a field with text
|
||||
}
|
||||
ev.preventDefault();
|
||||
top().back();
|
||||
return;
|
||||
}
|
||||
const dir = ({ 37: "left", 38: "up", 39: "right", 40: "down" } as Record<number, Dir>)[ev.keyCode];
|
||||
if (dir) {
|
||||
if (isTextInput(el) && (dir === "left" || dir === "right") && caretInside(el, dir)) return;
|
||||
if (typing && (dir === "left" || dir === "right") && caretInside(typing, dir)) return;
|
||||
if (typing) stopEditing(typing);
|
||||
ev.preventDefault();
|
||||
if (!el || !inLayer(el)) {
|
||||
focusFirst(top().root);
|
||||
@@ -153,7 +209,7 @@ function onKeyDown(ev: KeyboardEvent): void {
|
||||
return;
|
||||
}
|
||||
// View keys (numbers, colour buttons) only while no dialog or page is open.
|
||||
if (layers.length === 1 && !isTextInput(document.activeElement) && globalKeys(ev)) ev.preventDefault();
|
||||
if (layers.length === 1 && !typing && globalKeys(ev)) ev.preventDefault();
|
||||
}
|
||||
|
||||
function caretInside(input: HTMLInputElement, dir: Dir): boolean {
|
||||
|
||||
@@ -152,7 +152,7 @@ export class Toggle {
|
||||
}
|
||||
|
||||
export function textField(label: string, value: string, opts: { secret?: boolean; placeholder?: string; onInput?: (v: string) => void } = {}): { el: HTMLElement; input: HTMLInputElement } {
|
||||
const input = h("input.f", { type: opts.secret ? "password" : "text", value, placeholder: opts.placeholder ?? "", spellcheck: false, autocomplete: "off" });
|
||||
const input = h("input.f", { type: opts.secret ? "password" : "text", value, placeholder: opts.placeholder ?? "", spellcheck: false, autocomplete: "off", readOnly: true });
|
||||
if (opts.onInput) input.addEventListener("input", () => opts.onInput!(input.value));
|
||||
return { el: h("label.field", h("span.field-label", label), input), input };
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export class SearchView implements View {
|
||||
this.lists = kind === "movie" ? movieLists : showLists;
|
||||
const noun = kind === "movie" ? "movies" : "series";
|
||||
const ids = kind === "movie" ? "tmdb:603, imdb:tt0133093" : "tvdb:81189, imdb:tt0903747";
|
||||
this.input = h("input.search.f", { type: "search", placeholder: `Search ${noun} (title, ${ids})…`, spellcheck: false, autocomplete: "off" });
|
||||
this.input = h("input.search.f", { type: "search", placeholder: `Search ${noun} (title, ${ids})…`, spellcheck: false, autocomplete: "off", readOnly: true });
|
||||
this.input.addEventListener("input", () => {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = window.setTimeout(() => this.update(), 600);
|
||||
|
||||
@@ -248,3 +248,7 @@ input.search { width: 100%; font-size: 30px; padding: 14px 24px; }
|
||||
.toast-warn { border-left-color: var(--warn); }
|
||||
.toast-error { border-left-color: var(--bad); }
|
||||
.toast.gone { opacity: 0; }
|
||||
|
||||
/* Text fields: read-only (no keyboard) until OK; while typing, marked. */
|
||||
input.f[readonly]:focus { caret-color: transparent; }
|
||||
input.f:not([readonly]):focus { border-color: var(--ok); box-shadow: 0 0 0 3px var(--ok); }
|
||||
|
||||
Reference in New Issue
Block a user