fix(clip): a time's error line sits in the flow, the remove button off the cut, and every player surface rings amber
- 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.
This commit is contained in:
@@ -104,7 +104,7 @@ export const ROVING_HIGHLIGHT =
|
||||
"data-active:bg-accent/35 data-active:text-fg data-active:ring-1 data-active:ring-inset data-active:ring-accent/60";
|
||||
const HIGHLIGHT_SELECTED =
|
||||
"data-active:bg-accent/35 data-active:text-fg data-active:ring-2 data-active:ring-inset data-active:ring-accent/80";
|
||||
// On the always-dark media chrome (a player's menus, `bg-media-chrome` under white text) the theme's
|
||||
// On the always-dark media chrome (a player's menus, `bg-media-chrome media-focus` under white text) the theme's
|
||||
// `text-fg` is dark in the light theme: the highlighted row would go dark-on-dark. White instead.
|
||||
const MEDIA_HIGHLIGHT =
|
||||
"data-active:bg-white/15 data-active:text-white data-active:ring-1 data-active:ring-inset data-active:ring-white/40";
|
||||
|
||||
@@ -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 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` |
|
||||
| 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` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; 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)
|
||||
|
||||
|
||||
@@ -140,16 +140,6 @@ describe("RangeTrack", () => {
|
||||
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("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();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { trackFrac, trackPct } from "./trackPosition";
|
||||
|
||||
// A horizontal time track: the shared body of every timeline that marks points in a piece of media
|
||||
// (the download editor's cut list, the players' clip panel). It draws what it is given — regions
|
||||
@@ -48,10 +49,6 @@ 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 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;
|
||||
}
|
||||
|
||||
interface TrackRegion {
|
||||
@@ -108,9 +105,8 @@ export function RangeTrack({
|
||||
playheadClassName = "bg-white",
|
||||
}: Props) {
|
||||
const win: RangeWindow = view ?? { from: 0, to: duration };
|
||||
const span = Math.max(win.to - win.from, 1e-6);
|
||||
const frac = (t: number) => (t - win.from) / span;
|
||||
const pct = (f: number) => `${(clamp(f, 0, 1) * 100).toFixed(3)}%`;
|
||||
const frac = (t: number) => trackFrac(t, win);
|
||||
const pct = trackPct;
|
||||
// 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<{
|
||||
@@ -239,7 +235,7 @@ export function RangeTrack({
|
||||
e.stopPropagation();
|
||||
move(next, true);
|
||||
};
|
||||
return [
|
||||
return (
|
||||
<div
|
||||
key={h.id}
|
||||
role="slider"
|
||||
@@ -279,17 +275,8 @@ export function RangeTrack({
|
||||
}}
|
||||
>
|
||||
<div className={clsx("h-full w-1 rounded-sm", h.barClassName ?? "bg-white/90")} />
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// @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.
|
||||
// The time field: invalid only once a time was offered, said in words on the caller's line, 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";
|
||||
import { TimecodeError, TimecodeField } from "./TimecodeField";
|
||||
import { useTimecodeField } from "./useTimecodeField";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -13,25 +14,39 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Harness({ value, onCommit }: { value: number; onCommit: (s: number) => void }) {
|
||||
const field = useTimecodeField(value, false, onCommit);
|
||||
return (
|
||||
<>
|
||||
<TimecodeField aria-label="In" field={field} errorId="err" />
|
||||
{field.invalid && <TimecodeError id="err" hours={false} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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={() => {}} />);
|
||||
render(<Harness 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");
|
||||
// Once one was offered, the field re-checks as it is typed: the mark goes the moment it reads.
|
||||
fireEvent.change(field(), { target: { value: "1:0" } });
|
||||
expect(field().getAttribute("aria-invalid")).toBeNull();
|
||||
});
|
||||
|
||||
it("says in words, under the field and linked to it, how a time is typed", () => {
|
||||
render(<TimecodeField aria-label="In" value={10} onCommit={() => {}} />);
|
||||
it("says in words, read out and linked to the field, how a time is typed", () => {
|
||||
render(<Harness 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");
|
||||
expect(screen.getByRole("alert").id).toBe(id);
|
||||
fireEvent.change(field(), { target: { value: "0:12" } });
|
||||
fireEvent.keyDown(field(), { key: "Enter" });
|
||||
expect(field().getAttribute("aria-describedby")).toBeNull();
|
||||
@@ -39,10 +54,10 @@ describe("TimecodeField", () => {
|
||||
|
||||
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} />);
|
||||
const { rerender } = render(<Harness 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} />);
|
||||
rerender(<Harness value={40} onCommit={onCommit} />);
|
||||
expect(field().value).toBe("0:40.0");
|
||||
fireEvent.blur(field());
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
@@ -50,7 +65,7 @@ describe("TimecodeField", () => {
|
||||
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} />);
|
||||
rerender(<Harness value={41} onCommit={onCommit} />);
|
||||
expect(field().value).toBe("0:41.0");
|
||||
expect(field().getAttribute("aria-invalid")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,88 +1,67 @@
|
||||
import { useId, useState, type ComponentProps } from "react";
|
||||
import { type ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatTimecode, parseTimecode } from "../../lib/timecode";
|
||||
import { Input } from "./Input";
|
||||
import type { TimecodeDraft } from "./useTimecodeField";
|
||||
|
||||
// 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 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
|
||||
// stays for fixing rather than vanishing or seeming taken: marked invalid, with a line saying how to
|
||||
// type one. Nothing is marked while a first time is being typed; once one was offered (Enter,
|
||||
// leaving the field) and could not be read, the field re-checks as it is typed, so the mark goes the
|
||||
// moment the time reads. One element throughout, so Enter leaves the focus in the field (a field
|
||||
// re-created per value dropped it on <body>).
|
||||
//
|
||||
// The draft lives in `useTimecodeField`, held by the caller, because the line is the caller's to
|
||||
// place: in the flow under the fields' row (`TimecodeError`), never laid over the controls below it,
|
||||
// and outside a field's <label>, where it would become part of the field's name.
|
||||
|
||||
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;
|
||||
field: TimecodeDraft;
|
||||
/** The id of the `TimecodeError` that speaks for this field while it is invalid. */
|
||||
errorId: string;
|
||||
};
|
||||
|
||||
export function TimecodeField({
|
||||
value,
|
||||
hours = false,
|
||||
onCommit,
|
||||
field,
|
||||
errorId,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
"aria-describedby": describedBy,
|
||||
...rest
|
||||
}: Props) {
|
||||
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.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(" ");
|
||||
const described = [describedBy, field.invalid ? errorId : null].filter(Boolean).join(" ");
|
||||
return (
|
||||
<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>
|
||||
<Input
|
||||
{...rest}
|
||||
value={field.text}
|
||||
invalid={field.invalid}
|
||||
aria-describedby={described || undefined}
|
||||
onChange={(e) => field.type(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
field.offer();
|
||||
onBlur?.(e);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.key !== "Enter" || e.defaultPrevented) return;
|
||||
e.preventDefault();
|
||||
field.offer();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** How a time is typed, said once one could not be read: rendered by the caller only while one of
|
||||
* its fields is invalid, in the flow under them, and read out as it appears. */
|
||||
export function TimecodeError({ id, hours }: { id: string; hours: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<p id={id} role="alert" className="text-xs text-danger">
|
||||
{t(hours ? "common.timecodeInvalidHours" : "common.timecodeInvalid")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { RangeWindow } from "./RangeTrack";
|
||||
|
||||
// Where a time sits along a `RangeTrack` over a window: the track draws its regions, playhead and
|
||||
// handles by these, and a control laid beside the track (a cut's remove button under it) lines up
|
||||
// with the handle drawn at the same time.
|
||||
|
||||
export const trackFrac = (t: number, win: RangeWindow) =>
|
||||
(t - win.from) / Math.max(win.to - win.from, 1e-6);
|
||||
|
||||
/** A fraction of the track as a CSS length, held to the track. */
|
||||
export const trackPct = (f: number) => `${(Math.max(0, Math.min(1, f)) * 100).toFixed(3)}%`;
|
||||
|
||||
/** A time's place along the track, as its CSS `left`. */
|
||||
export const trackLeft = (t: number, win: RangeWindow) => trackPct(trackFrac(t, win));
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { formatTimecode, parseTimecode } from "../../lib/timecode";
|
||||
|
||||
// The draft of a time field (`TimecodeField`), held by the caller so it can place the line saying an
|
||||
// unreadable time (`TimecodeError`) in its own flow.
|
||||
|
||||
export interface TimecodeDraft {
|
||||
/** The text the field shows. */
|
||||
text: string;
|
||||
/** A time was offered and cannot be read. */
|
||||
invalid: boolean;
|
||||
type: (text: string) => void;
|
||||
/** Enter or leaving the field: take the draft if it reads, else mark it. */
|
||||
offer: () => void;
|
||||
}
|
||||
|
||||
/** The draft of one time field. `value` in seconds; `hours` as `formatTimecode` takes it. */
|
||||
export function useTimecodeField(
|
||||
value: number,
|
||||
hours: boolean,
|
||||
/** A readable time was typed (not called when it equals `value`). */
|
||||
onCommit: (seconds: number) => void,
|
||||
): TimecodeDraft {
|
||||
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);
|
||||
}
|
||||
return {
|
||||
text: draft?.text ?? formatTimecode(value, hours),
|
||||
invalid: draft != null && draft.offered && parseTimecode(draft.text) == null,
|
||||
type: (text) => setDraft({ text, offered: draft?.offered ?? false }),
|
||||
offer: () => {
|
||||
if (draft == 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user