feat(elecraft): the mouse wheel moves the console's sliders

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.
This commit is contained in:
2026-08-24 19:25:28 +02:00
parent a27fa08e4e
commit 6b0fc48389
3 changed files with 79 additions and 20 deletions
+13 -18
View File
@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst';
import { MeterBar } from '@/components/MeterBar';
import { WheelRange } from '@/components/WheelRange';
type KenwoodState = {
available: boolean; model?: string; elecraft: boolean; mode?: string;
@@ -212,53 +213,47 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-2">
<label className="flex items-center gap-2 text-xs">
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.power')}</span>
<input type="range" min={0} max={110} step={1} disabled={off}
<WheelRange min={0} max={110} step={5} disabled={off}
value={view.rf_power ?? 0}
onChange={(e) => put({ rf_power: Number(e.target.value) }, () => SetKenwoodPower(Number(e.target.value)))}
className="flex-1 accent-primary" />
onChange={(n) => put({ rf_power: n }, () => SetKenwoodPower(n))} />
<span className="w-12 text-right font-mono tabular-nums">{view.rf_power ?? 0} W</span>
</label>
<label className="flex items-center gap-2 text-xs">
<span className="w-16 shrink-0 text-muted-foreground flex items-center gap-1">
<AudioLines className="size-3.5" /> {t('k3.volume')}
</span>
<input type="range" min={0} max={100} step={1} disabled={off}
<WheelRange min={0} max={100} disabled={off}
value={view.af_gain ?? 0}
onChange={(e) => put({ af_gain: Number(e.target.value) }, () => SetKenwoodAFGain(Number(e.target.value)))}
className="flex-1 accent-primary" />
onChange={(n) => put({ af_gain: n }, () => SetKenwoodAFGain(n))} />
<span className="w-12 text-right font-mono tabular-nums">{view.af_gain ?? 0}</span>
</label>
<label className="flex items-center gap-2 text-xs">
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.rfGain')}</span>
<input type="range" min={0} max={100} step={1} disabled={off}
<WheelRange min={0} max={100} disabled={off}
value={view.rf_gain ?? 0}
onChange={(e) => put({ rf_gain: Number(e.target.value) }, () => SetKenwoodRFGain(Number(e.target.value)))}
className="flex-1 accent-primary" />
onChange={(n) => put({ rf_gain: n }, () => SetKenwoodRFGain(n))} />
<span className="w-12 text-right font-mono tabular-nums">{view.rf_gain ?? 0}</span>
</label>
<label className="flex items-center gap-2 text-xs">
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.micGain')}</span>
<input type="range" min={0} max={100} step={1} disabled={off}
<WheelRange min={0} max={100} disabled={off}
value={view.mic_gain ?? 0}
onChange={(e) => put({ mic_gain: Number(e.target.value) }, () => SetKenwoodMicGain(Number(e.target.value)))}
className="flex-1 accent-primary" />
onChange={(n) => put({ mic_gain: n }, () => SetKenwoodMicGain(n))} />
<span className="w-12 text-right font-mono tabular-nums">{view.mic_gain ?? 0}</span>
</label>
<label className="flex items-center gap-2 text-xs">
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.squelch')}</span>
<input type="range" min={0} max={100} step={1} disabled={off}
<WheelRange min={0} max={100} disabled={off}
value={view.squelch ?? 0}
onChange={(e) => put({ squelch: Number(e.target.value) }, () => SetKenwoodSquelch(Number(e.target.value)))}
className="flex-1 accent-primary" />
onChange={(n) => put({ squelch: n }, () => SetKenwoodSquelch(n))} />
<span className="w-12 text-right font-mono tabular-nums">{view.squelch ?? 0}</span>
</label>
{/* CW keyer speed — the radio's own keyer, the one the K3 sends with. */}
<label className="flex items-center gap-2 text-xs">
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.keySpeed')}</span>
<input type="range" min={8} max={50} step={1} disabled={off}
<WheelRange min={8} max={50} disabled={off}
value={view.key_speed || 20}
onChange={(e) => put({ key_speed: Number(e.target.value) }, () => SetKenwoodKeySpeed(Number(e.target.value)))}
className="flex-1 accent-primary" />
onChange={(n) => put({ key_speed: n }, () => SetKenwoodKeySpeed(n))} />
<span className="w-12 text-right font-mono tabular-nums">{view.key_speed || 0} wpm</span>
</label>
</div>
+62
View File
@@ -0,0 +1,62 @@
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,
}}
/>
);
}