import { useCallback, useEffect, useRef, useState } from "react"; import { Minus, Plus } from "lucide-react"; import { useTranslation } from "react-i18next"; import Tooltip from "./Tooltip"; import { useReturnFocus } from "./useReturnFocus"; type Size = "sm" | "md"; // Every part is a target: 24 / 28 on a mouse (B1), 44 on a coarse pointer (A3). const COARSE_VALUE = "pointer-coarse:h-(--touch-target) pointer-coarse:w-(--touch-target)"; const SIZED = { sm: { btn: "w-6 h-6 touch-target", icon: "w-3.5 h-3.5", value: `w-8 h-6 text-sm ${COARSE_VALUE}`, }, md: { btn: "w-7 h-7 touch-target", icon: "w-4 h-4", value: `w-10 h-7 text-sm ${COARSE_VALUE}` }, } as const; function PriorityStepButton({ delta, Icon, label, sizeCls, onHoldStart, onStop, onTap, }: { delta: number; Icon: typeof Plus; label: string; sizeCls: { btn: string; icon: string }; onHoldStart: (delta: number) => void; onStop: () => void; onTap: (delta: number) => void; }) { return ( ); } /** * A horizontal −/+ priority stepper with the value centred between the buttons and click-to-type * inline editing (negatives allowed). Shared by the channel manager table (a cell, `size="sm"`) and * the channel detail page (a header control, `size="md"`), so the two never drift. The caller owns * persistence: `onPriority(delta)` nudges by ±1 (with hold auto-repeat), `onSet(value)` writes a * typed-in absolute value. * * The `size="sm"` variant draws its own bordered pill (it stands alone in a table cell); `size="md"` * drops the outer border and keeps only the segment dividers, so it blends into the detail header's * surrounding lozenge instead of double-bordering it. */ export function PriorityStepper({ priority, onPriority, onSet, size = "sm", hint, }: { priority: number; onPriority: (delta: number) => void; onSet: (value: number) => void; size?: Size; /** What the number MEANS where it is used (the tooltip, and the value's accessible name). The * platform layer does not know — a channel's priority orders the feed, another's would not. */ hint: string; }) { const { t } = useTranslation(); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(""); const holdDelay = useRef | null>(null); const holdRepeat = useRef | null>(null); const suppressClick = useRef(false); const cls = SIZED[size]; const stop = useCallback(() => { if (holdDelay.current) clearTimeout(holdDelay.current); if (holdRepeat.current) clearTimeout(holdRepeat.current); holdDelay.current = holdRepeat.current = null; }, []); useEffect(() => stop, [stop]); // clear any running timers on unmount // Hold past a threshold → auto-repeat at an accelerating rate, and suppress the trailing click so // a hold doesn't double-count its final notch. A quick tap never crosses the threshold, so it // falls through to onClick — which also keeps the keyboard path (Enter/Space) working. const start = (delta: number) => { suppressClick.current = false; holdDelay.current = setTimeout(() => { suppressClick.current = true; let rate = 140; const tick = () => { onPriority(delta); rate = Math.max(40, rate - 12); holdRepeat.current = setTimeout(tick, rate); }; tick(); }, 350); }; const onTap = (delta: number) => { if (suppressClick.current) { suppressClick.current = false; return; } onPriority(delta); }; const editFocus = useReturnFocus(editing); const commitEdit = () => { const parsed = parseInt(draft, 10); if (!Number.isNaN(parsed) && parsed !== priority) onSet(parsed); setEditing(false); }; const beginEdit = () => { setDraft(String(priority)); setEditing(true); }; // Segmented control: [−] value [+]. The value slot keeps a FIXED width (`cls.value`) whether it's // showing the number or the edit input, so opening the editor never resizes the control or shifts // the buttons — the old absolutely-positioned overlay "popped" wider, this doesn't. While editing // the whole pill borders in `accent` to read as focused. const framed = size === "sm" ? `border rounded-md overflow-hidden ${editing ? "border-accent" : "border-border"}` : ""; return (
{editing && ( e.currentTarget.select()} onChange={(e) => setDraft(e.target.value)} onBlur={commitEdit} onKeyDown={(e) => { if (e.key === "Enter") { editFocus.arm(e); commitEdit(); } else if (e.key === "Escape") { editFocus.arm(e); setEditing(false); } }} aria-label={hint} className="absolute inset-0 w-full h-full text-center font-medium tabular-nums text-accent bg-card outline-hidden [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" /> )}
); }