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:
@@ -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 " +
|
||||
|
||||
Reference in New Issue
Block a user