svelte-p5-components 0.4.0 → 0.5.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.
@@ -2,31 +2,38 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import TimelineTrack, {
4
4
  type TimelineSegment,
5
- type TimelineViewWindow
5
+ type TimelineSelection
6
6
  } from './TimelineTrack.svelte';
7
7
 
8
- // Full timeline widget: scrub track + play/pause + speed picker + time
9
- // display. For apps that want the standard canvas-app timeline shape
10
- // without reinventing controls.
8
+ // Stacked player bar for canvas apps. Lays the timeline out like a
9
+ // video player:
11
10
  //
12
- // TimelineScrubber layers on top of <TimelineTrack> — reach for the track
13
- // directly if you want custom chrome around it.
11
+ // [=========================track (full width)==========================]
12
+ // [▶ ◀◀ 1×] [00:12 / 03:45]
14
13
  //
15
- // Bindable: currentTime, isPlaying, speed, viewWindow. The consumer owns
16
- // the animation loop that advances currentTime when isPlaying is true
17
- // this component just displays state and surfaces user intent.
14
+ // Track takes the full width of the component. Controls live on a row
15
+ // below it: play / skip / speed on the left, time display on the right.
16
+ // Clicking the time display toggles between total duration and
17
+ // remaining time (YouTube-style).
18
18
  //
19
- // `speedLocked` (with an optional reason) is the UX-disclosure hook for
20
- // video-driven playback, where the speed multiplier is effectively
21
- // ignored. Set it to true and the speed button renders muted with a
22
- // title-tip.
19
+ // The track itself is mostly chrome-free at rest — only the playhead
20
+ // and a tinted selection band are visible. Hovering the track reveals
21
+ // grab affordances for the selection handles and grows the playhead
22
+ // knob. This keeps the resting state quiet without hiding the
23
+ // existence of the selection range.
23
24
  //
24
- // See the package README for usage examples.
25
+ // Bindable: currentTime, isPlaying, speed, selectionStart, selectionEnd.
26
+ // The consumer owns the animation loop that advances currentTime when
27
+ // isPlaying is true.
25
28
 
26
29
  interface Props {
30
+ /** Total timeline length. */
27
31
  duration: number;
32
+ /** Current playhead position. Bindable. */
28
33
  currentTime?: number;
34
+ /** Is playback running? Bindable. */
29
35
  isPlaying?: boolean;
36
+ /** Current playback speed multiplier. Bindable. */
30
37
  speed?: number;
31
38
  /** Available speed presets. Default: [1, 2, 4, 8, 16]. */
32
39
  speedOptions?: number[];
@@ -34,17 +41,37 @@
34
41
  speedLocked?: boolean;
35
42
  /** Title/tooltip shown when `speedLocked` is true. */
36
43
  speedLockedReason?: string;
37
- /** Optional zoomed view window; bindable. */
38
- viewWindow?: TimelineViewWindow;
44
+ /** Selection range start. Bindable. */
45
+ selectionStart?: number;
46
+ /** Selection range end. Bindable. */
47
+ selectionEnd?: number;
48
+ /** Hide the selection band + handles. Default: auto (show when both bounds set). */
49
+ showSelection?: boolean;
50
+ /** Include a "skip to selection start" button next to play. */
51
+ showSkipToStart?: boolean;
52
+ /** Highlighted labeled regions. */
39
53
  segments?: TimelineSegment[];
40
- /** Format `currentTime` and `duration` for display. Default: mm:ss. */
54
+ /** Format a time value for display. Default: mm:ss (or h:mm:ss for ≥1h). */
41
55
  formatTime?: (seconds: number) => string;
56
+ /** Seek callback (from track click or playhead drag). */
42
57
  onSeek?: (time: number) => void;
58
+ /** Fired every frame while a selection handle is dragged. */
59
+ onSelectionChange?: (selection: TimelineSelection) => void;
60
+ /** Fired when a selection drag is released. */
61
+ onSelectionCommit?: (selection: TimelineSelection) => void;
62
+ /** Fired when the user toggles play. */
43
63
  onPlayToggle?: (nowPlaying: boolean) => void;
64
+ /** Fired when the user cycles the speed. */
44
65
  onSpeedChange?: (speed: number) => void;
66
+ /** Fired when the user clicks a segment. */
45
67
  onSegmentClick?: (segment: TimelineSegment) => void;
46
- /** Snippet rendered above the hovered time, e.g. for a turn-preview tooltip. */
47
- hoverIndicator?: Snippet<[{ time: number; left: number }]>;
68
+ /** Snippet rendered above the hover x position. */
69
+ hoverPreview?: Snippet<[{ time: number; xPercent: number }]>;
70
+ /**
71
+ * When dragging the selection start handle, also move the playhead to
72
+ * the new start (and fire `onSeek`). Default: false.
73
+ */
74
+ playheadFollowsSelectionStart?: boolean;
48
75
  class?: string;
49
76
  }
50
77
 
@@ -56,21 +83,36 @@
56
83
  speedOptions = [1, 2, 4, 8, 16],
57
84
  speedLocked = false,
58
85
  speedLockedReason,
59
- viewWindow = $bindable(undefined),
86
+ selectionStart = $bindable<number | undefined>(undefined),
87
+ selectionEnd = $bindable<number | undefined>(undefined),
88
+ showSelection,
89
+ showSkipToStart = true,
60
90
  segments = [],
61
91
  formatTime = defaultFormatTime,
62
92
  onSeek,
93
+ onSelectionChange,
94
+ onSelectionCommit,
63
95
  onPlayToggle,
64
96
  onSpeedChange,
65
97
  onSegmentClick,
66
- hoverIndicator,
98
+ hoverPreview,
99
+ playheadFollowsSelectionStart = false,
67
100
  class: className = ''
68
101
  }: Props = $props();
69
102
 
103
+ let timeMode = $state<'total' | 'remaining'>('total');
104
+
105
+ const remaining = $derived(Math.max(0, duration - currentTime));
106
+
70
107
  function defaultFormatTime(seconds: number): string {
71
108
  if (!isFinite(seconds)) return '00:00';
72
- const m = Math.floor(seconds / 60);
73
- const s = Math.floor(seconds % 60);
109
+ const total = Math.max(0, Math.floor(seconds));
110
+ const h = Math.floor(total / 3600);
111
+ const m = Math.floor((total % 3600) / 60);
112
+ const s = total % 60;
113
+ if (h > 0) {
114
+ return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
115
+ }
74
116
  return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
75
117
  }
76
118
 
@@ -87,38 +129,70 @@
87
129
  speed = next;
88
130
  onSpeedChange?.(next);
89
131
  }
132
+
133
+ function handleSkipToStart() {
134
+ const target = selectionStart ?? 0;
135
+ currentTime = target;
136
+ onSeek?.(target);
137
+ }
138
+
139
+ function toggleTimeMode() {
140
+ timeMode = timeMode === 'total' ? 'remaining' : 'total';
141
+ }
90
142
  </script>
91
143
 
92
144
  <div class="timeline-scrubber {className}">
93
- <div class="timeline-scrubber__track">
94
- <span class="timeline-scrubber__time" aria-label="start"
95
- >{formatTime(viewWindow?.start ?? 0)}</span
96
- >
145
+ <div class="timeline-scrubber__track-row">
97
146
  <TimelineTrack
98
147
  {duration}
99
148
  bind:currentTime
100
- bind:viewWindow
149
+ bind:selectionStart
150
+ bind:selectionEnd
151
+ {showSelection}
101
152
  {segments}
102
153
  {onSeek}
154
+ {onSelectionChange}
155
+ {onSelectionCommit}
103
156
  {onSegmentClick}
104
- {hoverIndicator}
157
+ {hoverPreview}
158
+ {playheadFollowsSelectionStart}
105
159
  />
106
- <span class="timeline-scrubber__time" aria-label="end">
107
- {formatTime(viewWindow?.end ?? duration)}
108
- </span>
109
160
  </div>
110
161
 
111
- <div class="timeline-scrubber__controls">
162
+ <div class="timeline-scrubber__controls-row">
112
163
  <button
113
164
  type="button"
114
- class="timeline-scrubber__btn"
165
+ class="timeline-scrubber__btn timeline-scrubber__btn--play"
115
166
  aria-pressed={isPlaying}
116
167
  aria-label={isPlaying ? 'Pause' : 'Play'}
168
+ title={isPlaying ? 'Pause' : 'Play'}
117
169
  onclick={togglePlay}
118
170
  >
119
- {isPlaying ? '❚❚' : '▶'}
171
+ {#if isPlaying}
172
+ <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
173
+ <path d="M6 4h4v16H6zM14 4h4v16h-4z" fill="currentColor" />
174
+ </svg>
175
+ {:else}
176
+ <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
177
+ <path d="M8 5v14l11-7z" fill="currentColor" />
178
+ </svg>
179
+ {/if}
120
180
  </button>
121
181
 
182
+ {#if showSkipToStart}
183
+ <button
184
+ type="button"
185
+ class="timeline-scrubber__btn"
186
+ aria-label="Skip to start"
187
+ title="Skip to start"
188
+ onclick={handleSkipToStart}
189
+ >
190
+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
191
+ <path d="M6 5h2v14H6zM20 5 9 12l11 7z" fill="currentColor" />
192
+ </svg>
193
+ </button>
194
+ {/if}
195
+
122
196
  <button
123
197
  type="button"
124
198
  class="timeline-scrubber__btn timeline-scrubber__btn--speed"
@@ -130,12 +204,24 @@
130
204
  disabled={speedLocked}
131
205
  onclick={cycleSpeed}
132
206
  >
133
- {speed}×{speedLocked ? ' ·' : ''}
207
+ <span>{speed}×</span>
134
208
  </button>
135
209
 
136
- <span class="timeline-scrubber__currtime" aria-label="Current time">
137
- {formatTime(currentTime)}
138
- </span>
210
+ <button
211
+ type="button"
212
+ class="timeline-scrubber__time-display"
213
+ aria-label="Toggle time display"
214
+ title={timeMode === 'total' ? 'Click to show remaining time' : 'Click to show total time'}
215
+ onclick={toggleTimeMode}
216
+ >
217
+ <span class="timeline-scrubber__time-curr">{formatTime(currentTime)}</span>
218
+ <span class="timeline-scrubber__time-sep">/</span>
219
+ {#if timeMode === 'remaining'}
220
+ <span class="timeline-scrubber__time-alt">-{formatTime(remaining)}</span>
221
+ {:else}
222
+ <span class="timeline-scrubber__time-total">{formatTime(duration)}</span>
223
+ {/if}
224
+ </button>
139
225
  </div>
140
226
  </div>
141
227
 
@@ -143,52 +229,77 @@
143
229
  .timeline-scrubber {
144
230
  display: flex;
145
231
  flex-direction: column;
232
+ width: 100%;
146
233
  gap: 4px;
234
+ padding: 6px 10px 8px;
235
+ box-sizing: border-box;
147
236
  font:
148
- 12px/1 system-ui,
237
+ 14px/1 system-ui,
149
238
  -apple-system,
150
239
  Segoe UI,
151
240
  sans-serif;
152
- }
153
-
154
- .timeline-scrubber__track {
155
- display: flex;
156
- align-items: center;
157
- gap: 8px;
158
- }
159
241
 
160
- .timeline-scrubber__track :global(.timeline-track) {
161
- flex: 1 1 auto;
162
- min-width: 0;
242
+ --ts-btn-bg: #ffffff;
243
+ --ts-btn-bg-hover: #f9fafb;
244
+ --ts-btn-bg-active: #f3f4f6;
245
+ --ts-btn-border: rgba(0, 0, 0, 0.12);
246
+ --ts-btn-border-hover: rgba(0, 0, 0, 0.22);
247
+ --ts-btn-fg: #1f2937;
248
+ --ts-btn-fg-hover: #111827;
249
+ --ts-time-fg: #1f2937;
250
+ --ts-time-muted-fg: #6b7280;
163
251
  }
164
252
 
165
- .timeline-scrubber__time {
166
- font-family: ui-monospace, 'SF Mono', Menlo, monospace;
167
- font-size: 11px;
168
- color: #6b7280;
169
- min-width: 3.5em;
170
- text-align: center;
253
+ .timeline-scrubber__track-row {
254
+ width: 100%;
171
255
  }
172
256
 
173
- .timeline-scrubber__controls {
257
+ .timeline-scrubber__controls-row {
174
258
  display: flex;
175
259
  align-items: center;
176
- gap: 8px;
177
- justify-content: center;
260
+ justify-content: flex-start;
261
+ width: 100%;
262
+ gap: 4px;
263
+ min-height: 36px;
264
+ flex-wrap: nowrap;
178
265
  }
179
266
 
180
267
  .timeline-scrubber__btn {
181
- background: #fff;
182
- border: 1px solid #e5e7eb;
183
- border-radius: 4px;
184
- padding: 4px 10px;
268
+ display: inline-flex;
269
+ align-items: center;
270
+ justify-content: center;
271
+ gap: 4px;
272
+ background: var(--ts-btn-bg);
273
+ border: 1px solid var(--ts-btn-border);
274
+ border-radius: 6px;
275
+ padding: 0 10px;
276
+ min-width: 36px;
277
+ height: 36px;
278
+ color: var(--ts-btn-fg);
185
279
  cursor: pointer;
186
280
  font: inherit;
187
281
  line-height: 1;
282
+ transition:
283
+ background-color 120ms ease,
284
+ border-color 120ms ease,
285
+ color 120ms ease,
286
+ opacity 120ms ease;
287
+ flex: 0 0 auto;
188
288
  }
189
289
 
190
290
  .timeline-scrubber__btn:hover:not(:disabled) {
191
- background: #f3f4f6;
291
+ background: var(--ts-btn-bg-hover);
292
+ border-color: var(--ts-btn-border-hover);
293
+ color: var(--ts-btn-fg-hover);
294
+ }
295
+
296
+ .timeline-scrubber__btn:active:not(:disabled) {
297
+ background: var(--ts-btn-bg-active);
298
+ }
299
+
300
+ .timeline-scrubber__btn:focus-visible {
301
+ outline: 2px solid var(--ts-btn-fg);
302
+ outline-offset: 2px;
192
303
  }
193
304
 
194
305
  .timeline-scrubber__btn:disabled {
@@ -196,19 +307,61 @@
196
307
  }
197
308
 
198
309
  .timeline-scrubber__btn--speed {
199
- min-width: 3em;
200
310
  font-variant-numeric: tabular-nums;
311
+ font-weight: 600;
312
+ font-size: 13px;
313
+ padding: 0 10px;
314
+ min-width: 42px;
201
315
  }
202
316
 
203
317
  .timeline-scrubber__btn--speed.is-locked {
204
318
  opacity: 0.5;
205
319
  }
206
320
 
207
- .timeline-scrubber__currtime {
321
+ .timeline-scrubber__time-display {
322
+ background: transparent;
323
+ border: 1px solid transparent;
324
+ padding: 6px 10px;
325
+ margin: 0;
326
+ border-radius: 6px;
327
+ cursor: pointer;
328
+ font: inherit;
208
329
  font-family: ui-monospace, 'SF Mono', Menlo, monospace;
209
- font-size: 12px;
210
- color: #111;
211
- min-width: 4em;
212
- text-align: right;
330
+ font-size: 13px;
331
+ font-variant-numeric: tabular-nums;
332
+ color: var(--ts-time-fg);
333
+ display: inline-flex;
334
+ align-items: center;
335
+ gap: 6px;
336
+ flex: 0 0 auto;
337
+ transition:
338
+ background-color 120ms ease,
339
+ border-color 120ms ease;
340
+ white-space: nowrap;
341
+ }
342
+
343
+ .timeline-scrubber__time-display:hover {
344
+ background: var(--ts-btn-bg-hover);
345
+ border-color: var(--ts-btn-border);
346
+ }
347
+
348
+ .timeline-scrubber__time-display:focus-visible {
349
+ outline: 2px solid var(--ts-btn-fg);
350
+ outline-offset: 2px;
351
+ }
352
+
353
+ .timeline-scrubber__time-curr {
354
+ color: var(--ts-time-fg);
355
+ font-weight: 500;
356
+ }
357
+
358
+ .timeline-scrubber__time-sep {
359
+ color: var(--ts-time-muted-fg);
360
+ opacity: 0.5;
361
+ }
362
+
363
+ .timeline-scrubber__time-total,
364
+ .timeline-scrubber__time-alt {
365
+ color: var(--ts-time-muted-fg);
213
366
  }
214
367
  </style>
@@ -1,9 +1,13 @@
1
1
  import type { Snippet } from 'svelte';
2
- import { type TimelineSegment, type TimelineViewWindow } from './TimelineTrack.svelte';
2
+ import { type TimelineSegment, type TimelineSelection } from './TimelineTrack.svelte';
3
3
  interface Props {
4
+ /** Total timeline length. */
4
5
  duration: number;
6
+ /** Current playhead position. Bindable. */
5
7
  currentTime?: number;
8
+ /** Is playback running? Bindable. */
6
9
  isPlaying?: boolean;
10
+ /** Current playback speed multiplier. Bindable. */
7
11
  speed?: number;
8
12
  /** Available speed presets. Default: [1, 2, 4, 8, 16]. */
9
13
  speedOptions?: number[];
@@ -11,22 +15,42 @@ interface Props {
11
15
  speedLocked?: boolean;
12
16
  /** Title/tooltip shown when `speedLocked` is true. */
13
17
  speedLockedReason?: string;
14
- /** Optional zoomed view window; bindable. */
15
- viewWindow?: TimelineViewWindow;
18
+ /** Selection range start. Bindable. */
19
+ selectionStart?: number;
20
+ /** Selection range end. Bindable. */
21
+ selectionEnd?: number;
22
+ /** Hide the selection band + handles. Default: auto (show when both bounds set). */
23
+ showSelection?: boolean;
24
+ /** Include a "skip to selection start" button next to play. */
25
+ showSkipToStart?: boolean;
26
+ /** Highlighted labeled regions. */
16
27
  segments?: TimelineSegment[];
17
- /** Format `currentTime` and `duration` for display. Default: mm:ss. */
28
+ /** Format a time value for display. Default: mm:ss (or h:mm:ss for ≥1h). */
18
29
  formatTime?: (seconds: number) => string;
30
+ /** Seek callback (from track click or playhead drag). */
19
31
  onSeek?: (time: number) => void;
32
+ /** Fired every frame while a selection handle is dragged. */
33
+ onSelectionChange?: (selection: TimelineSelection) => void;
34
+ /** Fired when a selection drag is released. */
35
+ onSelectionCommit?: (selection: TimelineSelection) => void;
36
+ /** Fired when the user toggles play. */
20
37
  onPlayToggle?: (nowPlaying: boolean) => void;
38
+ /** Fired when the user cycles the speed. */
21
39
  onSpeedChange?: (speed: number) => void;
40
+ /** Fired when the user clicks a segment. */
22
41
  onSegmentClick?: (segment: TimelineSegment) => void;
23
- /** Snippet rendered above the hovered time, e.g. for a turn-preview tooltip. */
24
- hoverIndicator?: Snippet<[{
42
+ /** Snippet rendered above the hover x position. */
43
+ hoverPreview?: Snippet<[{
25
44
  time: number;
26
- left: number;
45
+ xPercent: number;
27
46
  }]>;
47
+ /**
48
+ * When dragging the selection start handle, also move the playhead to
49
+ * the new start (and fire `onSeek`). Default: false.
50
+ */
51
+ playheadFollowsSelectionStart?: boolean;
28
52
  class?: string;
29
53
  }
30
- declare const TimelineScrubber: import("svelte").Component<Props, {}, "currentTime" | "viewWindow" | "isPlaying" | "speed">;
54
+ declare const TimelineScrubber: import("svelte").Component<Props, {}, "currentTime" | "selectionStart" | "selectionEnd" | "isPlaying" | "speed">;
31
55
  type TimelineScrubber = ReturnType<typeof TimelineScrubber>;
32
56
  export default TimelineScrubber;
@@ -53,14 +53,15 @@ describe('<TimelineScrubber>', () => {
53
53
  await fireEvent.click(btn);
54
54
  expect(onSpeedChange).not.toHaveBeenCalled();
55
55
  });
56
- it('uses the custom formatTime prop for both edge labels and the current-time readout', () => {
56
+ it('uses the custom formatTime prop for the current-time and duration readout', () => {
57
+ // YouTube-style readout shows `currentTime / duration`; there is no
58
+ // separate start-edge label (that was the pre-redesign layout).
57
59
  const formatTime = (s) => `[${Math.round(s)}s]`;
58
60
  const { container } = render(TimelineScrubber, {
59
61
  props: { duration: 120, currentTime: 42, formatTime }
60
62
  });
61
63
  const text = container.textContent ?? '';
62
- expect(text).toContain('[0s]');
63
- expect(text).toContain('[120s]');
64
64
  expect(text).toContain('[42s]');
65
+ expect(text).toContain('[120s]');
65
66
  });
66
67
  });