mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -05:00
headlight rework and laser addition
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// 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';
|
||||
|
||||
function isBoolean(value) {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
|
||||
export default function GPIOToggleControl({
|
||||
label,
|
||||
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);
|
||||
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">
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
{keyLabel ? (
|
||||
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
|
||||
{keyLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={`rounded px-1 py-0.5 text-[0.7rem] font-semibold ${statusClasses}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user