@zylem/ui 0.8.13 → 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.
Files changed (58) hide show
  1. package/dist/styles.css +1 -1
  2. package/package.json +1 -1
  3. package/src/components/Accordion/Accordion.tsx +4 -1
  4. package/src/components/BuildLabel/BuildLabel.tsx +0 -1
  5. package/src/components/BuildLabel/mount-build-label.ts +0 -1
  6. package/src/components/Callout/Callout.tsx +1 -1
  7. package/src/components/Card/Card.css.ts +1 -0
  8. package/src/components/Card/Card.tsx +11 -2
  9. package/src/components/Checkbox/Checkbox.tsx +4 -2
  10. package/src/components/ColorPalette/ColorPalette.tsx +1 -1
  11. package/src/components/Console/Console.tsx +3 -0
  12. package/src/components/Dialog/Dialog.css.ts +41 -17
  13. package/src/components/Dialog/Dialog.tsx +134 -53
  14. package/src/components/FrameStrip/FrameStrip.tsx +2 -3
  15. package/src/components/ItemPicker/ItemPicker.tsx +46 -33
  16. package/src/components/MenuButton/MenuButton.tsx +1 -0
  17. package/src/components/Menubar/Menubar.css.ts +29 -29
  18. package/src/components/Mixer/Knob.tsx +10 -3
  19. package/src/components/Mixer/Mixer.css.ts +19 -0
  20. package/src/components/Mixer/MixerStrip.tsx +29 -8
  21. package/src/components/NumberField/NumberField.tsx +5 -2
  22. package/src/components/Panel/Panel.css.ts +1 -0
  23. package/src/components/Panel/Panel.tsx +9 -1
  24. package/src/components/PianoRoll/PianoRoll.tsx +1 -0
  25. package/src/components/Progress/Progress.tsx +3 -0
  26. package/src/components/Property/Property.css.ts +13 -0
  27. package/src/components/Property/Property.tsx +3 -2
  28. package/src/components/SearchInput/SearchInput.tsx +1 -1
  29. package/src/components/Select/Select.tsx +3 -1
  30. package/src/components/Sidebar/Sidebar.css.ts +1 -0
  31. package/src/components/Sidebar/Sidebar.tsx +23 -5
  32. package/src/components/Slider/Slider.tsx +4 -2
  33. package/src/components/Switch/Switch.tsx +3 -1
  34. package/src/components/Tabs/Tabs.css.ts +0 -5
  35. package/src/components/Tabs/Tabs.tsx +23 -12
  36. package/src/components/TextField/TextField.tsx +4 -0
  37. package/src/components/TimelineLanes/TimelineLanes.css.ts +6 -0
  38. package/src/components/TimelineLanes/TimelineLanes.tsx +52 -4
  39. package/src/components/TimelineLanes/lanes-math.ts +51 -0
  40. package/src/components/TimelineTrack/TimelineTrack.tsx +16 -3
  41. package/src/components/TitleBar/TitleBar.css.ts +1 -0
  42. package/src/components/TitleBar/TitleBar.tsx +7 -1
  43. package/src/components/Toast/Toast.tsx +1 -2
  44. package/src/components/ToolButton/ToolButton.tsx +1 -1
  45. package/src/components/Toolbar/Toolbar.tsx +6 -1
  46. package/src/components/ToolbarButton/ToolbarButton.tsx +9 -2
  47. package/src/components/ToolboxButton/ToolboxButton.tsx +2 -1
  48. package/src/components/TrackHeader/TrackHeader.css.ts +21 -0
  49. package/src/components/TrackHeader/TrackHeader.tsx +37 -12
  50. package/src/components/TransportBar/TransportBar.tsx +4 -1
  51. package/src/components/Waveform/Waveform.css.ts +10 -0
  52. package/src/components/Waveform/Waveform.tsx +59 -27
  53. package/src/components/WindowControls/WindowControls.tsx +4 -4
  54. package/src/components/index.ts +1 -0
  55. package/src/components/shared/Heading.tsx +30 -0
  56. package/src/global/accordion.css.ts +6 -1
  57. package/src/global/common.css.ts +13 -0
  58. package/src/layers/LayerContext.tsx +2 -2
@@ -66,6 +66,12 @@ globalStyle('.zylem-lanes-ruler', {
66
66
  cursor: 'pointer',
67
67
  });
68
68
 
69
+ // Inset: the scrolling lanes would clip a ring drawn outside the sticky ruler.
70
+ globalStyle('.zylem-lanes-ruler:focus-visible', {
71
+ outline: 'none',
72
+ boxShadow: 'inset 0 0 0 2px rgba(97, 166, 232, 0.6)',
73
+ });
74
+
69
75
  globalStyle('.zylem-lanes-range', {
70
76
  position: 'absolute',
71
77
  top: 0,
@@ -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
- /** Fires while the ruler is clicked or dragged. */
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
- onSelect?: (event: PointerEvent) => void;
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
- onSelect?: (event: PointerEvent) => void;
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 class="zylem-timeline-thumb">
79
- <KSlider.Input class="zylem-timeline-thumb-input" />
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>
@@ -26,6 +26,7 @@ globalStyle('.zylem-titlebar--compact', {
26
26
 
27
27
  globalStyle('.zylem-titlebar-title', {
28
28
  flex: 1,
29
+ margin: 0,
29
30
  fontSize: '13px',
30
31
  fontWeight: 700,
31
32
  letterSpacing: '0.02em',
@@ -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
- <span class="zylem-titlebar-title">{local.title}</span>
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="status"
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
- selected={props.selected}
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 role="toolbar" class={classes()} {...rest}>
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 state renders as a live-green jewel.
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 ? 'true' : undefined}
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={props.muted ? 'Unmute' : 'Mute'}
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={props.solo ? 'Unsolo' : 'Solo'}
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={props.locked ? 'Unlock' : 'Lock'}
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 ? <Lock class="zylem-icon" /> : <LockOpen class="zylem-icon" />}
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,