Files
siftlode/frontend/src/components/ui/TimecodeField.tsx
T
peter 977ba244fa 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.
2026-10-10 21:29:55 +02:00

68 lines
2.5 KiB
TypeScript

import { type ComponentProps } from "react";
import { useTranslation } from "react-i18next";
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 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"
> & {
field: TimecodeDraft;
/** The id of the `TimecodeError` that speaks for this field while it is invalid. */
errorId: string;
};
export function TimecodeField({
field,
errorId,
onBlur,
onKeyDown,
"aria-describedby": describedBy,
...rest
}: Props) {
const described = [describedBy, field.invalid ? errorId : null].filter(Boolean).join(" ");
return (
<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>
);
}