@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.
- package/CHANGELOG.md +77 -0
- package/README.md +2 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/announce.d.ts +2 -0
- package/dist/lib/announce.d.ts.map +1 -0
- package/dist/lib/announce.js +38 -0
- package/dist/lib/announce.js.map +1 -0
- package/dist/lib/card-order.d.ts +21 -0
- package/dist/lib/card-order.d.ts.map +1 -0
- package/dist/lib/card-order.js +60 -0
- package/dist/lib/card-order.js.map +1 -0
- package/dist/lib/english.d.ts.map +1 -1
- package/dist/lib/english.js +2 -0
- package/dist/lib/english.js.map +1 -1
- package/dist/lib/locale.d.ts +3 -0
- package/dist/lib/locale.d.ts.map +1 -1
- package/dist/lib/locale.js.map +1 -1
- package/dist/styles/tokens.css +2 -0
- package/dist/tokens.json +10 -0
- package/dist/ui/drop-indicator.d.ts +5 -0
- package/dist/ui/drop-indicator.d.ts.map +1 -1
- package/dist/ui/drop-indicator.js +16 -2
- package/dist/ui/drop-indicator.js.map +1 -1
- package/dist/ui/grid-list.d.ts +74 -5
- package/dist/ui/grid-list.d.ts.map +1 -1
- package/dist/ui/grid-list.js +115 -3
- package/dist/ui/grid-list.js.map +1 -1
- package/dist/ui/timeline.d.ts +68 -7
- package/dist/ui/timeline.d.ts.map +1 -1
- package/dist/ui/timeline.js +365 -101
- package/dist/ui/timeline.js.map +1 -1
- package/package.json +1 -1
package/dist/ui/timeline.js
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 =
|
|
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
|
|
71
|
-
|
|
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
|
-
|
|
87
|
-
seek
|
|
139
|
+
hasPlayhead,
|
|
140
|
+
seek,
|
|
88
141
|
snapPoints,
|
|
89
142
|
selectedItem,
|
|
90
143
|
selectItem,
|
|
91
|
-
announce: setStatus,
|
|
92
144
|
picked,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
}),
|
|
212
|
-
|
|
213
|
-
|
|
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:
|
|
241
|
-
visibleEnd:
|
|
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 (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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):
|
|
398
|
-
*
|
|
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
|
-
|
|
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
|
|
427
|
-
//
|
|
428
|
-
const
|
|
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?.(
|
|
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
|
|
596
|
+
id,
|
|
451
597
|
label: marker.label,
|
|
452
598
|
put: (time, free) => {
|
|
453
|
-
const { 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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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 (
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|