- useTimecodeField holds a time field's draft for its caller, which places TimecodeError (role alert) in its own flow under the fields' row: it no longer lies over the crop row taking its clicks, runs out of the editor's card, or joins the field's <label> name. - The editor's remove-cut buttons sit in a row under the track at their cut (trackLeft), off the cut's grab area, where a press drags it; RangeTrack's adornment slot is gone. - media-focus re-points --focus to the media amber on the Plex player, the YouTube stage and every bg-media-chrome surface; a contract test holds each such class string to media-focus or media-scope, the field's focus edge to the focus token, and --color-focus to the inline theme. - An upload's quota headline says the converted file does not fit, not "Storage full". - DESIGN.md: the tokens paragraph says what is re-pointed where; the decision row measures all three colours on the chrome.
219 lines
8.7 KiB
TypeScript
219 lines
8.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The shared time track: a handle is a slider the keyboard steps (and the step stops at its range),
|
|
// a drag reports its moves as not-final and its release as final, a press on the bare track seeks,
|
|
// and the window decides what is drawn. jsdom lays nothing out, so the track's box is stubbed.
|
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
import { RangeTrack, RulerTicks, type TrackHandle } from "./RangeTrack";
|
|
|
|
afterEach(cleanup);
|
|
|
|
beforeAll(() => {
|
|
// jsdom has no PointerEvent: a MouseEvent carries the button and clientX a drag reads.
|
|
class PointerEventStub extends MouseEvent {
|
|
pointerId: number;
|
|
constructor(type: string, init: PointerEventInit = {}) {
|
|
super(type, init);
|
|
this.pointerId = init.pointerId ?? 1;
|
|
}
|
|
}
|
|
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
|
|
});
|
|
|
|
function box(el: Element, left = 0, width = 1000) {
|
|
el.getBoundingClientRect = () =>
|
|
({
|
|
left,
|
|
width,
|
|
top: 0,
|
|
height: 40,
|
|
right: left + width,
|
|
bottom: 40,
|
|
x: left,
|
|
y: 0,
|
|
}) as DOMRect;
|
|
}
|
|
|
|
function handle(over: Partial<TrackHandle> = {}): TrackHandle {
|
|
return {
|
|
id: "in",
|
|
time: 50,
|
|
min: 10,
|
|
max: 90,
|
|
label: "In",
|
|
valueText: "0:50.0",
|
|
step: 0.1,
|
|
bigStep: 1,
|
|
onChange: vi.fn(),
|
|
...over,
|
|
};
|
|
}
|
|
|
|
describe("RangeTrack", () => {
|
|
it("draws a handle as a slider with its time as the value", () => {
|
|
render(<RangeTrack duration={100} label="Clip" handles={[handle()]} />);
|
|
const s = screen.getByRole("slider", { name: "In" });
|
|
expect(s.getAttribute("aria-valuenow")).toBe("50");
|
|
expect(s.getAttribute("aria-valuetext")).toBe("0:50.0");
|
|
expect(s.style.left).toBe("50%");
|
|
});
|
|
|
|
it("steps a focused handle with the arrows, Shift for the big step, and takes the key", () => {
|
|
const h = handle();
|
|
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
|
|
const s = screen.getByRole("slider", { name: "In" });
|
|
const right = new KeyboardEvent("keydown", {
|
|
key: "ArrowRight",
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
s.dispatchEvent(right);
|
|
expect(h.onChange).toHaveBeenLastCalledWith(50.1, true);
|
|
expect(right.defaultPrevented).toBe(true);
|
|
fireEvent.keyDown(s, { key: "ArrowLeft", shiftKey: true });
|
|
expect(h.onChange).toHaveBeenLastCalledWith(49, true);
|
|
});
|
|
|
|
it("stops a step at the handle's range", () => {
|
|
const h = handle({ time: 89.95 });
|
|
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
|
|
const s = screen.getByRole("slider", { name: "In" });
|
|
fireEvent.keyDown(s, { key: "ArrowRight", shiftKey: true });
|
|
expect(h.onChange).toHaveBeenLastCalledWith(90, true);
|
|
fireEvent.keyDown(s, { key: "Home" });
|
|
expect(h.onChange).toHaveBeenLastCalledWith(10, true);
|
|
});
|
|
|
|
it("leaves a modified arrow to the page", () => {
|
|
const h = handle();
|
|
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
|
|
fireEvent.keyDown(screen.getByRole("slider", { name: "In" }), {
|
|
key: "ArrowRight",
|
|
ctrlKey: true,
|
|
});
|
|
expect(h.onChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports a drag's moves as not final and its release as final, clamped to the range", () => {
|
|
const h = handle();
|
|
const onSeek = vi.fn();
|
|
render(<RangeTrack duration={100} label="Clip" handles={[h]} onSeek={onSeek} />);
|
|
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 });
|
|
expect(h.onChange).toHaveBeenLastCalledWith(70, false);
|
|
fireEvent.pointerUp(s, { clientX: 990, pointerId: 1 });
|
|
expect(h.onChange).toHaveBeenLastCalledWith(90, true);
|
|
// The handle's press is not a seek of the track under it.
|
|
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("ends a drag whose handle leaves the window under it, so the track takes the next press", () => {
|
|
const onChange = vi.fn();
|
|
const onSeek = vi.fn();
|
|
const { rerender } = render(
|
|
<RangeTrack duration={200} label="Clip" handles={[handle({ onChange })]} onSeek={onSeek} />,
|
|
);
|
|
const track = screen.getByRole("group", { name: "Clip" });
|
|
box(track);
|
|
fireEvent.pointerDown(screen.getByRole("slider", { name: "In" }), {
|
|
button: 0,
|
|
clientX: 250,
|
|
pointerId: 7,
|
|
});
|
|
// A key moves the mark out of the window while the finger is still down: the handle unmounts.
|
|
rerender(
|
|
<RangeTrack
|
|
duration={200}
|
|
label="Clip"
|
|
handles={[handle({ onChange, time: 20 })]}
|
|
view={{ from: 100, to: 200 }}
|
|
onSeek={onSeek}
|
|
/>,
|
|
);
|
|
expect(screen.queryByRole("slider", { name: "In" })).toBeNull();
|
|
// Released where the handle no longer is: the drag ends, as final, where the mark now is.
|
|
fireEvent.pointerUp(document.body, { pointerId: 7 });
|
|
expect(onChange).toHaveBeenLastCalledWith(20, true);
|
|
fireEvent.pointerDown(track, { button: 0, clientX: 500, pointerId: 8 });
|
|
expect(onSeek).toHaveBeenCalledWith(150, false);
|
|
});
|
|
|
|
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} />);
|
|
const track = screen.getByRole("group", { name: "Clip" });
|
|
box(track);
|
|
fireEvent.pointerDown(track, { button: 0, clientX: 250, pointerId: 1 });
|
|
expect(onSeek).toHaveBeenLastCalledWith(50, false);
|
|
fireEvent.pointerMove(track, { clientX: 500, pointerId: 1 });
|
|
expect(onSeek).toHaveBeenLastCalledWith(100, false);
|
|
fireEvent.pointerUp(track, { clientX: 500, pointerId: 1 });
|
|
expect(onSeek).toHaveBeenLastCalledWith(100, true);
|
|
});
|
|
|
|
it("maps time through its window, and draws nothing that falls outside it", () => {
|
|
const onSeek = vi.fn();
|
|
render(
|
|
<RangeTrack
|
|
duration={3600}
|
|
view={{ from: 600, to: 700 }}
|
|
label="Zoom"
|
|
playhead={50}
|
|
onSeek={onSeek}
|
|
handles={[
|
|
handle({ id: "in", label: "In", time: 650, min: 0, max: 3600 }),
|
|
handle({ id: "out", label: "Out", time: 800, min: 0, max: 3600 }),
|
|
]}
|
|
/>,
|
|
);
|
|
const track = screen.getByRole("group", { name: "Zoom" });
|
|
box(track);
|
|
expect(screen.getByRole("slider", { name: "In" }).style.left).toBe("50%");
|
|
expect(screen.queryByRole("slider", { name: "Out" })).toBeNull();
|
|
expect(screen.queryByTestId("range-playhead")).toBeNull();
|
|
fireEvent.pointerDown(track, { button: 0, clientX: 100, pointerId: 1 });
|
|
expect(onSeek).toHaveBeenLastCalledWith(610, false);
|
|
});
|
|
});
|
|
|
|
describe("RulerTicks", () => {
|
|
it("picks a step that keeps the window to a handful of ticks", () => {
|
|
const { container } = render(<RulerTicks view={{ from: 0, to: 60 }} format={(t) => `${t}s`} />);
|
|
const labels = [...container.querySelectorAll("span")].map((s) => s.textContent);
|
|
expect(labels).toEqual(["0s", "10s", "20s", "30s", "40s", "50s", "60s"]);
|
|
});
|
|
});
|