fix(clip): typed times belong to their value, and every mark on the player chrome can be seen

TimecodeField drops a draft when the value moves under it (another segment selected, a nudge), so
leaving the field never commits it to another cut; it is invalid only once offered, with a line
under it saying how to type a time. On the always-dark chrome media-scope points every focus ring
at --media-mark and errors at the new --media-danger (a light theme's accent and danger were under
3:1 there). A track whose dragged handle unmounts ends the drag on the pointer's release instead of
locking up; a remove refused while clips are queued leaves no focus to take later; each remove-cut
button names its cut and is a 24px target. DESIGN.md records the media tokens; stale comments fixed.
This commit is contained in:
2026-10-10 20:28:52 +02:00
parent a867145bcb
commit c27cfdcb9c
18 changed files with 303 additions and 71 deletions
@@ -137,7 +137,7 @@ describe("ClipPanel", () => {
expect(range()).toBe("102.5-130");
});
it("never seeks the player onto the item's very end (that would end it, and the clip with it)", () => {
it("never seeks the player onto the item's very end (that would finish the item)", () => {
const onSeek = vi.fn();
render(<Harness onSeek={onSeek} duration={600} start={{ start: 590, end: 595 }} />);
fireEvent.keyDown(screen.getByRole("slider", { name: "clip.out" }), { key: "End" });
+7 -6
View File
@@ -84,15 +84,17 @@ interface Props {
const NUDGE_S = 0.1;
// How far short of the item's end a seek stops: a mark on the very end moves the player to its last
// moment, not past it — a player put at its end ENDS (a Plex film closes, a YouTube queue steps on)
// and takes the clip with it.
// moment, not past it. A player put at its end ENDS, and the item counts as finished — YouTube marks
// it watched and shows its end screen, a Plex position reads as the whole — while a mark there is only
// a place. (The players keep themselves and the clip open at an end while one is marked; this keeps
// the item unfinished.)
const LAST_MOMENT_S = 0.25;
// On the always-dark chrome the ends are white (a light theme's accent is a dark blue there, which
// all but vanished on the bar) and the playhead amber, so an end and the playhead are never confused.
// A focused end is ringed in amber too, as the crop frame is: the accent ring was under 3:1 there.
// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar.
// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar. Every
// focus ring in the panel is that amber as well (media-scope re-points --focus), as the crop frame's
// is: the theme accent's ring was under 3:1 on the chrome.
const PLAYHEAD = "bg-media-mark";
const END_RING = "focus-visible:outline-media-mark";
export default function ClipPanel({
src,
@@ -247,7 +249,6 @@ export default function ClipPanel({
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={seek}
handleRingClassName={END_RING}
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
background={
<RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} />
+2 -1
View File
@@ -950,7 +950,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// (`useHistorySubview`'s back).
// - The on-screen arrow is a click on "leave", not a cancel, so it does not stop a countdown.
// - Escape in fullscreen leaves fullscreen (the browser's own Escape) and not the player.
// - The arrow leaves past a clip as it does past a panel; the keys step out of it.
// - The arrow still closes an open panel or the stats first, but it leaves past a clip, as past a
// countdown: only the keys step out of a clip.
const back = useCallback(
(from: "arrow" | "key" | "escape") => {
if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current();
@@ -102,6 +102,41 @@ describe("VideoEditor — a split export that fails part-way", () => {
});
});
// A time typed into the selected segment's field belongs to that segment: selecting another one
// drops it, and never commits it to the new selection's cut.
describe("VideoEditor — a typed time and another segment", () => {
it("does not move the newly selected segment's cut with a time typed for the old one", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
for (const at of [30, 60]) {
Object.defineProperty(video, "currentTime", {
value: at,
configurable: true,
writable: true,
});
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
}
const segs = () => screen.getAllByTitle("editor.keptSeg");
fireEvent.pointerDown(segs()[1]!);
const inField = screen.getByRole("textbox", { name: "editor.in" });
act(() => inField.focus());
fireEvent.change(inField, { target: { value: "0:40" } });
// Another segment is selected before the field loses the focus (a press on it).
fireEvent.pointerDown(segs()[2]!);
fireEvent.blur(inField);
const cuts = screen
.getAllByRole("slider", { name: "editor.cutN" })
.map((c) => c.getAttribute("aria-valuenow"));
expect(cuts).toEqual(["30", "60"]);
});
});
// The cuts sit on the shared time track: each is a slider the keyboard steps, and a step stops a
// tenth short of the next cut, so no segment ever closes up.
describe("VideoEditor — a cut on the keyboard", () => {
@@ -172,6 +207,34 @@ describe("VideoEditor — a cut's remove button", () => {
);
});
it("a remove refused while the clips are queued leaves no focus to take later", async () => {
let refuse: (e: unknown) => void = () => {};
vi.spyOn(api, "enqueueEdit").mockImplementation(
() =>
new Promise((_, reject) => {
refuse = reject;
}) as never,
);
split();
fireEvent.click(screen.getByText("editor.createN"));
await waitFor(() => expect(api.enqueueEdit).toHaveBeenCalled());
// The cut list holds still while the clips are queued: the remove is refused.
const remove = screen.getByRole("button", { name: "editor.removeCut" });
act(() => remove.focus());
fireEvent.click(remove);
expect(screen.getByRole("slider", { name: "editor.cutN" })).toBeTruthy();
await act(async () => refuse(new Error("refused")));
// The create failed and the editor stays; a later split keeps the focus where it was.
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
fireEvent.timeUpdate(video);
const splitButton = screen.getByText("editor.splitHere").closest("button")!;
act(() => splitButton.focus());
fireEvent.click(splitButton);
expect(screen.getAllByRole("slider", { name: "editor.cutN" })).toHaveLength(2);
expect(document.activeElement).toBe(splitButton);
});
it("sits at the cut's foot, under the next segment's keep toggle at its head", () => {
split();
const remove = screen.getByRole("button", { name: "editor.removeCut" });
+7 -3
View File
@@ -435,12 +435,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
<Pressable
onPointerDown={(e) => e.stopPropagation()}
onClick={() => {
// Refused while the clips are queued (the list holds still): then nothing is
// removed, and no focus is left waiting to be taken by a later change.
if (creating.current) return;
refocusAfterRemove.current = bi;
deleteBoundary(bi);
}}
className="pointer-events-auto absolute bottom-0 -right-0.5 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger focus-visible:-outline-offset-2"
aria-label={t("editor.removeCut")}
title={t("editor.removeCut")}
// 24px, the target floor; white on its black disc in either theme.
className="pointer-events-auto absolute bottom-0 -right-0.5 p-1.5 rounded-full bg-black/70 text-white/80 hover:text-white focus-visible:-outline-offset-2"
aria-label={t("editor.removeCut", { n: bi + 1 })}
title={t("editor.removeCut", { n: bi + 1 })}
>
<X className="w-3 h-3" />
</Pressable>
+7 -7
View File
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
| area | components |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `TimecodeField` (a cut's time typed in: the draft until Enter or blur takes it, then the value as it landed; unreadable stays, marked invalid), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
| area | components |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `TimecodeField` (a cut's time typed in: the draft until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid with a line saying how to type one; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
## The size contract (E10 S1c)
@@ -150,18 +150,35 @@ describe("RangeTrack", () => {
expect(h.onChange).not.toHaveBeenCalled();
});
it("rings a focused handle in the caller's colour, for a track on the dark media chrome", () => {
render(
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={100}
duration={200}
label="Clip"
handles={[handle()]}
handleRingClassName="focus-visible:outline-warn"
handles={[handle({ onChange, time: 20 })]}
view={{ from: 100, to: 200 }}
onSeek={onSeek}
/>,
);
expect(screen.getByRole("slider", { name: "In" }).className).toContain(
"focus-visible:outline-warn",
);
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", () => {
+35 -11
View File
@@ -1,4 +1,6 @@
import {
useEffect,
useEffectEvent,
useRef,
useState,
type CSSProperties,
@@ -80,9 +82,6 @@ interface Props {
/** Accessible name of the whole track. */
label: string;
playheadClassName?: string;
/** A focused handle's ring colour (a `focus-visible:outline-*` class) where the theme accent does
* not read on the track, e.g. on the dark media chrome. */
handleRingClassName?: string;
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
@@ -107,7 +106,6 @@ export function RangeTrack({
className,
label,
playheadClassName = "bg-white",
handleRingClassName,
}: Props) {
const win: RangeWindow = view ?? { from: 0, to: duration };
const span = Math.max(win.to - win.from, 1e-6);
@@ -115,12 +113,40 @@ export function RangeTrack({
const pct = (f: number) => `${(clamp(f, 0, 1) * 100).toFixed(3)}%`;
// The handle a pointer is dragging, and the press on the bare track (a scrub), so a move is only
// acted on between its own down and up.
const [dragging, setDragging] = useState<{ id: string; pointer: number } | null>(null);
const [dragging, setDragging] = useState<{
id: string;
pointer: number;
el: HTMLElement;
} | null>(null);
const [scrubbing, setScrubbing] = useState<number | null>(null);
// Where the drag last put its handle, for a cancel to report as final.
const lastRef = useRef(0);
const busy = dragging != null || scrubbing != null;
// A handle can go while its pointer is still down: its mark moved out of the window under it (a
// key marked the other end and carried this one along). Its own up / cancel then never come — the
// capture went with the node — and the drag would hold the track `busy` for good (a finger never
// comes back with the same pointer). So the pointer's release anywhere ends it, reported final
// where the mark now is: the caller's final-only work (a seek, a frozen view let go) still runs.
const endLostDrag = useEffectEvent((id: string) => {
setDragging(null);
const h = handles.find((x) => x.id === id);
if (h) h.onChange(clamp(h.time, h.min, h.max), true);
});
useEffect(() => {
if (!dragging) return;
const { id, pointer, el } = dragging;
const end = (e: globalThis.PointerEvent) => {
if (e.pointerId === pointer && !el.isConnected) endLostDrag(id);
};
document.addEventListener("pointerup", end);
document.addEventListener("pointercancel", end);
return () => {
document.removeEventListener("pointerup", end);
document.removeEventListener("pointercancel", end);
};
}, [dragging]);
const capture = (e: PointerEvent<HTMLElement>) => {
try {
e.currentTarget.setPointerCapture(e.pointerId);
@@ -224,11 +250,9 @@ export function RangeTrack({
aria-valuenow={Math.round(h.time * 10) / 10}
aria-valuetext={h.valueText}
data-handle={h.id}
// The ring sits inside the hit area: the track clips its overflow.
className={clsx(
"absolute inset-y-0 z-base flex w-6 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center rounded-sm focus-visible:-outline-offset-2",
handleRingClassName,
)}
// The ring sits inside the hit area: the track clips its overflow. Its colour is the base
// focus ring's, which a scope over another ground re-points (media-scope: the media amber).
className="absolute inset-y-0 z-base flex w-6 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center rounded-sm focus-visible:-outline-offset-2"
style={{ left: pct(f) }}
onKeyDown={onKeyDown}
onPointerDown={(e) => {
@@ -237,7 +261,7 @@ export function RangeTrack({
if (busy) return;
capture(e);
lastRef.current = h.time;
setDragging({ id: h.id, pointer: e.pointerId });
setDragging({ id: h.id, pointer: e.pointerId, el: e.currentTarget });
}}
onPointerMove={(e) => {
if (mine(e)) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
//
// The time field: invalid only once a time was offered, said in words under the field, and a draft
// that never outlives the value it was typed over.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { TimecodeField } from "./TimecodeField";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
afterEach(cleanup);
const field = () => screen.getByRole("textbox") as HTMLInputElement;
describe("TimecodeField", () => {
it("is not invalid while a time is still being typed, only once it is offered", () => {
render(<TimecodeField aria-label="In" value={10} onCommit={() => {}} />);
fireEvent.change(field(), { target: { value: "" } });
fireEvent.change(field(), { target: { value: "1:" } });
expect(field().getAttribute("aria-invalid")).toBeNull();
fireEvent.blur(field());
expect(field().getAttribute("aria-invalid")).toBe("true");
});
it("says in words, under the field and linked to it, how a time is typed", () => {
render(<TimecodeField aria-label="In" value={10} onCommit={() => {}} />);
fireEvent.change(field(), { target: { value: "abc" } });
fireEvent.keyDown(field(), { key: "Enter" });
const id = field().getAttribute("aria-describedby");
expect(id).toBeTruthy();
expect(document.getElementById(id!)?.textContent).toBe("common.timecodeInvalid");
fireEvent.change(field(), { target: { value: "0:12" } });
fireEvent.keyDown(field(), { key: "Enter" });
expect(field().getAttribute("aria-describedby")).toBeNull();
});
it("drops a draft when the value moves under it, and never commits it to the new value", () => {
const onCommit = vi.fn();
const { rerender } = render(<TimecodeField aria-label="In" value={10} onCommit={onCommit} />);
fireEvent.change(field(), { target: { value: "0:05" } });
// Another segment selected under the same field (or a nudge): the field edits a new value now.
rerender(<TimecodeField aria-label="In" value={40} onCommit={onCommit} />);
expect(field().value).toBe("0:40.0");
fireEvent.blur(field());
expect(onCommit).not.toHaveBeenCalled();
// An unreadable one goes too, and its mark with it.
fireEvent.change(field(), { target: { value: "x" } });
fireEvent.blur(field());
expect(field().getAttribute("aria-invalid")).toBe("true");
rerender(<TimecodeField aria-label="In" value={41} onCommit={onCommit} />);
expect(field().value).toBe("0:41.0");
expect(field().getAttribute("aria-invalid")).toBeNull();
});
});
+53 -23
View File
@@ -1,12 +1,15 @@
import { useState, type ComponentProps } from "react";
import { useId, useState, type ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { formatTimecode, parseTimecode } from "../../lib/timecode";
import { Input } from "./Input";
// A field a cut's time is typed into (lib/timecode): the editor's segment ends, the clip panel's In /
// Out. A typed draft is the field's until Enter or leaving the field takes it; then the field shows
// the value as it landed — the caller may clamp it (a time past the end) — and a time it cannot read
// stays, marked invalid, for fixing rather than vanishing or seeming taken. One element throughout,
// so Enter leaves the focus in the field (a field re-created per value dropped it on <body>).
// stays for fixing rather than vanishing or seeming taken: marked invalid, with a line under the
// field saying how to type one. Invalid is said of a time offered (Enter, leaving the field), never
// of one still being typed. One element throughout, so Enter leaves the focus in the field (a field
// re-created per value dropped it on <body>).
type Props = Omit<
ComponentProps<typeof Input>,
@@ -26,33 +29,60 @@ export function TimecodeField({
onCommit,
onBlur,
onKeyDown,
"aria-describedby": describedBy,
...rest
}: Props) {
const [draft, setDraft] = useState<string | null>(null);
const invalid = draft != null && parseTimecode(draft) == null;
const { t } = useTranslation();
const errorId = useId();
const [draft, setDraft] = useState<{ text: string; offered: boolean } | null>(null);
// A draft belongs to the value it was typed over. When the value moves without it (a nudge, "to
// the playhead", another segment selected under the same field), the draft is dropped: kept, it
// would show over the new value, and leaving the field would commit it to whatever the field
// edits now — another cut.
const [typedOver, setTypedOver] = useState(value);
if (typedOver !== value) {
setTypedOver(value);
setDraft(null);
}
const invalid = draft != null && draft.offered && parseTimecode(draft.text) == null;
const commit = () => {
if (draft == null) return;
const v = parseTimecode(draft);
if (v == null) return;
const v = parseTimecode(draft.text);
if (v == null) {
if (!draft.offered) setDraft({ text: draft.text, offered: true });
return;
}
setDraft(null);
if (Math.abs(v - value) > 1e-3) onCommit(v);
};
const described = [describedBy, invalid ? errorId : null].filter(Boolean).join(" ");
return (
<Input
{...rest}
value={draft ?? formatTimecode(value, hours)}
invalid={invalid}
onChange={(e) => setDraft(e.target.value)}
onBlur={(e) => {
commit();
onBlur?.(e);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.key !== "Enter" || e.defaultPrevented) return;
e.preventDefault();
commit();
}}
/>
<span className="relative inline-flex">
<Input
{...rest}
value={draft?.text ?? formatTimecode(value, hours)}
invalid={invalid}
aria-describedby={described || undefined}
onChange={(e) => setDraft({ text: e.target.value, offered: draft?.offered ?? false })}
onBlur={(e) => {
commit();
onBlur?.(e);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.key !== "Enter" || e.defaultPrevented) return;
e.preventDefault();
commit();
}}
/>
{invalid && (
<span
id={errorId}
className="absolute left-0 top-full z-menu mt-1 whitespace-nowrap rounded-md border border-danger bg-bg px-2 py-0.5 text-xs text-danger"
>
{t(hours ? "common.timecodeInvalidHours" : "common.timecodeInvalid")}
</span>
)}
</span>
);
}
+3 -2
View File
@@ -25,13 +25,14 @@ const SIZES: Record<FieldSize, string> = {
};
// The edge is --edge-strong (the fg mixed 55% into the bg, ≥ 3:1 on every scheme — WCAG 1.4.11); focus
// turns it into a 2px accent edge (the border plus a 1px ring), the outlined-field convention. Invalid
// turns it into a 2px accent edge (the border plus a 1px ring; `focus`, so a scope over another ground
// can name its own, see media-scope), the outlined-field convention. Invalid
// is the danger edge, focused or not. A wrapper frame keys both states to its own INPUT, not to any
// descendant: the header search's Go button is disabled while the query is empty, and a bare
// `has-disabled:` faded the whole box. On a coarse pointer the box grows to the 44 touch target.
const BASE =
"min-w-0 border edge-strong text-fg placeholder:text-muted transition-colors " +
"focus-within:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:outline-hidden " +
"focus-within:border-focus focus-within:ring-1 focus-within:ring-focus focus-within:outline-hidden " +
"aria-invalid:border-danger aria-invalid:ring-danger " +
"has-[input[aria-invalid=true]]:border-danger has-[input[aria-invalid=true]]:ring-danger " +
"disabled:cursor-not-allowed disabled:opacity-50 " +