- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch - ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date filter on one trigger, a pill in a toolbar, r8 in a form - ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput - every raw input swept (~100 sites); inputCls retired; local/input-primitive - useReturnFocus: an in-place editor hands the focus back to its trigger on Enter/Escape (the playlist and Plex title renames, the priority number); the Plex playlist rename gains Escape and an accessible name - back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log
196 lines
6.7 KiB
TypeScript
196 lines
6.7 KiB
TypeScript
// @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();
|
||
vi.useRealTimers(); // any fake-timer test resets here so it can't leak into the next
|
||
});
|
||
|
||
const raise = () => screen.getByRole("button", { name: "common.priority.raise" });
|
||
const lower = () => screen.getByRole("button", { name: "common.priority.lower" });
|
||
const valueBtn = () => screen.getByRole("button", { name: "common.priority.edit" });
|
||
|
||
describe("PriorityStepper", () => {
|
||
it("hands the focus back to the value after Enter or Escape, not to <body>", () => {
|
||
const onSet = vi.fn();
|
||
render(
|
||
<PriorityStepper hint="what it orders" priority={3} onPriority={vi.fn()} onSet={onSet} />,
|
||
);
|
||
for (const key of ["Escape", "Enter"]) {
|
||
fireEvent.click(valueBtn());
|
||
const field = screen.getByRole("spinbutton");
|
||
field.focus();
|
||
fireEvent.keyDown(field, { key });
|
||
expect(document.activeElement).toBe(valueBtn());
|
||
}
|
||
});
|
||
|
||
it("shows the current priority and both step buttons", () => {
|
||
render(
|
||
<PriorityStepper hint="what it orders" 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
|
||
hint="what it orders"
|
||
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 hint="what it orders" 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 hint="what it orders" 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 hint="what it orders" 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 hint="what it orders" 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
|
||
hint="what it orders"
|
||
priority={3}
|
||
onPriority={onPriority}
|
||
onSet={vi.fn()}
|
||
size="md"
|
||
/>,
|
||
);
|
||
expect(valueBtn().textContent).toBe("3");
|
||
fireEvent.click(raise());
|
||
expect(onPriority).toHaveBeenCalledWith(1);
|
||
});
|
||
|
||
it("holding a step button auto-repeats (accelerating) and suppresses the trailing click", () => {
|
||
vi.useFakeTimers();
|
||
const onPriority = vi.fn();
|
||
render(
|
||
<PriorityStepper
|
||
hint="what it orders"
|
||
priority={0}
|
||
onPriority={onPriority}
|
||
onSet={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.pointerDown(raise());
|
||
vi.advanceTimersByTime(349);
|
||
expect(onPriority).not.toHaveBeenCalled(); // a tap below the 350ms threshold doesn't repeat
|
||
vi.advanceTimersByTime(1); // cross the threshold → first auto-repeat tick
|
||
expect(onPriority).toHaveBeenCalledTimes(1);
|
||
|
||
vi.advanceTimersByTime(1000); // several accelerating repeats while held
|
||
const heldCount = onPriority.mock.calls.length;
|
||
expect(heldCount).toBeGreaterThan(3);
|
||
expect(onPriority.mock.calls.every(([d]) => d === 1)).toBe(true);
|
||
|
||
fireEvent.pointerUp(raise());
|
||
vi.advanceTimersByTime(1000);
|
||
expect(onPriority.mock.calls.length).toBe(heldCount); // stops repeating on release
|
||
|
||
fireEvent.click(raise()); // the click that trails a hold is swallowed (no double-count)
|
||
expect(onPriority.mock.calls.length).toBe(heldCount);
|
||
});
|
||
|
||
it("a quick tap (released before the hold threshold) still nudges exactly once", () => {
|
||
vi.useFakeTimers();
|
||
const onPriority = vi.fn();
|
||
render(
|
||
<PriorityStepper
|
||
hint="what it orders"
|
||
priority={0}
|
||
onPriority={onPriority}
|
||
onSet={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.pointerDown(lower());
|
||
vi.advanceTimersByTime(100); // below the threshold → no auto-repeat armed
|
||
fireEvent.pointerUp(lower());
|
||
fireEvent.click(lower()); // the tap's click is NOT suppressed
|
||
expect(onPriority.mock.calls).toEqual([[-1]]);
|
||
});
|
||
|
||
it("clears the auto-repeat timer on unmount (no fire after the row is gone)", () => {
|
||
vi.useFakeTimers();
|
||
const onPriority = vi.fn();
|
||
const { unmount } = render(
|
||
<PriorityStepper
|
||
hint="what it orders"
|
||
priority={0}
|
||
onPriority={onPriority}
|
||
onSet={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.pointerDown(raise());
|
||
vi.advanceTimersByTime(350); // repeat armed + first tick
|
||
const before = onPriority.mock.calls.length;
|
||
expect(before).toBeGreaterThan(0);
|
||
|
||
unmount();
|
||
vi.advanceTimersByTime(1000);
|
||
expect(onPriority.mock.calls.length).toBe(before); // no post-unmount timer fire
|
||
});
|
||
});
|