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:
2026-08-27 03:29:42 +02:00
parent 56a2ecdec3
commit 8dc4976c2c
4 changed files with 146 additions and 37 deletions
@@ -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);
});
});