Add gamepad profiles and controller UI improvements

Introduce a new gamepadProfile module to detect controller type, map glyphs, and watch for active gamepad changes. Integrate controller profile and glyph refresh into app state (auto-refresh + event) and start the watcher during initialization. Update lock view to show controller badge, contextual copy, improved status/confirm handling, and use a new lock hints template. Move and scope guide backdrop into the main area and tweak related CSS; refine guide styling. Refactor keyboard overlay layout and behavior: rows instead of grid, variable key widths, caps/shift modifiers, improved key activation and navigation. Minor text change in user setup copy and assorted styling adjustments.
This commit is contained in:
Andrew Zambazos
2026-05-17 19:40:44 +12:00
parent ab24298c16
commit b141c0a058
11 changed files with 614 additions and 112 deletions
+183
View File
@@ -0,0 +1,183 @@
const PROFILE_GLYPH_MAP = {
xbox: {
accept: "A",
back: "B",
menu: "☰",
up: "↑",
down: "↓",
left: "←",
right: "→",
l1: "LB",
r1: "RB",
l2: "LT",
r2: "RT",
clear: "X",
y: "Y",
},
playstation: {
accept: "✕",
back: "○",
menu: "☰",
up: "↑",
down: "↓",
left: "←",
right: "→",
l1: "L1",
r1: "R1",
l2: "L2",
r2: "R2",
clear: "□",
y: "△",
},
switch: {
accept: "B",
back: "A",
menu: "+",
up: "↑",
down: "↓",
left: "←",
right: "→",
l1: "L",
r1: "R",
l2: "ZL",
r2: "ZR",
clear: "Y",
y: "X",
},
generic: {
accept: "A",
back: "B",
menu: "☰",
up: "↑",
down: "↓",
left: "←",
right: "→",
l1: "L1",
r1: "R1",
l2: "L2",
r2: "R2",
clear: "X",
y: "Y",
},
};
const GLYPH_ACTIONS = [
"accept",
"back",
"menu",
"up",
"down",
"left",
"right",
"l1",
"r1",
"l2",
"r2",
"clear",
"y",
];
const CORE_GLYPH_IDS = {
accept: "confirm",
back: "back",
menu: "menu",
up: "dpad-up",
down: "dpad-down",
left: "dpad-left",
right: "dpad-right",
l1: "lb",
r1: "rb",
l2: "lt",
r2: "rt",
clear: "x",
y: "y",
};
export const CONTROLLER_PROFILES = ["xbox", "playstation", "switch", "generic"];
export const PROFILE_LABELS = {
xbox: "Xbox",
playstation: "PlayStation",
switch: "Nintendo Switch",
generic: "controller",
};
export const detectGamepadProfile = (gamepad) => {
const id = `${gamepad?.id ?? ""} ${gamepad?.mapping ?? ""}`.toLowerCase();
if (/xbox|x-input|microsoft|045e|8bitdo.*xbox|8bitdo.*x/i.test(id)) {
return "xbox";
}
if (/playstation|dualsense|dualshock|ps5|ps4|054c|sony|wireless controller/i.test(id)) {
return "playstation";
}
if (/nintendo|switch|pro controller|057e|joy-con|joycon/i.test(id)) {
return "switch";
}
return "generic";
};
export const getActiveGamepad = () => {
const pads = navigator.getGamepads?.() ?? [];
return pads.find((pad) => pad?.connected) ?? null;
};
export const detectActiveProfile = () => {
const pad = getActiveGamepad();
return pad ? detectGamepadProfile(pad) : "generic";
};
export const getFallbackGlyphs = (profile = "generic") => ({
...(PROFILE_GLYPH_MAP[profile] ?? PROFILE_GLYPH_MAP.generic),
});
export const resolveGlyphsForProfile = (profile, glyphsModule) => {
const safeProfile = CONTROLLER_PROFILES.includes(profile) ? profile : "generic";
const platform = safeProfile === "generic" ? "xbox" : safeProfile;
const fallback = getFallbackGlyphs(safeProfile);
if (typeof glyphsModule?.getGlyph !== "function") {
return fallback;
}
const resolved = {};
GLYPH_ACTIONS.forEach((action) => {
const glyphId = CORE_GLYPH_IDS[action];
resolved[action] = glyphsModule.getGlyph(platform, glyphId) ?? fallback[action];
});
return resolved;
};
export const watchGamepadProfile = (onChange) => {
let current = detectActiveProfile();
const emitIfChanged = () => {
const next = detectActiveProfile();
if (next === current) {
return;
}
current = next;
onChange(next);
};
window.addEventListener("gamepadconnected", emitIfChanged);
window.addEventListener("gamepaddisconnected", emitIfChanged);
let rafId = 0;
const poll = () => {
emitIfChanged();
rafId = requestAnimationFrame(poll);
};
rafId = requestAnimationFrame(poll);
return () => {
window.removeEventListener("gamepadconnected", emitIfChanged);
window.removeEventListener("gamepaddisconnected", emitIfChanged);
if (rafId) {
cancelAnimationFrame(rafId);
}
};
};
+29 -35
View File
@@ -1,3 +1,9 @@
import {
detectActiveProfile,
getFallbackGlyphs,
resolveGlyphsForProfile,
watchGamepadProfile,
} from "./gamepadProfile.js";
import { createPasskeyController } from "./passkey.js";
import { loadExistingUser } from "./users.js";
@@ -33,22 +39,6 @@ const FALLBACK_THEME = {
},
};
const FALLBACK_GLYPHS = {
accept: "A",
back: "B",
menu: "≡",
up: "↑",
down: "↓",
left: "←",
right: "→",
l1: "LB",
r1: "RB",
l2: "LT",
r2: "RT",
clear: "X",
y: "Y",
};
export const createAppState = () => {
const passkey = createPasskeyController();
const state = {
@@ -68,7 +58,8 @@ export const createAppState = () => {
ui: null,
},
theme: FALLBACK_THEME,
glyphs: { ...FALLBACK_GLYPHS },
controllerProfile: "generic",
glyphs: getFallbackGlyphs("generic"),
settingsCategory: "system",
settingsValues: {
network: true,
@@ -80,6 +71,25 @@ export const createAppState = () => {
passkeySetupRequired: !passkey.hasPasskey(),
};
state.refreshControllerGlyphs = (profile = detectActiveProfile()) => {
state.controllerProfile = profile;
state.glyphs = resolveGlyphsForProfile(profile, state.nebula.glyphs);
window.dispatchEvent(
new CustomEvent("nebula-controller-profile", {
detail: { profile },
}),
);
};
state.startGamepadProfileWatcher = () => {
if (state.stopGamepadProfileWatcher) {
state.stopGamepadProfileWatcher();
}
state.stopGamepadProfileWatcher = watchGamepadProfile((profile) => {
state.refreshControllerGlyphs(profile);
});
};
const applyThemeToDocument = () => {
const root = document.documentElement;
const { colors, spacing, radius, typography } = state.theme;
@@ -112,23 +122,7 @@ export const createAppState = () => {
state.theme = typeof theme.createTheme === "function" ? theme.createTheme({}) : FALLBACK_THEME;
if (typeof glyphs.getGlyph === "function") {
state.glyphs = {
accept: glyphs.getGlyph("xbox", "confirm") ?? FALLBACK_GLYPHS.accept,
back: glyphs.getGlyph("xbox", "back") ?? FALLBACK_GLYPHS.back,
menu: glyphs.getGlyph("xbox", "menu") ?? FALLBACK_GLYPHS.menu,
up: glyphs.getGlyph("xbox", "dpad-up") ?? FALLBACK_GLYPHS.up,
down: glyphs.getGlyph("xbox", "dpad-down") ?? FALLBACK_GLYPHS.down,
left: glyphs.getGlyph("xbox", "dpad-left") ?? FALLBACK_GLYPHS.left,
right: glyphs.getGlyph("xbox", "dpad-right") ?? FALLBACK_GLYPHS.right,
l1: glyphs.getGlyph("xbox", "lb") ?? FALLBACK_GLYPHS.l1,
r1: glyphs.getGlyph("xbox", "rb") ?? FALLBACK_GLYPHS.r1,
l2: glyphs.getGlyph("xbox", "lt") ?? FALLBACK_GLYPHS.l2,
r2: glyphs.getGlyph("xbox", "rt") ?? FALLBACK_GLYPHS.r2,
clear: glyphs.getGlyph("xbox", "x") ?? FALLBACK_GLYPHS.clear,
y: glyphs.getGlyph("xbox", "y") ?? FALLBACK_GLYPHS.y,
};
}
state.refreshControllerGlyphs(detectActiveProfile());
} catch (_error) {
state.nebula = {
...state.nebula,
@@ -136,7 +130,7 @@ export const createAppState = () => {
source: "local-fallback",
};
state.theme = FALLBACK_THEME;
state.glyphs = { ...FALLBACK_GLYPHS };
state.refreshControllerGlyphs("generic");
}
applyThemeToDocument();
+13 -2
View File
@@ -27,12 +27,11 @@
<div class="nebula-layer vignette"></div>
</div>
<div class="guide-backdrop" id="guide-backdrop" hidden aria-hidden="true"></div>
<div class="app-layout">
<div id="guide-shell-root" class="guide-mount"></div>
<div class="app-main-area">
<div class="guide-backdrop" id="guide-backdrop" hidden aria-hidden="true"></div>
<main id="app" class="app-shell"></main>
<footer class="app-footer" id="app-footer"></footer>
</div>
@@ -71,10 +70,22 @@
</div>
</template>
<template id="lock-hints-template">
<div class="hint-row">
<span class="hint"><span data-glyph="up"></span>/<span data-glyph="down"></span>/<span data-glyph="left"></span>/<span data-glyph="right"></span> Digits 14</span>
<span class="hint"><span data-glyph="l2"></span>/<span data-glyph="r2"></span> 56</span>
<span class="hint"><span data-glyph="l1"></span>/<span data-glyph="r1"></span> 78</span>
<span class="hint"><span data-glyph="y"></span> 9 · <span data-glyph="clear"></span> 0</span>
<span class="hint"><span data-glyph="back"></span> Delete</span>
<span class="hint"><span data-glyph="menu"></span> Confirm</span>
</div>
</template>
<template id="keyboard-hints-template">
<div class="hint-row">
<span class="hint"><span data-glyph="accept"></span> Type</span>
<span class="hint"><span data-glyph="back"></span> Backspace</span>
<span class="hint"><span data-glyph="clear"></span> Clear</span>
<span class="hint"><span data-glyph="l1"></span>/<span data-glyph="r1"></span> Field</span>
<span class="hint"><span data-glyph="menu"></span> Done</span>
</div>
+9
View File
@@ -249,6 +249,11 @@ const handleAction = (action) => {
}
if (action === "menu") {
if (SIDEBAR_HIDDEN_VIEWS.has(state.activeView)) {
currentViewContract.onMenu?.();
return;
}
if (guideSidebar.isExpanded()) {
guideSidebar.close();
return;
@@ -336,12 +341,16 @@ const initialize = async () => {
await state.initializeUser();
await state.initializeNebulaCore();
state.startGamepadProfileWatcher?.();
registerViews();
renderView(state.userSetupRequired ? "user-setup" : "lock");
updateClockLabels();
window.setInterval(updateClockLabels, 1000);
window.addEventListener("nebula-navigation-refresh", refreshNavigation);
window.addEventListener("nebula-controller-profile", () => {
setFooterHints(currentViewContract?.hintsTemplate ?? "#global-hints-template", state.glyphs);
});
window.addEventListener("nebula-guide-close", (event) => {
remountNavigation({ focusKey: event.detail?.focusKey });
});
+1
View File
@@ -71,6 +71,7 @@ body {
/* ─── Main content area ─── */
.app-main-area {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
+3 -7
View File
@@ -183,8 +183,6 @@
flex-direction: column;
overflow: hidden;
background: var(--guide-glass);
backdrop-filter: blur(20px) saturate(1.2);
-webkit-backdrop-filter: blur(20px) saturate(1.2);
border-right: 1px solid var(--guide-glass-border);
box-shadow:
8px 0 40px rgba(0, 0, 0, 0.45),
@@ -577,14 +575,12 @@
opacity: 0.75;
}
/* Dim layer over content when guide is open */
/* Dim layer over main content when guide is open (scoped to .app-main-area) */
.guide-backdrop {
position: fixed;
position: absolute;
inset: 0;
z-index: 25;
z-index: 10;
background: rgba(3, 5, 14, 0.62);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
opacity: 0;
pointer-events: none;
transition: opacity var(--nebula-duration-slow) var(--nebula-ease-standard);
+65
View File
@@ -59,6 +59,13 @@
font-size: 18px;
}
.lock-title-row {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.lock-title {
margin: 0;
font-size: clamp(36px, 5vw, 52px);
@@ -66,6 +73,64 @@
letter-spacing: -0.01em;
}
.lock-controller-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34px;
height: 34px;
padding: 0 10px;
border-radius: 999px;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
color: var(--nebula-color-text);
box-shadow: 0 0 18px rgba(79, 216, 255, 0.16);
}
.lock-controller-badge--xbox {
border-color: rgba(107, 190, 70, 0.55);
background: rgba(107, 190, 70, 0.18);
}
.lock-controller-badge--playstation {
border-color: rgba(70, 130, 220, 0.55);
background: rgba(70, 130, 220, 0.18);
}
.lock-controller-badge--switch {
border-color: rgba(230, 70, 70, 0.55);
background: rgba(230, 70, 70, 0.16);
}
.lock-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
margin: 0 4px;
padding: 0 8px;
border-radius: var(--nebula-radius-sm);
font-size: 14px;
font-weight: 700;
vertical-align: middle;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--nebula-color-text);
}
.lock-glyph-menu {
border-color: rgba(79, 216, 255, 0.45);
box-shadow: 0 0 14px rgba(79, 216, 255, 0.22);
}
.lock-status .lock-glyph-menu {
transform: translateY(-1px);
}
.lock-copy {
margin: 0;
color: var(--nebula-color-muted);
+82 -12
View File
@@ -1,3 +1,11 @@
import { PROFILE_LABELS } from "../../core/gamepadProfile.js";
const PROFILE_BADGE_LABELS = {
xbox: "Xbox",
playstation: "PS",
switch: "Switch",
};
const LOCK_TEMPLATE = `
<section class="view lock-view" data-view="lock">
<section class="lock-layout panel">
@@ -6,10 +14,13 @@ const LOCK_TEMPLATE = `
<span class="lock-avatar" aria-hidden="true"></span>
<p class="lock-username" data-username>Nebula User</p>
</div>
<div class="lock-title-row">
<h1 class="lock-title">Enter your passkey</h1>
<span class="lock-controller-badge" data-controller-badge hidden aria-hidden="true"></span>
</div>
<p class="lock-copy" data-copy>Using your controller, enter your 6-digit passkey.</p>
<div class="lock-dots" data-passkey-dots></div>
<p class="lock-status" data-status></p>
<p class="lock-status" data-status aria-live="polite"></p>
</section>
<section class="lock-right panel" data-focus-root>
@@ -42,9 +53,21 @@ export const createLockView = ({ state, renderView, keyboard }) => {
let busy = false;
let lastEntryAt = 0;
let keyboardListener = null;
let profileListener = null;
let awaitingConfirm = false;
const config = () => state.passkey.getConfig();
const controllerLabel = () => PROFILE_LABELS[state.controllerProfile] ?? PROFILE_LABELS.generic;
const menuGlyphMarkup = () => {
const glyph = state.glyphs.menu ?? "Menu";
return `<span class="lock-glyph lock-glyph-menu" aria-hidden="true">${glyph}</span>`;
};
const menuConfirmMarkup = (prefix = "Press ", suffix = " to confirm.") =>
`${prefix}${menuGlyphMarkup()}${suffix}`;
const ACTION_TO_DIGIT = {
up: "1",
left: "2",
@@ -88,16 +111,38 @@ export const createLockView = ({ state, renderView, keyboard }) => {
navigator.vibrate?.(ms);
};
const setStatus = (text = "", danger = false) => {
const setStatus = (text = "", danger = false, { html = false } = {}) => {
const status = document.querySelector("[data-status]");
if (!status) {
return;
}
if (html) {
status.innerHTML = text;
} else {
status.textContent = text;
}
status.classList.toggle("is-danger", danger);
};
const setConfirmStatus = () => {
awaitingConfirm = true;
setStatus(menuConfirmMarkup(), false, { html: true });
};
const updateControllerBadge = () => {
const badge = document.querySelector("[data-controller-badge]");
if (!badge) {
return;
}
const profile = state.controllerProfile ?? "generic";
badge.hidden = profile === "generic";
badge.className = `lock-controller-badge lock-controller-badge--${profile}`;
badge.textContent = PROFILE_BADGE_LABELS[profile] ?? state.glyphs.menu ?? "";
badge.title = `${controllerLabel()} controller detected`;
};
const updateCopy = () => {
const copy = document.querySelector("[data-copy]");
if (!copy) {
@@ -105,19 +150,23 @@ export const createLockView = ({ state, renderView, keyboard }) => {
}
const length = config().length;
const label = controllerLabel();
if (state.passkeySetupRequired) {
copy.textContent = setupPhase === "confirm"
? `Re-enter the same ${length}-digit passkey, then press Start to confirm.`
: `Use Xbox passkey controls to enter your ${length}-digit passkey.`;
if (setupPhase === "confirm") {
copy.innerHTML = `Re-enter the same ${length}-digit passkey, then ${menuConfirmMarkup("press ", ".")}`;
return;
}
copy.textContent = `Use your ${label} controller buttons to enter your ${length}-digit passkey.`;
return;
}
if (config().requireConfirm) {
copy.textContent = `Use Xbox passkey controls to enter ${length} digits, then press Start.`;
copy.innerHTML = `Use your ${label} controller buttons to enter ${length} digits, then ${menuConfirmMarkup("press ", ".")}`;
return;
}
copy.textContent = `Use Xbox passkey controls to enter your ${length}-digit passkey.`;
copy.textContent = `Use your ${label} controller buttons to enter your ${length}-digit passkey.`;
};
const updateMapLabels = () => {
@@ -153,6 +202,7 @@ export const createLockView = ({ state, renderView, keyboard }) => {
const clearDigits = () => {
digits = [];
awaitingConfirm = false;
renderDots();
};
@@ -208,7 +258,7 @@ export const createLockView = ({ state, renderView, keyboard }) => {
clearDigits();
setupPhase = "confirm";
updateCopy();
setStatus("Re-enter your passkey and press Start.");
setConfirmStatus();
busy = false;
return;
}
@@ -288,12 +338,12 @@ export const createLockView = ({ state, renderView, keyboard }) => {
if (digits.length === config().length && !config().requireConfirm) {
if (state.passkeySetupRequired) {
setStatus("Press Start to confirm.");
setConfirmStatus();
} else {
submitDigits();
}
} else if (digits.length === config().length && config().requireConfirm) {
setStatus("Press Start to confirm.");
setConfirmStatus();
}
};
@@ -302,6 +352,7 @@ export const createLockView = ({ state, renderView, keyboard }) => {
return;
}
digits = digits.slice(0, -1);
awaitingConfirm = false;
renderDots();
setStatus("");
};
@@ -354,12 +405,30 @@ export const createLockView = ({ state, renderView, keyboard }) => {
document.documentElement.dataset.animSpeed = config().animationSpeed;
document.documentElement.classList.toggle("high-contrast", config().highContrast);
updateMapLabels();
updateControllerBadge();
state.refreshControllerGlyphs?.();
if (keyboardListener) {
window.removeEventListener("keydown", keyboardListener);
}
keyboardListener = handleNumberKey;
window.addEventListener("keydown", keyboardListener);
if (profileListener) {
window.removeEventListener("nebula-controller-profile", profileListener);
}
profileListener = () => {
updateMapLabels();
updateCopy();
updateControllerBadge();
if (awaitingConfirm) {
setConfirmStatus();
}
if (state.activeView === "lock") {
window.dispatchEvent(new CustomEvent("nebula-navigation-refresh"));
}
};
window.addEventListener("nebula-controller-profile", profileListener);
},
getNavigationContract: () => {
const root = document.querySelector("[data-focus-root]");
@@ -369,7 +438,7 @@ export const createLockView = ({ state, renderView, keyboard }) => {
focusRoot: root,
defaultFocus,
layout: { type: "grid", cols: 3, rows: 4 },
hintsTemplate: "#global-hints-template",
hintsTemplate: "#lock-hints-template",
nebulaNavigation: state.nebula.navigation,
captureDirectionalInput: true,
onAccept: (element) => {
@@ -385,8 +454,9 @@ export const createLockView = ({ state, renderView, keyboard }) => {
onMenu: () => {
if (digits.length === config().length) {
submitDigits();
return;
}
return false;
setStatus(`Enter ${config().length} digits first.`, true);
},
onAction: (action) => {
const mappedDigit = ACTION_TO_DIGIT[action];
+1 -1
View File
@@ -178,7 +178,7 @@ export const createUserSetupView = ({ state, renderView, keyboard }) => {
nextField();
},
});
setStatus("Enter first name, then choose Done.");
setStatus("Enter first name, then press Enter or Menu.");
},
getNavigationContract: () => {
return {
+47 -11
View File
@@ -13,7 +13,7 @@
}
.overlay-keyboard-inner {
width: min(1080px, 98vw);
width: min(1180px, 98vw);
margin: 0 auto;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
@@ -27,27 +27,63 @@
padding: 10px;
}
.overlay-keyboard-grid {
display: grid;
grid-template-columns: repeat(10, minmax(44px, 1fr));
gap: 8px;
.overlay-keyboard-rows {
display: flex;
flex-direction: column;
gap: 6px;
}
.overlay-keyboard-row {
display: flex;
gap: 6px;
align-items: stretch;
}
.overlay-keyboard-row.is-space-row {
justify-content: center;
padding-top: 2px;
}
.overlay-keyboard-key {
height: 44px;
flex: 1 1 0;
min-width: 0;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 10px;
border-radius: 8px;
background: linear-gradient(160deg, rgba(60, 95, 143, 0.38), rgba(13, 24, 42, 0.84));
color: var(--nebula-color-text);
font-size: 14px;
font-size: 13px;
font-weight: 650;
padding: 0 6px;
}
.overlay-keyboard-key.is-wide {
grid-column: span 4;
.overlay-keyboard-key[data-width="2"] {
flex: 1.55 1 0;
}
.overlay-keyboard-key.is-done {
.overlay-keyboard-key.is-space {
flex: 0 1 70%;
max-width: 760px;
min-height: 42px;
}
.overlay-keyboard-key.is-modifier,
.overlay-keyboard-key.is-enter {
font-size: 11px;
letter-spacing: 0.02em;
}
.overlay-keyboard-key.is-modifier.is-active {
border-color: rgba(79, 216, 255, 0.55);
background: linear-gradient(160deg, rgba(79, 216, 255, 0.28), rgba(13, 24, 42, 0.9));
color: rgba(79, 216, 255, 0.95);
}
.overlay-keyboard.is-uppercase .overlay-keyboard-key.is-letter {
color: rgba(220, 240, 255, 0.98);
}
.overlay-keyboard-key.is-enter {
border-color: rgba(122, 255, 168, 0.46);
}
+179 -42
View File
@@ -1,30 +1,69 @@
const letter = (ch) => ({ id: ch.toUpperCase(), label: ch });
const symbol = (ch) => ({ id: ch, label: ch });
const KEY_ROWS = [
["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"],
["K", "L", "M", "N", "O", "P", "Q", "R", "S", "T"],
["U", "V", "W", "X", "Y", "Z", "-", "'", ".", ","],
["SPACE", "BACK", "CLR", "DONE"],
[
symbol("'"),
..."1234567890".split("").map((ch) => symbol(ch)),
symbol("-"),
symbol("="),
{ id: "BACKSP", label: "Backsp", width: 2 },
],
[
{ id: "TAB", label: "Tab", width: 2 },
..."qwertyuiop".split("").map(letter),
symbol("["),
symbol("]"),
{ id: "\\", label: "\\", width: 2 },
],
[
{ id: "CAPS", label: "Caps", width: 2 },
..."asdfghjkl".split("").map(letter),
symbol(";"),
symbol("#"),
{ id: "ENTER", label: "Enter", width: 2 },
],
[
{ id: "SHIFT", label: "Shift", width: 2 },
..."zxcvbnm".split("").map(letter),
symbol(","),
symbol("."),
symbol("/"),
{ id: "SHIFT_R", label: "Shift", width: 2 },
],
[{ id: "SPACE", label: "", width: 1, space: true }],
];
const KEYBOARD_TEMPLATE = `
<section class="overlay-keyboard" data-overlay-keyboard hidden>
<div class="overlay-keyboard-inner">
<div class="overlay-keyboard-grid" data-overlay-keyboard-grid></div>
<div class="overlay-keyboard-rows" data-overlay-keyboard-rows></div>
</div>
</section>
`;
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const isLetter = (key) => /^[A-Z]$/.test(key);
export const createKeyboardOverlay = ({ mountRoot }) => {
mountRoot.innerHTML = KEYBOARD_TEMPLATE;
const root = mountRoot.querySelector("[data-overlay-keyboard]");
const grid = mountRoot.querySelector("[data-overlay-keyboard-grid]");
const rowsRoot = mountRoot.querySelector("[data-overlay-keyboard-rows]");
const keyButtons = [];
let openState = false;
let selectedRow = 0;
let selectedCol = 0;
let handlers = null;
let capsLock = false;
let shiftActive = false;
let capsButton = null;
const shiftButtons = [];
const rowWidth = (rowIndex) => KEY_ROWS[rowIndex]?.length ?? 0;
const lettersAreUppercase = () => capsLock !== shiftActive;
const applySelection = () => {
keyButtons.forEach((button) => button.classList.remove("is-focused"));
@@ -43,32 +82,98 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
return selected?.dataset.key ?? null;
};
const build = () => {
if (!grid) {
const updateModifierState = () => {
const uppercase = lettersAreUppercase();
root?.classList.toggle("is-uppercase", uppercase);
capsButton?.classList.toggle("is-active", capsLock);
shiftButtons.forEach((button) => button.classList.toggle("is-active", shiftActive));
keyButtons.forEach((button) => {
const key = button.dataset.key;
if (!isLetter(key)) {
return;
}
grid.innerHTML = "";
const base = button.dataset.baseLabel ?? key.toLowerCase();
button.textContent = uppercase ? base.toUpperCase() : base;
});
};
const resolveOutput = (key) => {
if (key === "SPACE") {
return " ";
}
if (isLetter(key)) {
return lettersAreUppercase() ? key : key.toLowerCase();
}
return key;
};
const consumeShift = () => {
if (!shiftActive) {
return;
}
shiftActive = false;
updateModifierState();
};
const build = () => {
if (!rowsRoot) {
return;
}
rowsRoot.innerHTML = "";
keyButtons.length = 0;
capsButton = null;
shiftButtons.length = 0;
KEY_ROWS.forEach((row, rowIndex) => {
row.forEach((key, colIndex) => {
const rowEl = document.createElement("div");
rowEl.className = "overlay-keyboard-row";
if (row.some((key) => key.space)) {
rowEl.classList.add("is-space-row");
}
row.forEach((keyDef, colIndex) => {
const button = document.createElement("button");
button.type = "button";
button.className = "overlay-keyboard-key";
button.dataset.row = String(rowIndex);
button.dataset.col = String(colIndex);
button.dataset.key = key;
button.textContent = key === "SPACE" ? "Space" : key;
if (key === "DONE") {
button.classList.add("is-done");
button.dataset.key = keyDef.id;
button.dataset.width = String(keyDef.width ?? 1);
if (isLetter(keyDef.id)) {
button.dataset.baseLabel = (keyDef.label ?? keyDef.id).toLowerCase();
button.classList.add("is-letter");
}
if (key === "SPACE") {
button.textContent = keyDef.label ?? keyDef.id;
if (keyDef.width && keyDef.width > 1) {
button.classList.add("is-wide");
}
grid.append(button);
if (keyDef.space) {
button.classList.add("is-space");
button.setAttribute("aria-label", "Space");
}
if (keyDef.id === "ENTER") {
button.classList.add("is-enter");
}
if (keyDef.id === "CAPS") {
button.classList.add("is-modifier");
capsButton = button;
}
if (keyDef.id === "SHIFT" || keyDef.id === "SHIFT_R") {
button.classList.add("is-modifier");
shiftButtons.push(button);
}
rowEl.append(button);
keyButtons.push(button);
});
rowsRoot.append(rowEl);
});
updateModifierState();
};
build();
@@ -76,9 +181,12 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
const open = (nextHandlers = {}) => {
handlers = nextHandlers;
openState = true;
selectedRow = 0;
selectedCol = 0;
capsLock = false;
shiftActive = false;
selectedRow = 2;
selectedCol = 1;
root.hidden = false;
updateModifierState();
applySelection();
};
@@ -86,28 +194,68 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
openState = false;
root.hidden = true;
handlers = null;
capsLock = false;
shiftActive = false;
updateModifierState();
};
const move = (direction) => {
const rowWidth = KEY_ROWS[selectedRow]?.length ?? 0;
if (direction === "left") {
selectedCol = clamp(selectedCol - 1, 0, Math.max(0, rowWidth - 1));
selectedCol = clamp(selectedCol - 1, 0, Math.max(0, rowWidth(selectedRow) - 1));
return;
}
if (direction === "right") {
selectedCol = clamp(selectedCol + 1, 0, Math.max(0, rowWidth - 1));
selectedCol = clamp(selectedCol + 1, 0, Math.max(0, rowWidth(selectedRow) - 1));
return;
}
if (direction === "up") {
selectedRow = clamp(selectedRow - 1, 0, KEY_ROWS.length - 1);
const nextWidth = KEY_ROWS[selectedRow]?.length ?? 0;
selectedCol = clamp(selectedCol, 0, Math.max(0, nextWidth - 1));
selectedCol = clamp(selectedCol, 0, Math.max(0, rowWidth(selectedRow) - 1));
return;
}
if (direction === "down") {
selectedRow = clamp(selectedRow + 1, 0, KEY_ROWS.length - 1);
const nextWidth = KEY_ROWS[selectedRow]?.length ?? 0;
selectedCol = clamp(selectedCol, 0, Math.max(0, nextWidth - 1));
selectedCol = clamp(selectedCol, 0, Math.max(0, rowWidth(selectedRow) - 1));
}
};
const activateKey = (key) => {
if (!key) {
return;
}
if (key === "ENTER") {
handlers?.onSubmit?.();
return;
}
if (key === "BACKSP") {
handlers?.onBackspace?.();
return;
}
if (key === "TAB") {
handlers?.onNextField?.();
return;
}
if (key === "CAPS") {
capsLock = !capsLock;
updateModifierState();
return;
}
if (key === "SHIFT" || key === "SHIFT_R") {
shiftActive = !shiftActive;
updateModifierState();
return;
}
if (key === "SPACE") {
handlers?.onKey?.(" ");
consumeShift();
return;
}
const output = resolveOutput(key);
if (output) {
handlers?.onKey?.(output);
consumeShift();
}
};
@@ -123,23 +271,7 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
}
if (action === "accept") {
const key = currentKey();
if (!key) {
return true;
}
if (key === "DONE") {
handlers?.onSubmit?.();
return true;
}
if (key === "BACK") {
handlers?.onBackspace?.();
return true;
}
if (key === "CLR") {
handlers?.onClear?.();
return true;
}
handlers?.onKey?.(key === "SPACE" ? " " : key);
activateKey(currentKey());
return true;
}
@@ -153,6 +285,11 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
return true;
}
if (action === "clear") {
handlers?.onClear?.();
return true;
}
if (action === "l1") {
handlers?.onPrevField?.();
return true;