feat(channels): horizontal −/+ priority stepper with pop-free inline edit (S5)
Redesign the shared PriorityStepper (channel manager cell + detail header) from a value-plus-stacked-arrows control into a horizontal [−] value [+] segmented stepper. The value now sits centred between the buttons and opens an inline editor in a FIXED-width slot, so click-to-type no longer "pops" the control wider (the old absolutely-positioned overlay grew w-6 → w-14). One `size` prop drives the two surfaces: `sm` draws its own bordered pill for the standalone table cell; `md` drops the outer border and keeps only the segment dividers, blending into the detail header's lozenge instead of double-bordering it. Hold-to-repeat, negatives, and the debounced-flush persistence hook are unchanged. The manager priority column widens 3.5rem → 5.5rem to fit the horizontal control under fixed table layout. Tests: 7 new DOM tests for the stepper interaction contract.
This commit is contained in:
@@ -418,6 +418,7 @@ export default function ChannelPage({
|
||||
priority={ch.priority}
|
||||
onPriority={(d) => bumpPriority(channelId, d)}
|
||||
onSet={(v) => setPriority(channelId, v)}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Interaction contract of the shared PriorityStepper (channel manager cell + detail header). i18n is
|
||||
// mocked to a key-passthrough so aria-label lookups read stable keys, not translations. The visual
|
||||
// size/border split (`sm` vs `md`) is CSS-only, so we assert behaviour, not styling.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { PriorityStepper } from "./PriorityStepper";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const raise = () => screen.getByRole("button", { name: "channels.row.raisePriority" });
|
||||
const lower = () => screen.getByRole("button", { name: "channels.row.lowerPriority" });
|
||||
const valueBtn = () => screen.getByRole("button", { name: "channels.row.editPriority" });
|
||||
|
||||
describe("PriorityStepper", () => {
|
||||
it("shows the current priority and both step buttons", () => {
|
||||
render(<PriorityStepper priority={12} onPriority={vi.fn()} onSet={vi.fn()} />);
|
||||
expect(valueBtn().textContent).toBe("12");
|
||||
expect(raise()).toBeTruthy();
|
||||
expect(lower()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tapping + / − nudges by ±1", () => {
|
||||
const onPriority = vi.fn();
|
||||
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
|
||||
fireEvent.click(raise());
|
||||
fireEvent.click(lower());
|
||||
expect(onPriority.mock.calls).toEqual([[1], [-1]]);
|
||||
});
|
||||
|
||||
it("clicking the value opens an inline editor seeded with the current value", () => {
|
||||
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={vi.fn()} />);
|
||||
fireEvent.click(valueBtn());
|
||||
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||
expect(input.value).toBe("7");
|
||||
});
|
||||
|
||||
it("typing a new value and pressing Enter commits it via onSet (negatives allowed)", () => {
|
||||
const onSet = vi.fn();
|
||||
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
|
||||
fireEvent.click(valueBtn());
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "-3" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onSet).toHaveBeenCalledWith(-3);
|
||||
});
|
||||
|
||||
it("Escape cancels the edit without committing", () => {
|
||||
const onSet = vi.fn();
|
||||
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
|
||||
fireEvent.click(valueBtn());
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "99" } });
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(onSet).not.toHaveBeenCalled();
|
||||
// editor closed → the value button is back
|
||||
expect(valueBtn().textContent).toBe("7");
|
||||
});
|
||||
|
||||
it("committing an unchanged value does not call onSet", () => {
|
||||
const onSet = vi.fn();
|
||||
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
|
||||
fireEvent.click(valueBtn());
|
||||
fireEvent.keyDown(screen.getByRole("spinbutton"), { key: "Enter" });
|
||||
expect(onSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the same interactive contract at size=md", () => {
|
||||
const onPriority = vi.fn();
|
||||
render(<PriorityStepper priority={3} onPriority={onPriority} onSet={vi.fn()} size="md" />);
|
||||
expect(valueBtn().textContent).toBe("3");
|
||||
fireEvent.click(raise());
|
||||
expect(onPriority).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,28 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowDown, ArrowUp } from "lucide-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 ArrowUp;
|
||||
Icon: typeof Plus;
|
||||
label: string;
|
||||
sizeCls: { btn: string; icon: string };
|
||||
onHoldStart: (delta: number) => void;
|
||||
onStop: () => void;
|
||||
onTap: (delta: number) => void;
|
||||
@@ -32,28 +41,35 @@ function PriorityStepButton({
|
||||
onPointerUp={onStop}
|
||||
onPointerCancel={onStop}
|
||||
onClick={() => onTap(delta)}
|
||||
className="text-muted hover:text-accent leading-none"
|
||||
className={`inline-flex items-center justify-center text-muted hover:text-accent ${sizeCls.btn}`}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<Icon className={sizeCls.icon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A press-and-hold-repeat priority control with an inline-editable value (negatives allowed).
|
||||
* Shared by the channel manager table (a cell) and the channel detail page (a header control), 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.
|
||||
* 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);
|
||||
@@ -61,6 +77,7 @@ export function PriorityStepper({
|
||||
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);
|
||||
@@ -103,21 +120,40 @@ export function PriorityStepper({
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
// The value sits to the LEFT of the arrows (not between them) so the cursor — which rests on the
|
||||
// arrows while you press-and-hold — never covers the live value you're setting. Clicking the value
|
||||
// opens an inline edit to type any integer directly (negatives allowed).
|
||||
// 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 className="inline-flex items-center gap-1.5 cursor-help">
|
||||
{/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely
|
||||
positioned OVER it (out of flow) so opening the editor never changes the row height/width —
|
||||
it just overlays, extending left for a longer number. */}
|
||||
<span className="relative inline-block w-6 h-5 text-right leading-5">
|
||||
<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={`text-sm font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
|
||||
className={`w-full text-center font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
|
||||
>
|
||||
{priority}
|
||||
</button>
|
||||
@@ -134,28 +170,19 @@ export function PriorityStepper({
|
||||
else if (e.key === "Escape") setEditing(false);
|
||||
}}
|
||||
aria-label={t("channels.row.priorityHint")}
|
||||
className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded-sm px-1 outline-hidden z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
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>
|
||||
<span className="inline-flex flex-col">
|
||||
<PriorityStepButton
|
||||
delta={1}
|
||||
Icon={ArrowUp}
|
||||
label={t("channels.row.raisePriority")}
|
||||
onHoldStart={start}
|
||||
onStop={stop}
|
||||
onTap={onTap}
|
||||
/>
|
||||
<PriorityStepButton
|
||||
delta={-1}
|
||||
Icon={ArrowDown}
|
||||
label={t("channels.row.lowerPriority")}
|
||||
onHoldStart={start}
|
||||
onStop={stop}
|
||||
onTap={onTap}
|
||||
/>
|
||||
</span>
|
||||
<PriorityStepButton
|
||||
delta={1}
|
||||
Icon={Plus}
|
||||
label={t("channels.row.raisePriority")}
|
||||
sizeCls={cls}
|
||||
onHoldStart={start}
|
||||
onStop={stop}
|
||||
onTap={onTap}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -198,7 +198,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
key: "priority",
|
||||
header: t("channels.cols.priority"),
|
||||
align: "center",
|
||||
width: "3.5rem",
|
||||
width: "5.5rem",
|
||||
sortable: true,
|
||||
hideInCard: true,
|
||||
sortValue: (c) => c.priority,
|
||||
|
||||
Reference in New Issue
Block a user