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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user