mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 12:46:49 +02:00
Focusing any text field on a SpoolBuddy screen (inventory Search, or the Search / Color Name / Brand fields on write-tag New Spool) blanked the UI with React error #130 ("Element type is invalid ... but got: object"). It hit both internal and Spoolman inventories, so it was not data-specific. The SpoolBuddy shell mounts VirtualKeyboard, an on-screen keyboard that pops up on focusin for any input -- so every field on every SpoolBuddy page tripped it, while the main app (no on-screen keyboard) was fine. VirtualKeyboard imports the default export of react-simple-keyboard, a CommonJS package; under the current bundler's CJS->ESM interop that default resolves to the module namespace object ({ KeyboardReact, default }) rather than the component, so <Keyboard> renders an object as an element type and React throws. vitest's interop returns the real component, so it only manifested in the browser build -- a runtime, not a type, problem. Add a small resolveInteropDefault helper that unwraps such an interop-wrapped default: it returns the value as-is when already a usable element type (function/class, tag string, or a $$typeof-marked forwardRef/memo/lazy) and otherwise falls through to .default and named exports. VirtualKeyboard resolves the real component through it.
207 lines
7.5 KiB
TypeScript
207 lines
7.5 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
import KeyboardImport from 'react-simple-keyboard';
|
|
import 'react-simple-keyboard/build/css/index.css';
|
|
import './VirtualKeyboard.css';
|
|
import { resolveInteropDefault } from '../utils/interopDefault';
|
|
|
|
// react-simple-keyboard is published as CommonJS. Depending on the bundler's
|
|
// CJS->ESM interop, the default import arrives either as the Keyboard component
|
|
// itself or as the module namespace object ({ KeyboardReact, default }). Under
|
|
// the current Vite build (and Node's ESM loader) it's the latter, so rendering
|
|
// <KeyboardImport> puts an object where an element type belongs and React throws
|
|
// "Element type is invalid ... got: object" (#130) — crashing every SpoolBuddy
|
|
// screen the instant a text input is focused and this keyboard mounts (#2616).
|
|
// Resolve the real component defensively so it renders under any interop shape.
|
|
// The TYPE of the default import is already the component (from the .d.ts), so
|
|
// the cast keeps JSX + ref typing intact while fixing only the runtime value.
|
|
const Keyboard = resolveInteropDefault<typeof KeyboardImport>(KeyboardImport, ['KeyboardReact']);
|
|
|
|
const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url', 'number']);
|
|
|
|
/**
|
|
* 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({ onVisibilityChange }: { onVisibilityChange?: (visible: boolean) => void }) {
|
|
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);
|
|
|
|
// Notify parent when keyboard visibility changes
|
|
useEffect(() => {
|
|
onVisibilityChange?.(visible);
|
|
}, [visible, onVisibilityChange]);
|
|
|
|
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 focused input into view after the keyboard renders and layout reflows
|
|
setTimeout(() => {
|
|
const card = target.closest('.bg-zinc-800, .rounded-lg, [data-vkb-group]') as HTMLElement | null;
|
|
(card ?? target).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}, 100);
|
|
}, []);
|
|
|
|
const handleFocusOut = useCallback(() => {
|
|
// 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]);
|
|
|
|
// 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);
|
|
}, []);
|
|
|
|
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, dismiss]);
|
|
|
|
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="relative z-[9999] shrink-0"
|
|
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|