- F12 (owner: A): SwitchRow, the whole settings row toggles; a Config boolean toggles from its title and hint (a label pointed at the switch). - F14 (owner: B): the form dropdown trigger wears the field frame (see-through, the >= 3:1 edge); labelled pills keep the faint edge and the accent-edge hover. DESIGN says so, with log rows. - F6: ~15 bg-card / bg-border / opacity hovers onto the B3 state layer; a lint rule (HOVER_SWAP) refuses the swap, contract-tested. - F10: bare 14px icon links onto IconLink / LinkButton; a local lint rule refuses a glyph-only <a>. - F13: reason-disabled controls are aria-disabled (Users, Channels backfill, Discovery, Scheduler runs, Config tests); FocusSafeButton now swallows the click of a caller's aria-disabled; the Config tests say why while dirty (testDirty, HU/EN). - F5/F11: every control measured live at 24 (mouse) and 44 (coarse) across every page; touch-target is a utility that wins over a caller's min-h-6; link variants carry it too; SegmentedToggle, Tabs, NavRail, menu rows, PriorityStepper, sliders, CheckRow, InnerInput, card links and the rest. - F9: DismissibleChip's label reaches the chip's 24; the Combobox chips are DismissibleChips. - F15: the "+N" / "More..." open state worded as it renders.
208 lines
6.9 KiB
TypeScript
208 lines
6.9 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 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 (
|
||
<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={editFocus.target}
|
||
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") {
|
||
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"
|
||
/>
|
||
)}
|
||
</span>
|
||
<PriorityStepButton
|
||
delta={1}
|
||
Icon={Plus}
|
||
label={t("common.priority.raise")}
|
||
sizeCls={cls}
|
||
onHoldStart={start}
|
||
onStop={stop}
|
||
onTap={onTap}
|
||
/>
|
||
</div>
|
||
</Tooltip>
|
||
);
|
||
}
|