KeyboardHud
Hold a modifier on an attached keyboard to see the screen's shortcuts, found from data, conflicts reported, focus left alone.
Demo
Keyboard shortcuts
Hold to keep showingThis screen has no shortcuts.
Bad scope
Hold to keep showingThis screen has no shortcuts.
Hold Cmd on an attached keyboard (Ctrl elsewhere), or use the button.
Nothing yet.
None reported.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/keyboard_hud.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/keyboard_hud.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/keyboard_hud.qwe
let keyboard_hud_demo_css = css`.keyboard-hud-demo-btn { min-height: 44px; min-width: 44px; padding: 0 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 14px var(--ui-font); }`component KeyboardHudDemoLive() { style global keyboard_hud_demo_css state ran = "Nothing yet." state found = "None reported." <div.demo-stack> <div.demo-tablet> <div style="display: grid; gap: 12px; padding: 16px; align-content: start; height: 100%; box-sizing: border-box; font: 15px var(--ui-font)"> <div.demo-row> <button.keyboard-hud-demo-btn id="kh-save" data-shortcut="Mod+S" data-shortcut-group="File" @click={() => { ran = "Saved." }}>Save</button> <button.keyboard-hud-demo-btn id="kh-export" data-shortcut="s+mod" data-shortcut-label="Export" data-shortcut-group="File" @click={() => { ran = "Exported." }}>Share</button> <button.keyboard-hud-demo-btn aria-keyshortcuts="Shift+Meta+F Control+F" aria-label="Find" data-shortcut-group="Edit" @click={() => { ran = "Find." }}>Find</button> <button.keyboard-hud-demo-btn data-shortcut="Mod+Tab" data-shortcut-group="View" @click={() => { ran = "Switched." }}>Switch</button> <button.keyboard-hud-demo-btn disabled="true" data-shortcut="Mod+P" data-shortcut-group="File">Print</button> <button.keyboard-hud-demo-btn hidden="true" data-shortcut="Mod+J">Hidden</button> </div> <label>Notes <input id="kh-notes" style="height: 44px; font: 16px var(--ui-font); padding: 0 8px"/></label> <KeyboardHud shortcuts={[{ keys: "Mod+/", label: "Show help", group: "Help" }, { keys: "", label: "No keys" }, null]} on_conflict={(c) => { found = join(c | map((x) => `${x.kind} ${x.keys}: ${join(x.labels, "/")}`), "; ") }}/> <KeyboardHud trigger_label="With a bad scope" title="Bad scope" scope="[[" modifier="Alt"/> <p.demo-note>Hold Cmd on an attached keyboard (Ctrl elsewhere), or use the button.</p> <p.demo-note id="keyboard-hud-ran">{ran}</p> <p.demo-note id="keyboard-hud-found">{found}</p> </div> </div> </div>}view KeyboardHudDemo() = <KeyboardHudDemoLive/>Source
ui/keyboard_hud.qwe
// KeyboardHud: hold a modifier on a tablet's attached keyboard and the screen's shortcuts// show, as iPadOS does for an app. They are found from data, not typed in twice: whatever on// the page carries data-shortcut or aria-keyshortcuts is listed while it can be seen, so a// screen that changes shows what it has now. Two things on one combination, and a combination// the system keeps for itself, are reported. It moves no focus and takes no key: the caret stays// in its field and a shortcut pressed while holding goes on to whatever handles it.//// import ui/keyboard_hud.qwe use KeyboardHud//// <button data-shortcut="Mod+S" data-shortcut-group="File" @click={save}>Save</button>// <button aria-keyshortcuts="Meta+K">Search</button>// <KeyboardHud shortcuts={[{ keys: "Mod+/", label: "Show help", group: "Help" }]}// on_conflict={(found) => { report(found) }}/>//// On the page: data-shortcut, keys joined by +, where Mod is Cmd on Apple's systems and Ctrl// elsewhere; data-shortcut-label, what it does, else the element's aria-label or text;// data-shortcut-group, the heading it goes under. aria-keyshortcuts is read too, each// alternative in it. An element out of sight is left out, and a disabled one is// listed as not available.//// shortcuts: more to list, each { keys, label, group? }, for those no element stands for.// scope: a CSS selector for where to look ("body"); one that means nothing shows no// shortcuts and says so. modifier: the key to hold, "Meta", "Control" or "Alt", or// "auto" for Cmd on Apple's systems and Ctrl elsewhere. delay: how long to hold it, in// milliseconds (500). reserved: combinations the system keeps; by default, Cmd+Tab and the like, or// Alt+Tab and the like. trigger: a button that shows the same list, for a tablet with no// keyboard and for screen readers (true; text in trigger_label). title. class.// on_conflict(found): inside one of your components, called when the conflicts change, with// a list of { keys, labels, kind }, kind being "clash" or "system".//// Pressing a row presses what it stands for, and focus stays. Anything else lets go: the// modifier coming up, another key, or the app leaving the screen. Escape or a press outside// closes it when the button opened it.import ui/theme.qwe use ui_clslet keyboard_hud_css = css`.ui-keyboard-hud-open { display: inline-flex; align-items: center; min-height: 44px; padding: 0 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer; }.ui-keyboard-hud-open:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-keyboard-hud-open:hover { background: var(--ui-subtle); } }.ui-keyboard-hud { box-sizing: border-box; position: fixed; inset: auto 0 calc(16px + env(safe-area-inset-bottom)) 0; width: min(880px, calc(100vw - 32px)); max-height: min(70dvh, 620px); margin: 0 auto; padding: 16px 18px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--ui-border); border-radius: calc(var(--ui-radius) + 6px); background: color-mix(in srgb, var(--ui-surface) 94%, transparent); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); box-shadow: var(--ui-shadow-lg); color: var(--ui-fg); font: 14px/1.35 var(--ui-font); container-type: inline-size; }.ui-keyboard-hud-on { display: block; animation: ui-keyboard-hud-in .14s ease-out; }@keyframes ui-keyboard-hud-in { from { opacity: 0; transform: translateY(6px); } }.ui-keyboard-hud-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }.ui-keyboard-hud-head h2 { margin: 0; font-size: 15px; font-weight: 600; }.ui-keyboard-hud-head span { color: var(--ui-muted); font-size: 13px; }.ui-keyboard-hud-groups { display: grid; grid-template-columns: 1fr; gap: 14px 24px; }@container (min-width: 560px) { .ui-keyboard-hud-groups { grid-template-columns: 1fr 1fr; } }@container (min-width: 820px) { .ui-keyboard-hud-groups { grid-template-columns: 1fr 1fr 1fr; } }.ui-keyboard-hud-group h3 { margin: 0 0 4px; font-size: 12px; font-weight: 600; color: var(--ui-muted); }.ui-keyboard-hud-group ul { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }.ui-keyboard-hud-row { box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; min-height: 44px; padding: 4px 8px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: inherit; font: inherit; text-align: left; }button.ui-keyboard-hud-row { cursor: pointer; }button.ui-keyboard-hud-row:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { button.ui-keyboard-hud-row:hover { background: var(--ui-subtle); } }.ui-keyboard-hud-off { color: var(--ui-muted); }.ui-keyboard-hud-keys { display: inline-flex; flex: none; gap: 3px; }.ui-keyboard-hud-keys kbd { display: inline-grid; place-items: center; min-width: 22px; height: 24px; padding: 0 5px; box-sizing: border-box; border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-subtle); font: 500 13px/1 var(--ui-font); }.ui-keyboard-hud-issues { display: grid; gap: 2px; margin: 12px 0 0; padding: 8px 10px; list-style: none; border-radius: var(--ui-radius-sm); background: var(--ui-warning-soft); color: var(--ui-warning); font-size: 13px; }.ui-keyboard-hud-empty { margin: 0; color: var(--ui-muted); }@media (prefers-reduced-motion: reduce) { .ui-keyboard-hud-on { animation: none; } }`// Is this one of Apple's systems? iPadOS calls itself a Mac.fn keyboard_hud_is_apple(platform) -> bool { let p = lower(str(platform ?? "")) contains(p, "mac") || contains(p, "iphone") || contains(p, "ipad")}// A key's one spelling, whatever it was written as.fn keyboard_hud_key(k: str) -> str { let low = lower(k) match low { "esc" => "Escape" "return" => "Enter" "del" => "Delete" "spacebar" => "Space" "up" => "ArrowUp" "down" => "ArrowDown" "left" => "ArrowLeft" "right" => "ArrowRight" _ => len(k) == 1 ? upper(k) : upper(slice(k, 0, 1)) + slice(k, 1, len(k)) }}// Keys as text in one order, so that Cmd+Shift+S, shift+meta+s and Mod+Shift+S (where Mod is// Meta) are one combination. mod: what Mod stands for. Null when no key is named.fn keyboard_hud_normal(text, mod_key: str) { if type_of(text) != "str" { return null } let plus = ends_with(text, "++") let parts = split(plus ? slice(text, 0, len(text) - 2) : text, "+") | map((p) => trim(p)) | filter((p) => p != "") let named = parts | map((p) => { let low = lower(p) if low == "mod" { return mod_key } if low == "cmd" || low == "command" || low == "meta" { return "Meta" } if low == "ctrl" || low == "control" { return "Control" } if low == "alt" || low == "opt" || low == "option" { return "Alt" } if low == "shift" { return "Shift" } keyboard_hud_key(p) }) let mods = ["Control", "Alt", "Shift", "Meta"] | filter((m) => contains(named, m)) let rest = named | filter((n) => !contains(mods, n)) let main = plus ? ["+"] : rest if len(main) != 1 { return null } join(mods + main, "+")}// The caps to show for a combination.fn keyboard_hud_caps(keys: str, apple: bool) -> list { let parts = ends_with(keys, "++") ? (split(slice(keys, 0, len(keys) - 2), "+") | push("+")) : split(keys, "+") parts | map((p) => { match p { "Meta" => apple ? "⌘" : "Meta" "Control" => apple ? "⌃" : "Ctrl" "Alt" => apple ? "⌥" : "Alt" "Shift" => apple ? "⇧" : "Shift" "Enter" => "↩" "Escape" => "esc" "ArrowUp" => "↑" "ArrowDown" => "↓" "ArrowLeft" => "←" "ArrowRight" => "→" "Backspace" => "⌫" _ => p } })}// Combinations the system keeps for itself.fn keyboard_hud_reserved(apple: bool) -> list { if apple { return ["Meta+Tab", "Meta+Space", "Meta+H", "Meta+Shift+3", "Meta+Shift+4"] } ["Alt+Tab", "Control+W", "Control+T", "Control+N"]}// What the rows get wrong: one combination with two meanings, and those the system keeps.// The same label twice on a combination is one thing shown twice, and no conflict.fn keyboard_hud_conflicts(rows: list, reserved: list) -> list { let seen = unique(rows | map((r) => r.keys)) let clashes = seen | map((k) => { let labels = unique(rows | filter((r) => r.keys == k) | map((r) => r.label)) { keys: k, labels, kind: "clash" } }) | filter((c) => len(c.labels) > 1) let kept = reserved | map((k) => keyboard_hud_normal(k, "Meta")) let system = seen | filter((k) => contains(kept, k)) | map((k) => { { keys: k, labels: unique(rows | filter((r) => r.keys == k) | map((r) => r.label)), kind: "system" } }) clashes + system}// The rows under their headings, in the order the headings first come.fn keyboard_hud_groups(rows: list) -> list { let names = unique(rows | map((r) => r.group)) names | map((n) => { { name: n, rows: rows | filter((r) => r.group == n) } })}// Rows from the data given.fn keyboard_hud_given(shortcuts, mod_key: str) -> list { if type_of(shortcuts) != "list" { return [] } let shaped = shortcuts | filter((s) => type_of(s) == "map" && type_of(get(s, "label")) == "str" && keyboard_hud_normal(get(s, "keys"), mod_key) != null) shaped | map((s) => { { keys: keyboard_hud_normal(s.keys, mod_key), label: s.label, group: type_of(get(s, "group")) == "str" ? s.group : "This screen", el: null, off: false } })}// Rows from an element of the page: one for each combination it names.fn keyboard_hud_read(el, mod_key: str) -> list { let label = el.getAttribute("data-shortcut-label") ?? el.getAttribute("aria-label") ?? slice(trim(el.textContent ?? ""), 0, 60) let group = el.getAttribute("data-shortcut-group") ?? "This screen" let off = el.disabled == true || el.getAttribute("aria-disabled") == "true" let own = el.getAttribute("data-shortcut") let spoken = split(el.getAttribute("aria-keyshortcuts") ?? "", " ") let names = unique(([own] + spoken) | map((k) => keyboard_hud_normal(k, mod_key)) | filter((k) => k != null)) names | map((k) => { { keys: k, label: label != "" ? label : "Unnamed", group, el, off } })}component KeyboardHud(shortcuts = [], scope: str = "body", modifier: str = "auto", delay: int = 500, reserved = null, trigger: bool = true, trigger_label: str = "Keyboard shortcuts", title: str = "Keyboard shortcuts", on_conflict = null, class: str? = null) { style global keyboard_hud_css state win = null state doc = null state hud = null state apple = false state mod_key = "Meta" state hold_key = "Control" state armed = false state open = false state pinned = false state rows = [] state issues = [] state scope_bad = false state g_sig = "" state said = "" let groups = keyboard_hud_groups(rows) let scan = () => { let valid = win.CSS.supports(`selector(${scope})`) scope_bad = !valid let root = valid ? doc.querySelector(scope) : null let found = root != null ? root.querySelectorAll("[data-shortcut], [aria-keyshortcuts]") : null let n = found != null ? found.length : 0 let els = range(0, n) | map((i) => found[i]) | filter((el) => el.closest(".ui-keyboard-hud") == null && el.getClientRects().length > 0) let from_page = flatten(els | map((el) => keyboard_hud_read(el, mod_key))) let all = from_page + keyboard_hud_given(shortcuts, mod_key) rows = all issues = keyboard_hud_conflicts(all, reserved ?? keyboard_hud_reserved(apple)) let sig = join(issues | map((c) => `${c.kind} ${c.keys} ${join(c.labels, ",")}`), ";") if sig != g_sig { g_sig = sig on_conflict && on_conflict(issues) } } let show = () => { scan() open = true hud.classList.add("ui-keyboard-hud-on") if hud.showPopover != null && !hud.matches(":popover-open") { hud.showPopover() } said = `${len(rows)} shortcuts shown${len(issues) > 0 ? `, ${len(issues)} with a problem` : ""}.` } let hide = () => { armed = false pinned = false if open { open = false hud.classList.remove("ui-keyboard-hud-on") if hud.showPopover != null && hud.matches(":popover-open") { hud.hidePopover() } } } if armed { every(delay, () => { if armed { armed = false show() } }) } <div.ui-keyboard-hud-host style="display: contents" @mount={(me) => { win = js("window") doc = js("document") hud = me.querySelector(".ui-keyboard-hud") apple = keyboard_hud_is_apple(js("navigator").platform) mod_key = apple ? "Meta" : "Control" hold_key = modifier == "auto" ? mod_key : modifier let press = (e) => { if e.key == hold_key { if !e.repeat && !open && !armed { armed = true } } else if pinned { if e.key == "Escape" { hide() } } else { // Another key with it down is a shortcut being used, not a look at the list. hide() } } let release = (e) => { if e.key == hold_key && !pinned { hide() } } let leave = (_e) => { if !pinned { hide() } } let away = (e) => { if pinned && !e.target.closest(".ui-keyboard-hud") && !e.target.closest(".ui-keyboard-hud-open") { hide() } } // Capture, so a handler that stops a key does not leave this holding on. A modifier's // keyup never comes once the app has lost the keyboard, as with Cmd+Tab. doc.addEventListener("keydown", press, true) doc.addEventListener("keyup", release, true) doc.addEventListener("pointerdown", away, true) doc.addEventListener("visibilitychange", leave) win.addEventListener("blur", leave) () => { doc.removeEventListener("keydown", press, true) doc.removeEventListener("keyup", release, true) doc.removeEventListener("pointerdown", away, true) doc.removeEventListener("visibilitychange", leave) win.removeEventListener("blur", leave) } }}> {trigger && <button.ui-keyboard-hud-open type="button" aria-expanded={open ? "true" : "false"} @mousedown={(e) => { e.preventDefault() }} @click={() => { if open { hide() } else { pinned = true show() } }}>{trigger_label}</button>} <div class={ui_cls(["ui-keyboard-hud", class])} popover="manual" role="group" aria-label={title}> <div.ui-keyboard-hud-head> <h2>{title}</h2> <span>{pinned ? "Press a row to use it" : "Hold to keep showing"}</span> </div> {len(rows) == 0 && <p.ui-keyboard-hud-empty>{scope_bad ? `Nothing to show: "${scope}" is not a selector.` : "This screen has no shortcuts."}</p>} <div.ui-keyboard-hud-groups> {for g in groups => <section.ui-keyboard-hud-group key={g.name}> <h3>{g.name}</h3> <ul> {for r in g.rows => <li key={`${r.keys} ${r.label}`}> {r.el != null && !r.off ? <button.ui-keyboard-hud-row type="button" tabindex={pinned ? "0" : "-1"} @mousedown={(e) => { e.preventDefault() }} @click={() => { hide() r.el.click() }}> <span>{r.label}</span> <span.ui-keyboard-hud-keys aria-label={r.keys}>{for c in keyboard_hud_caps(r.keys, apple) => <kbd>{c}</kbd>}</span> </button> : <div class={ui_cls(["ui-keyboard-hud-row", r.off && "ui-keyboard-hud-off"])}> <span>{r.label}{r.off ? " (not available)" : ""}</span> <span.ui-keyboard-hud-keys aria-label={r.keys}>{for c in keyboard_hud_caps(r.keys, apple) => <kbd>{c}</kbd>}</span> </div>} </li>} </ul> </section>} </div> {len(issues) > 0 && <ul.ui-keyboard-hud-issues> {for c in issues => <li key={`${c.kind} ${c.keys}`}>{join(keyboard_hud_caps(c.keys, apple), " ")}: {c.kind == "clash" ? `used by ${join(c.labels, " and ")}` : `kept by the system, so ${join(c.labels, " and ")} may never get it`}</li>} </ul>} </div> <span.ui-sr role="status">{said}</span> </div>}Related
AppTabBarBottom tabs for a phone app: badges, safe-area padding, a second press scrolls to the top, each tab keeps its place, gone while the keyboard is up; a rail on a tablet.3 views · 8 downloadsChatScreenMessages grouped by sender and day, keeping your place while older ones load, at the bottom as new ones come, and clear of the keyboard.3 views · 7 downloadsCommentSheetComments in a sheet: threads, a reply bar above the keyboard, sort, and a count that stays right. A column at the side on a tablet.2 views · 7 downloadsNoteEditorA note with a toolbar on the keyboard, autosave, checklists and Markdown shortcuts.2 views · 7 downloadsQuickAddBarType a line and it finds the date, list and priority in the words, marked as you go.3 views · 7 downloadsSearchHeaderA header whose search field opens in place with Cancel, recent searches and your results; the keyboard stays up through them; a dropdown panel on a tablet.3 views · 5 downloads
Thread
Thread 0 posts
On KeyboardHud: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.