Dragging a four-pixel slider to change the power by five watts is a fussy gesture, and the Flex and Yaesu consoles have had the wheel for a while — an operator moving between them expects it. React registers onWheel as PASSIVE, so preventDefault() there is ignored and the panel scrolls under the pointer while the value changes; the listener is attached natively instead, with the live values read through refs. Both existing panels solved it the same way, so this is a third copy turned into one shared component -- used by the new console only. The other two are left alone deliberately: they work, they are in daily use, and their styling differs in ways a merge would have to guess at. Power steps 5 W a notch, matching the K3's own coarse step.
63 lines
3.0 KiB
TypeScript
63 lines
3.0 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// A range slider the mouse wheel can move.
|
|
//
|
|
// Dragging a 4-pixel-tall slider to change the power by five watts is a fussy
|
|
// gesture; rolling the wheel over it is not, and it is what an operator reaches
|
|
// for after using the Flex and Yaesu consoles, which have had it for a while.
|
|
//
|
|
// React's own onWheel is registered PASSIVE, so preventDefault() inside it is
|
|
// ignored and the panel scrolls under the pointer while the value changes. The
|
|
// listener therefore has to be attached natively with { passive: false }, and
|
|
// the live values read through refs — the listener is installed once and would
|
|
// otherwise capture the first render's value for ever.
|
|
//
|
|
// The Flex and Yaesu panels each grew their own copy of this before it was worth
|
|
// sharing. They are left alone deliberately: they work, they are in daily use,
|
|
// and their styling differs in small ways that a merge would have to guess at.
|
|
export function WheelRange({ value, onChange, min = 0, max = 100, step = 1, disabled, accent = 'var(--primary)', className }: {
|
|
value: number; onChange: (v: number) => void;
|
|
min?: number; max?: number; step?: number; disabled?: boolean; accent?: string; className?: string;
|
|
}) {
|
|
const v = Math.max(min, Math.min(max, value));
|
|
const pct = max > min ? ((v - min) / (max - min)) * 100 : 0;
|
|
const ref = useRef<HTMLInputElement>(null);
|
|
const valRef = useRef(v); valRef.current = v;
|
|
const cbRef = useRef(onChange); cbRef.current = onChange;
|
|
const disRef = useRef(disabled); disRef.current = disabled;
|
|
const stepRef = useRef(step); stepRef.current = step;
|
|
const minRef = useRef(min); minRef.current = min;
|
|
const maxRef = useRef(max); maxRef.current = max;
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const onWheel = (e: WheelEvent) => {
|
|
if (disRef.current) return;
|
|
e.preventDefault();
|
|
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
|
|
const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + d));
|
|
if (nv !== valRef.current) cbRef.current(nv);
|
|
};
|
|
el.addEventListener('wheel', onWheel, { passive: false });
|
|
return () => el.removeEventListener('wheel', onWheel);
|
|
}, []);
|
|
|
|
return (
|
|
<input
|
|
ref={ref}
|
|
type="range" min={min} max={max} step={step} value={v} disabled={disabled}
|
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
|
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
|
'[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer',
|
|
className)}
|
|
style={{
|
|
background: `linear-gradient(to right, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%)`,
|
|
borderColor: accent,
|
|
}}
|
|
/>
|
|
);
|
|
}
|