Add on-screen virtual keyboard for SpoolBuddy kiosk UI

The Raspberry Pi kiosk has no physical keyboard and system-level virtual
  keyboards (squeekboard, wvkbd) don't auto-show/hide with labwc/Chromium.
  Add a react-simple-keyboard QWERTY keyboard that auto-shows on input
  focus, with dark theme, shift/caps/backspace, email keys (@, .), and a
  two-phase close that prevents ghost-click passthrough to elements below.
  Inputs with data-vkb="false" opt out (e.g. SpoolBuddySettingsPage numpad).
This commit is contained in:
maziggy
2026-03-02 10:20:22 +01:00
parent 9f6acd6e77
commit bffbac54e4
11 changed files with 8984 additions and 8693 deletions
@@ -0,0 +1,82 @@
/*
* Dark theme for react-simple-keyboard — matches bambu-dark / bambu-green palette.
* Tailwind v4 preflight resets button display/flex, so we must explicitly
* restore the layout that react-simple-keyboard expects.
*/
.simple-keyboard.vkb-theme {
background: #1a1a1a;
border-top: 1px solid #333;
padding: 8px 4px;
font-family: inherit;
}
/* Row layout — Tailwind preflight strips flex from generic elements */
.simple-keyboard.vkb-theme .hg-row {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
gap: 4px;
margin-bottom: 4px;
}
.simple-keyboard.vkb-theme .hg-row:last-child {
margin-bottom: 0;
}
/* Key buttons — must restore inline-flex sizing */
.simple-keyboard.vkb-theme .hg-button {
display: inline-flex !important;
align-items: center;
justify-content: center;
flex-grow: 1;
flex-shrink: 1;
flex-basis: auto;
background: #2d2d2d;
color: #e0e0e0;
border: none;
border-radius: 6px;
height: 44px;
font-size: 16px;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: background 0.1s;
cursor: pointer;
padding: 0 2px;
min-width: 0;
}
.simple-keyboard.vkb-theme .hg-button:active {
background: #00ae42;
color: #fff;
}
/* Functional keys */
.simple-keyboard.vkb-theme .hg-button-bksp,
.simple-keyboard.vkb-theme .hg-button-shift,
.simple-keyboard.vkb-theme .hg-button-lock {
background: #3a3a3a;
color: #aaa;
flex-grow: 1.5;
}
.simple-keyboard.vkb-theme .hg-button-close {
background: #3a3a3a;
color: #aaa;
flex-grow: 2;
font-weight: 600;
}
.simple-keyboard.vkb-theme .hg-button-close:active {
background: #555;
}
.simple-keyboard.vkb-theme .hg-button-space {
flex-grow: 7;
}
/* Active shift/caps indicator */
.simple-keyboard.vkb-theme .hg-activeButton {
background: #00ae42;
color: #fff;
}
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import Keyboard from 'react-simple-keyboard';
import 'react-simple-keyboard/build/css/index.css';
import './VirtualKeyboard.css';
const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url']);
/**
* Set value on a controlled React input using the native setter,
* then dispatch an input event so React picks up the change.
*/
function setNativeValue(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
const setter =
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set ??
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
setter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
export function VirtualKeyboard() {
const [visible, setVisible] = useState(false);
const [closing, setClosing] = useState(false);
const closingRef = useRef(false);
const [layoutName, setLayoutName] = useState('default');
const activeInput = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const keyboardRef = useRef<ReturnType<typeof Keyboard> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const handleFocusIn = useCallback((e: FocusEvent) => {
if (closingRef.current) return;
const target = e.target as HTMLElement;
// Skip inputs that opt out (e.g. SpoolBuddySettingsPage numpad field)
if (target.closest('[data-vkb="false"]')) return;
if (target instanceof HTMLInputElement) {
if (!FOCUSABLE_TYPES.has(target.type)) return;
} else if (!(target instanceof HTMLTextAreaElement)) {
return;
}
activeInput.current = target as HTMLInputElement | HTMLTextAreaElement;
setVisible(true);
setLayoutName('default');
// Sync keyboard display with current value
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(keyboardRef.current as any)?.setInput?.(activeInput.current.value);
// Scroll input into view above the keyboard
setTimeout(() => {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}, []);
const handleFocusOut = useCallback((_e: FocusEvent) => {
// Delay to allow click on keyboard buttons to register
setTimeout(() => {
const active = document.activeElement;
// Keep visible if focus moved to keyboard or back to same input
if (
active &&
(containerRef.current?.contains(active) || active === activeInput.current)
) {
return;
}
setVisible(false);
activeInput.current = null;
}, 150);
}, []);
useEffect(() => {
document.addEventListener('focusin', handleFocusIn);
document.addEventListener('focusout', handleFocusOut);
return () => {
document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('focusout', handleFocusOut);
};
}, [handleFocusIn, handleFocusOut]);
const onKeyPress = useCallback((button: string) => {
const input = activeInput.current;
if (!input) return;
if (button === '{shift}') {
setLayoutName(prev => prev === 'default' ? 'shift' : 'default');
return;
}
if (button === '{lock}') {
setLayoutName(prev => prev === 'default' ? 'shift' : 'default');
return;
}
if (button === '{close}') {
dismiss();
return;
}
if (button === '{bksp}') {
setNativeValue(input, input.value.slice(0, -1));
} else if (button === '{space}') {
setNativeValue(input, input.value + ' ');
} else {
setNativeValue(input, input.value + button);
// Auto-unshift after typing one character (like mobile keyboards)
if (layoutName === 'shift') {
setLayoutName('default');
}
}
// Keep focus on the input
input.focus();
// Sync keyboard internal state
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(keyboardRef.current as any)?.setInput?.(input.value);
}, [layoutName]);
// Two-phase close: hide the keyboard immediately but keep the backdrop
// alive for 400ms to absorb the ghost click that touch devices synthesize.
const dismiss = useCallback(() => {
closingRef.current = true;
setClosing(true);
activeInput.current?.blur();
activeInput.current = null;
setTimeout(() => {
setVisible(false);
setClosing(false);
closingRef.current = false;
}, 400);
}, []);
if (!visible) return null;
return (
<>
{/* Backdrop: absorbs taps so they don't reach elements under the keyboard.
Stays alive during closing phase to catch ghost clicks. */}
<div
className="fixed inset-0 z-[9998] bg-transparent"
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); if (!closing) dismiss(); }}
onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); if (!closing) dismiss(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
/>
{!closing && (
<div
ref={containerRef}
className="fixed bottom-0 left-0 right-0 z-[9999]"
onMouseDown={(e) => e.preventDefault()}
onTouchStart={(e) => {
// Prevent focus loss but allow button interaction
if (!(e.target as HTMLElement).closest('.hg-button')) {
e.preventDefault();
}
}}
>
<Keyboard
keyboardRef={(r: ReturnType<typeof Keyboard>) => { keyboardRef.current = r; }}
layoutName={layoutName}
onKeyPress={onKeyPress}
theme="simple-keyboard vkb-theme"
layout={{
default: [
'1 2 3 4 5 6 7 8 9 0 {bksp}',
'q w e r t y u i o p',
'{lock} a s d f g h j k l',
'{shift} z x c v b n m . @',
'{space} {close}',
],
shift: [
'! @ # $ % ^ & * ( ) {bksp}',
'Q W E R T Y U I O P',
'{lock} A S D F G H J K L',
'{shift} Z X C V B N M , _',
'{space} {close}',
],
}}
display={{
'{bksp}': '\u232B',
'{close}': '\u2715 Close',
'{shift}': '\u21E7',
'{lock}': '\u21EA',
'{space}': ' ',
}}
/>
</div>
)}
</>
);
}