E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.
- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
a ref, so every component holding one was skipped (21 call sites, the engine and its surface
among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
useSyncExternalStore, which React asks on every render instead of a cached memo.
Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
"one writer" rule; the writer comes first now and the effect goes through it.
The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
208 lines
7.0 KiB
TypeScript
208 lines
7.0 KiB
TypeScript
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 (
|
||
<button
|
||
onPointerDown={(e) => {
|
||
// Keep receiving pointer events even if the pointer drifts off the small button mid-hold.
|
||
try {
|
||
e.currentTarget.setPointerCapture(e.pointerId);
|
||
} catch {
|
||
/* invalid pointer id (e.g. synthetic events) — capture is a nicety, not required */
|
||
}
|
||
onHoldStart(delta);
|
||
}}
|
||
onPointerUp={onStop}
|
||
onPointerCancel={onStop}
|
||
onClick={() => onTap(delta)}
|
||
className={`inline-flex items-center justify-center text-muted hover:text-accent ${sizeCls.btn}`}
|
||
aria-label={label}
|
||
>
|
||
<Icon className={sizeCls.icon} />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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<ReturnType<typeof setTimeout> | null>(null);
|
||
const holdRepeat = useRef<ReturnType<typeof setTimeout> | 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 { target: editTarget, arm: armEditReturn } = 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 (
|
||
<Tooltip hint={hint}>
|
||
<div
|
||
role="group"
|
||
aria-label={hint}
|
||
className={`inline-flex items-center cursor-help ${framed}`}
|
||
>
|
||
<PriorityStepButton
|
||
delta={-1}
|
||
Icon={Minus}
|
||
label={t("common.priority.lower")}
|
||
sizeCls={cls}
|
||
onHoldStart={start}
|
||
onStop={stop}
|
||
onTap={onTap}
|
||
/>
|
||
<span
|
||
className={`relative inline-flex items-center justify-center border-x ${
|
||
editing ? "border-accent" : "border-border"
|
||
} ${cls.value}`}
|
||
>
|
||
<button
|
||
ref={editTarget}
|
||
onClick={beginEdit}
|
||
aria-label={t("common.priority.edit")}
|
||
title={t("common.priority.edit")}
|
||
className={`h-full w-full text-center font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
|
||
>
|
||
{priority}
|
||
</button>
|
||
{editing && (
|
||
<input
|
||
type="number"
|
||
value={draft}
|
||
autoFocus
|
||
onFocus={(e) => e.currentTarget.select()}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onBlur={commitEdit}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
armEditReturn(e);
|
||
commitEdit();
|
||
} else if (e.key === "Escape") {
|
||
armEditReturn(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"
|
||
/>
|
||
)}
|
||
</span>
|
||
<PriorityStepButton
|
||
delta={1}
|
||
Icon={Plus}
|
||
label={t("common.priority.raise")}
|
||
sizeCls={cls}
|
||
onHoldStart={start}
|
||
onStop={stop}
|
||
onTap={onTap}
|
||
/>
|
||
</div>
|
||
</Tooltip>
|
||
);
|
||
}
|