DEV Community

Jonas Geschke
Jonas Geschke

Posted on

Simulating Mobile Virtual Keyboards in Progressive Web Apps

As a web developer working on progressive web apps (PWAs), I've discovered that mobile development tools can feel frustratingly limited compared to native app development. One specific challenge that often gets overlooked is how virtual keyboards interact with your app's interface.

The Mobile Keyboard Challenge

When developing for mobile platforms, the virtual keyboard is a unique beast. Unlike desktop environments, mobile devices overlay the keyboard on top of your app, typically obscuring around 40% of the screen. This can create unexpected user experience issues, especially for apps with multiple input types.

In my current project, chordel - a collaborative song writing app - I frequently encountered a subtle but annoying problem: some touch interactions were accidentally triggering text input focus and summoning the virtual keyboard when it wasn't needed.

Why Simulation Matters

Testing mobile behaviors on desktop browsers often means missing these nuanced interactions. While real device testing is an option, it comes with complications:

  • Requires physically having the device
  • Involves setting up remote debugging
  • Can be time-consuming to switch between development environments

A Custom Virtual Keyboard Simulator

I developed a React component to simulate mobile keyboard behavior during development. Here's how it works:

  1. Use MutationObserver to detect when the DOM is fully loaded
  2. Attach focus and blur handlers to all text-editable elements
  3. Show/hide a simulated keyboard overlay with the handlers
  4. Add smooth scrolling and animations (optionally :))

Key Component Features

The simulator provides:

  • Automatic keyboard overlay
  • Smooth scrolling to focused elements
  • A dismissal mechanism
  • Optional animation effects

Here is a quick demo:
Showing the virtual keyboard simulation in chordel

Here's the complete implementation:


const MobileKeyboardSimulator = ({
                                     enableAutoScroll = true,
                                     keyboardHeight = 300
                                 }) => {
    const [activeElement, setActiveElement] = useState<HTMLElement | null>(null);
    const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
    const [isKeyboardDismissing, setIsKeyboardDismissing] = useState(false);
    const keyboardOverlayRef = useRef(null);
    const originalScrollPositionRef = useRef<null | number>(null);
    const blurTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);

    // Dismiss keyboard function
    const dismissKeyboard = useCallback(() => {
        // Trigger slide-down animation
        setIsKeyboardDismissing(true);

        // Use a slight delay to allow animation to complete
        setTimeout(() => {
            if (activeElement) {
                activeElement.blur();
            }
            setIsKeyboardVisible(false);
            setIsKeyboardDismissing(false);

            // Restore original scroll position if auto-scrolled
            if (originalScrollPositionRef.current !== null) {
                window.scrollTo(0, originalScrollPositionRef.current);
                originalScrollPositionRef.current = null;
            }
        }, 300); // Match the animation duration
    }, [activeElement]);

    // Memoized focus handler to ensure stable reference
    const createFocusHandler = useCallback(() => {
        return (e: Event) => {
            console.debug('Focus handler triggered', e.target);

            // Clear any pending blur timeout
            if (blurTimeoutRef.current) {
                clearTimeout(blurTimeoutRef.current);
                blurTimeoutRef.current = null;
            }

            setActiveElement(e.target as HTMLElement | null);
            setIsKeyboardVisible(true);
            setIsKeyboardDismissing(false);

            // Auto-scroll functionality
            if (enableAutoScroll) {
                // Store original scroll position
                originalScrollPositionRef.current = window.scrollY;

                // Calculate element position
                const rect = (e.target as HTMLElement).getBoundingClientRect();
                const viewportHeight = window.innerHeight;

                // Calculate scroll amount to center the element
                const scrollAmount =
                    rect.top -
                    (viewportHeight - keyboardHeight - rect.height) / 2;

                // Smooth scroll to element
                window.scrollTo({
                    top: scrollAmount,
                    behavior: 'smooth'
                });
            }
        };
    }, [enableAutoScroll, keyboardHeight]);

    // Memoized blur handler
    const createBlurHandler = useCallback(() => {
        return () => {
            console.debug('Blur handler triggered');
            // Create a timeout to delay keyboard dismissal
            blurTimeoutRef.current = setTimeout(() => {
                // Check if the new active element is another input/contenteditable
                const newActiveElement = document.activeElement;
                const isNewElementInteractive =
                    newActiveElement?.matches('input, textarea, [contenteditable="true"]');

                if (!isNewElementInteractive) {
                    dismissKeyboard();
                    setActiveElement(null);
                }
            }, 100);
        };
    }, [dismissKeyboard]);

    const attachListeners = useCallback(() => {
        const selectableElements = document.querySelectorAll(
            'input, textarea, [contenteditable], [contenteditable="true"]'
        );

        const focusHandler = createFocusHandler();
        const blurHandler = createBlurHandler();

        const cleanup: (() => void)[] =[]

        selectableElements.forEach((element, index) => {
            element.addEventListener('focus', focusHandler);
            element.addEventListener('blur', blurHandler);

            cleanup.push(() => {
                element.removeEventListener('focus', focusHandler);
                element.removeEventListener('blur', blurHandler);
            })
        });

        return () => {
            cleanup.forEach((fn) => {
                fn();
            });
        };
    }, [createBlurHandler, createFocusHandler]);

    useEffect(() => {
        let cleanup: null | (() => void) = null;
        const observer = new MutationObserver(() => {
            cleanup = attachListeners();
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        return () => {
            observer.disconnect();
            if(cleanup)
                cleanup();
        };
    }, [attachListeners]);

    // Don't render if keyboard is not visible
    if (!isKeyboardVisible) return null;

    return (
        <div
            ref={keyboardOverlayRef}
            className={`
              touch-none
              pointer-events-none
              fixed bottom-0 left-0 w-full
              flex flex-col
              p-4 
              bg-muted
              border-t border-border
              z-[1000] 
              shadow-[0_-2px_10px_rgba(0,0,0,0.1)] 
              ${isKeyboardDismissing
                ? 'animate-[slideDown_0.3s_ease-out]'
                : 'animate-[slideUp_0.3s_ease-out]'} 
              animate-fill-forward
            `}
            style={{height: `${keyboardHeight}px`}}
        >
            <div className='w-full flex justify-end items-center mb-4'>
                <button
                    className='px-2 py-1 pointer-events-auto'
                    onClick={dismissKeyboard}
                >
                    Dismiss
                </button>
            </div>
            <div>
                {/* Example keyboard layout */}
                {['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'].map((key) => (
                    <button
                        key={key}
                        className='px-2 py-1 w-[30px] h-[40px] border border-border rounded-md mr-2 mb-2 text-muted-foreground'
                    >
                        {key}
                    </button>
                ))}
            </div>

            {/* Global style for animations */}
            <style>{`
        @keyframes slideUp {
          from { transform: translateY(100%); }
          to { transform: translateY(0); }
        }
        @keyframes slideDown {
          from { transform: translateY(0); }
          to { transform: translateY(100%); }
        }
      `}</style>
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

Platform-Specific Considerations

To ensure the simulator only runs when appropriate, I added a detection method for iOS devices, but something similar can be done for Android as well:

const isiOS = useCallback(() => {
        const iosQuirkPresent = function () {
            const audio = new Audio();

            audio.volume = 0.5;
            return audio.volume === 1;   // volume cannot be changed from "1" on iOS 12 and below
        };

        const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
        const isAppleDevice = navigator.userAgent.includes('Macintosh');
        const isTouchScreen = navigator.maxTouchPoints >= 1;   // true for iOS 13 (and hopefully beyond)

        return isIOS || (isAppleDevice && (isTouchScreen || iosQuirkPresent()));
    }, []);
Enter fullscreen mode Exit fullscreen mode

Usage and Deployment

Simply drop the MobileKeyboardSimulator component into your layout. It's designed to only render during development and on mobile platforms.
By using Vite's environment variables (import.meta.env.PROD), you can easily prevent the simulator from rendering in production.

Final Thoughts

While not a complete solution for all mobile testing scenarios, this custom keyboard simulator provides developers with a quick, lightweight way to catch mobile interaction edge cases earlier in the development process.

Testing is crucial, but having tools that help us simulate real-world conditions can save significant time and frustration.

Top comments (0)