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:
@@ -107,6 +107,19 @@ describe("CropFrame", () => {
|
||||
expect(rect()).toBe("0.300 0.300 0.400 0.400");
|
||||
});
|
||||
|
||||
it("follows the pointer that took it, not a second finger landing on it", () => {
|
||||
render(<Harness />);
|
||||
const frame = screen.getByRole("group", { name: "common.cropFrame.frame" });
|
||||
fireEvent.pointerDown(frame, { button: 0, clientX: 500, clientY: 300, pointerId: 1 });
|
||||
// A second finger lands 200 px away and stays: it must not become the drag's origin.
|
||||
fireEvent.pointerDown(frame, { button: 0, clientX: 700, clientY: 300, pointerId: 2 });
|
||||
fireEvent.pointerMove(frame, { clientX: 660, clientY: 390, pointerId: 1 });
|
||||
expect(rect()).toBe("0.300 0.300 0.400 0.400");
|
||||
fireEvent.pointerMove(frame, { clientX: 900, clientY: 300, pointerId: 2 });
|
||||
expect(rect()).toBe("0.300 0.300 0.400 0.400");
|
||||
fireEvent.pointerUp(frame, { clientX: 660, clientY: 390, pointerId: 1 });
|
||||
});
|
||||
|
||||
it("takes the focus on a press, so the arrows go on from where the pointer left it", () => {
|
||||
render(<Harness />);
|
||||
const frame = screen.getByRole("group", { name: "common.cropFrame.frame" });
|
||||
@@ -116,6 +129,7 @@ describe("CropFrame", () => {
|
||||
const se = screen.getByRole("group", { name: "common.cropFrame.se" });
|
||||
fireEvent.pointerDown(se, { button: 0, clientX: 960, clientY: 540 });
|
||||
expect(document.activeElement).toBe(se);
|
||||
fireEvent.pointerUp(se, { clientX: 960, clientY: 540 });
|
||||
// An edge is the pointer's only: its press puts the focus on the frame it belongs to.
|
||||
fireEvent.pointerDown(document.querySelector<HTMLElement>(".cursor-ew-resize")!, {
|
||||
button: 0,
|
||||
|
||||
@@ -75,7 +75,9 @@ const SMALL_HANDLE_BOX: Record<CropHandle, string> = {
|
||||
// Below this many px on its shorter side a frame takes the small handles.
|
||||
const SMALL_FRAME_PX = 64;
|
||||
|
||||
type Drag = { handle: CropHandle | "move"; x: number; y: number; from: Frac };
|
||||
// A drag is its pointer's: a second finger on the frame neither restarts it from where it landed nor
|
||||
// moves it (two origins at once made the frame jump between them).
|
||||
type Drag = { handle: CropHandle | "move"; pointer: number; x: number; y: number; from: Frac };
|
||||
// Not the app's accent ring: over a video it can be any colour, and a light theme's dark-blue accent
|
||||
// all but vanished on a dark picture. Amber, like the players' playhead, reads on both.
|
||||
const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warn";
|
||||
@@ -106,6 +108,7 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
|
||||
// The press is the frame's: not the video's play / pause, not a scrub of anything under it.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (drag) return;
|
||||
// preventDefault also stops the press from focusing: focused by hand, so the arrows go on from
|
||||
// here (an edge is the pointer's only, so its frame takes the focus).
|
||||
e.currentTarget.closest<HTMLElement>("[tabindex]")?.focus({ preventScroll: true });
|
||||
@@ -114,10 +117,10 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
|
||||
} catch {
|
||||
// No capture (a synthetic event): the drag still follows while the pointer stays on it.
|
||||
}
|
||||
setDrag({ handle, x: e.clientX, y: e.clientY, from: rect });
|
||||
setDrag({ handle, pointer: e.pointerId, x: e.clientX, y: e.clientY, from: rect });
|
||||
};
|
||||
const follow = (e: PointerEvent<HTMLElement>) => {
|
||||
if (!drag || !pic) return;
|
||||
if (!drag || drag.pointer !== e.pointerId || !pic) return;
|
||||
const dx = (e.clientX - drag.x) / pic.width;
|
||||
const dy = (e.clientY - drag.y) / pic.height;
|
||||
onChange(
|
||||
@@ -126,7 +129,9 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
|
||||
: resizeCrop(drag.from, drag.handle, dx, dy, ratio),
|
||||
);
|
||||
};
|
||||
const end = () => setDrag(null);
|
||||
const end = (e: PointerEvent<HTMLElement>) => {
|
||||
if (drag?.pointer === e.pointerId) setDrag(null);
|
||||
};
|
||||
const pointer = (handle: Drag["handle"]) => ({
|
||||
onPointerDown: begin(handle),
|
||||
onPointerMove: follow,
|
||||
|
||||
@@ -38,7 +38,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
||||
| 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`, `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` |
|
||||
| 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` |
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
@@ -18,8 +19,11 @@ import clsx from "clsx";
|
||||
// Its drawn bar is thin and its hit area 1.5rem wide, at least twice the bar — the long-timeline
|
||||
// editors that drew the hit area as thin as the bar were the ones people missed.
|
||||
//
|
||||
// Every move reports `final`: false while a pointer drags, true on release or a key. A player whose
|
||||
// seek is expensive (a transcode restarts) acts on the final one only; a cheap one follows the drag.
|
||||
// Every move reports `final`: false while a pointer drags, true on release or a key — and when the
|
||||
// browser takes the pointer away mid-drag (a cancel: a system gesture on a tablet), true where it
|
||||
// stopped. A player whose seek is expensive (a transcode restarts) acts on the final one only; a cheap
|
||||
// one follows the drag. A drag is its pointer's: a second finger neither takes it over nor starts
|
||||
// another, so two fingers on the track never fight over one mark.
|
||||
|
||||
export interface RangeWindow {
|
||||
from: number;
|
||||
@@ -42,7 +46,9 @@ export interface TrackHandle {
|
||||
onChange: (time: number, final: boolean) => void;
|
||||
/** The drawn bar's look (its colour); the hit area around it is the track's. */
|
||||
barClassName?: string;
|
||||
/** Drawn on the handle, e.g. a remove button; it should stop its own pointer events. */
|
||||
/** Drawn at the handle, e.g. a remove button; it should stop its own pointer events. Beside the
|
||||
* slider, not in it: a slider's content is not a control to assistive tech, and its keys are the
|
||||
* slider's. */
|
||||
adornment?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -74,6 +80,9 @@ 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));
|
||||
@@ -98,6 +107,7 @@ 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);
|
||||
@@ -105,8 +115,11 @@ 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<string | null>(null);
|
||||
const [scrubbing, setScrubbing] = useState(false);
|
||||
const [dragging, setDragging] = useState<{ id: string; pointer: number } | 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;
|
||||
|
||||
const capture = (e: PointerEvent<HTMLElement>) => {
|
||||
try {
|
||||
@@ -123,22 +136,25 @@ export function RangeTrack({
|
||||
aria-label={label}
|
||||
className={clsx("relative touch-none select-none overflow-hidden", className)}
|
||||
onPointerDown={(e) => {
|
||||
if (!onSeek || e.button !== 0) return;
|
||||
if (!onSeek || e.button !== 0 || busy) return;
|
||||
capture(e);
|
||||
setScrubbing(true);
|
||||
setScrubbing(e.pointerId);
|
||||
onSeek(timeAtPointer(e.currentTarget, e.clientX, win), false);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const time = timeAtPointer(e.currentTarget, e.clientX, win);
|
||||
onHover?.({ x: e.clientX - e.currentTarget.getBoundingClientRect().left, time });
|
||||
if (scrubbing) onSeek?.(time, false);
|
||||
if (scrubbing === e.pointerId) onSeek?.(time, false);
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!scrubbing) return;
|
||||
setScrubbing(false);
|
||||
if (scrubbing !== e.pointerId) return;
|
||||
setScrubbing(null);
|
||||
onSeek?.(timeAtPointer(e.currentTarget, e.clientX, win), true);
|
||||
}}
|
||||
onPointerCancel={() => setScrubbing(false)}
|
||||
// A cancelled scrub seeks no further: where it was taken away is not a place anyone chose.
|
||||
onPointerCancel={(e) => {
|
||||
if (scrubbing === e.pointerId) setScrubbing(null);
|
||||
}}
|
||||
onPointerLeave={() => onHover?.(null)}
|
||||
>
|
||||
{background}
|
||||
@@ -171,7 +187,12 @@ export function RangeTrack({
|
||||
{handles.map((h) => {
|
||||
const f = frac(h.time);
|
||||
if (f < 0 || f > 1) return null;
|
||||
const move = (time: number, final: boolean) => h.onChange(clamp(time, h.min, h.max), final);
|
||||
const move = (time: number, final: boolean) => {
|
||||
const t = clamp(time, h.min, h.max);
|
||||
lastRef.current = t;
|
||||
h.onChange(t, final);
|
||||
};
|
||||
const mine = (e: PointerEvent) => dragging?.id === h.id && dragging.pointer === e.pointerId;
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const big = e.shiftKey ? h.bigStep : h.step;
|
||||
const to: Record<string, number> = {
|
||||
@@ -192,7 +213,7 @@ export function RangeTrack({
|
||||
e.stopPropagation();
|
||||
move(next, true);
|
||||
};
|
||||
return (
|
||||
return [
|
||||
<div
|
||||
key={h.id}
|
||||
role="slider"
|
||||
@@ -204,30 +225,47 @@ export function RangeTrack({
|
||||
aria-valuetext={h.valueText}
|
||||
data-handle={h.id}
|
||||
// The ring sits inside the hit area: the track clips its overflow.
|
||||
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"
|
||||
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,
|
||||
)}
|
||||
style={{ left: pct(f) }}
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
if (busy) return;
|
||||
capture(e);
|
||||
setDragging(h.id);
|
||||
lastRef.current = h.time;
|
||||
setDragging({ id: h.id, pointer: e.pointerId });
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (dragging === h.id) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
|
||||
if (mine(e)) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (dragging !== h.id) return;
|
||||
if (!mine(e)) return;
|
||||
e.stopPropagation();
|
||||
setDragging(null);
|
||||
move(timeAtPointer(e.currentTarget, e.clientX, win), true);
|
||||
}}
|
||||
onPointerCancel={() => setDragging(null)}
|
||||
onPointerCancel={(e) => {
|
||||
if (!mine(e)) return;
|
||||
setDragging(null);
|
||||
move(lastRef.current, true);
|
||||
}}
|
||||
>
|
||||
<div className={clsx("h-full w-1 rounded-sm", h.barClassName ?? "bg-white/90")} />
|
||||
{h.adornment}
|
||||
</div>
|
||||
);
|
||||
</div>,
|
||||
h.adornment != null && (
|
||||
<div
|
||||
key={`${h.id}-adornment`}
|
||||
className="pointer-events-none absolute inset-y-0 z-base w-6 -translate-x-1/2"
|
||||
style={{ left: pct(f) }}
|
||||
>
|
||||
{h.adornment}
|
||||
</div>
|
||||
),
|
||||
];
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, type ComponentProps } from "react";
|
||||
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>).
|
||||
|
||||
type Props = Omit<
|
||||
ComponentProps<typeof Input>,
|
||||
"value" | "defaultValue" | "onChange" | "invalid"
|
||||
> & {
|
||||
/** Seconds. */
|
||||
value: number;
|
||||
/** Show hours (pass `duration >= 3600`, so one item's fields agree). */
|
||||
hours?: boolean;
|
||||
/** A readable time was typed (not called when it equals `value`). */
|
||||
onCommit: (seconds: number) => void;
|
||||
};
|
||||
|
||||
export function TimecodeField({
|
||||
value,
|
||||
hours = false,
|
||||
onCommit,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
...rest
|
||||
}: Props) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const invalid = draft != null && parseTimecode(draft) == null;
|
||||
const commit = () => {
|
||||
if (draft == null) return;
|
||||
const v = parseTimecode(draft);
|
||||
if (v == null) return;
|
||||
setDraft(null);
|
||||
if (Math.abs(v - value) > 1e-3) onCommit(v);
|
||||
};
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user