- PriorityStepper labelled itself with channels.row.* and told every caller it orders the Feed; Combobox said plex.loading / plex.filter.moreRefine / sidebar.match on the feed sidebar. The generic strings moved to common.*, and what a priority MEANS arrives as the stepper's `hint` prop. uiI18n.contract.test.ts pins components/ui/ to the platform namespaces — the import boundary could never see an i18n key. - 32 px-frozen box utilities (w-[150px], max-w-[1600px], left-[18px], …) became spacing-scale steps (w-37.5, max-w-400, left-4.5): identical at the default text size, and now they grow with the Settings slider instead of clipping or re-wrapping their own labels. An eslint rule refuses the px form beside the older text-[Npx] one, pinned by pxBox.contract.test.ts; measured in the browser that every new step is generated at its old px. - chipLane's import-strip regex ran past a side-effect import into the next statement's `from`, hiding a lane between them; it now stops at the import's own `;`.
194 lines
6.4 KiB
TypeScript
194 lines
6.4 KiB
TypeScript
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",
|
||
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 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
|
||
onClick={beginEdit}
|
||
aria-label={t("common.priority.edit")}
|
||
title={t("common.priority.edit")}
|
||
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={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>
|
||
);
|
||
}
|