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.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user