@zylem/ui 0.8.14 → 0.9.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/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/Accordion/Accordion.tsx +4 -1
- package/src/components/BuildLabel/BuildLabel.tsx +0 -1
- package/src/components/BuildLabel/mount-build-label.ts +0 -1
- package/src/components/Callout/Callout.tsx +1 -1
- package/src/components/Card/Card.css.ts +1 -0
- package/src/components/Card/Card.tsx +11 -2
- package/src/components/Checkbox/Checkbox.tsx +4 -2
- package/src/components/ColorPalette/ColorPalette.tsx +1 -1
- package/src/components/Console/Console.tsx +3 -0
- package/src/components/Dialog/Dialog.css.ts +41 -17
- package/src/components/Dialog/Dialog.tsx +134 -53
- package/src/components/FrameStrip/FrameStrip.tsx +2 -3
- package/src/components/ItemPicker/ItemPicker.tsx +46 -33
- package/src/components/MenuButton/MenuButton.tsx +1 -0
- package/src/components/Mixer/Knob.tsx +10 -3
- package/src/components/Mixer/Mixer.css.ts +19 -0
- package/src/components/Mixer/MixerStrip.tsx +29 -8
- package/src/components/NumberField/NumberField.tsx +5 -2
- package/src/components/Panel/Panel.css.ts +1 -0
- package/src/components/Panel/Panel.tsx +9 -1
- package/src/components/PianoRoll/PianoRoll.tsx +1 -0
- package/src/components/Progress/Progress.tsx +3 -0
- package/src/components/Property/Property.css.ts +13 -0
- package/src/components/Property/Property.tsx +3 -2
- package/src/components/SearchInput/SearchInput.tsx +1 -1
- package/src/components/Select/Select.tsx +3 -1
- package/src/components/Sidebar/Sidebar.css.ts +1 -0
- package/src/components/Sidebar/Sidebar.tsx +23 -5
- package/src/components/Slider/Slider.tsx +4 -2
- package/src/components/Switch/Switch.tsx +3 -1
- package/src/components/Tabs/Tabs.css.ts +0 -5
- package/src/components/Tabs/Tabs.tsx +23 -12
- package/src/components/TextField/TextField.tsx +4 -0
- package/src/components/TimelineLanes/TimelineLanes.css.ts +6 -0
- package/src/components/TimelineLanes/TimelineLanes.tsx +52 -4
- package/src/components/TimelineLanes/lanes-math.ts +51 -0
- package/src/components/TimelineTrack/TimelineTrack.tsx +16 -3
- package/src/components/TitleBar/TitleBar.css.ts +1 -0
- package/src/components/TitleBar/TitleBar.tsx +7 -1
- package/src/components/Toast/Toast.tsx +1 -2
- package/src/components/ToolButton/ToolButton.tsx +1 -1
- package/src/components/Toolbar/Toolbar.tsx +6 -1
- package/src/components/ToolbarButton/ToolbarButton.tsx +9 -2
- package/src/components/ToolboxButton/ToolboxButton.tsx +2 -1
- package/src/components/TrackHeader/TrackHeader.css.ts +21 -0
- package/src/components/TrackHeader/TrackHeader.tsx +37 -12
- package/src/components/TransportBar/TransportBar.tsx +4 -1
- package/src/components/Waveform/Waveform.css.ts +10 -0
- package/src/components/Waveform/Waveform.tsx +59 -27
- package/src/components/WindowControls/WindowControls.tsx +4 -4
- package/src/components/index.ts +1 -0
- package/src/components/shared/Heading.tsx +30 -0
- package/src/global/accordion.css.ts +6 -1
- package/src/global/common.css.ts +13 -0
- package/src/layers/LayerContext.tsx +2 -2
|
@@ -12,8 +12,10 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
type ClipBounds,
|
|
14
14
|
clamp,
|
|
15
|
+
describeTime,
|
|
15
16
|
resolveClipDrag,
|
|
16
17
|
rulerTicks,
|
|
18
|
+
sliderKeyValue,
|
|
17
19
|
snapTime,
|
|
18
20
|
type TimeScale,
|
|
19
21
|
} from './lanes-math';
|
|
@@ -59,7 +61,11 @@ export interface TimelineLanesProps {
|
|
|
59
61
|
/** Timeline length in units. */
|
|
60
62
|
length: number;
|
|
61
63
|
playhead?: number;
|
|
62
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Fires while the ruler is clicked or dragged, and for its keys once it has
|
|
66
|
+
* focus: the arrows step by the ruler's ticks (by its labelled ticks with
|
|
67
|
+
* Shift), Home and End jump to the ends.
|
|
68
|
+
*/
|
|
63
69
|
onSeek?: (time: number) => void;
|
|
64
70
|
/** Drag snap in units, applied to clips and markers (not to seeking). */
|
|
65
71
|
snap?: number;
|
|
@@ -134,6 +140,24 @@ export function TimelineLanes(props: TimelineLanesProps) {
|
|
|
134
140
|
window.addEventListener('pointercancel', stop);
|
|
135
141
|
};
|
|
136
142
|
|
|
143
|
+
const seekFromKey = (event: KeyboardEvent) => {
|
|
144
|
+
if (props.disabled || !props.onSeek) return;
|
|
145
|
+
const step = minorStep() > 0 ? minorStep() / ppu() : 1;
|
|
146
|
+
const major = majorStep() / ppu();
|
|
147
|
+
const next = sliderKeyValue(event, {
|
|
148
|
+
value: props.playhead ?? 0,
|
|
149
|
+
min: 0,
|
|
150
|
+
max: props.length,
|
|
151
|
+
step,
|
|
152
|
+
bigStep: major > step ? major : step * 10,
|
|
153
|
+
});
|
|
154
|
+
if (next === null) return;
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
// Page-level transport shortcuts (Home, arrows) must not seek a second time.
|
|
157
|
+
event.stopPropagation();
|
|
158
|
+
props.onSeek(next);
|
|
159
|
+
};
|
|
160
|
+
|
|
137
161
|
const handleWheel = (event: WheelEvent) => {
|
|
138
162
|
if (!props.onZoom || !(event.ctrlKey || event.metaKey)) return;
|
|
139
163
|
event.preventDefault();
|
|
@@ -171,11 +195,17 @@ export function TimelineLanes(props: TimelineLanesProps) {
|
|
|
171
195
|
class="zylem-lanes-ruler"
|
|
172
196
|
style={{ width: `${bodyWidth()}px` }}
|
|
173
197
|
onPointerDown={startSeek}
|
|
198
|
+
onKeyDown={seekFromKey}
|
|
174
199
|
role={props.onSeek ? 'slider' : undefined}
|
|
200
|
+
tabIndex={props.onSeek && !props.disabled ? 0 : undefined}
|
|
175
201
|
aria-label={props.onSeek ? 'Playhead' : undefined}
|
|
176
202
|
aria-valuemin={props.onSeek ? 0 : undefined}
|
|
177
203
|
aria-valuemax={props.onSeek ? props.length : undefined}
|
|
178
|
-
aria-valuenow={props.onSeek ? props.playhead : undefined}
|
|
204
|
+
aria-valuenow={props.onSeek ? (props.playhead ?? 0) : undefined}
|
|
205
|
+
aria-valuetext={
|
|
206
|
+
props.onSeek ? describeTime(props.playhead ?? 0, props.scale) : undefined
|
|
207
|
+
}
|
|
208
|
+
aria-disabled={props.onSeek && props.disabled ? 'true' : undefined}
|
|
179
209
|
>
|
|
180
210
|
<Show when={props.range}>
|
|
181
211
|
{(range) => (
|
|
@@ -298,7 +328,8 @@ export interface TimelineClipProps {
|
|
|
298
328
|
movable?: boolean;
|
|
299
329
|
resizable?: boolean;
|
|
300
330
|
disabled?: boolean;
|
|
301
|
-
|
|
331
|
+
/** Pointer down, or Enter / Space while focused. */
|
|
332
|
+
onSelect?: (event: MouseEvent | KeyboardEvent) => void;
|
|
302
333
|
/** Fires continuously while dragging. */
|
|
303
334
|
onChange?: (change: TimelineClipChange) => void;
|
|
304
335
|
/** Fires once when a drag ends, with the final bounds. */
|
|
@@ -378,6 +409,17 @@ export function TimelineClip(props: TimelineClipProps) {
|
|
|
378
409
|
aria-label={props.label}
|
|
379
410
|
aria-pressed={props.selected ? 'true' : 'false'}
|
|
380
411
|
onPointerDown={beginDrag('move')}
|
|
412
|
+
onKeyDown={(event) => {
|
|
413
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
414
|
+
event.preventDefault();
|
|
415
|
+
// Space is also a page-level play shortcut; on a focused clip it selects.
|
|
416
|
+
event.stopPropagation();
|
|
417
|
+
props.onSelect?.(event);
|
|
418
|
+
}}
|
|
419
|
+
onClick={(event) => {
|
|
420
|
+
// Assistive tech activates with a click and no pointer down before it.
|
|
421
|
+
if (event.detail === 0) props.onSelect?.(event);
|
|
422
|
+
}}
|
|
381
423
|
onDblClick={(event) => {
|
|
382
424
|
event.stopPropagation();
|
|
383
425
|
props.onDoubleClick?.();
|
|
@@ -412,7 +454,8 @@ export interface KeyframeMarkerProps {
|
|
|
412
454
|
color?: string;
|
|
413
455
|
selected?: boolean;
|
|
414
456
|
disabled?: boolean;
|
|
415
|
-
|
|
457
|
+
/** Pointer down, or a click from the keyboard or assistive tech. */
|
|
458
|
+
onSelect?: (event: MouseEvent | KeyboardEvent) => void;
|
|
416
459
|
/** Fires continuously while dragging. */
|
|
417
460
|
onChange?: (time: number) => void;
|
|
418
461
|
/** Fires once when a drag ends. */
|
|
@@ -476,6 +519,11 @@ export function KeyframeMarker(props: KeyframeMarkerProps) {
|
|
|
476
519
|
aria-pressed={props.selected ? 'true' : 'false'}
|
|
477
520
|
title={props.label}
|
|
478
521
|
onPointerDown={beginDrag}
|
|
522
|
+
onClick={(event) => {
|
|
523
|
+
// The pointer already selected on pointer down; keys and assistive tech
|
|
524
|
+
// click without one.
|
|
525
|
+
if (event.detail === 0) props.onSelect?.(event);
|
|
526
|
+
}}
|
|
479
527
|
onDblClick={(event) => {
|
|
480
528
|
event.stopPropagation();
|
|
481
529
|
props.onDoubleClick?.();
|
|
@@ -30,6 +30,57 @@ export function clamp(value: number, min: number, max: number): number {
|
|
|
30
30
|
return Math.min(max, Math.max(min, value));
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface SliderKeyOptions {
|
|
34
|
+
value: number;
|
|
35
|
+
min: number;
|
|
36
|
+
max: number;
|
|
37
|
+
step: number;
|
|
38
|
+
/** Shift + arrow; defaults to ten steps. */
|
|
39
|
+
bigStep?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Where a slider key moves `value`: arrows step (Shift for a big step), Home
|
|
44
|
+
* and End jump to the ends. Null for any other key, which the slider should
|
|
45
|
+
* let through.
|
|
46
|
+
*/
|
|
47
|
+
export function sliderKeyValue(
|
|
48
|
+
event: Pick<KeyboardEvent, 'key' | 'shiftKey'>,
|
|
49
|
+
options: SliderKeyOptions,
|
|
50
|
+
): number | null {
|
|
51
|
+
const step = event.shiftKey ? (options.bigStep ?? options.step * 10) : options.step;
|
|
52
|
+
switch (event.key) {
|
|
53
|
+
case 'ArrowRight':
|
|
54
|
+
case 'ArrowUp':
|
|
55
|
+
return clamp(options.value + step, options.min, options.max);
|
|
56
|
+
case 'ArrowLeft':
|
|
57
|
+
case 'ArrowDown':
|
|
58
|
+
return clamp(options.value - step, options.min, options.max);
|
|
59
|
+
case 'Home':
|
|
60
|
+
return options.min;
|
|
61
|
+
case 'End':
|
|
62
|
+
return options.max;
|
|
63
|
+
default:
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Seconds as spoken text for `aria-valuetext`: `1.5 seconds`. */
|
|
69
|
+
export function describeSeconds(seconds: number): string {
|
|
70
|
+
const rounded = Math.round(Math.max(0, seconds) * 100) / 100;
|
|
71
|
+
return `${rounded} ${rounded === 1 ? 'second' : 'seconds'}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A time on `scale` as spoken text: `1.5 seconds`, or `bar 2, beat 1` (both 1-based). */
|
|
75
|
+
export function describeTime(time: number, scale: Pick<TimeScale, 'unit' | 'beatsPerBar'>): string {
|
|
76
|
+
if (scale.unit === 'seconds') return describeSeconds(time);
|
|
77
|
+
const bpb = Math.max(1, scale.beatsPerBar ?? 4);
|
|
78
|
+
const total = Math.round(Math.max(0, time) * 100) / 100;
|
|
79
|
+
const bar = Math.floor(total / bpb) + 1;
|
|
80
|
+
const beat = Math.round((total - (bar - 1) * bpb + 1) * 100) / 100;
|
|
81
|
+
return `bar ${bar}, beat ${beat}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
33
84
|
export interface ClipBounds {
|
|
34
85
|
start: number;
|
|
35
86
|
length: number;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Slider as KSlider } from '@kobalte/core';
|
|
2
2
|
import { For, Show, createMemo } from 'solid-js';
|
|
3
|
+
import { describeSeconds } from '../TimelineLanes/lanes-math';
|
|
3
4
|
import { formatSeconds, intervalTicks, markerPercent } from './timeline-math';
|
|
4
5
|
|
|
5
6
|
export interface TimelineTrackProps {
|
|
@@ -19,6 +20,10 @@ export interface TimelineTrackProps {
|
|
|
19
20
|
showTime?: boolean;
|
|
20
21
|
disabled?: boolean;
|
|
21
22
|
label?: string;
|
|
23
|
+
/** Accessible name when there is no visible `label`. */
|
|
24
|
+
'aria-label'?: string;
|
|
25
|
+
/** The value as a screen reader reads it (default: seconds). */
|
|
26
|
+
valueText?: (value: number) => string;
|
|
22
27
|
class?: string;
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -34,6 +39,7 @@ export function TimelineTrack(props: TimelineTrackProps) {
|
|
|
34
39
|
return [];
|
|
35
40
|
});
|
|
36
41
|
const pct = (t: number) => `${markerPercent(t, min(), props.max)}%`;
|
|
42
|
+
const valueText = () => (props.valueText ?? describeSeconds)(props.value);
|
|
37
43
|
|
|
38
44
|
return (
|
|
39
45
|
<div class={props.class ? `zylem-timeline ${props.class}` : 'zylem-timeline'}>
|
|
@@ -45,7 +51,6 @@ export function TimelineTrack(props: TimelineTrackProps) {
|
|
|
45
51
|
maxValue={props.max}
|
|
46
52
|
step={props.step ?? 0.01}
|
|
47
53
|
disabled={props.disabled}
|
|
48
|
-
getValueLabel={(params) => formatSeconds(params.values[0] ?? 0)}
|
|
49
54
|
>
|
|
50
55
|
<Show when={props.label}>
|
|
51
56
|
<KSlider.Label class="zylem-timeline-label">{props.label}</KSlider.Label>
|
|
@@ -75,8 +80,16 @@ export function TimelineTrack(props: TimelineTrackProps) {
|
|
|
75
80
|
</For>
|
|
76
81
|
</div>
|
|
77
82
|
</Show>
|
|
78
|
-
<KSlider.Thumb
|
|
79
|
-
|
|
83
|
+
<KSlider.Thumb
|
|
84
|
+
class="zylem-timeline-thumb"
|
|
85
|
+
aria-label={props['aria-label']}
|
|
86
|
+
aria-valuetext={valueText()}
|
|
87
|
+
>
|
|
88
|
+
<KSlider.Input
|
|
89
|
+
class="zylem-timeline-thumb-input"
|
|
90
|
+
aria-label={props['aria-label']}
|
|
91
|
+
aria-valuetext={valueText()}
|
|
92
|
+
/>
|
|
80
93
|
</KSlider.Thumb>
|
|
81
94
|
</KSlider.Track>
|
|
82
95
|
</KSlider.Root>
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { Show, splitProps } from 'solid-js';
|
|
2
2
|
import type { JSX } from 'solid-js';
|
|
3
|
+
import { Heading, type HeadingLevel } from '../shared/Heading';
|
|
3
4
|
import { WindowControls, type WindowControlsProps } from '../WindowControls/WindowControls';
|
|
4
5
|
|
|
5
6
|
export interface TitleBarProps
|
|
6
7
|
extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'title' | 'onClose'>,
|
|
7
8
|
WindowControlsProps {
|
|
8
9
|
title?: JSX.Element;
|
|
10
|
+
/** Heading level of the title (default 2). */
|
|
11
|
+
headingLevel?: HeadingLevel;
|
|
9
12
|
/** Smaller 32px bar for panel headers. */
|
|
10
13
|
compact?: boolean;
|
|
11
14
|
/** Show grab cursor for draggable bars. */
|
|
@@ -21,6 +24,7 @@ export interface TitleBarProps
|
|
|
21
24
|
export function TitleBar(props: TitleBarProps) {
|
|
22
25
|
const [local, rest] = splitProps(props, [
|
|
23
26
|
'title',
|
|
27
|
+
'headingLevel',
|
|
24
28
|
'compact',
|
|
25
29
|
'draggable',
|
|
26
30
|
'children',
|
|
@@ -45,7 +49,9 @@ export function TitleBar(props: TitleBarProps) {
|
|
|
45
49
|
return (
|
|
46
50
|
<div class={classes()} {...rest}>
|
|
47
51
|
<Show when={local.title}>
|
|
48
|
-
<
|
|
52
|
+
<Heading level={local.headingLevel ?? 2} class="zylem-titlebar-title">
|
|
53
|
+
{local.title}
|
|
54
|
+
</Heading>
|
|
49
55
|
</Show>
|
|
50
56
|
{local.children}
|
|
51
57
|
<WindowControls
|
|
@@ -25,8 +25,7 @@ export function Toast(props: ToastProps) {
|
|
|
25
25
|
<Show when={props.message}>
|
|
26
26
|
<div
|
|
27
27
|
class={props.class ? `zylem-toast ${props.class}` : 'zylem-toast'}
|
|
28
|
-
role=
|
|
29
|
-
aria-live="polite"
|
|
28
|
+
role={props.tone === 'error' ? 'alert' : 'status'}
|
|
30
29
|
data-tone={props.tone}
|
|
31
30
|
data-position={props.position}
|
|
32
31
|
>
|
|
@@ -81,7 +81,7 @@ export function ToolButton(props: ToolButtonProps) {
|
|
|
81
81
|
return (
|
|
82
82
|
<ToolbarButton
|
|
83
83
|
label={label()}
|
|
84
|
-
|
|
84
|
+
pressed={props.selected ?? false}
|
|
85
85
|
disabled={props.disabled}
|
|
86
86
|
onClick={props.onClick}
|
|
87
87
|
class={props.class ? `zylem-tool-btn ${props.class}` : 'zylem-tool-btn'}
|
|
@@ -23,7 +23,12 @@ export function Toolbar(props: ToolbarProps) {
|
|
|
23
23
|
.join(' ');
|
|
24
24
|
|
|
25
25
|
return (
|
|
26
|
-
<div
|
|
26
|
+
<div
|
|
27
|
+
role="toolbar"
|
|
28
|
+
aria-orientation={local.orientation === 'vertical' ? 'vertical' : undefined}
|
|
29
|
+
class={classes()}
|
|
30
|
+
{...rest}
|
|
31
|
+
>
|
|
27
32
|
{local.children}
|
|
28
33
|
</div>
|
|
29
34
|
);
|
|
@@ -5,7 +5,13 @@ import { useLayer } from '../../layers';
|
|
|
5
5
|
export interface ToolbarButtonProps {
|
|
6
6
|
label: string;
|
|
7
7
|
onClick?: (event: MouseEvent) => void;
|
|
8
|
+
/**
|
|
9
|
+
* The selected look alone, for a button whose label already says its state
|
|
10
|
+
* (Play / Pause). For a toggle with a constant label, use `pressed`.
|
|
11
|
+
*/
|
|
8
12
|
selected?: boolean;
|
|
13
|
+
/** Makes the button a toggle: the state is exposed as `aria-pressed` and drawn as selected. */
|
|
14
|
+
pressed?: boolean;
|
|
9
15
|
disabled?: boolean;
|
|
10
16
|
children: JSX.Element;
|
|
11
17
|
class?: string;
|
|
@@ -13,7 +19,7 @@ export interface ToolbarButtonProps {
|
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* HyperGlass toolbar button: square glass icon button with a tooltip.
|
|
16
|
-
* Selected
|
|
22
|
+
* Selected and pressed states render as a live-green jewel.
|
|
17
23
|
*
|
|
18
24
|
* The tooltip portals into the layer container for its own tree, so it stays
|
|
19
25
|
* styled when the toolbar lives inside a web component's shadow root.
|
|
@@ -26,6 +32,7 @@ export function ToolbarButton(props: ToolbarButtonProps) {
|
|
|
26
32
|
<KTooltip.Trigger as="span" ref={layer.anchorRef}>
|
|
27
33
|
<KButton.Root
|
|
28
34
|
aria-label={props.label}
|
|
35
|
+
aria-pressed={props.pressed}
|
|
29
36
|
onClick={props.onClick}
|
|
30
37
|
disabled={props.disabled}
|
|
31
38
|
class={
|
|
@@ -33,7 +40,7 @@ export function ToolbarButton(props: ToolbarButtonProps) {
|
|
|
33
40
|
? `zylem-toolbar-btn ${props.class}`
|
|
34
41
|
: 'zylem-toolbar-btn'
|
|
35
42
|
}
|
|
36
|
-
data-selected={props.selected ? '' : undefined}
|
|
43
|
+
data-selected={props.selected || props.pressed ? '' : undefined}
|
|
37
44
|
>
|
|
38
45
|
{props.children}
|
|
39
46
|
</KButton.Root>
|
|
@@ -11,6 +11,7 @@ export interface ToolboxButtonProps {
|
|
|
11
11
|
label: string;
|
|
12
12
|
/** Icon rendered above the label. */
|
|
13
13
|
children: JSX.Element;
|
|
14
|
+
/** Given at all, the tile is a toggle and exposes this as `aria-pressed`. */
|
|
14
15
|
selected?: boolean;
|
|
15
16
|
disabled?: boolean;
|
|
16
17
|
/** Overrides the size inherited from the enclosing `ToolboxGrid`. */
|
|
@@ -29,7 +30,7 @@ export function ToolboxButton(props: ToolboxButtonProps) {
|
|
|
29
30
|
class={props.class ? `zylem-toolbox-btn ${props.class}` : 'zylem-toolbox-btn'}
|
|
30
31
|
data-size={props.size}
|
|
31
32
|
data-selected={props.selected ? '' : undefined}
|
|
32
|
-
aria-pressed={props.selected
|
|
33
|
+
aria-pressed={props.selected}
|
|
33
34
|
disabled={props.disabled}
|
|
34
35
|
onClick={props.onClick}
|
|
35
36
|
>
|
|
@@ -61,6 +61,27 @@ globalStyle('.zylem-track-header-name', {
|
|
|
61
61
|
textOverflow: 'ellipsis',
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
// A selectable header's name is a button that still reads as the name.
|
|
65
|
+
globalStyle('button.zylem-track-header-name', {
|
|
66
|
+
appearance: 'none',
|
|
67
|
+
minWidth: 0,
|
|
68
|
+
margin: 0,
|
|
69
|
+
padding: 0,
|
|
70
|
+
border: 0,
|
|
71
|
+
background: 'none',
|
|
72
|
+
color: 'inherit',
|
|
73
|
+
fontFamily: 'inherit',
|
|
74
|
+
lineHeight: 'inherit',
|
|
75
|
+
textAlign: 'start',
|
|
76
|
+
cursor: 'inherit',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
globalStyle('button.zylem-track-header-name:focus-visible', {
|
|
80
|
+
outline: 'none',
|
|
81
|
+
borderRadius: '3px',
|
|
82
|
+
boxShadow: vars.effects.focusRing,
|
|
83
|
+
});
|
|
84
|
+
|
|
64
85
|
globalStyle('.zylem-track-header[data-muted] .zylem-track-header-name', {
|
|
65
86
|
color: vars.colors.textSecondary,
|
|
66
87
|
});
|
|
@@ -11,6 +11,7 @@ export interface TrackHeaderProps {
|
|
|
11
11
|
/** Secondary line under the name (instrument preset, camera kind). */
|
|
12
12
|
detail?: string;
|
|
13
13
|
selected?: boolean;
|
|
14
|
+
/** Pointer down anywhere on the header; given, the name is also a button for the keyboard. */
|
|
14
15
|
onSelect?: () => void;
|
|
15
16
|
/** Double-click the name to rename; the new name arrives here on Enter/blur. */
|
|
16
17
|
onRename?: (name: string) => void;
|
|
@@ -47,6 +48,31 @@ export function TrackHeader(props: TrackHeaderProps) {
|
|
|
47
48
|
if (next && next !== props.name) props.onRename?.(next);
|
|
48
49
|
};
|
|
49
50
|
|
|
51
|
+
const name = () => (
|
|
52
|
+
<Show
|
|
53
|
+
when={props.onSelect}
|
|
54
|
+
fallback={
|
|
55
|
+
<span class="zylem-track-header-name" onDblClick={startRename} title={props.name}>
|
|
56
|
+
{props.name}
|
|
57
|
+
</span>
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
class="zylem-track-header-name"
|
|
63
|
+
aria-current={props.selected ? 'true' : undefined}
|
|
64
|
+
title={props.name}
|
|
65
|
+
onClick={(event) => {
|
|
66
|
+
// The pointer selected on pointer down; keys and assistive tech click without one.
|
|
67
|
+
if (event.detail === 0) props.onSelect?.();
|
|
68
|
+
}}
|
|
69
|
+
onDblClick={startRename}
|
|
70
|
+
>
|
|
71
|
+
{props.name}
|
|
72
|
+
</button>
|
|
73
|
+
</Show>
|
|
74
|
+
);
|
|
75
|
+
|
|
50
76
|
return (
|
|
51
77
|
<div
|
|
52
78
|
class={props.class ? `zylem-track-header ${props.class}` : 'zylem-track-header'}
|
|
@@ -54,6 +80,8 @@ export function TrackHeader(props: TrackHeaderProps) {
|
|
|
54
80
|
data-selected={props.selected ? '' : undefined}
|
|
55
81
|
data-muted={props.muted ? '' : undefined}
|
|
56
82
|
data-disabled={props.disabled ? '' : undefined}
|
|
83
|
+
role="group"
|
|
84
|
+
aria-label={props.name}
|
|
57
85
|
onPointerDown={() => props.onSelect?.()}
|
|
58
86
|
>
|
|
59
87
|
<span class="zylem-track-header-swatch" aria-hidden="true" />
|
|
@@ -62,14 +90,7 @@ export function TrackHeader(props: TrackHeaderProps) {
|
|
|
62
90
|
<Show when={props.icon}>
|
|
63
91
|
<span class="zylem-track-header-icon">{props.icon}</span>
|
|
64
92
|
</Show>
|
|
65
|
-
<Show
|
|
66
|
-
when={editing()}
|
|
67
|
-
fallback={
|
|
68
|
-
<span class="zylem-track-header-name" onDblClick={startRename} title={props.name}>
|
|
69
|
-
{props.name}
|
|
70
|
-
</span>
|
|
71
|
-
}
|
|
72
|
-
>
|
|
93
|
+
<Show when={editing()} fallback={name()}>
|
|
73
94
|
<input
|
|
74
95
|
class="zylem-track-header-input"
|
|
75
96
|
value={draft()}
|
|
@@ -95,7 +116,7 @@ export function TrackHeader(props: TrackHeaderProps) {
|
|
|
95
116
|
class="zylem-track-toggle"
|
|
96
117
|
data-kind="mute"
|
|
97
118
|
aria-pressed={props.muted ? 'true' : 'false'}
|
|
98
|
-
aria-label=
|
|
119
|
+
aria-label="Mute"
|
|
99
120
|
title={props.muted ? 'Unmute' : 'Mute'}
|
|
100
121
|
disabled={props.disabled}
|
|
101
122
|
onPointerDown={(event) => event.stopPropagation()}
|
|
@@ -110,7 +131,7 @@ export function TrackHeader(props: TrackHeaderProps) {
|
|
|
110
131
|
class="zylem-track-toggle"
|
|
111
132
|
data-kind="solo"
|
|
112
133
|
aria-pressed={props.solo ? 'true' : 'false'}
|
|
113
|
-
aria-label=
|
|
134
|
+
aria-label="Solo"
|
|
114
135
|
title={props.solo ? 'Unsolo' : 'Solo'}
|
|
115
136
|
disabled={props.disabled}
|
|
116
137
|
onPointerDown={(event) => event.stopPropagation()}
|
|
@@ -125,13 +146,17 @@ export function TrackHeader(props: TrackHeaderProps) {
|
|
|
125
146
|
class="zylem-track-toggle"
|
|
126
147
|
data-kind="lock"
|
|
127
148
|
aria-pressed={props.locked ? 'true' : 'false'}
|
|
128
|
-
aria-label=
|
|
149
|
+
aria-label="Lock"
|
|
129
150
|
title={props.locked ? 'Unlock' : 'Lock'}
|
|
130
151
|
disabled={props.disabled}
|
|
131
152
|
onPointerDown={(event) => event.stopPropagation()}
|
|
132
153
|
onClick={() => props.onToggleLock?.()}
|
|
133
154
|
>
|
|
134
|
-
{props.locked ?
|
|
155
|
+
{props.locked ? (
|
|
156
|
+
<Lock class="zylem-icon" aria-hidden="true" />
|
|
157
|
+
) : (
|
|
158
|
+
<LockOpen class="zylem-icon" aria-hidden="true" />
|
|
159
|
+
)}
|
|
135
160
|
</button>
|
|
136
161
|
</Show>
|
|
137
162
|
<Show when={props.actions}>
|
|
@@ -3,7 +3,7 @@ import { type JSX, Show } from 'solid-js';
|
|
|
3
3
|
import { NumberField } from '../NumberField/NumberField';
|
|
4
4
|
import { PlaybackControls } from '../PlaybackControls/PlaybackControls';
|
|
5
5
|
import { Select } from '../Select/Select';
|
|
6
|
-
import { formatBeats, formatClock, type TimeUnit } from '../TimelineLanes/lanes-math';
|
|
6
|
+
import { describeTime, formatBeats, formatClock, type TimeUnit } from '../TimelineLanes/lanes-math';
|
|
7
7
|
import { TimelineTrack } from '../TimelineTrack/TimelineTrack';
|
|
8
8
|
import { ToolbarButton } from '../ToolbarButton/ToolbarButton';
|
|
9
9
|
|
|
@@ -107,6 +107,8 @@ export function TransportBar(props: TransportBarProps) {
|
|
|
107
107
|
max={props.length}
|
|
108
108
|
onChange={(value) => props.onSeek?.(value)}
|
|
109
109
|
disabled={props.disabled || !props.onSeek}
|
|
110
|
+
aria-label="Position"
|
|
111
|
+
valueText={(value) => describeTime(value, { unit: unit(), beatsPerBar: bpb() })}
|
|
110
112
|
class="zylem-transport-track"
|
|
111
113
|
/>
|
|
112
114
|
<span class="zylem-transport-time" aria-live="off">
|
|
@@ -133,6 +135,7 @@ export function TransportBar(props: TransportBarProps) {
|
|
|
133
135
|
<Show when={props.timeSignature}>
|
|
134
136
|
{(signature) => (
|
|
135
137
|
<Select
|
|
138
|
+
aria-label="Time signature"
|
|
136
139
|
options={signatureOptions()}
|
|
137
140
|
value={signatureKey(signature())}
|
|
138
141
|
disabled={props.disabled || !props.onTimeSignatureChange}
|
|
@@ -66,6 +66,16 @@ globalStyle('.zylem-waveform-edge::after', {
|
|
|
66
66
|
boxSizing: 'border-box',
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
// The ring goes on the grip: around the full-height edge it would be clipped.
|
|
70
|
+
globalStyle('.zylem-waveform-edge:focus-visible', {
|
|
71
|
+
outline: 'none',
|
|
72
|
+
boxShadow: 'none',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
globalStyle('.zylem-waveform-edge:focus-visible::after', {
|
|
76
|
+
boxShadow: vars.effects.focusRing,
|
|
77
|
+
});
|
|
78
|
+
|
|
69
79
|
globalStyle('.zylem-waveform-playhead', {
|
|
70
80
|
position: 'absolute',
|
|
71
81
|
top: 0,
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js';
|
|
2
|
-
import { clamp } from '../TimelineLanes/lanes-math';
|
|
1
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js';
|
|
2
|
+
import { clamp, describeSeconds, sliderKeyValue } from '../TimelineLanes/lanes-math';
|
|
3
3
|
import { computePeaks, peaksFromAudioBuffer } from './peaks';
|
|
4
4
|
|
|
5
|
+
/** Seconds the trim edges must stay apart. */
|
|
6
|
+
const MIN_REGION = 0.01;
|
|
7
|
+
|
|
8
|
+
const EDGES = [
|
|
9
|
+
{ edge: 'start', label: 'Trim start' },
|
|
10
|
+
{ edge: 'end', label: 'Trim end' },
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
5
13
|
export type { PeakSource } from './peaks';
|
|
6
14
|
|
|
7
15
|
export interface WaveformRegion {
|
|
@@ -17,7 +25,10 @@ export interface WaveformProps {
|
|
|
17
25
|
buffer?: { duration: number; numberOfChannels: number; getChannelData(channel: number): Float32Array };
|
|
18
26
|
/** Seconds the peaks span; defaults to `buffer.duration`. Needed for regions and the playhead. */
|
|
19
27
|
duration?: number;
|
|
20
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Highlighted span with draggable edges. Focused, an edge also takes the
|
|
30
|
+
* arrow keys (1% of the duration, 10% with Shift) and Home / End.
|
|
31
|
+
*/
|
|
21
32
|
region?: WaveformRegion;
|
|
22
33
|
onRegionChange?: (region: WaveformRegion) => void;
|
|
23
34
|
onRegionCommit?: (region: WaveformRegion) => void;
|
|
@@ -106,14 +117,15 @@ export function Waveform(props: WaveformProps) {
|
|
|
106
117
|
event.preventDefault();
|
|
107
118
|
event.stopPropagation();
|
|
108
119
|
const target = event.currentTarget as HTMLElement;
|
|
120
|
+
target.focus({ preventScroll: true });
|
|
109
121
|
target.setPointerCapture?.(event.pointerId);
|
|
110
122
|
let last = region;
|
|
111
123
|
const move = (moveEvent: PointerEvent) => {
|
|
112
124
|
const seconds = secondsAt(moveEvent.clientX);
|
|
113
125
|
last =
|
|
114
126
|
edge === 'start'
|
|
115
|
-
? { start: Math.min(seconds, region.end -
|
|
116
|
-
: { start: region.start, end: Math.max(seconds, region.start +
|
|
127
|
+
? { start: Math.min(seconds, region.end - MIN_REGION), end: region.end }
|
|
128
|
+
: { start: region.start, end: Math.max(seconds, region.start + MIN_REGION) };
|
|
117
129
|
props.onRegionChange?.(last);
|
|
118
130
|
};
|
|
119
131
|
const stop = () => {
|
|
@@ -128,6 +140,29 @@ export function Waveform(props: WaveformProps) {
|
|
|
128
140
|
target.addEventListener('pointercancel', stop);
|
|
129
141
|
};
|
|
130
142
|
|
|
143
|
+
/** The span each edge may move in, short of crossing the other edge. */
|
|
144
|
+
const edgeBounds = (edge: 'start' | 'end', region: WaveformRegion) =>
|
|
145
|
+
edge === 'start'
|
|
146
|
+
? { min: 0, max: Math.max(0, region.end - MIN_REGION) }
|
|
147
|
+
: { min: Math.min(duration(), region.start + MIN_REGION), max: duration() };
|
|
148
|
+
|
|
149
|
+
const nudgeEdge = (edge: 'start' | 'end') => (event: KeyboardEvent) => {
|
|
150
|
+
const region = props.region;
|
|
151
|
+
if (props.disabled || !region) return;
|
|
152
|
+
const next = sliderKeyValue(event, {
|
|
153
|
+
value: region[edge],
|
|
154
|
+
...edgeBounds(edge, region),
|
|
155
|
+
step: duration() / 100,
|
|
156
|
+
});
|
|
157
|
+
if (next === null) return;
|
|
158
|
+
event.preventDefault();
|
|
159
|
+
// Page-level shortcuts (Home to rewind, arrows to seek) must not also act on it.
|
|
160
|
+
event.stopPropagation();
|
|
161
|
+
const changed = { ...region, [edge]: next };
|
|
162
|
+
props.onRegionChange?.(changed);
|
|
163
|
+
props.onRegionCommit?.(changed);
|
|
164
|
+
};
|
|
165
|
+
|
|
131
166
|
return (
|
|
132
167
|
<div
|
|
133
168
|
ref={host}
|
|
@@ -153,28 +188,25 @@ export function Waveform(props: WaveformProps) {
|
|
|
153
188
|
class="zylem-waveform-shade"
|
|
154
189
|
style={{ left: `${percent(region().end)}%`, right: 0 }}
|
|
155
190
|
/>
|
|
156
|
-
<
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
aria-valuemin={0}
|
|
176
|
-
aria-valuemax={duration()}
|
|
177
|
-
/>
|
|
191
|
+
<For each={EDGES}>
|
|
192
|
+
{(edge) => (
|
|
193
|
+
<div
|
|
194
|
+
class="zylem-waveform-edge"
|
|
195
|
+
data-edge={edge.edge}
|
|
196
|
+
style={{ left: `${percent(region()[edge.edge])}%` }}
|
|
197
|
+
onPointerDown={beginRegionDrag(edge.edge)}
|
|
198
|
+
onKeyDown={nudgeEdge(edge.edge)}
|
|
199
|
+
role="slider"
|
|
200
|
+
tabIndex={props.disabled ? -1 : 0}
|
|
201
|
+
aria-label={edge.label}
|
|
202
|
+
aria-valuenow={region()[edge.edge]}
|
|
203
|
+
aria-valuemin={edgeBounds(edge.edge, region()).min}
|
|
204
|
+
aria-valuemax={edgeBounds(edge.edge, region()).max}
|
|
205
|
+
aria-valuetext={describeSeconds(region()[edge.edge])}
|
|
206
|
+
aria-disabled={props.disabled ? 'true' : undefined}
|
|
207
|
+
/>
|
|
208
|
+
)}
|
|
209
|
+
</For>
|
|
178
210
|
</>
|
|
179
211
|
);
|
|
180
212
|
}}
|