// GPIO Toggle Control // Purpose: Renders a direct press target for rover GPIO-backed toggles such as the headlight and laser. // Scope: Owns optimistic button state and touch/click de-duplication while callers provide device labels and actions. import { useEffect, useMemo, useRef, useState } from 'react'; import '../MobileControls/mobileControls.css'; import { triggerTouchHaptic } from '../../lib/touchHaptics.js'; function isBoolean(value) { return typeof value === 'boolean'; } export default function GPIOToggleControl({ label, icon: Icon, on, disabled, onToggle, keyLabel, className = '', heightClass = '', }) { const [optimistic, setOptimistic] = useState( isBoolean(on) ? on : null, ); const suppressClickRef = useRef(false); const suppressClickTimerRef = useRef(null); useEffect(() => { if (isBoolean(on)) { // Server-confirmed state can arrive after an optimistic click. Defer the // reconciliation one tick so this effect stays a synchronization point // instead of triggering React's synchronous set-state-in-effect lint rule. const timer = window.setTimeout(() => setOptimistic(on), 0); return () => window.clearTimeout(timer); } return undefined; }, [on]); useEffect( () => () => { if (suppressClickTimerRef.current) { clearTimeout(suppressClickTimerRef.current); } }, [], ); const hasState = isBoolean(optimistic); const displayOn = hasState ? optimistic : false; const statusLabel = hasState ? (displayOn ? 'On' : 'Off') : '—'; const statusClasses = displayOn ? 'bg-emerald-500 text-emerald-950' : 'bg-slate-700 text-slate-200'; const handleToggle = () => { if (disabled) return; const next = hasState ? !displayOn : true; setOptimistic(next); // The optimistic state transition is the control's accepted action, so its // feedback belongs here rather than on an unrelated global pointer event. triggerTouchHaptic('button'); onToggle?.(next); }; const handlePointerDown = (event) => { if (disabled) return; if (event.pointerType === 'mouse') return; /* Mobile browsers, especially Safari, do not always dispatch a reliable synthetic click for a second finger while another finger is held on the drive pad. Toggle on the real touch pointerdown instead, then suppress the follow-up click so one tap cannot flip the GPIO-backed device twice. */ event.preventDefault(); suppressClickRef.current = true; if (suppressClickTimerRef.current) { clearTimeout(suppressClickTimerRef.current); } /* The next click normally clears this flag, but Safari may suppress the click completely after preventDefault(). The timer prevents a stale touch flag from swallowing a later keyboard or desktop click. */ suppressClickTimerRef.current = window.setTimeout(() => { suppressClickRef.current = false; suppressClickTimerRef.current = null; }, 800); handleToggle(); }; const handleClick = (event) => { if (suppressClickRef.current) { event.preventDefault(); suppressClickRef.current = false; if (suppressClickTimerRef.current) { clearTimeout(suppressClickTimerRef.current); suppressClickTimerRef.current = null; } return; } handleToggle(); }; const buttonClasses = useMemo(() => { // This control is used as a direct mobile press target, so selection and // Safari callout suppression live on the button itself rather than only on // the surrounding mobile column. const base = 'mobile-touch-control group flex w-full flex-col items-center justify-center gap-0.35 rounded-xl border-2 px-1 py-0.75 text-center select-none no-touch-select'; const active = 'border-emerald-300/70 bg-emerald-800 text-emerald-50 hover:bg-emerald-700'; const inactive = 'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800'; return [base, displayOn ? active : inactive, 'disabled:opacity-50', heightClass, className] .filter(Boolean) .join(' '); }, [className, displayOn, heightClass]); return ( ); }