mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
adjusting stylings
This commit is contained in:
@@ -20,10 +20,11 @@ export default function AccessoriesExpansion({ roverId }) {
|
||||
label="Accessories"
|
||||
ariaLabel={open ? 'Hide accessory controls' : 'Show accessory controls'}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`pointer-events-auto !h-28 rounded-l-none ${open ? 'rounded-r-none' : ''}`}
|
||||
hud
|
||||
className="pointer-events-auto !h-28"
|
||||
/>
|
||||
{open ? (
|
||||
<div className="pointer-events-auto h-[70%] min-h-48 max-h-[32rem] w-72 overflow-hidden rounded-r-xl border-2 border-l-0 border-cyan-300/70 bg-slate-950/95 shadow-2xl">
|
||||
<div className="pointer-events-auto h-[70%] min-h-48 max-h-[32rem] w-64 overflow-hidden rounded-r-xl bg-black/60 p-0.5">
|
||||
{/* This is exactly the renderer mounted by AuxColumn. The desktop
|
||||
wrapper changes available dimensions, never control behavior. */}
|
||||
<RoverAccessoryControls roverId={roverId} className="h-full" />
|
||||
|
||||
@@ -199,17 +199,19 @@ function RoverAuxColumn({ roverId, layout, className }) {
|
||||
return (
|
||||
<div className={`mobile-touch-control flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
|
||||
{showAccessories && hasAccessories ? (
|
||||
<div className="mobile-touch-control relative h-full min-h-0 overflow-hidden rounded-xl border-2 border-cyan-300/70 bg-slate-950/95">
|
||||
{/* The compact return tab overlays only the first heading corner. It
|
||||
does not reserve an otherwise empty rail down the full column. */}
|
||||
<AccessoriesToggle
|
||||
label="Aux"
|
||||
ariaLabel="Return to auxiliary controls"
|
||||
compact
|
||||
onClick={() => setShowAccessories(false)}
|
||||
className="absolute right-1 top-1 z-10"
|
||||
<div className="mobile-touch-control h-full min-h-0 overflow-hidden">
|
||||
<RoverAccessoryControls
|
||||
roverId={roverId}
|
||||
className="h-full"
|
||||
headerAction={(
|
||||
<AccessoriesToggle
|
||||
label="Aux"
|
||||
ariaLabel="Return to auxiliary controls"
|
||||
compact
|
||||
onClick={() => setShowAccessories(false)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<RoverAccessoryControls roverId={roverId} className="h-full pr-1" />
|
||||
</div>
|
||||
) : (
|
||||
<AuxColumnContent
|
||||
|
||||
@@ -9,8 +9,14 @@ export default function AccessoriesToggle({
|
||||
ariaLabel,
|
||||
onClick,
|
||||
compact = false,
|
||||
hud = false,
|
||||
className = '',
|
||||
}) {
|
||||
const sizeClass = compact ? 'h-6 w-10' : 'h-full w-8';
|
||||
const toneClass = hud
|
||||
? 'rounded-none border-0 bg-black/60 text-white/75 shadow-none hover:bg-black hover:text-white'
|
||||
: 'rounded-xl border-2 border-cyan-300/70 bg-cyan-900 text-cyan-50 shadow-md hover:brightness-110 active:brightness-125';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -19,11 +25,11 @@ export default function AccessoriesToggle({
|
||||
triggerTouchHaptic('button');
|
||||
onClick();
|
||||
}}
|
||||
className={`mobile-touch-control flex shrink-0 items-center justify-center rounded-xl border-2 border-cyan-300/70 bg-cyan-900 text-sm font-semibold text-cyan-50 shadow-md transition hover:brightness-110 active:scale-[0.98] active:brightness-125 ${compact ? 'h-14 w-7' : 'h-full w-8'} ${className}`.trim()}
|
||||
className={`mobile-touch-control flex shrink-0 items-center justify-center text-sm font-semibold transition active:scale-[0.98] ${sizeClass} ${toneClass} ${className}`.trim()}
|
||||
>
|
||||
{/* Vertical writing keeps the launcher readable in the narrow wall space
|
||||
without rotating the glyph itself away from its natural orientation. */}
|
||||
<span className="flex items-center gap-1 [writing-mode:vertical-rl] rotate-180">
|
||||
{/* Full launchers use vertical writing in the narrow wall space. The
|
||||
compact Aux return stays horizontal so it consumes only one heading. */}
|
||||
<span className={compact ? 'flex items-center' : 'flex items-center gap-1 [writing-mode:vertical-rl] rotate-180'}>
|
||||
{!compact ? <FaPuzzlePiece className="shrink-0 text-sm" aria-hidden="true" /> : null}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Accessory Control Field
|
||||
// Purpose: Maps one firmware-advertised generic control to a large rover-control surface.
|
||||
// Scope: Owns browser-local values and input semantics; transport and device-specific behavior stay outside this file.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
// Purpose: Maps one firmware-advertised generic control to a compact rover-control surface.
|
||||
// Scope: Owns browser-local input semantics; transport and device-specific behavior stay outside this file.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
function integerBound(value, fallback) {
|
||||
@@ -20,7 +20,7 @@ function trimUnicode(value, maximumLength) {
|
||||
return Array.from(String(value ?? '')).slice(0, maximumLength).join('');
|
||||
}
|
||||
|
||||
const CARD_CLASS = 'mobile-touch-control rounded-xl border-2 px-2.5 py-2 text-slate-50 shadow-md';
|
||||
const CARD_CLASS = 'mobile-touch-control rounded-xl border-2 px-2 py-1 text-slate-50';
|
||||
const DISABLED_CLASS = 'disabled:cursor-not-allowed disabled:opacity-40';
|
||||
|
||||
function SliderControl({ peripheralId, control, disabled, send, value: storedValue }) {
|
||||
@@ -29,33 +29,114 @@ function SliderControl({ peripheralId, control, disabled, send, value: storedVal
|
||||
const value = Number.isInteger(storedValue)
|
||||
? clampInteger(storedValue, minimum, maximum)
|
||||
: minimum;
|
||||
const trackRef = useRef(null);
|
||||
const pointerIdRef = useRef(null);
|
||||
const lastHapticValueRef = useRef(value);
|
||||
|
||||
const updateValue = (event) => {
|
||||
const next = clampInteger(event.target.value, minimum, maximum);
|
||||
const valuePercent = useMemo(() => {
|
||||
if (maximum === minimum) return 50;
|
||||
return ((value - minimum) / (maximum - minimum)) * 100;
|
||||
}, [maximum, minimum, value]);
|
||||
|
||||
const sendValue = useCallback((nextValue) => {
|
||||
const next = clampInteger(nextValue, minimum, maximum);
|
||||
const hapticStep = Math.max(1, Math.round((maximum - minimum) / 20));
|
||||
if (Math.abs(next - lastHapticValueRef.current) >= hapticStep) {
|
||||
triggerTouchHaptic('camera');
|
||||
lastHapticValueRef.current = next;
|
||||
}
|
||||
send(peripheralId, control.id, next);
|
||||
}, [control.id, maximum, minimum, peripheralId, send]);
|
||||
|
||||
const valueFromPointer = useCallback((event) => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return value;
|
||||
const bounds = track.getBoundingClientRect();
|
||||
const rawPercent = (event.clientX - bounds.left) / Math.max(1, bounds.width);
|
||||
return minimum + Math.max(0, Math.min(1, rawPercent)) * (maximum - minimum);
|
||||
}, [maximum, minimum, value]);
|
||||
|
||||
const updateFromPointer = useCallback((event) => {
|
||||
sendValue(valueFromPointer(event));
|
||||
}, [sendValue, valueFromPointer]);
|
||||
|
||||
const handlePointerDown = useCallback((event) => {
|
||||
if (disabled || pointerIdRef.current !== null) return;
|
||||
// This follows VerticalCameraTilt's custom pointer-capture path so a range
|
||||
// drag remains reliable while another finger is operating the drive pad.
|
||||
event.preventDefault();
|
||||
pointerIdRef.current = event.pointerId;
|
||||
lastHapticValueRef.current = value;
|
||||
trackRef.current?.setPointerCapture?.(event.pointerId);
|
||||
updateFromPointer(event);
|
||||
}, [disabled, updateFromPointer, value]);
|
||||
|
||||
const handlePointerMove = useCallback((event) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
updateFromPointer(event);
|
||||
}, [updateFromPointer]);
|
||||
|
||||
const handlePointerEnd = useCallback((event) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
pointerIdRef.current = null;
|
||||
trackRef.current?.releasePointerCapture?.(event.pointerId);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (disabled) return;
|
||||
let next = null;
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') next = value - 1;
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') next = value + 1;
|
||||
if (event.key === 'Home') next = minimum;
|
||||
if (event.key === 'End') next = maximum;
|
||||
if (next == null) return;
|
||||
event.preventDefault();
|
||||
sendValue(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={`${CARD_CLASS} block border-emerald-300/70 bg-emerald-900`}>
|
||||
<span className="flex items-center justify-between gap-2 text-sm font-semibold">
|
||||
<span>{control.name}</span>
|
||||
<span className="font-mono text-emerald-100">{value}</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={minimum}
|
||||
max={maximum}
|
||||
step="1"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={updateValue}
|
||||
className={`mobile-touch-control mt-2 h-8 w-full cursor-pointer accent-emerald-300 ${DISABLED_CLASS}`}
|
||||
/>
|
||||
<span className="flex justify-between text-xs text-emerald-100/80" aria-hidden="true">
|
||||
<span>{minimum}</span>
|
||||
<span>{maximum}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className={`${CARD_CLASS} border-emerald-300/70 bg-emerald-900 ${disabled ? 'cursor-not-allowed opacity-40' : ''}`}>
|
||||
<div className="flex items-center justify-between gap-1 text-sm font-semibold">
|
||||
<span className="min-w-0 truncate">{control.name}</span>
|
||||
<span className="shrink-0 font-mono text-emerald-100">{value}</span>
|
||||
</div>
|
||||
<div
|
||||
ref={trackRef}
|
||||
role="slider"
|
||||
aria-label={control.name}
|
||||
aria-valuemin={minimum}
|
||||
aria-valuemax={maximum}
|
||||
aria-valuenow={value}
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (pointerIdRef.current === event.pointerId) pointerIdRef.current = null;
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
style={{ touchAction: 'none' }}
|
||||
className="mobile-touch-control mobile-drag-control relative mt-1 h-7 w-full rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200"
|
||||
>
|
||||
{/* An inset track gives the thumb room to remain entirely inside the
|
||||
card at both endpoints without browser-specific range styling. */}
|
||||
<div className="pointer-events-none absolute inset-1">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-emerald-400"
|
||||
style={{ width: `${valuePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-emerald-950 bg-emerald-200 shadow"
|
||||
style={{ left: `clamp(0.4375rem, ${valuePercent}%, calc(100% - 0.4375rem))` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,8 +145,7 @@ function ToggleControl({ peripheralId, control, disabled, send, value }) {
|
||||
|
||||
const toggle = () => {
|
||||
if (disabled) return;
|
||||
const next = !enabled;
|
||||
send(peripheralId, control.id, next);
|
||||
send(peripheralId, control.id, !enabled);
|
||||
triggerTouchHaptic('button');
|
||||
};
|
||||
|
||||
@@ -75,10 +155,10 @@ function ToggleControl({ peripheralId, control, disabled, send, value }) {
|
||||
aria-pressed={enabled}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-[4.5rem] w-full items-center justify-between gap-2 font-semibold transition active:scale-[0.99] ${enabled ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50'}`}
|
||||
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-12 w-full items-center justify-between gap-1 font-semibold transition active:scale-[0.99] ${enabled ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50'}`}
|
||||
>
|
||||
<span>{control.name}</span>
|
||||
<span className="text-sm">{enabled ? 'On' : 'Off'}</span>
|
||||
<span className="min-w-0 truncate">{control.name}</span>
|
||||
<span className="shrink-0 text-xs">{enabled ? 'On' : 'Off'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -92,8 +172,8 @@ function MomentaryControl({ peripheralId, control, disabled, send, value }) {
|
||||
if (!pressedRef.current) return;
|
||||
pressedRef.current = false;
|
||||
pointerIdRef.current = null;
|
||||
// Always pair a successful press with false. In particular, this cleanup
|
||||
// path runs when a touch is cancelled or the Accessories view is replaced.
|
||||
// Always pair a successful press with false, including cancellation,
|
||||
// permission loss, and replacement of the Accessories view.
|
||||
send(peripheralId, control.id, false);
|
||||
}, [control.id, peripheralId, send]);
|
||||
|
||||
@@ -105,8 +185,6 @@ function MomentaryControl({ peripheralId, control, disabled, send, value }) {
|
||||
|
||||
useEffect(() => release, [release]);
|
||||
useEffect(() => {
|
||||
// Browsers do not guarantee pointer-up after a held button becomes
|
||||
// disabled. Proactively emit the neutral edge at the permission boundary.
|
||||
if (disabled) release();
|
||||
}, [disabled, release]);
|
||||
|
||||
@@ -143,7 +221,7 @@ function MomentaryControl({ peripheralId, control, disabled, send, value }) {
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-[4.5rem] w-full items-center justify-center text-center font-semibold transition active:scale-[0.99] ${pressed ? 'border-fuchsia-200 bg-fuchsia-600 text-white' : 'border-fuchsia-300/70 bg-fuchsia-900 text-fuchsia-50'}`}
|
||||
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-12 w-full items-center justify-center text-center font-semibold transition active:scale-[0.99] ${pressed ? 'border-fuchsia-200 bg-fuchsia-600 text-white' : 'border-fuchsia-300/70 bg-fuchsia-900 text-fuchsia-50'}`}
|
||||
>
|
||||
{control.name}
|
||||
</button>
|
||||
@@ -169,11 +247,8 @@ function NumberControl({ peripheralId, control, disabled, send, value: storedVal
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={`${CARD_CLASS} block border-indigo-300/70 bg-indigo-900`}>
|
||||
<span className="flex items-center justify-between gap-2 text-sm font-semibold">
|
||||
<span>{control.name}</span>
|
||||
<span className="text-xs text-indigo-100/80">{minimum}–{maximum}</span>
|
||||
</span>
|
||||
<label className={`${CARD_CLASS} flex min-h-12 items-center gap-1 border-indigo-300/70 bg-indigo-900`}>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{control.name}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
@@ -182,6 +257,7 @@ function NumberControl({ peripheralId, control, disabled, send, value: storedVal
|
||||
step="1"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={`${control.name}, ${minimum} to ${maximum}`}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
@@ -191,7 +267,7 @@ function NumberControl({ peripheralId, control, disabled, send, value: storedVal
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className={`mobile-touch-control mt-2 min-h-11 w-full rounded-lg border border-indigo-200/70 bg-indigo-950 px-3 text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-indigo-200 ${DISABLED_CLASS}`}
|
||||
className={`mobile-touch-control h-9 w-[45%] min-w-16 rounded-lg border border-indigo-200/70 bg-indigo-950 px-1.5 text-right text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-indigo-200 ${DISABLED_CLASS}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
@@ -215,15 +291,13 @@ function TextControl({ peripheralId, control, disabled, send, value: storedValue
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={`${CARD_CLASS} block border-sky-300/70 bg-sky-900`}>
|
||||
<span className="flex items-center justify-between gap-2 text-sm font-semibold">
|
||||
<span>{control.name}</span>
|
||||
<span className="text-xs text-sky-100/80">{Array.from(value).length}/{maximumLength}</span>
|
||||
</span>
|
||||
<label className={`${CARD_CLASS} flex min-h-12 items-center gap-1 border-sky-300/70 bg-sky-900`}>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{control.name}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={`${control.name}, maximum ${maximumLength} characters`}
|
||||
onChange={(event) => setValue(trimUnicode(event.target.value, maximumLength))}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
@@ -233,7 +307,7 @@ function TextControl({ peripheralId, control, disabled, send, value: storedValue
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className={`mobile-touch-control mt-2 min-h-11 w-full rounded-lg border border-sky-200/70 bg-sky-950 px-3 text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-sky-200 ${DISABLED_CLASS}`}
|
||||
className={`mobile-touch-control h-9 w-[55%] min-w-20 rounded-lg border border-sky-200/70 bg-sky-950 px-1.5 text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-sky-200 ${DISABLED_CLASS}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import useRoverAccessories from './useRoverAccessories.js';
|
||||
|
||||
const EMPTY_ACCESSORY_VALUES = Object.freeze({});
|
||||
|
||||
export default function RoverAccessoryControls({ roverId, className = '' }) {
|
||||
export default function RoverAccessoryControls({ roverId, headerAction = null, className = '' }) {
|
||||
const { peripherals } = useRoverAccessories(roverId);
|
||||
const canControl = useCanControlRover(roverId);
|
||||
const { setPeripheralControl } = useControlActions();
|
||||
@@ -25,30 +25,36 @@ export default function RoverAccessoryControls({ roverId, className = '' }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mobile-touch-control min-h-0 overflow-y-auto overscroll-contain p-1.5 text-slate-100 ${className}`.trim()}
|
||||
className={`mobile-touch-control min-h-0 overflow-y-auto overscroll-contain text-slate-100 ${className}`.trim()}
|
||||
aria-label="Rover accessories"
|
||||
>
|
||||
{peripherals.map((peripheral) => (
|
||||
<section key={peripheral.id} className="mb-2 last:mb-0">
|
||||
{/* The firmware's array order is authoritative. Mapping directly over
|
||||
it keeps physical authoring order intact across every UI host. */}
|
||||
<h3 className="mb-1.5 border-b border-cyan-300/40 px-1 pr-8 pb-1 text-sm font-semibold text-cyan-100">
|
||||
{peripheral.name}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{peripheral.controls.map((control) => (
|
||||
<AccessoryControlField
|
||||
key={control.id}
|
||||
peripheralId={peripheral.id}
|
||||
control={control}
|
||||
value={values[peripheral.id]?.[control.id]}
|
||||
disabled={!roverId || !canControl}
|
||||
send={send}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{peripherals.map((peripheral, peripheralIndex) => {
|
||||
const showHeading = peripherals.length > 1 || (peripheralIndex === 0 && headerAction);
|
||||
return (
|
||||
<section key={peripheral.id} className="mb-0.5 last:mb-0">
|
||||
{/* The firmware's array order is authoritative. Mapping directly over
|
||||
it keeps physical authoring order intact across every UI host. */}
|
||||
{showHeading ? (
|
||||
<div className="mb-0.5 flex min-h-7 items-center gap-1 bg-black/60 px-1 text-xs font-semibold text-cyan-100">
|
||||
<h3 className="min-w-0 flex-1 truncate">{peripheral.name}</h3>
|
||||
{peripheralIndex === 0 ? headerAction : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{peripheral.controls.map((control) => (
|
||||
<AccessoryControlField
|
||||
key={control.id}
|
||||
peripheralId={peripheral.id}
|
||||
control={control}
|
||||
value={values[peripheral.id]?.[control.id]}
|
||||
disabled={!roverId || !canControl}
|
||||
send={send}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user