fix(clip): typed times belong to their value, and every mark on the player chrome can be seen
TimecodeField drops a draft when the value moves under it (another segment selected, a nudge), so leaving the field never commits it to another cut; it is invalid only once offered, with a line under it saying how to type a time. On the always-dark chrome media-scope points every focus ring at --media-mark and errors at the new --media-danger (a light theme's accent and danger were under 3:1 there). A track whose dragged handle unmounts ends the drag on the pointer's release instead of locking up; a remove refused while clips are queued leaves no focus to take later; each remove-cut button names its cut and is a 24px target. DESIGN.md records the media tokens; stale comments fixed.
This commit is contained in:
+10
-4
@@ -112,7 +112,10 @@ style guide.
|
||||
own chip), never `--warn`: a score is not a warning. What sits on a picture looks the same in both themes,
|
||||
because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an
|
||||
error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus
|
||||
(owner decision F16). Black and white are the media scrims and their text. Five schemes × dark
|
||||
(owner decision F16). On that chrome `--media-mark` is a marked place (the playhead, the crop frame, a
|
||||
Plex marker) and every focus ring, and `--media-danger` an error: a form there (`media-scope`) re-points
|
||||
`--focus` and `--danger` to them, as a light theme's accent and danger are dark on the chrome
|
||||
(measured in `theme.contract.test.ts`). Black and white are the media scrims and their text. Five schemes × dark
|
||||
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their
|
||||
place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept
|
||||
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
|
||||
@@ -556,9 +559,11 @@ of a full-width panel and nothing on the right.
|
||||
around the selection with the two ends (`ui/RangeTrack`: each end a slider — arrows step a tenth,
|
||||
Shift a second; drawn 4px, hit 1.5rem), Start / End fields with a tenth back / forward and "to the
|
||||
playhead", the length, Play selection (loops), a name, Cancel / Cut clip. On the always-dark chrome the
|
||||
ends are white, a focused end's ring and the playhead the media amber `--media-mark` (the same in both
|
||||
themes; a light theme's accent and warn all but vanished there). A typed time that cannot be read
|
||||
stays in its field, marked invalid; one past the end shows where it landed (decimal point or comma).
|
||||
ends are white, every focus ring in the panel and the playhead the media amber `--media-mark` (the same
|
||||
in both themes; a light theme's accent and warn all but vanished there). A typed time that cannot be
|
||||
read stays in its field once offered (Enter, leaving it), marked invalid with a line under it saying
|
||||
how to type one; a time typed over a mark that then moves (a nudge, another segment) is dropped; one
|
||||
past the end shows where it landed (decimal point or comma).
|
||||
The strip's window follows the selection when it settles, never under a dragging pointer. Plex seeks once a drag is
|
||||
released (a seek may restart the transcode); YouTube follows the drag; a mark on the very end seeks a
|
||||
moment short of it, and while a clip is marked the end of the item never closes the player or steps the
|
||||
@@ -760,3 +765,4 @@ of a full-width panel and nothing on the right.
|
||||
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
|
||||
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
|
||||
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
|
||||
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-scope` points its focus rings (`--focus`) and errors at them | The theme's accent ring, warn and danger | On the always-dark chrome a light theme's accent ring (Slate 2.8:1), warn (2.3:1) and danger (2.8:1) all fell under 3:1 |
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("ClipPanel", () => {
|
||||
expect(range()).toBe("102.5-130");
|
||||
});
|
||||
|
||||
it("never seeks the player onto the item's very end (that would end it, and the clip with it)", () => {
|
||||
it("never seeks the player onto the item's very end (that would finish the item)", () => {
|
||||
const onSeek = vi.fn();
|
||||
render(<Harness onSeek={onSeek} duration={600} start={{ start: 590, end: 595 }} />);
|
||||
fireEvent.keyDown(screen.getByRole("slider", { name: "clip.out" }), { key: "End" });
|
||||
|
||||
@@ -84,15 +84,17 @@ interface Props {
|
||||
|
||||
const NUDGE_S = 0.1;
|
||||
// How far short of the item's end a seek stops: a mark on the very end moves the player to its last
|
||||
// moment, not past it — a player put at its end ENDS (a Plex film closes, a YouTube queue steps on)
|
||||
// and takes the clip with it.
|
||||
// moment, not past it. A player put at its end ENDS, and the item counts as finished — YouTube marks
|
||||
// it watched and shows its end screen, a Plex position reads as the whole — while a mark there is only
|
||||
// a place. (The players keep themselves and the clip open at an end while one is marked; this keeps
|
||||
// the item unfinished.)
|
||||
const LAST_MOMENT_S = 0.25;
|
||||
// On the always-dark chrome the ends are white (a light theme's accent is a dark blue there, which
|
||||
// all but vanished on the bar) and the playhead amber, so an end and the playhead are never confused.
|
||||
// A focused end is ringed in amber too, as the crop frame is: the accent ring was under 3:1 there.
|
||||
// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar.
|
||||
// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar. Every
|
||||
// focus ring in the panel is that amber as well (media-scope re-points --focus), as the crop frame's
|
||||
// is: the theme accent's ring was under 3:1 on the chrome.
|
||||
const PLAYHEAD = "bg-media-mark";
|
||||
const END_RING = "focus-visible:outline-media-mark";
|
||||
|
||||
export default function ClipPanel({
|
||||
src,
|
||||
@@ -247,7 +249,6 @@ export default function ClipPanel({
|
||||
playhead={current}
|
||||
playheadClassName={PLAYHEAD}
|
||||
onSeek={seek}
|
||||
handleRingClassName={END_RING}
|
||||
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
|
||||
background={
|
||||
<RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} />
|
||||
|
||||
@@ -950,7 +950,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// (`useHistorySubview`'s back).
|
||||
// - The on-screen arrow is a click on "leave", not a cancel, so it does not stop a countdown.
|
||||
// - Escape in fullscreen leaves fullscreen (the browser's own Escape) and not the player.
|
||||
// - The arrow leaves past a clip as it does past a panel; the keys step out of it.
|
||||
// - The arrow still closes an open panel or the stats first, but it leaves past a clip, as past a
|
||||
// countdown: only the keys step out of a clip.
|
||||
const back = useCallback(
|
||||
(from: "arrow" | "key" | "escape") => {
|
||||
if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||
|
||||
@@ -102,6 +102,41 @@ describe("VideoEditor — a split export that fails part-way", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A time typed into the selected segment's field belongs to that segment: selecting another one
|
||||
// drops it, and never commits it to the new selection's cut.
|
||||
describe("VideoEditor — a typed time and another segment", () => {
|
||||
it("does not move the newly selected segment's cut with a time typed for the old one", () => {
|
||||
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<VideoEditor job={job} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const video = document.querySelector("video")!;
|
||||
for (const at of [30, 60]) {
|
||||
Object.defineProperty(video, "currentTime", {
|
||||
value: at,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
fireEvent.timeUpdate(video);
|
||||
fireEvent.click(screen.getByText("editor.splitHere"));
|
||||
}
|
||||
const segs = () => screen.getAllByTitle("editor.keptSeg");
|
||||
fireEvent.pointerDown(segs()[1]!);
|
||||
const inField = screen.getByRole("textbox", { name: "editor.in" });
|
||||
act(() => inField.focus());
|
||||
fireEvent.change(inField, { target: { value: "0:40" } });
|
||||
// Another segment is selected before the field loses the focus (a press on it).
|
||||
fireEvent.pointerDown(segs()[2]!);
|
||||
fireEvent.blur(inField);
|
||||
const cuts = screen
|
||||
.getAllByRole("slider", { name: "editor.cutN" })
|
||||
.map((c) => c.getAttribute("aria-valuenow"));
|
||||
expect(cuts).toEqual(["30", "60"]);
|
||||
});
|
||||
});
|
||||
|
||||
// The cuts sit on the shared time track: each is a slider the keyboard steps, and a step stops a
|
||||
// tenth short of the next cut, so no segment ever closes up.
|
||||
describe("VideoEditor — a cut on the keyboard", () => {
|
||||
@@ -172,6 +207,34 @@ describe("VideoEditor — a cut's remove button", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("a remove refused while the clips are queued leaves no focus to take later", async () => {
|
||||
let refuse: (e: unknown) => void = () => {};
|
||||
vi.spyOn(api, "enqueueEdit").mockImplementation(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
refuse = reject;
|
||||
}) as never,
|
||||
);
|
||||
split();
|
||||
fireEvent.click(screen.getByText("editor.createN"));
|
||||
await waitFor(() => expect(api.enqueueEdit).toHaveBeenCalled());
|
||||
// The cut list holds still while the clips are queued: the remove is refused.
|
||||
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
||||
act(() => remove.focus());
|
||||
fireEvent.click(remove);
|
||||
expect(screen.getByRole("slider", { name: "editor.cutN" })).toBeTruthy();
|
||||
await act(async () => refuse(new Error("refused")));
|
||||
// The create failed and the editor stays; a later split keeps the focus where it was.
|
||||
const video = document.querySelector("video")!;
|
||||
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
|
||||
fireEvent.timeUpdate(video);
|
||||
const splitButton = screen.getByText("editor.splitHere").closest("button")!;
|
||||
act(() => splitButton.focus());
|
||||
fireEvent.click(splitButton);
|
||||
expect(screen.getAllByRole("slider", { name: "editor.cutN" })).toHaveLength(2);
|
||||
expect(document.activeElement).toBe(splitButton);
|
||||
});
|
||||
|
||||
it("sits at the cut's foot, under the next segment's keep toggle at its head", () => {
|
||||
split();
|
||||
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
||||
|
||||
@@ -435,12 +435,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
<Pressable
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => {
|
||||
// Refused while the clips are queued (the list holds still): then nothing is
|
||||
// removed, and no focus is left waiting to be taken by a later change.
|
||||
if (creating.current) return;
|
||||
refocusAfterRemove.current = bi;
|
||||
deleteBoundary(bi);
|
||||
}}
|
||||
className="pointer-events-auto absolute bottom-0 -right-0.5 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger focus-visible:-outline-offset-2"
|
||||
aria-label={t("editor.removeCut")}
|
||||
title={t("editor.removeCut")}
|
||||
// 24px, the target floor; white on its black disc in either theme.
|
||||
className="pointer-events-auto absolute bottom-0 -right-0.5 p-1.5 rounded-full bg-black/70 text-white/80 hover:text-white focus-visible:-outline-offset-2"
|
||||
aria-label={t("editor.removeCut", { n: bi + 1 })}
|
||||
title={t("editor.removeCut", { n: bi + 1 })}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Pressable>
|
||||
|
||||
@@ -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, 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` |
|
||||
| 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` |
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
|
||||
@@ -150,18 +150,35 @@ describe("RangeTrack", () => {
|
||||
expect(h.onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rings a focused handle in the caller's colour, for a track on the dark media chrome", () => {
|
||||
render(
|
||||
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();
|
||||
const { rerender } = render(
|
||||
<RangeTrack duration={200} label="Clip" handles={[handle({ onChange })]} onSeek={onSeek} />,
|
||||
);
|
||||
const track = screen.getByRole("group", { name: "Clip" });
|
||||
box(track);
|
||||
fireEvent.pointerDown(screen.getByRole("slider", { name: "In" }), {
|
||||
button: 0,
|
||||
clientX: 250,
|
||||
pointerId: 7,
|
||||
});
|
||||
// A key moves the mark out of the window while the finger is still down: the handle unmounts.
|
||||
rerender(
|
||||
<RangeTrack
|
||||
duration={100}
|
||||
duration={200}
|
||||
label="Clip"
|
||||
handles={[handle()]}
|
||||
handleRingClassName="focus-visible:outline-warn"
|
||||
handles={[handle({ onChange, time: 20 })]}
|
||||
view={{ from: 100, to: 200 }}
|
||||
onSeek={onSeek}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("slider", { name: "In" }).className).toContain(
|
||||
"focus-visible:outline-warn",
|
||||
);
|
||||
expect(screen.queryByRole("slider", { name: "In" })).toBeNull();
|
||||
// Released where the handle no longer is: the drag ends, as final, where the mark now is.
|
||||
fireEvent.pointerUp(document.body, { pointerId: 7 });
|
||||
expect(onChange).toHaveBeenLastCalledWith(20, true);
|
||||
fireEvent.pointerDown(track, { button: 0, clientX: 500, pointerId: 8 });
|
||||
expect(onSeek).toHaveBeenCalledWith(150, false);
|
||||
});
|
||||
|
||||
it("seeks on a press of the bare track, following the drag, final on release", () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
@@ -80,9 +82,6 @@ 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));
|
||||
@@ -107,7 +106,6 @@ 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);
|
||||
@@ -115,12 +113,40 @@ 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<{ id: string; pointer: number } | null>(null);
|
||||
const [dragging, setDragging] = useState<{
|
||||
id: string;
|
||||
pointer: number;
|
||||
el: HTMLElement;
|
||||
} | 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;
|
||||
|
||||
// A handle can go while its pointer is still down: its mark moved out of the window under it (a
|
||||
// key marked the other end and carried this one along). Its own up / cancel then never come — the
|
||||
// capture went with the node — and the drag would hold the track `busy` for good (a finger never
|
||||
// comes back with the same pointer). So the pointer's release anywhere ends it, reported final
|
||||
// where the mark now is: the caller's final-only work (a seek, a frozen view let go) still runs.
|
||||
const endLostDrag = useEffectEvent((id: string) => {
|
||||
setDragging(null);
|
||||
const h = handles.find((x) => x.id === id);
|
||||
if (h) h.onChange(clamp(h.time, h.min, h.max), true);
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const { id, pointer, el } = dragging;
|
||||
const end = (e: globalThis.PointerEvent) => {
|
||||
if (e.pointerId === pointer && !el.isConnected) endLostDrag(id);
|
||||
};
|
||||
document.addEventListener("pointerup", end);
|
||||
document.addEventListener("pointercancel", end);
|
||||
return () => {
|
||||
document.removeEventListener("pointerup", end);
|
||||
document.removeEventListener("pointercancel", end);
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
const capture = (e: PointerEvent<HTMLElement>) => {
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
@@ -224,11 +250,9 @@ export function RangeTrack({
|
||||
aria-valuenow={Math.round(h.time * 10) / 10}
|
||||
aria-valuetext={h.valueText}
|
||||
data-handle={h.id}
|
||||
// The ring sits inside the hit area: the track clips its overflow.
|
||||
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,
|
||||
)}
|
||||
// The ring sits inside the hit area: the track clips its overflow. Its colour is the base
|
||||
// focus ring's, which a scope over another ground re-points (media-scope: the media amber).
|
||||
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"
|
||||
style={{ left: pct(f) }}
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerDown={(e) => {
|
||||
@@ -237,7 +261,7 @@ export function RangeTrack({
|
||||
if (busy) return;
|
||||
capture(e);
|
||||
lastRef.current = h.time;
|
||||
setDragging({ id: h.id, pointer: e.pointerId });
|
||||
setDragging({ id: h.id, pointer: e.pointerId, el: e.currentTarget });
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (mine(e)) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// @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.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { TimecodeField } from "./TimecodeField";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
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={() => {}} />);
|
||||
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");
|
||||
});
|
||||
|
||||
it("says in words, under the field and linked to it, how a time is typed", () => {
|
||||
render(<TimecodeField aria-label="In" 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");
|
||||
fireEvent.change(field(), { target: { value: "0:12" } });
|
||||
fireEvent.keyDown(field(), { key: "Enter" });
|
||||
expect(field().getAttribute("aria-describedby")).toBeNull();
|
||||
});
|
||||
|
||||
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} />);
|
||||
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} />);
|
||||
expect(field().value).toBe("0:40.0");
|
||||
fireEvent.blur(field());
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
// An unreadable one goes too, and its mark with it.
|
||||
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} />);
|
||||
expect(field().value).toBe("0:41.0");
|
||||
expect(field().getAttribute("aria-invalid")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import { useId, useState, type ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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>).
|
||||
// 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
|
||||
// re-created per value dropped it on <body>).
|
||||
|
||||
type Props = Omit<
|
||||
ComponentProps<typeof Input>,
|
||||
@@ -26,33 +29,60 @@ export function TimecodeField({
|
||||
onCommit,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
"aria-describedby": describedBy,
|
||||
...rest
|
||||
}: Props) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const invalid = draft != null && parseTimecode(draft) == null;
|
||||
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);
|
||||
if (v == 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(" ");
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,13 +25,14 @@ const SIZES: Record<FieldSize, string> = {
|
||||
};
|
||||
|
||||
// The edge is --edge-strong (the fg mixed 55% into the bg, ≥ 3:1 on every scheme — WCAG 1.4.11); focus
|
||||
// turns it into a 2px accent edge (the border plus a 1px ring), the outlined-field convention. Invalid
|
||||
// turns it into a 2px accent edge (the border plus a 1px ring; `focus`, so a scope over another ground
|
||||
// can name its own, see media-scope), the outlined-field convention. Invalid
|
||||
// is the danger edge, focused or not. A wrapper frame keys both states to its own INPUT, not to any
|
||||
// descendant: the header search's Go button is disabled while the query is empty, and a bare
|
||||
// `has-disabled:` faded the whole box. On a coarse pointer the box grows to the 44 touch target.
|
||||
const BASE =
|
||||
"min-w-0 border edge-strong text-fg placeholder:text-muted transition-colors " +
|
||||
"focus-within:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:outline-hidden " +
|
||||
"focus-within:border-focus focus-within:ring-1 focus-within:ring-focus focus-within:outline-hidden " +
|
||||
"aria-invalid:border-danger aria-invalid:ring-danger " +
|
||||
"has-[input[aria-invalid=true]]:border-danger has-[input[aria-invalid=true]]:ring-danger " +
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 " +
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"privacyPolicy": "Privacy policy",
|
||||
"termsOfService": "Terms of service",
|
||||
"sourceCode": "Source code",
|
||||
"timecodeInvalid": "Not a time — type it like 1:05.5",
|
||||
"timecodeInvalidHours": "Not a time — type it like 1:02:05.5",
|
||||
"close": "Close",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"dropped": "Dropped",
|
||||
"keptSeg": "Kept segment",
|
||||
"droppedSeg": "Dropped segment",
|
||||
"removeCut": "Remove cut",
|
||||
"removeCut": "Remove cut {{n}}",
|
||||
"keptSummary": "{{n}} kept · {{dur}}",
|
||||
"createJoin": "Create joined clip",
|
||||
"timeline": "Timeline",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"privacyPolicy": "Adatvédelmi irányelvek",
|
||||
"termsOfService": "Felhasználási feltételek",
|
||||
"sourceCode": "Forráskód",
|
||||
"timecodeInvalid": "Ez nem időpont — így írd: 1:05,5",
|
||||
"timecodeInvalidHours": "Ez nem időpont — így írd: 1:02:05,5",
|
||||
"close": "Bezárás",
|
||||
"cancel": "Mégse",
|
||||
"confirm": "Megerősítés",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"dropped": "Eldobva",
|
||||
"keptSeg": "Megtartott szegmens",
|
||||
"droppedSeg": "Eldobott szegmens",
|
||||
"removeCut": "Vágás törlése",
|
||||
"removeCut": "{{n}}. vágás törlése",
|
||||
"keptSummary": "{{n}} megtartva · {{dur}}",
|
||||
"createJoin": "Összefűzött klip",
|
||||
"timeline": "Idővonal",
|
||||
|
||||
+12
-3
@@ -31,6 +31,8 @@
|
||||
--color-fg: var(--fg);
|
||||
--color-muted: var(--muted);
|
||||
--color-accent: var(--accent);
|
||||
/* A focus ring's colour: the accent, unless a scope over another ground names its own. */
|
||||
--color-focus: var(--focus, var(--accent));
|
||||
--color-accent-fg: var(--accent-fg);
|
||||
--color-info: var(--info);
|
||||
--color-info-fg: var(--info-fg);
|
||||
@@ -145,7 +147,9 @@
|
||||
|
||||
/* A form on the always-dark player chrome (the clip panel's fields): the neutral tokens the field
|
||||
primitives read are re-pointed to white-on-chrome for this subtree, so a light theme's dark text
|
||||
never lands on the dark bar. The accent stays the theme's. */
|
||||
never lands on the dark bar. The accent stays the theme's for what is chosen; what must be seen
|
||||
there is not left to it: a focus ring is the media amber and an error the media red, as a light
|
||||
theme's accent and danger are dark (under 3:1 on the chrome). */
|
||||
@utility media-scope {
|
||||
--fg: #ffffff;
|
||||
--muted: rgb(255 255 255 / 0.62);
|
||||
@@ -155,6 +159,8 @@
|
||||
/* A card on the chrome (a segmented control's track): a veil of it, never the theme's card — a
|
||||
light theme's white under this scope's white text left the unchosen options unreadable. */
|
||||
--card: rgb(255 255 255 / 0.08);
|
||||
--danger: var(--media-danger);
|
||||
--focus: var(--media-mark);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
@@ -298,8 +304,9 @@
|
||||
[role="tab"]:focus-visible,
|
||||
[role="slider"]:focus-visible,
|
||||
summary:focus-visible {
|
||||
/* Solid accent: the earlier 60% mix all but vanished on the dark glass. */
|
||||
outline: 2px solid var(--accent);
|
||||
/* Solid accent: the earlier 60% mix all but vanished on the dark glass. A scope over another
|
||||
ground (media-scope) may name its own ring colour in --focus. */
|
||||
outline: 2px solid var(--focus, var(--accent));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -1158,6 +1165,8 @@ html[data-theme="light"] {
|
||||
/* A place marked on that chrome or over a video (a playhead, a focused end's ring, the crop
|
||||
frame's ring, an intro / credits marker): amber in both themes, as the chrome is dark in both. */
|
||||
--media-mark: #fbbf24;
|
||||
/* An error said on that chrome (a time field the clip panel cannot read): its edge and its line. */
|
||||
--media-danger: #f87171;
|
||||
}
|
||||
|
||||
/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
|
||||
|
||||
@@ -55,6 +55,21 @@ describe("the media colours", () => {
|
||||
const strip = mixHex("#ffffff", media()["media-chrome"]!, 0.1);
|
||||
expect(hexContrast(media()["media-mark"]!, strip)).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
// A form on that chrome (the clip panel, `media-scope`) keeps neither the theme's danger nor its
|
||||
// accent for what must be seen: a light theme's are dark (#b91c1c 2.8:1, Slate's accent 2.8:1 on
|
||||
// the chrome). Its error edge and text take the media red, and every focus ring in it — the base
|
||||
// ring and a field's focus edge both read --focus — the media amber. [WCAG 1.4.11, 1.4.3]
|
||||
it("a form on the player chrome says an error and a focus in colours that hold there", () => {
|
||||
expect(hexContrast(media()["media-danger"]!, media()["media-chrome"]!)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
const at = css.indexOf("@utility media-scope {");
|
||||
const scope = css.slice(at, css.indexOf("\n}", at));
|
||||
expect(scope).toMatch(/--danger:\s*var\(--media-danger\);/);
|
||||
expect(scope).toMatch(/--focus:\s*var\(--media-mark\);/);
|
||||
expect(css).toMatch(/outline: 2px solid var\(--focus, var\(--accent\)\);/);
|
||||
expect(css).toMatch(/--color-focus: var\(--focus, var\(--accent\)\);/);
|
||||
});
|
||||
});
|
||||
|
||||
it("styles exactly the schemes the app offers", () => {
|
||||
|
||||
Reference in New Issue
Block a user