Files
MultiRoombaRover/webui/src/components/GPIOToggleControl/index.jsx
T

145 lines
5.0 KiB
React

// 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 (
<button
type="button"
onPointerDown={handlePointerDown}
onClick={handleClick}
onContextMenu={(event) => event.preventDefault()}
disabled={disabled}
aria-pressed={displayOn}
className={buttonClasses}
>
<span className="flex items-center gap-0.5">
{/* Callers opt into an icon so shared desktop GPIO controls retain their
established density while narrow mobile controls gain faster recognition. */}
{Icon ? <Icon className="shrink-0 text-base" aria-hidden="true" /> : null}
<span className="text-sm font-semibold">{label}</span>
{keyLabel ? (
<span className="rounded-sm bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
{keyLabel}
</span>
) : null}
</span>
<span className={`rounded-sm px-1 py-0.5 text-[0.7rem] font-semibold ${statusClasses}`}>
{statusLabel}
</span>
</button>
);
}