mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user