fix(clip): clip mode keeps its range, its focus and its keys through the player's own moves

- A mark on the very end seeks a moment short of it; while a clip is marked the end of the item
  neither closes the player nor steps the queue, and Plex's auto-skip waits.
- Plex: Escape and Back peel what is over the picture (info, up-next, the countdown) before the
  clip; Back under a dialog is the dialog's; no range opens while the length is unknown.
- YouTube: the focus goes back to the scissors the user can see, else the player; the panel keeps
  its name, Loop and focus when the view is maximised or restored under it.
- A typed time stays in its field until taken, then shows where it landed; unreadable stays marked
  invalid; a decimal comma is read (ui/TimecodeField, the editor's fields too).
- RangeTrack: a drag is its pointer's, a cancelled drag reports final, an adornment sits beside the
  slider, a caller-chosen focus ring (amber on the clip strip). CropFrame: a drag is its pointer's.
- The editor's remove-cut is a real button at the cut's foot; a segment's keep toggle sits over the
  cut's hit area.
This commit is contained in:
2026-10-10 15:35:21 +02:00
parent b4bf71ea64
commit c489b60c8b
18 changed files with 607 additions and 121 deletions
@@ -110,6 +110,60 @@ describe("RangeTrack", () => {
expect(onSeek).not.toHaveBeenCalled();
});
it("reports a drag the browser took away (a cancel) as final where it stopped", () => {
// A caller that acts on the final move only (the Plex seek, the clip strip's window) was left
// with a not-final one for good.
const h = handle();
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
box(screen.getByRole("group", { name: "Clip" }));
const s = screen.getByRole("slider", { name: "In" });
fireEvent.pointerDown(s, { button: 0, clientX: 500, pointerId: 1 });
fireEvent.pointerMove(s, { clientX: 700, pointerId: 1 });
fireEvent.pointerCancel(s, { pointerId: 1 });
expect(h.onChange).toHaveBeenLastCalledWith(70, true);
});
it("follows the pointer that took a handle, not a second finger", () => {
const a = handle({ id: "in", label: "In", time: 30 });
const b = handle({ id: "out", label: "Out", time: 70 });
render(<RangeTrack duration={100} label="Clip" handles={[a, b]} />);
box(screen.getByRole("group", { name: "Clip" }));
const inH = screen.getByRole("slider", { name: "In" });
const outH = screen.getByRole("slider", { name: "Out" });
fireEvent.pointerDown(inH, { button: 0, clientX: 300, pointerId: 1 });
fireEvent.pointerDown(outH, { button: 0, clientX: 700, pointerId: 2 });
fireEvent.pointerMove(inH, { clientX: 400, pointerId: 1 });
expect(a.onChange).toHaveBeenLastCalledWith(40, false);
fireEvent.pointerMove(outH, { clientX: 800, pointerId: 2 });
expect(b.onChange).not.toHaveBeenCalled();
fireEvent.pointerUp(inH, { clientX: 400, pointerId: 1 });
expect(a.onChange).toHaveBeenLastCalledWith(40, true);
});
it("puts a handle's adornment beside its slider, not inside it, where a button stays a button", () => {
const h = handle({ adornment: <button>Remove</button> });
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
const button = screen.getByRole("button", { name: "Remove" });
expect(screen.getByRole("slider", { name: "In" }).contains(button)).toBe(false);
// The slider's arrows are not the button's.
fireEvent.keyDown(button, { key: "ArrowLeft" });
expect(h.onChange).not.toHaveBeenCalled();
});
it("rings a focused handle in the caller's colour, for a track on the dark media chrome", () => {
render(
<RangeTrack
duration={100}
label="Clip"
handles={[handle()]}
handleRingClassName="focus-visible:outline-warn"
/>,
);
expect(screen.getByRole("slider", { name: "In" }).className).toContain(
"focus-visible:outline-warn",
);
});
it("seeks on a press of the bare track, following the drag, final on release", () => {
const onSeek = vi.fn();
render(<RangeTrack duration={200} label="Clip" onSeek={onSeek} />);