Files
siftlode/frontend/src/components/ui/PriorityStepper.tsx
T
peter 5bfcf5d83a refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with
arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17),
PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among
~160 feature components with nothing marking it as one. Several of those
files describe themselves as 'the one place' or 'canonical' and were
still bypassed by hand-rolled markup, because a layer nobody can see is a
layer nobody uses; three separate inventory passes of this codebase
misread it the same way.

Moves the 33 domain-free modules into components/ui/, rewrites the
imports in 89 files, and makes the boundary a lint rule rather than a
convention: inside ui/ a one-level-up import (a feature component) and
any lib/api slice are errors. The rule was verified to fire on both and
to leave generic lib/ helpers alone. README.md states which side of the
line a component belongs on and why.

No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests
all green; no backend file touched.
2026-09-05 16:58:29 +02:00

190 lines
6.3 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";
type Size = "sm" | "md";
const SIZED = {
sm: { btn: "w-6 h-6", icon: "w-3.5 h-3.5", value: "w-8 h-6 text-sm" },
md: { btn: "w-7 h-7", icon: "w-4 h-4", value: "w-10 h-7 text-sm" },
} 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",
}: {
priority: number;
onPriority: (delta: number) => void;
onSet: (value: number) => void;
size?: Size;
}) {
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 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={t("channels.row.priorityHint")}>
<div
role="group"
aria-label={t("channels.row.priorityHint")}
className={`inline-flex items-center cursor-help ${framed}`}
>
<PriorityStepButton
delta={-1}
Icon={Minus}
label={t("channels.row.lowerPriority")}
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
onClick={beginEdit}
aria-label={t("channels.row.editPriority")}
title={t("channels.row.editPriority")}
className={`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") commitEdit();
else if (e.key === "Escape") setEditing(false);
}}
aria-label={t("channels.row.priorityHint")}
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("channels.row.raisePriority")}
sizeCls={cls}
onHoldStart={start}
onStop={stop}
onTap={onTap}
/>
</div>
</Tooltip>
);
}