Files
siftlode/frontend/src/components/ui/PriorityStepper.tsx
T
peter 295c1db5f1 fix(design): claim audit part 1b — whole-row switches, the field-family edge, state-layer hovers, icon links, reason-disabled controls, 24/44 targets (E28)
- 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.
2026-09-25 03:58:17 +02:00

208 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}