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}
|
priority={ch.priority}
|
||||||
onPriority={(d) => bumpPriority(channelId, d)}
|
onPriority={(d) => bumpPriority(channelId, d)}
|
||||||
onSet={(v) => setPriority(channelId, v)}
|
onSet={(v) => setPriority(channelId, v)}
|
||||||
|
size="md"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
import { Minus, Plus } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Tooltip from "./Tooltip";
|
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({
|
function PriorityStepButton({
|
||||||
delta,
|
delta,
|
||||||
Icon,
|
Icon,
|
||||||
label,
|
label,
|
||||||
|
sizeCls,
|
||||||
onHoldStart,
|
onHoldStart,
|
||||||
onStop,
|
onStop,
|
||||||
onTap,
|
onTap,
|
||||||
}: {
|
}: {
|
||||||
delta: number;
|
delta: number;
|
||||||
Icon: typeof ArrowUp;
|
Icon: typeof Plus;
|
||||||
label: string;
|
label: string;
|
||||||
|
sizeCls: { btn: string; icon: string };
|
||||||
onHoldStart: (delta: number) => void;
|
onHoldStart: (delta: number) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
onTap: (delta: number) => void;
|
onTap: (delta: number) => void;
|
||||||
@@ -32,28 +41,35 @@ function PriorityStepButton({
|
|||||||
onPointerUp={onStop}
|
onPointerUp={onStop}
|
||||||
onPointerCancel={onStop}
|
onPointerCancel={onStop}
|
||||||
onClick={() => onTap(delta)}
|
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}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<Icon className="w-3.5 h-3.5" />
|
<Icon className={sizeCls.icon} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A press-and-hold-repeat priority control with an inline-editable value (negatives allowed).
|
* A horizontal −/+ priority stepper with the value centred between the buttons and click-to-type
|
||||||
* Shared by the channel manager table (a cell) and the channel detail page (a header control), so
|
* inline editing (negatives allowed). Shared by the channel manager table (a cell, `size="sm"`) and
|
||||||
* the two never drift. The caller owns persistence: `onPriority(delta)` nudges by ±1 (with hold
|
* the channel detail page (a header control, `size="md"`), so the two never drift. The caller owns
|
||||||
* auto-repeat), `onSet(value)` writes a typed-in absolute value.
|
* 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({
|
export function PriorityStepper({
|
||||||
priority,
|
priority,
|
||||||
onPriority,
|
onPriority,
|
||||||
onSet,
|
onSet,
|
||||||
|
size = "sm",
|
||||||
}: {
|
}: {
|
||||||
priority: number;
|
priority: number;
|
||||||
onPriority: (delta: number) => void;
|
onPriority: (delta: number) => void;
|
||||||
onSet: (value: number) => void;
|
onSet: (value: number) => void;
|
||||||
|
size?: Size;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@@ -61,6 +77,7 @@ export function PriorityStepper({
|
|||||||
const holdDelay = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const holdDelay = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const holdRepeat = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const holdRepeat = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const suppressClick = useRef(false);
|
const suppressClick = useRef(false);
|
||||||
|
const cls = SIZED[size];
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
if (holdDelay.current) clearTimeout(holdDelay.current);
|
if (holdDelay.current) clearTimeout(holdDelay.current);
|
||||||
@@ -103,21 +120,40 @@ export function PriorityStepper({
|
|||||||
setEditing(true);
|
setEditing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The value sits to the LEFT of the arrows (not between them) so the cursor — which rests on the
|
// Segmented control: [−] value [+]. The value slot keeps a FIXED width (`cls.value`) whether it's
|
||||||
// arrows while you press-and-hold — never covers the live value you're setting. Clicking the value
|
// showing the number or the edit input, so opening the editor never resizes the control or shifts
|
||||||
// opens an inline edit to type any integer directly (negatives allowed).
|
// 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 (
|
return (
|
||||||
<Tooltip hint={t("channels.row.priorityHint")}>
|
<Tooltip hint={t("channels.row.priorityHint")}>
|
||||||
<div className="inline-flex items-center gap-1.5 cursor-help">
|
<div
|
||||||
{/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely
|
role="group"
|
||||||
positioned OVER it (out of flow) so opening the editor never changes the row height/width —
|
aria-label={t("channels.row.priorityHint")}
|
||||||
it just overlays, extending left for a longer number. */}
|
className={`inline-flex items-center cursor-help ${framed}`}
|
||||||
<span className="relative inline-block w-6 h-5 text-right leading-5">
|
>
|
||||||
|
<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
|
<button
|
||||||
onClick={beginEdit}
|
onClick={beginEdit}
|
||||||
aria-label={t("channels.row.editPriority")}
|
aria-label={t("channels.row.editPriority")}
|
||||||
title={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}
|
{priority}
|
||||||
</button>
|
</button>
|
||||||
@@ -134,28 +170,19 @@ export function PriorityStepper({
|
|||||||
else if (e.key === "Escape") setEditing(false);
|
else if (e.key === "Escape") setEditing(false);
|
||||||
}}
|
}}
|
||||||
aria-label={t("channels.row.priorityHint")}
|
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>
|
||||||
<span className="inline-flex flex-col">
|
<PriorityStepButton
|
||||||
<PriorityStepButton
|
delta={1}
|
||||||
delta={1}
|
Icon={Plus}
|
||||||
Icon={ArrowUp}
|
label={t("channels.row.raisePriority")}
|
||||||
label={t("channels.row.raisePriority")}
|
sizeCls={cls}
|
||||||
onHoldStart={start}
|
onHoldStart={start}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
onTap={onTap}
|
onTap={onTap}
|
||||||
/>
|
/>
|
||||||
<PriorityStepButton
|
|
||||||
delta={-1}
|
|
||||||
Icon={ArrowDown}
|
|
||||||
label={t("channels.row.lowerPriority")}
|
|
||||||
onHoldStart={start}
|
|
||||||
onStop={stop}
|
|
||||||
onTap={onTap}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
|||||||
key: "priority",
|
key: "priority",
|
||||||
header: t("channels.cols.priority"),
|
header: t("channels.cols.priority"),
|
||||||
align: "center",
|
align: "center",
|
||||||
width: "3.5rem",
|
width: "5.5rem",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
hideInCard: true,
|
hideInCard: true,
|
||||||
sortValue: (c) => c.priority,
|
sortValue: (c) => c.priority,
|
||||||
|
|||||||
Reference in New Issue
Block a user