@setsquare/ui 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Children, createContext, isValidElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
4
- import { ChevronRight } from 'lucide-react';
3
+ import { Children, createContext, isValidElement, memo, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
4
+ import { ChevronRight, EllipsisVertical } from 'lucide-react';
5
5
  import { Button as RACButton, Focusable, ToggleButton, Tree as RACTree, TreeItem as RACTreeItem, TreeItemContent, TooltipTrigger, useLocale, } from 'react-aria-components';
6
6
  import { cn } from '../lib/cn.js';
7
7
  import { useLabels } from '../lib/locale.js';
8
8
  import { clampTime, formatTime, nudge, place, speakTime, stackTiers, tickSteps, ticks, } from '../lib/timeline-scale.js';
9
+ import { Menu, MenuItem, MenuTrigger, Popover } from './menu.js';
9
10
  import { Tooltip } from './tooltip.js';
10
11
  export const timelineLabels = {
11
12
  playhead: 'Playhead',
@@ -18,27 +19,55 @@ export const timelineLabels = {
18
19
  placed: '{label} moved to {time}',
19
20
  cancelled: '{label} left at {time}',
20
21
  markerHint: 'Drag it, or use the arrow keys. Or click it, then click where it should go. Hold Alt or Option to move it freely.',
22
+ markerActionHint: 'Press Enter or click it to open it. Drag it, or use the arrow keys, to move it; hold Alt or Option to move it freely. Its menu moves it without dragging.',
23
+ markerOpenHint: 'Press Enter or click it to open it.',
24
+ markerMenuHint: 'Shift+F10 opens its menu.',
25
+ markerMenu: 'Options for {label}',
26
+ open: 'Open',
27
+ move: 'Move',
28
+ reset: 'Reset to {time}',
29
+ wasReset: '{label} reset to {time}',
21
30
  };
22
31
  const TimelineContext = createContext(null);
32
+ /**
33
+ * The playhead's time, on its own: it changes every frame while media plays, and only the playhead and its line
34
+ * read it, so lanes, items and markers don't render again with it.
35
+ */
36
+ const TimeContext = createContext(undefined);
37
+ /** The part in view, for the ruler's ticks and the slots: it changes as the timeline scrolls. */
38
+ const ViewContext = createContext({ start: 0, end: 0 });
23
39
  function useTimeline(part) {
24
40
  const value = useContext(TimelineContext);
25
41
  if (!value)
26
42
  throw new Error(`${part} goes inside a Timeline.`);
27
43
  return value;
28
44
  }
45
+ /**
46
+ * The marker being dragged or moved by key, if any: its id, where the move started, where it is now and the
47
+ * difference. Null otherwise, and for a pick-up-and-place, which moves it in one step. For drawing what moves with a
48
+ * marker at its new place, as `moveWith` does for spans and points. Inside a Timeline.
49
+ */
50
+ export function useTimelineMove() {
51
+ return useTimeline('useTimelineMove').moving;
52
+ }
29
53
  function useControlled(value, initial, onChange) {
30
54
  const [own, setOwn] = useState(initial);
31
55
  const controlled = value !== undefined;
32
- return [
33
- controlled ? value : own,
34
- (next) => {
35
- if (!controlled)
36
- setOwn(next);
37
- onChange?.(next);
38
- },
39
- ];
56
+ // The setter keeps its identity across renders, so the context it goes in can too.
57
+ const latest = useRef(onChange);
58
+ useEffect(() => {
59
+ latest.current = onChange;
60
+ });
61
+ const set = useCallback((next) => {
62
+ if (!controlled)
63
+ setOwn(next);
64
+ latest.current?.(next);
65
+ }, [controlled]);
66
+ return [controlled ? value : own, set];
40
67
  }
41
68
  const defaultStep = (format, fps) => format === 'frames' || format === 'timecode' ? 1 / fps : format === 'date' ? 900 : 0.1;
69
+ /** One empty list for every timeline without snap points: a new `[]` each render would change the context. */
70
+ const NO_POINTS = [];
42
71
  /** AAA as products mark it and as the design-system artifact does. */
43
72
  const AAA = '[data-conformance="aaa"], [data-theme$="-aaa"]';
44
73
  /**
@@ -52,7 +81,7 @@ const AAA = '[data-conformance="aaa"], [data-theme$="-aaa"]';
52
81
  * axis runs left to right; right-to-left layouts aren't supported yet. Under it, a line says what moving a marker
53
82
  * did (snapped, picked up, put down), and is read out as it changes.
54
83
  */
55
- export function Timeline({ duration, start = 0, format = 'seconds', fps = 25, timeZone, pxPerSecond: pps = 100, step: givenStep, time: givenTime, defaultTime, onTimeChange, snapPoints = [], selectedItem: givenItem, defaultSelectedItem = null, onSelectedItemChange, labelWidth = 160, maxHeight, followPlayhead = true, labels, className, children, ...name }) {
84
+ export function Timeline({ duration, start = 0, format = 'seconds', fps = 25, timeZone, pxPerSecond: pps = 100, step: givenStep, time: givenTime, defaultTime, onTimeChange, snapPoints = NO_POINTS, selectedItem: givenItem, defaultSelectedItem = null, onSelectedItemChange, labelWidth = 160, maxHeight, followPlayhead = true, labels, className, children, ...name }) {
56
85
  const t = useLabels('timeline', timelineLabels, labels);
57
86
  const { locale } = useLocale();
58
87
  const end = start + duration;
@@ -64,38 +93,60 @@ export function Timeline({ duration, start = 0, format = 'seconds', fps = 25, ti
64
93
  const [selectedItem, selectItem] = useControlled(givenItem, defaultSelectedItem, onSelectedItemChange);
65
94
  const [status, setStatus] = useState('');
66
95
  const [picked, pick] = useState(null);
96
+ const [moving, setMoving] = useState(null);
67
97
  // Before the scroller is measured (and on the server), assume a wide screen's worth is in view.
68
98
  const [scroll, setScroll] = useState({ left: 0, width: 1600 });
69
99
  const scroller = useRef(null);
70
- const toX = (s) => (s - start) * pps;
71
- const value = {
100
+ const seek = useCallback((s) => setTime(clampTime(s, start, end)), [setTime, start, end]);
101
+ // Everything but the playhead's time and the part in view, kept as one value while none of it changes: playback
102
+ // and scrolling don't render lanes, items and markers again.
103
+ const value = useMemo(() => {
104
+ const toX = (s) => (s - start) * pps;
105
+ return {
106
+ start,
107
+ end,
108
+ pps,
109
+ step,
110
+ trackWidth,
111
+ scale,
112
+ toX,
113
+ timeAt: (clientX, track) => clampTime(start + (clientX - track.getBoundingClientRect().left) / pps, start, end),
114
+ show: (s, shownStep = step) => formatTime(s, scale, shownStep),
115
+ speak: (s) => speakTime(s, scale, {
116
+ frames: (count) => t('frames', { count }),
117
+ frame: (frame) => t('frame', { frame }),
118
+ }),
119
+ t,
120
+ hasPlayhead,
121
+ seek,
122
+ snapPoints,
123
+ selectedItem,
124
+ selectItem,
125
+ announce: setStatus,
126
+ picked,
127
+ pick,
128
+ moving,
129
+ setMoving,
130
+ };
131
+ }, [
72
132
  start,
73
133
  end,
74
134
  pps,
75
135
  step,
76
136
  trackWidth,
77
137
  scale,
78
- toX,
79
- timeAt: (clientX, track) => clampTime(start + (clientX - track.getBoundingClientRect().left) / pps, start, end),
80
- show: (s, shownStep = step) => formatTime(s, scale, shownStep),
81
- speak: (s) => speakTime(s, scale, {
82
- frames: (count) => t('frames', { count }),
83
- frame: (frame) => t('frame', { frame }),
84
- }),
85
138
  t,
86
- time: hasPlayhead ? time : undefined,
87
- seek: (s) => setTime(clampTime(s, start, end)),
139
+ hasPlayhead,
140
+ seek,
88
141
  snapPoints,
89
142
  selectedItem,
90
143
  selectItem,
91
- announce: setStatus,
92
144
  picked,
93
- pick,
94
- view: {
95
- start: clampTime(start + scroll.left / pps, start, end),
96
- end: clampTime(start + (scroll.left + scroll.width) / pps, start, end),
97
- },
98
- };
145
+ moving,
146
+ ]);
147
+ const viewStart = clampTime(start + scroll.left / pps, start, end);
148
+ const viewEnd = clampTime(start + (scroll.left + scroll.width) / pps, start, end);
149
+ const view = useMemo(() => ({ start: viewStart, end: viewEnd }), [viewStart, viewEnd]);
99
150
  // Track what's in view, for the ruler's ticks and the slots: on scroll, and as the scroller resizes.
100
151
  useEffect(() => {
101
152
  const el = scroller.current;
@@ -138,9 +189,9 @@ export function Timeline({ duration, start = 0, format = 'seconds', fps = 25, ti
138
189
  picked.cancel();
139
190
  }
140
191
  };
141
- return (_jsx(TimelineContext.Provider, { value: value, children: _jsxs("div", { role: "group", ...name, onKeyDownCapture: onKeyDownCapture, style: {
142
- '--timeline-label-width': `${labelWidth}px`,
143
- }, className: cn('relative flex flex-col overflow-hidden rounded-lg border border-border bg-background text-sm', className), children: [_jsx("div", { ref: scroller, "data-timeline-scroller": "", style: { maxHeight }, className: "relative overflow-auto overscroll-contain [scroll-padding-block-start:var(--lane-height)] [scroll-padding-inline-start:var(--timeline-label-width)]", children: _jsxs("div", { className: "relative", style: { width: `calc(var(--timeline-label-width) + ${trackWidth}px)` }, children: [children, playheadX !== null && (_jsx("div", { "aria-hidden": "true", "data-timeline-playhead-line": "", className: "pointer-events-none absolute inset-y-0 z-10 w-0.5 -translate-x-1/2 bg-primary forced-colors:bg-[Highlight] forced-colors:forced-color-adjust-none", style: { left: `calc(var(--timeline-label-width) + ${playheadX}px)` } }))] }) }), _jsx("p", { "aria-live": "polite", "aria-atomic": "true", "data-timeline-status": "", className: "border-t border-border px-3 py-1 text-[13px] leading-5 text-muted-foreground empty:border-0 empty:p-0", children: status })] }) }));
192
+ return (_jsx(TimelineContext.Provider, { value: value, children: _jsx(ViewContext.Provider, { value: view, children: _jsx(TimeContext.Provider, { value: hasPlayhead ? time : undefined, children: _jsxs("div", { role: "group", ...name, onKeyDownCapture: onKeyDownCapture, style: {
193
+ '--timeline-label-width': `${labelWidth}px`,
194
+ }, className: cn('relative flex flex-col overflow-hidden rounded-lg border border-border bg-background text-sm', className), children: [_jsx("div", { ref: scroller, "data-timeline-scroller": "", style: { maxHeight }, className: "relative overflow-auto overscroll-contain [scroll-padding-block-start:var(--lane-height)] [scroll-padding-inline-start:var(--timeline-label-width)]", children: _jsxs("div", { className: "relative", style: { width: `calc(var(--timeline-label-width) + ${trackWidth}px)` }, children: [children, playheadX !== null && (_jsx("div", { "aria-hidden": "true", "data-timeline-playhead-line": "", className: "pointer-events-none absolute inset-y-0 z-10 w-0.5 -translate-x-1/2 bg-primary forced-colors:bg-[Highlight] forced-colors:forced-color-adjust-none", style: { left: `calc(var(--timeline-label-width) + ${playheadX}px)` } }))] }) }), _jsx("p", { "aria-live": "polite", "aria-atomic": "true", "data-timeline-status": "", className: "border-t border-border px-3 py-1 text-[13px] leading-5 text-muted-foreground empty:border-0 empty:p-0", children: status })] }) }) }) }));
144
195
  }
145
196
  /** The label column's cell at the start of a row: it stays put while the track scrolls under it. */
146
197
  function LabelCell({ className, style, children, }) {
@@ -157,7 +208,8 @@ function LabelCell({ className, style, children, }) {
157
208
  */
158
209
  export function TimeRuler({ corner, className }) {
159
210
  const tl = useTimeline('TimeRuler');
160
- const { start, end, pps, scale, time, view, toX, show } = tl;
211
+ const view = useContext(ViewContext);
212
+ const { start, end, pps, scale, toX, show } = tl;
161
213
  const [dragging, setDragging] = useState(false);
162
214
  const thumb = useRef(null);
163
215
  // A label is about as wide as its widest text at this step: 8.5px a character, and room either side.
@@ -173,7 +225,7 @@ export function TimeRuler({ corner, className }) {
173
225
  tl.picked.put(at, e.altKey);
174
226
  return;
175
227
  }
176
- if (time === undefined)
228
+ if (!tl.hasPlayhead)
177
229
  return;
178
230
  e.preventDefault();
179
231
  e.currentTarget.setPointerCapture(e.pointerId);
@@ -188,16 +240,7 @@ export function TimeRuler({ corner, className }) {
188
240
  tl.seek(place(at, { step: tl.step, origin: start, pxPerSecond: pps, free: e.altKey }).time);
189
241
  };
190
242
  const stop = () => setDragging(false);
191
- const onKeyDown = (e) => {
192
- if (time === undefined)
193
- return;
194
- const next = keyTime(e, time, tl);
195
- if (next === null)
196
- return;
197
- e.preventDefault();
198
- tl.seek(next);
199
- };
200
- return (_jsxs("div", { "data-timeline-ruler": "", className: cn('sticky top-0 z-30 flex h-lane min-h-target bg-background', className), children: [_jsx(LabelCell, { className: "justify-end shadow-[inset_0_-1px_0_var(--border)]", children: _jsx("span", { "aria-hidden": "true", className: "leading-5 font-semibold text-foreground tabular-nums", children: corner ?? (time !== undefined ? show(time) : null) }) }), _jsxs("div", { "data-timeline-ruler-track": "", onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: stop, onPointerCancel: stop, className: cn('relative shrink-0 touch-none shadow-[inset_0_-1px_0_var(--border)] select-none', time !== undefined || tl.picked ? 'cursor-pointer' : 'cursor-default'), style: { width: tl.trackWidth }, children: [marks.map((mark) => (_jsx("span", { "aria-hidden": "true", className: cn('absolute bottom-0 w-px bg-muted-foreground', mark.major ? 'h-2.5' : 'h-1.5'), style: { left: toX(mark.t) } }, mark.t))), marks
243
+ return (_jsxs("div", { "data-timeline-ruler": "", className: cn('sticky top-0 z-30 flex h-lane min-h-target bg-background', className), children: [_jsx(LabelCell, { className: "justify-end shadow-[inset_0_-1px_0_var(--border)]", children: _jsx("span", { "aria-hidden": "true", className: "leading-5 font-semibold text-foreground tabular-nums", children: corner ?? _jsx(PlayheadTime, {}) }) }), _jsxs("div", { "data-timeline-ruler-track": "", onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: stop, onPointerCancel: stop, className: cn('relative shrink-0 touch-none shadow-[inset_0_-1px_0_var(--border)] select-none', tl.hasPlayhead || tl.picked ? 'cursor-pointer' : 'cursor-default'), style: { width: tl.trackWidth }, children: [marks.map((mark) => (_jsx("span", { "aria-hidden": "true", className: cn('absolute bottom-0 w-px bg-muted-foreground', mark.major ? 'h-2.5' : 'h-1.5'), style: { left: toX(mark.t) } }, mark.t))), marks
201
244
  .filter((mark) => mark.major)
202
245
  .map((mark) => {
203
246
  const x = toX(mark.t);
@@ -208,9 +251,30 @@ export function TimeRuler({ corner, className }) {
208
251
  if (last && mark.t - major >= start && x - toX(mark.t - major) < labelPx * 2)
209
252
  return null;
210
253
  return (_jsx("span", { "aria-hidden": "true", className: "absolute top-0 text-sm leading-5 whitespace-nowrap text-muted-foreground tabular-nums", style: last ? { right: tl.trackWidth - x + 4 } : { left: x + 4 }, children: show(mark.t, major) }, mark.t));
211
- }), time !== undefined && (_jsx("div", { ref: thumb, role: "slider", tabIndex: 0, "aria-label": tl.t('playhead'), "aria-orientation": "horizontal", "aria-valuemin": start, "aria-valuemax": end, "aria-valuenow": Math.round(time * 1000) / 1000, "aria-valuetext": tl.speak(time), "data-dragging": dragging || undefined, onKeyDown: onKeyDown,
212
- // Layer 1: one target square, centred on the time. Dragged, so grab.
213
- className: "group/playhead absolute top-0 z-10 flex h-full w-target -translate-x-1/2 cursor-grab justify-center outline-none data-dragging:cursor-grabbing", style: { left: toX(time) }, children: _jsx("span", { "aria-hidden": "true", className: "mt-0.5 h-[calc(100%-4px)] w-2.5 rounded-sm bg-primary group-focus-visible/playhead:focus-ring forced-colors:bg-[Highlight] forced-colors:forced-color-adjust-none" }) }))] })] }));
254
+ }), tl.hasPlayhead && _jsx(Playhead, { thumb: thumb, dragging: dragging })] })] }));
255
+ }
256
+ /** The playhead's time as the ruler shows it, for the corner: one of the few things that follows the time. */
257
+ function PlayheadTime() {
258
+ const tl = useTimeline('TimeRuler');
259
+ const time = useContext(TimeContext);
260
+ return time === undefined ? null : tl.show(time);
261
+ }
262
+ /** The playhead's slider. Its own component, so the ticks and labels don't render again as the time moves. */
263
+ function Playhead({ thumb, dragging, }) {
264
+ const tl = useTimeline('TimeRuler');
265
+ const time = useContext(TimeContext);
266
+ if (time === undefined)
267
+ return null;
268
+ const onKeyDown = (e) => {
269
+ const next = keyTime(e, time, tl);
270
+ if (next === null)
271
+ return;
272
+ e.preventDefault();
273
+ tl.seek(next);
274
+ };
275
+ return (_jsx("div", { ref: thumb, role: "slider", tabIndex: 0, "aria-label": tl.t('playhead'), "aria-orientation": "horizontal", "aria-valuemin": tl.start, "aria-valuemax": tl.end, "aria-valuenow": Math.round(time * 1000) / 1000, "aria-valuetext": tl.speak(time), "data-dragging": dragging || undefined, onKeyDown: onKeyDown,
276
+ // Layer 1: one target square, centred on the time. Dragged, so grab.
277
+ className: "group/playhead absolute top-0 z-10 flex h-full w-target -translate-x-1/2 cursor-grab justify-center outline-none data-dragging:cursor-grabbing", style: { left: tl.toX(time) }, children: _jsx("span", { "aria-hidden": "true", className: "mt-0.5 h-[calc(100%-4px)] w-2.5 rounded-sm bg-primary group-focus-visible/playhead:focus-ring forced-colors:bg-[Highlight] forced-colors:forced-color-adjust-none" }) }));
214
278
  }
215
279
  /** The time a key moves a slider on the timeline to, or null for a key it doesn't use. */
216
280
  function keyTime(e, from, tl) {
@@ -234,11 +298,12 @@ function keyTime(e, from, tl) {
234
298
  */
235
299
  export function TimelineSlot({ label, height = 48, 'aria-label': named, children, className, }) {
236
300
  const tl = useTimeline('TimelineSlot');
301
+ const inView = useContext(ViewContext);
237
302
  const view = {
238
303
  start: tl.start,
239
304
  end: tl.end,
240
- visibleStart: tl.view.start,
241
- visibleEnd: tl.view.end,
305
+ visibleStart: inView.start,
306
+ visibleEnd: inView.end,
242
307
  pxPerSecond: tl.pps,
243
308
  width: tl.trackWidth,
244
309
  toX: tl.toX,
@@ -266,16 +331,26 @@ const itemTimes = (node) => {
266
331
  * chevron to open it. Selected, it takes the `brand-subtle` ground and, in forced colours, a Highlight bar.
267
332
  */
268
333
  export function TimelineLane({ id, label, items, children, className }) {
334
+ return (_jsxs(RACTreeItem, { id: id, textValue: label, className: cn('group/lane relative flex h-lane min-h-target cursor-default outline-none', 'data-hovered:cursor-pointer data-hovered:bg-accent data-selected:bg-brand-subtle data-focus-visible:focus-ring-inset', className), children: [_jsx(TreeItemContent, { children: ({ hasChildItems, level }) => (_jsxs(_Fragment, { children: [_jsxs(LabelCell, { style: { '--lane-indent': level - 1 }, className: cn('gap-0.5 ps-[calc(var(--lane-indent)*12px)] shadow-[inset_0_-1px_0_var(--border)] group-data-hovered/lane:bg-accent group-data-selected/lane:bg-brand-subtle',
335
+ // Forced colours drop the selected ground: a Highlight bar instead, on the cell that stays in view.
336
+ "forced-colors:group-data-selected/lane:before:absolute forced-colors:group-data-selected/lane:before:inset-y-0.5 forced-colors:group-data-selected/lane:before:start-0 forced-colors:group-data-selected/lane:before:w-[3px] forced-colors:group-data-selected/lane:before:bg-[Highlight] forced-colors:group-data-selected/lane:before:forced-color-adjust-none forced-colors:group-data-selected/lane:before:content-['']"), children: [hasChildItems ? (_jsx(RACButton, { slot: "chevron", className: "group/lane-chevron relative flex size-target shrink-0 cursor-pointer items-center justify-center outline-none", children: _jsx("span", { className: "flex size-5 items-center justify-center rounded-sm text-muted-foreground group-data-focus-visible/lane-chevron:focus-ring group-data-hovered/lane-chevron:bg-muted group-data-hovered/lane-chevron:text-foreground", children: _jsx(ChevronRight, { "aria-hidden": "true", className: "size-4 group-data-expanded/lane:rotate-90" }) }) })) : (_jsx("span", { "aria-hidden": "true", className: "w-target shrink-0" })), _jsx("span", { className: "min-w-0 truncate leading-5 font-medium text-foreground group-data-selected/lane:font-semibold", children: label })] }), _jsx(LaneTrack, { items: items })] })) }), children] }));
337
+ }
338
+ /**
339
+ * A lane's track and its items, in time order. A component of its own, and memoised on `items`: React Aria renders a
340
+ * row again as its focus and hover change, and this keeps that from reaching the items, which render again only when
341
+ * the lane's items, the zoom, the selection or a move changes.
342
+ */
343
+ const LaneTrack = memo(function LaneTrack({ items }) {
269
344
  const tl = useTimeline('TimelineLane');
270
345
  const sorted = Children.toArray(items)
271
346
  .filter(isValidElement)
272
347
  .sort((a, b) => itemTimes(a)[0] - itemTimes(b)[0]);
273
- return (_jsxs(RACTreeItem, { id: id, textValue: label, className: cn('group/lane relative flex h-lane min-h-target cursor-default outline-none', 'data-hovered:cursor-pointer data-hovered:bg-accent data-selected:bg-brand-subtle data-focus-visible:focus-ring-inset', className), children: [_jsx(TreeItemContent, { children: ({ hasChildItems, level }) => (_jsxs(_Fragment, { children: [_jsxs(LabelCell, { style: { '--lane-indent': level - 1 }, className: cn('gap-0.5 ps-[calc(var(--lane-indent)*12px)] shadow-[inset_0_-1px_0_var(--border)] group-data-hovered/lane:bg-accent group-data-selected/lane:bg-brand-subtle',
274
- // Forced colours drop the selected ground: a Highlight bar instead, on the cell that stays in view.
275
- "forced-colors:group-data-selected/lane:before:absolute forced-colors:group-data-selected/lane:before:inset-y-0.5 forced-colors:group-data-selected/lane:before:start-0 forced-colors:group-data-selected/lane:before:w-[3px] forced-colors:group-data-selected/lane:before:bg-[Highlight] forced-colors:group-data-selected/lane:before:forced-color-adjust-none forced-colors:group-data-selected/lane:before:content-['']"), children: [hasChildItems ? (_jsx(RACButton, { slot: "chevron", className: "group/lane-chevron relative flex size-target shrink-0 cursor-pointer items-center justify-center outline-none", children: _jsx("span", { className: "flex size-5 items-center justify-center rounded-sm text-muted-foreground group-data-focus-visible/lane-chevron:focus-ring group-data-hovered/lane-chevron:bg-muted group-data-hovered/lane-chevron:text-foreground", children: _jsx(ChevronRight, { "aria-hidden": "true", className: "size-4 group-data-expanded/lane:rotate-90" }) }) })) : (_jsx("span", { "aria-hidden": "true", className: "w-target shrink-0" })), _jsx("span", { className: "min-w-0 truncate leading-5 font-medium text-foreground group-data-selected/lane:font-semibold", children: label })] }), _jsx("div", { className: "relative shrink-0 shadow-[inset_0_-1px_0_var(--border)]", style: { width: tl.trackWidth }, children: sorted.map((item, i) => (_jsx(ItemRoomContext.Provider, { value: {
276
- before: i > 0 ? tl.toX(itemTimes(sorted[i - 1])[1]) : -Infinity,
277
- after: i < sorted.length - 1 ? tl.toX(itemTimes(sorted[i + 1])[0]) : Infinity,
278
- }, children: item }, item.key ?? i))) })] })) }), children] }));
348
+ return (_jsx("div", { className: "relative shrink-0 shadow-[inset_0_-1px_0_var(--border)]", style: { width: tl.trackWidth }, children: sorted.map((item, i) => (_jsx(Neighbours, { before: i > 0 ? tl.toX(itemTimes(sorted[i - 1])[1]) : -Infinity, after: i < sorted.length - 1 ? tl.toX(itemTimes(sorted[i + 1])[0]) : Infinity, children: item }, item.key ?? i))) }));
349
+ });
350
+ /** Where an item's neighbours are, kept as one value while they don't move. */
351
+ function Neighbours({ before, after, children }) {
352
+ const room = useMemo(() => ({ before, after }), [before, after]);
353
+ return _jsx(ItemRoomContext.Provider, { value: room, children: children });
279
354
  }
280
355
  // The badge's pairs, whose text the contrast audit promises against their ground; the edge is the text colour.
281
356
  const tones = {
@@ -285,6 +360,28 @@ const tones = {
285
360
  info: 'bg-brand-subtle text-brand',
286
361
  neutral: 'bg-muted text-muted-foreground',
287
362
  };
363
+ /** The tones' edge colours alone, for a ghost. */
364
+ const toneEdges = {
365
+ success: 'text-success',
366
+ warning: 'text-warning',
367
+ danger: 'text-destructive',
368
+ info: 'text-brand',
369
+ neutral: 'text-muted-foreground',
370
+ };
371
+ /** How far, in px, a ghost of an item that moves with `moveWith` is drawn from it, or null for no ghost. */
372
+ function useGhost(moveWith) {
373
+ const tl = useTimeline('A timeline item');
374
+ const m = tl.moving;
375
+ if (!m || !moveWith || Math.abs(m.delta) < 1e-9)
376
+ return null;
377
+ const ids = typeof moveWith === 'string' ? [moveWith] : moveWith;
378
+ return ids.includes(m.id) ? m.delta * tl.pps : null;
379
+ }
380
+ /**
381
+ * A dashed outline where an item would go, for `moveWith`: no target, no name, nothing to focus. In the tone's edge
382
+ * colour at full strength, which the contrast audit holds at 3:1 on a lane's grounds.
383
+ */
384
+ const ghost = 'pointer-events-none absolute border-2 border-dashed border-current forced-colors:border-[CanvasText]';
288
385
  /**
289
386
  * Hides a span's label when the span is too narrow for it, rather than cut it short: the label is always in the
290
387
  * tooltip and the name. Read when the span arrives and whenever it or its label resizes (zoom, text spacing),
@@ -356,36 +453,41 @@ function ItemButton({ id, label, time, shown, details, style, className, childre
356
453
  * on 2.5.8's Essential exception for the time axis; keyboard focus reaches every item, in time order, whatever its
357
454
  * width. Items in a lane shouldn't overlap in time: put overlapping things in lanes of their own.
358
455
  */
359
- export function TimelineSpan({ id, label, start, end, tone = 'info', details, className, }) {
456
+ export function TimelineSpan({ id, label, start, end, tone = 'info', details, moveWith, className, }) {
360
457
  const tl = useTimeline('TimelineSpan');
361
458
  const ref = useLabelFits();
459
+ const shift = useGhost(moveWith);
362
460
  const left = tl.toX(start);
363
- return (_jsx(ItemButton, { id: id, label: label, time: tl.t('span', { start: tl.speak(start), end: tl.speak(end) }), shown: `${tl.show(start)}–${tl.show(end)}`, details: details, className: cn('group/item', className), style: { left, width: Math.max(2, tl.toX(end) - left) }, children: _jsx("span", { ref: ref, "aria-hidden": "true", className: cn(
364
- // overflow-clip, not hidden, so the label can stick: hidden would make the bar its scroller.
365
- 'absolute inset-x-0 inset-y-0.5 flex items-center overflow-clip rounded-sm border border-current', tones[tone], 'group-data-hovered/item:brightness-95 dark:group-data-hovered/item:brightness-125',
366
- // Selected: a 2px foreground ring as a shadow, so focus's outline still shows on top of it.
367
- 'group-data-selected/item:shadow-[0_0_0_1px_var(--background),0_0_0_3px_var(--foreground)]', 'group-data-focus-visible/item:focus-ring', 'forced-colors:border-[CanvasText] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText] forced-colors:forced-color-adjust-none forced-colors:group-data-selected/item:border-[Highlight] forced-colors:group-data-selected/item:bg-[Highlight] forced-colors:group-data-selected/item:text-[HighlightText]'), children: _jsx("span", { "data-timeline-item-label": "",
368
- // Sticks clear of the label column, so a long bar scrolled half out of view still says what it is.
369
- className: "sticky start-(--timeline-label-width) px-1.5 text-[13px] leading-4 font-medium whitespace-nowrap text-foreground forced-colors:text-inherit", children: label }) }) }));
461
+ const width = Math.max(2, tl.toX(end) - left);
462
+ return (_jsxs(_Fragment, { children: [_jsx(ItemButton, { id: id, label: label, time: tl.t('span', { start: tl.speak(start), end: tl.speak(end) }), shown: `${tl.show(start)}–${tl.show(end)}`, details: details, className: cn('group/item', className), style: { left, width }, children: _jsx("span", { ref: ref, "aria-hidden": "true", className: cn(
463
+ // overflow-clip, not hidden, so the label can stick: hidden would make the bar its scroller.
464
+ 'absolute inset-x-0 inset-y-0.5 flex items-center overflow-clip rounded-sm border border-current', tones[tone], 'group-data-hovered/item:brightness-95 dark:group-data-hovered/item:brightness-125',
465
+ // Selected: a 2px foreground ring as a shadow, so focus's outline still shows on top of it.
466
+ 'group-data-selected/item:shadow-[0_0_0_1px_var(--background),0_0_0_3px_var(--foreground)]', 'group-data-focus-visible/item:focus-ring', 'forced-colors:border-[CanvasText] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText] forced-colors:forced-color-adjust-none forced-colors:group-data-selected/item:border-[Highlight] forced-colors:group-data-selected/item:bg-[Highlight] forced-colors:group-data-selected/item:text-[HighlightText]'), children: _jsx("span", { "data-timeline-item-label": "",
467
+ // Sticks clear of the label column, so a long bar scrolled half out of view still says what it is.
468
+ className: "sticky start-(--timeline-label-width) px-1.5 text-[13px] leading-4 font-medium whitespace-nowrap text-foreground forced-colors:text-inherit", children: label }) }) }), shift !== null && (_jsx("span", { "aria-hidden": "true", "data-timeline-ghost": id, className: cn(ghost, 'inset-y-0.5 rounded-sm', toneEdges[tone]), style: { left: left + shift, width } }))] }));
370
469
  }
371
470
  /**
372
471
  * Something that happens at an instant, `at`: a cue, an error, a release. A diamond in its tone. Its target is
373
472
  * `--target-min` wide, centred on its time, or narrower where a neighbour is closer than that, so targets never
374
473
  * overlap; the narrow case rests on 2.5.8's Essential exception, as a span's does.
375
474
  */
376
- export function TimelinePoint({ id, label, at, tone = 'info', details, className, }) {
475
+ export function TimelinePoint({ id, label, at, tone = 'info', details, moveWith, className, }) {
377
476
  const tl = useTimeline('TimelinePoint');
378
477
  const room = useContext(ItemRoomContext);
478
+ const shift = useGhost(moveWith);
379
479
  const x = tl.toX(at);
380
480
  const half = Math.min(x - room.before, room.after - x);
381
- return (_jsx(ItemButton, { id: id, label: label, time: tl.speak(at), shown: tl.show(at), details: details, className: cn('group/item flex -translate-x-1/2 items-center justify-center', className), style: {
382
- left: x,
383
- width: Number.isFinite(half)
384
- ? `min(var(--target-min), ${Math.max(2, half * 2)}px)`
385
- : undefined,
386
- minWidth: Number.isFinite(half) ? undefined : 'var(--target-min)',
387
- }, children: _jsx("span", { "aria-hidden": "true", className: cn('size-3 rotate-45 rounded-[2px] border border-current', tones[tone], 'group-data-hovered/item:scale-110', 'group-data-selected/item:shadow-[0_0_0_1px_var(--background),0_0_0_3px_var(--foreground)]', 'group-data-focus-visible/item:focus-ring', 'forced-colors:border-[CanvasText] forced-colors:bg-[CanvasText] forced-colors:forced-color-adjust-none forced-colors:group-data-selected/item:border-[Highlight] forced-colors:group-data-selected/item:bg-[Highlight]') }) }));
481
+ return (_jsxs(_Fragment, { children: [_jsx(ItemButton, { id: id, label: label, time: tl.speak(at), shown: tl.show(at), details: details, className: cn('group/item flex -translate-x-1/2 items-center justify-center', className), style: {
482
+ left: x,
483
+ width: Number.isFinite(half)
484
+ ? `min(var(--target-min), ${Math.max(2, half * 2)}px)`
485
+ : undefined,
486
+ minWidth: Number.isFinite(half) ? undefined : 'var(--target-min)',
487
+ }, children: _jsx("span", { "aria-hidden": "true", className: cn('size-3 rotate-45 rounded-[2px] border border-current', tones[tone], 'group-data-hovered/item:scale-110', 'group-data-selected/item:shadow-[0_0_0_1px_var(--background),0_0_0_3px_var(--foreground)]', 'group-data-focus-visible/item:focus-ring', 'forced-colors:border-[CanvasText] forced-colors:bg-[CanvasText] forced-colors:forced-color-adjust-none forced-colors:group-data-selected/item:border-[Highlight] forced-colors:group-data-selected/item:bg-[Highlight]') }) }), shift !== null && (_jsx("span", { "aria-hidden": "true", "data-timeline-ghost": id, className: cn(ghost, 'top-1/2 size-3 -translate-1/2 rotate-45 rounded-[2px]', toneEdges[tone]), style: { left: x + shift } }))] }));
388
488
  }
489
+ /** How long the arrow keys must rest before a run of presses counts as one finished move, in ms. */
490
+ const KEYS_SETTLE = 600;
389
491
  /**
390
492
  * Flags at times, each with a short label, on a band of their own: under the ruler for markers and cue points, or
391
493
  * beside the lanes for beats. Flags that would overlap stack into tiers, each a `--lane-height` row.
@@ -394,14 +496,20 @@ export function TimelinePoint({ id, label, at, tone = 'info', details, className
394
496
  * - dragging it, snapping to the snap points within 8px and otherwise to the step (a frame); Alt or Option moves it
395
497
  * freely. The point it snaps to is said, once each time it snaps.
396
498
  * - the arrow keys: a step, Shift ten, caught by any snap point on the way; Alt or Option without snapping.
397
- * - without dragging (2.5.7): click it to pick it up, then click the ruler or this band where it should go. Escape
398
- * puts it back. What happened shows, and is said, in the line under the timeline.
499
+ * - without dragging (2.5.7): pick it up, by pressing it (`pressAction="move"`) or from its menu, then click the
500
+ * ruler or this band where it should go.
501
+ *
502
+ * Escape puts a marker back where the move started: a drag, a run of keys, or a pick-up. What happened shows, and
503
+ * is said, in the line under the timeline. A flag with something in its menu (Open, Move, Reset) has a menu button
504
+ * beside it, its own tab stop; Shift+F10, the context-menu key and a right-click open it from the flag too.
399
505
  */
400
- export function TimelineMarkers({ label, markers, onMove, snapPoints, className, }) {
506
+ export function TimelineMarkers({ label, markers, onMove, onMoveEnd, onAction, pressAction, actionLabel, snapPoints, className, }) {
401
507
  const tl = useTimeline('TimelineMarkers');
402
508
  const hint = useId();
509
+ const menuHint = useId();
403
510
  const [widths, setWidths] = useState({});
404
511
  const points = snapPoints ?? tl.snapPoints;
512
+ const press = pressAction ?? (onAction ? 'action' : 'move');
405
513
  // Lay the flags out: each starts at its time, or ends there when it would run past the end.
406
514
  const boxes = markers.map((m) => {
407
515
  const x = tl.toX(m.time);
@@ -415,17 +523,35 @@ export function TimelineMarkers({ label, markers, onMove, snapPoints, className,
415
523
  return;
416
524
  tl.picked.put(tl.timeAt(e.clientX, e.currentTarget), e.altKey);
417
525
  };
418
- return (_jsxs("div", { role: "group", "aria-label": label, "data-timeline-markers": "", className: cn('flex', className), style: { height: `calc(var(--lane-height) * ${count})`, minHeight: 'var(--target-min)' }, children: [_jsx(LabelCell, { className: "items-start pt-0.5 text-muted-foreground shadow-[inset_0_-1px_0_var(--border)]", children: _jsx("span", { className: "min-w-0 truncate leading-5", children: label }) }), _jsxs("div", { onPointerDown: onTrackPointerDown, className: cn('relative shrink-0 shadow-[inset_0_-1px_0_var(--border)]', tl.picked ? 'cursor-pointer' : 'cursor-default'), style: { width: tl.trackWidth }, children: [_jsx("span", { id: hint, hidden: true, children: tl.t('markerHint') }), markers.map((m, i) => (_jsx(Flag, { marker: m, box: boxes[i], tier: tiers[i] ?? 0, setWidths: setWidths, hint: onMove ? hint : undefined, onMove: onMove, points: points }, m.id)))] })] }));
526
+ const hintText = press === 'action'
527
+ ? onMove
528
+ ? tl.t('markerActionHint')
529
+ : tl.t('markerOpenHint')
530
+ : onMove
531
+ ? tl.t('markerHint')
532
+ : null;
533
+ return (_jsxs("div", { role: "group", "aria-label": label, "data-timeline-markers": "", className: cn('flex', className), style: { height: `calc(var(--lane-height) * ${count})`, minHeight: 'var(--target-min)' }, children: [_jsx(LabelCell, { className: "items-start pt-0.5 text-muted-foreground shadow-[inset_0_-1px_0_var(--border)]", children: _jsx("span", { className: "min-w-0 truncate leading-5", children: label }) }), _jsxs("div", { onPointerDown: onTrackPointerDown, className: cn('relative shrink-0 shadow-[inset_0_-1px_0_var(--border)]', tl.picked ? 'cursor-pointer' : 'cursor-default'), style: { width: tl.trackWidth }, children: [hintText && (_jsx("span", { id: hint, hidden: true, children: hintText })), _jsx("span", { id: menuHint, hidden: true, children: tl.t('markerMenuHint') }), markers.map((m, i) => (_jsx(Flag, { marker: m, box: boxes[i], tier: tiers[i] ?? 0, setWidths: setWidths, hint: hintText ? hint : undefined, menuHint: menuHint, press: press, actionLabel: actionLabel, onMove: onMove, onMoveEnd: onMoveEnd, onAction: onAction, points: points }, m.id)))] })] }));
419
534
  }
420
- function Flag({ marker, box, tier, setWidths, hint, onMove, points, }) {
535
+ function Flag({ marker, box, tier, setWidths, hint, menuHint, press, actionLabel, onMove, onMoveEnd, onAction, points, }) {
421
536
  const tl = useTimeline('A marker');
537
+ const id = marker.id;
538
+ const slider = useRef(null);
422
539
  const drag = useRef(null);
540
+ // A run of arrow keys, finished once they rest: where it started, where it is, and the timer.
541
+ const keys = useRef(null);
542
+ // The product's onMoveEnd when the keys' timer fires, which can be renders after it was set.
543
+ const ended = useRef(onMoveEnd);
544
+ useEffect(() => {
545
+ ended.current = onMoveEnd;
546
+ });
423
547
  const [dragging, setDragging] = useState(false);
424
548
  const [tip, setTip] = useState(false);
425
549
  const [quiet, setQuiet] = useState(false);
426
- const picked = tl.picked?.id === marker.id;
427
- // Its width, for the tiers: read when it arrives and as its label or text spacing changes it.
428
- const id = marker.id;
550
+ const [menu, setMenu] = useState(false);
551
+ // Opened from the flag by key or right-click: focus goes to its first item, and comes back to the flag.
552
+ const [fromFlag, setFromFlag] = useState(false);
553
+ const picked = tl.picked?.id === id;
554
+ // Its width, with its menu button, for the tiers: read when it arrives and as its label or text spacing change it.
429
555
  const measure = useCallback((el) => {
430
556
  if (!el)
431
557
  return;
@@ -436,51 +562,99 @@ function Flag({ marker, box, tier, setWidths, hint, onMove, points, }) {
436
562
  return watch(el, [el], read);
437
563
  }, [id, setWidths]);
438
564
  const others = points.filter((p) => Math.abs(p.time - marker.time) > 1e-9 || p.label !== marker.label);
565
+ const same = (a, b) => Math.abs(a - b) < 1e-9;
566
+ // What the menu holds: Open when a press moves it, Move when a press opens it, and Reset with a default time.
567
+ const items = [];
568
+ if (press === 'move' && onAction)
569
+ items.push({ id: 'open', label: actionLabel ?? tl.t('open') });
570
+ if (press === 'action' && onMove)
571
+ items.push({ id: 'move', label: tl.t('move') });
572
+ if (marker.defaultTime !== undefined && onMove)
573
+ items.push({
574
+ id: 'reset',
575
+ label: tl.t('reset', { time: tl.show(marker.defaultTime) }),
576
+ disabled: same(marker.time, marker.defaultTime),
577
+ });
439
578
  const move = (time, point, was) => {
440
- onMove?.(marker.id, clampTime(time, tl.start, tl.end));
579
+ onMove?.(id, clampTime(time, tl.start, tl.end));
441
580
  if (point && point !== was)
442
581
  tl.announce(tl.t('snapped', { point: point.label }));
443
582
  else if (!point && was)
444
583
  tl.announce('');
445
584
  };
585
+ const moving = (from, time) => tl.setMoving({ id, from, time, delta: time - from });
586
+ /** Puts it back where a move started, without finishing a move. */
587
+ const putBack = (from) => {
588
+ onMove?.(id, from);
589
+ tl.setMoving(null);
590
+ tl.announce(tl.t('cancelled', { label: marker.label, time: tl.speak(from) }));
591
+ };
446
592
  const pickUp = () => {
447
593
  const from = marker.time;
448
594
  tl.announce(tl.t('picked', { label: marker.label }));
449
595
  tl.pick({
450
- id: marker.id,
596
+ id,
451
597
  label: marker.label,
452
598
  put: (time, free) => {
453
- const { time: to } = place(time, {
599
+ const { time: placed } = place(time, {
454
600
  step: tl.step,
455
601
  origin: tl.start,
456
602
  points: others,
457
603
  pxPerSecond: tl.pps,
458
604
  free,
459
605
  });
460
- onMove?.(marker.id, clampTime(to, tl.start, tl.end));
606
+ const to = clampTime(placed, tl.start, tl.end);
607
+ onMove?.(id, to);
461
608
  tl.pick(null);
462
609
  tl.announce(tl.t('placed', { label: marker.label, time: tl.speak(to) }));
610
+ if (!same(to, from))
611
+ onMoveEnd?.(id, to);
463
612
  },
464
613
  cancel: () => {
465
- onMove?.(marker.id, from);
466
614
  tl.pick(null);
467
- tl.announce(tl.t('cancelled', { label: marker.label, time: tl.speak(from) }));
615
+ putBack(from);
468
616
  },
469
617
  });
470
618
  };
619
+ const reset = () => {
620
+ const to = marker.defaultTime;
621
+ if (to === undefined)
622
+ return;
623
+ onMove?.(id, to);
624
+ tl.announce(tl.t('wasReset', { label: marker.label, time: tl.speak(to) }));
625
+ if (!same(to, marker.time))
626
+ onMoveEnd?.(id, to);
627
+ };
628
+ const finishKeys = () => {
629
+ const k = keys.current;
630
+ if (!k)
631
+ return;
632
+ clearTimeout(k.timer);
633
+ keys.current = null;
634
+ tl.setMoving(null);
635
+ if (!same(k.time, k.from))
636
+ ended.current?.(id, k.time);
637
+ };
471
638
  const onPointerDown = (e) => {
472
- if (e.button !== 0 || !onMove)
639
+ if (e.button !== 0 || (!onMove && !onAction))
473
640
  return;
474
641
  e.stopPropagation();
475
642
  e.preventDefault();
476
643
  e.currentTarget.focus({ preventScroll: true });
477
644
  e.currentTarget.setPointerCapture(e.pointerId);
478
- drag.current = { x: e.clientX, from: marker.time, moved: false, snapped: null };
645
+ drag.current = {
646
+ x: e.clientX,
647
+ pointer: e.pointerId,
648
+ from: marker.time,
649
+ time: marker.time,
650
+ moved: false,
651
+ snapped: null,
652
+ };
479
653
  setQuiet(true);
480
654
  };
481
655
  const onPointerMove = (e) => {
482
656
  const d = drag.current;
483
- if (!d)
657
+ if (!d || !onMove)
484
658
  return;
485
659
  if (!d.moved && Math.abs(e.clientX - d.x) < 4)
486
660
  return;
@@ -500,26 +674,79 @@ function Flag({ marker, box, tier, setWidths, hint, onMove, points, }) {
500
674
  });
501
675
  move(time, point, d.snapped);
502
676
  d.snapped = point;
677
+ d.time = clampTime(time, tl.start, tl.end);
678
+ moving(d.from, d.time);
503
679
  };
504
680
  const onPointerUp = () => {
505
681
  const d = drag.current;
506
682
  drag.current = null;
507
683
  setDragging(false);
508
- if (d && !d.moved) {
509
- if (picked)
510
- tl.picked?.cancel();
511
- else
512
- pickUp();
684
+ if (!d)
685
+ return;
686
+ if (d.moved) {
687
+ tl.setMoving(null);
688
+ if (!same(d.time, d.from))
689
+ onMoveEnd?.(id, d.time);
513
690
  }
691
+ else if (press === 'action')
692
+ onAction?.(id);
693
+ else if (picked)
694
+ tl.picked?.cancel();
695
+ else if (onMove)
696
+ pickUp();
697
+ };
698
+ // A pointer the browser took back (a touch turned into a scroll) puts the marker back.
699
+ const onPointerCancel = () => {
700
+ const d = drag.current;
701
+ drag.current = null;
702
+ setDragging(false);
703
+ if (d?.moved)
704
+ putBack(d.from);
705
+ };
706
+ const openMenu = () => {
707
+ setFromFlag(true);
708
+ setMenu(true);
514
709
  };
515
710
  const onKeyDown = (e) => {
711
+ setQuiet(true);
712
+ if (items.length && (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'))) {
713
+ e.preventDefault();
714
+ openMenu();
715
+ return;
716
+ }
717
+ if (e.key === 'Escape') {
718
+ const d = drag.current;
719
+ if (d?.moved) {
720
+ e.stopPropagation();
721
+ drag.current = null;
722
+ setDragging(false);
723
+ if (slider.current?.hasPointerCapture(d.pointer))
724
+ slider.current.releasePointerCapture(d.pointer);
725
+ putBack(d.from);
726
+ }
727
+ else if (keys.current) {
728
+ e.stopPropagation();
729
+ const { from, timer } = keys.current;
730
+ clearTimeout(timer);
731
+ keys.current = null;
732
+ putBack(from);
733
+ }
734
+ return;
735
+ }
736
+ if ((e.key === 'Enter' || e.key === ' ') && press === 'action' && onAction) {
737
+ e.preventDefault();
738
+ onAction(id);
739
+ return;
740
+ }
516
741
  if (!onMove)
517
742
  return;
518
- setQuiet(true);
519
743
  const target = keyTime(e, marker.time, tl);
520
744
  if (target === null)
521
745
  return;
522
746
  e.preventDefault();
747
+ // The keys move it now, so it isn't waiting to be put down.
748
+ if (picked)
749
+ tl.pick(null);
523
750
  const arrow = e.key.startsWith('Arrow');
524
751
  const { time, point } = arrow
525
752
  ? nudge(marker.time, target - marker.time, {
@@ -531,13 +758,50 @@ function Flag({ marker, box, tier, setWidths, hint, onMove, points, }) {
531
758
  move(time, point, null);
532
759
  if (!point)
533
760
  tl.announce('');
761
+ const k = keys.current ?? { from: marker.time, time: marker.time, timer: setTimeout(() => { }) };
762
+ clearTimeout(k.timer);
763
+ k.time = clampTime(time, tl.start, tl.end);
764
+ k.timer = setTimeout(finishKeys, KEYS_SETTLE);
765
+ keys.current = k;
766
+ moving(k.from, k.time);
767
+ };
768
+ const onMenuOpenChange = (open) => {
769
+ setMenu(open);
770
+ if (open || !fromFlag)
771
+ return;
772
+ setFromFlag(false);
773
+ // Opened from the flag, it hands focus back to the flag, not to its button, once React Aria has restored it.
774
+ requestAnimationFrame(() => requestAnimationFrame(() => slider.current?.focus()));
534
775
  };
535
- return (_jsxs(TooltipTrigger, { delay: 600, isOpen: tip && !quiet && !dragging, onOpenChange: setTip, children: [_jsx(Focusable, { children: _jsxs("div", { ref: measure, role: "slider", tabIndex: 0, "aria-label": marker.label, "aria-orientation": "horizontal", "aria-valuemin": tl.start, "aria-valuemax": tl.end, "aria-valuenow": Math.round(marker.time * 1000) / 1000, "aria-valuetext": tl.speak(marker.time), "aria-readonly": onMove ? undefined : true, "aria-describedby": hint, "data-marker": marker.id, "data-picked": picked || undefined, "data-dragging": dragging || undefined, "data-flipped": box.flipped || undefined, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerUp, onKeyDown: onKeyDown, onBlur: () => setQuiet(false), onPointerLeave: () => setQuiet(false), className: cn(
536
- // Layer 1: the flag, a lane tall and at least a target wide, starting (or ending) at its time.
537
- 'group/marker absolute flex h-lane min-h-target min-w-target touch-none items-center outline-none select-none', onMove ? 'cursor-grab data-dragging:cursor-grabbing' : 'cursor-default', box.flipped ? 'flex-row-reverse' : ''), style: {
538
- left: box.flipped ? undefined : box.x,
539
- right: box.flipped ? `calc(100% - ${box.x}px)` : undefined,
540
- top: `calc(var(--lane-height) * ${tier})`,
541
- }, children: [_jsx("span", { "aria-hidden": "true", className: "w-0.5 self-stretch bg-foreground group-data-picked/marker:bg-primary forced-colors:bg-[Highlight] forced-colors:forced-color-adjust-none" }), _jsx("span", { "aria-hidden": "true", className: cn('h-5 rounded-sm border border-foreground bg-background px-1.5 text-[13px] leading-[18px] font-medium whitespace-nowrap text-foreground', box.flipped ? 'rounded-e-none' : 'rounded-s-none', 'group-hover/marker:bg-accent group-data-dragging/marker:bg-accent', 'group-data-picked/marker:border-primary group-data-picked/marker:bg-primary group-data-picked/marker:text-primary-foreground', 'group-focus-visible/marker:focus-ring', 'forced-colors:border-[Highlight] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText] forced-colors:forced-color-adjust-none forced-colors:group-data-picked/marker:bg-[Highlight] forced-colors:group-data-picked/marker:text-[HighlightText]'), children: marker.label })] }) }), _jsxs(Tooltip, { placement: "top", children: [_jsx("span", { className: "block font-semibold", children: marker.label }), _jsx("span", { className: "block tabular-nums", children: tl.show(marker.time) })] })] }));
776
+ const pressable = onMove || onAction;
777
+ return (_jsxs("div", { ref: measure, "data-marker-flag": id, className: cn('absolute flex h-lane min-h-target', box.flipped && 'flex-row-reverse'), style: {
778
+ left: box.flipped ? undefined : box.x,
779
+ right: box.flipped ? `calc(100% - ${box.x}px)` : undefined,
780
+ top: `calc(var(--lane-height) * ${tier})`,
781
+ }, children: [_jsxs(TooltipTrigger, { delay: 600, isOpen: tip && !quiet && !dragging, onOpenChange: setTip, children: [_jsx(Focusable, { children: _jsxs("div", { ref: slider, role: "slider", tabIndex: 0, "aria-label": marker.label, "aria-orientation": "horizontal", "aria-valuemin": tl.start, "aria-valuemax": tl.end, "aria-valuenow": Math.round(marker.time * 1000) / 1000, "aria-valuetext": tl.speak(marker.time), "aria-readonly": onMove ? undefined : true, "aria-describedby": [hint, items.length ? menuHint : ''].filter(Boolean).join(' ') || undefined, "data-marker": id, "data-picked": picked || undefined, "data-dragging": dragging || undefined, "data-flipped": box.flipped || undefined, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerCancel, onContextMenu: (e) => {
782
+ if (!items.length)
783
+ return;
784
+ e.preventDefault();
785
+ openMenu();
786
+ }, onKeyDown: onKeyDown, onBlur: () => {
787
+ finishKeys();
788
+ setQuiet(false);
789
+ }, onPointerLeave: () => setQuiet(false), className: cn(
790
+ // Layer 1: the flag, a lane tall and at least a target wide, starting (or ending) at its time.
791
+ 'group/marker relative flex h-full min-w-target touch-none items-center outline-none select-none', onMove
792
+ ? 'cursor-grab data-dragging:cursor-grabbing'
793
+ : pressable
794
+ ? 'cursor-pointer'
795
+ : 'cursor-default', box.flipped ? 'flex-row-reverse' : ''), children: [_jsx("span", { "aria-hidden": "true", className: "w-0.5 self-stretch bg-foreground group-data-picked/marker:bg-primary forced-colors:bg-[Highlight] forced-colors:forced-color-adjust-none" }), _jsx("span", { "aria-hidden": "true", className: cn('h-5 rounded-sm border border-foreground bg-background px-1.5 text-[13px] leading-[18px] font-medium whitespace-nowrap text-foreground', box.flipped ? 'rounded-e-none' : 'rounded-s-none', 'group-hover/marker:bg-accent group-data-dragging/marker:bg-accent', 'group-data-picked/marker:border-primary group-data-picked/marker:bg-primary group-data-picked/marker:text-primary-foreground', 'group-focus-visible/marker:focus-ring', 'forced-colors:border-[Highlight] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText] forced-colors:forced-color-adjust-none forced-colors:group-data-picked/marker:bg-[Highlight] forced-colors:group-data-picked/marker:text-[HighlightText]'), children: marker.label })] }) }), _jsxs(Tooltip, { placement: "top", children: [_jsx("span", { className: "block font-semibold", children: marker.label }), _jsx("span", { className: "block tabular-nums", children: tl.show(marker.time) })] })] }), items.length > 0 && (_jsxs(MenuTrigger, { isOpen: menu, onOpenChange: onMenuOpenChange, children: [_jsx(RACButton, { "aria-label": tl.t('markerMenu', { label: marker.label }), "data-marker-menu": id, className: "group/marker-menu relative flex size-target shrink-0 cursor-pointer items-center justify-center outline-none", children: _jsx("span", { className: "flex size-5 items-center justify-center rounded-sm text-muted-foreground group-data-hovered/marker-menu:bg-muted group-data-hovered/marker-menu:text-foreground group-data-pressed/marker-menu:bg-muted group-data-focus-visible/marker-menu:focus-ring", children: _jsx(EllipsisVertical, { "aria-hidden": "true", className: "size-4" }) }) }), _jsx(Popover, { placement: "bottom start", children: _jsx(Menu, { "aria-label": tl.t('markerMenu', { label: marker.label }),
796
+ // React Aria's focus strategy, not the HTML attribute: the first item, as a menu button's ↓ does.
797
+ // oxlint-disable-next-line jsx-a11y/no-autofocus
798
+ autoFocus: fromFlag ? 'first' : undefined, disabledKeys: items.filter((item) => item.disabled).map((item) => item.id), onAction: (key) => {
799
+ if (key === 'open')
800
+ onAction?.(id);
801
+ else if (key === 'move')
802
+ pickUp();
803
+ else if (key === 'reset')
804
+ reset();
805
+ }, children: items.map((item) => (_jsx(MenuItem, { id: item.id, children: item.label }, item.id))) }) })] }))] }));
542
806
  }
543
807
  //# sourceMappingURL=timeline.js.map