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:
2026-10-10 21:29:55 +02:00
parent 89d18b46a6
commit 977ba244fa
22 changed files with 306 additions and 153 deletions
@@ -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";
+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 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();
+6 -19
View File
@@ -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();
});
+44 -65
View File
@@ -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);
},
};
}