svelte-p5-components 0.2.1 → 0.4.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.
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import { createRawSnippet } from 'svelte';
4
+ import SplitPane from './SplitPane.svelte';
5
+ const snippet = (html) => createRawSnippet(() => ({ render: () => html }));
6
+ describe('<SplitPane>', () => {
7
+ it('renders both panels when not collapsed', () => {
8
+ const { container } = render(SplitPane, {
9
+ props: {
10
+ first: snippet('<div>FIRST</div>'),
11
+ second: snippet('<div>SECOND</div>')
12
+ }
13
+ });
14
+ expect(container.textContent).toContain('FIRST');
15
+ expect(container.textContent).toContain('SECOND');
16
+ expect(container.querySelectorAll('.split-pane__panel')).toHaveLength(2);
17
+ });
18
+ it('shows the divider between panels', () => {
19
+ const { container } = render(SplitPane, {
20
+ props: { first: snippet('<div>a</div>'), second: snippet('<div>b</div>') }
21
+ });
22
+ expect(container.querySelector('.split-pane__divider')).not.toBeNull();
23
+ });
24
+ it('hides the divider when collapsed', () => {
25
+ const { container } = render(SplitPane, {
26
+ props: {
27
+ first: snippet('<div>a</div>'),
28
+ second: snippet('<div>b</div>'),
29
+ collapsed: true
30
+ }
31
+ });
32
+ expect(container.querySelector('.split-pane__divider')).toBeNull();
33
+ });
34
+ it('applies orientation class to the root', () => {
35
+ const { container: v } = render(SplitPane, {
36
+ props: {
37
+ first: snippet('<div>a</div>'),
38
+ second: snippet('<div>b</div>'),
39
+ orientation: 'vertical'
40
+ }
41
+ });
42
+ expect(v.querySelector('.split-pane.vertical')).not.toBeNull();
43
+ const { container: h } = render(SplitPane, {
44
+ props: {
45
+ first: snippet('<div>a</div>'),
46
+ second: snippet('<div>b</div>'),
47
+ orientation: 'horizontal'
48
+ }
49
+ });
50
+ expect(h.querySelector('.split-pane.horizontal')).not.toBeNull();
51
+ });
52
+ it('reflects sizes prop in inline styles on panels', () => {
53
+ const { container } = render(SplitPane, {
54
+ props: {
55
+ first: snippet('<div>a</div>'),
56
+ second: snippet('<div>b</div>'),
57
+ sizes: [30, 70]
58
+ }
59
+ });
60
+ const panels = container.querySelectorAll('.split-pane__panel');
61
+ expect(panels[0]?.style.height).toBe('30%');
62
+ expect(panels[1]?.style.height).toBe('70%');
63
+ });
64
+ it('arrow keys resize and fire onresize', async () => {
65
+ const onresize = vi.fn();
66
+ const { container } = render(SplitPane, {
67
+ props: {
68
+ first: snippet('<div>a</div>'),
69
+ second: snippet('<div>b</div>'),
70
+ sizes: [50, 50],
71
+ orientation: 'vertical',
72
+ onresize
73
+ }
74
+ });
75
+ const divider = container.querySelector('.split-pane__divider');
76
+ await fireEvent.keyDown(divider, { key: 'ArrowDown' });
77
+ expect(onresize).toHaveBeenCalledTimes(1);
78
+ const call = onresize.mock.calls[0];
79
+ if (!call)
80
+ throw new Error('onresize call unexpectedly missing');
81
+ expect(call[0].sizes[0]).toBeGreaterThan(50);
82
+ expect(call[0].sizes[1]).toBeLessThan(50);
83
+ expect(call[0].sizes[0] + call[0].sizes[1]).toBeCloseTo(100);
84
+ });
85
+ });
@@ -0,0 +1,214 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import TimelineTrack, {
4
+ type TimelineSegment,
5
+ type TimelineViewWindow
6
+ } from './TimelineTrack.svelte';
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.
11
+ //
12
+ // TimelineScrubber layers on top of <TimelineTrack> — reach for the track
13
+ // directly if you want custom chrome around it.
14
+ //
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.
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.
23
+ //
24
+ // See the package README for usage examples.
25
+
26
+ interface Props {
27
+ duration: number;
28
+ currentTime?: number;
29
+ isPlaying?: boolean;
30
+ speed?: number;
31
+ /** Available speed presets. Default: [1, 2, 4, 8, 16]. */
32
+ speedOptions?: number[];
33
+ /** Render speed as disabled / muted. Use when video playback overrides it. */
34
+ speedLocked?: boolean;
35
+ /** Title/tooltip shown when `speedLocked` is true. */
36
+ speedLockedReason?: string;
37
+ /** Optional zoomed view window; bindable. */
38
+ viewWindow?: TimelineViewWindow;
39
+ segments?: TimelineSegment[];
40
+ /** Format `currentTime` and `duration` for display. Default: mm:ss. */
41
+ formatTime?: (seconds: number) => string;
42
+ onSeek?: (time: number) => void;
43
+ onPlayToggle?: (nowPlaying: boolean) => void;
44
+ onSpeedChange?: (speed: number) => void;
45
+ 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 }]>;
48
+ class?: string;
49
+ }
50
+
51
+ let {
52
+ duration,
53
+ currentTime = $bindable(0),
54
+ isPlaying = $bindable(false),
55
+ speed = $bindable(1),
56
+ speedOptions = [1, 2, 4, 8, 16],
57
+ speedLocked = false,
58
+ speedLockedReason,
59
+ viewWindow = $bindable(undefined),
60
+ segments = [],
61
+ formatTime = defaultFormatTime,
62
+ onSeek,
63
+ onPlayToggle,
64
+ onSpeedChange,
65
+ onSegmentClick,
66
+ hoverIndicator,
67
+ class: className = ''
68
+ }: Props = $props();
69
+
70
+ function defaultFormatTime(seconds: number): string {
71
+ if (!isFinite(seconds)) return '00:00';
72
+ const m = Math.floor(seconds / 60);
73
+ const s = Math.floor(seconds % 60);
74
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
75
+ }
76
+
77
+ function togglePlay() {
78
+ isPlaying = !isPlaying;
79
+ onPlayToggle?.(isPlaying);
80
+ }
81
+
82
+ function cycleSpeed() {
83
+ if (speedLocked) return;
84
+ if (speedOptions.length === 0) return;
85
+ const idx = speedOptions.indexOf(speed);
86
+ const next = speedOptions[(idx + 1) % speedOptions.length] ?? speedOptions[0] ?? 1;
87
+ speed = next;
88
+ onSpeedChange?.(next);
89
+ }
90
+ </script>
91
+
92
+ <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
+ >
97
+ <TimelineTrack
98
+ {duration}
99
+ bind:currentTime
100
+ bind:viewWindow
101
+ {segments}
102
+ {onSeek}
103
+ {onSegmentClick}
104
+ {hoverIndicator}
105
+ />
106
+ <span class="timeline-scrubber__time" aria-label="end">
107
+ {formatTime(viewWindow?.end ?? duration)}
108
+ </span>
109
+ </div>
110
+
111
+ <div class="timeline-scrubber__controls">
112
+ <button
113
+ type="button"
114
+ class="timeline-scrubber__btn"
115
+ aria-pressed={isPlaying}
116
+ aria-label={isPlaying ? 'Pause' : 'Play'}
117
+ onclick={togglePlay}
118
+ >
119
+ {isPlaying ? '❚❚' : '▶'}
120
+ </button>
121
+
122
+ <button
123
+ type="button"
124
+ class="timeline-scrubber__btn timeline-scrubber__btn--speed"
125
+ class:is-locked={speedLocked}
126
+ aria-label="Playback speed: {speed}x{speedLocked
127
+ ? ` (locked${speedLockedReason ? ': ' + speedLockedReason : ''})`
128
+ : ''}"
129
+ title={speedLocked ? (speedLockedReason ?? 'Speed locked') : 'Click to change speed'}
130
+ disabled={speedLocked}
131
+ onclick={cycleSpeed}
132
+ >
133
+ {speed}×{speedLocked ? ' ·' : ''}
134
+ </button>
135
+
136
+ <span class="timeline-scrubber__currtime" aria-label="Current time">
137
+ {formatTime(currentTime)}
138
+ </span>
139
+ </div>
140
+ </div>
141
+
142
+ <style>
143
+ .timeline-scrubber {
144
+ display: flex;
145
+ flex-direction: column;
146
+ gap: 4px;
147
+ font:
148
+ 12px/1 system-ui,
149
+ -apple-system,
150
+ Segoe UI,
151
+ sans-serif;
152
+ }
153
+
154
+ .timeline-scrubber__track {
155
+ display: flex;
156
+ align-items: center;
157
+ gap: 8px;
158
+ }
159
+
160
+ .timeline-scrubber__track :global(.timeline-track) {
161
+ flex: 1 1 auto;
162
+ min-width: 0;
163
+ }
164
+
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;
171
+ }
172
+
173
+ .timeline-scrubber__controls {
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 8px;
177
+ justify-content: center;
178
+ }
179
+
180
+ .timeline-scrubber__btn {
181
+ background: #fff;
182
+ border: 1px solid #e5e7eb;
183
+ border-radius: 4px;
184
+ padding: 4px 10px;
185
+ cursor: pointer;
186
+ font: inherit;
187
+ line-height: 1;
188
+ }
189
+
190
+ .timeline-scrubber__btn:hover:not(:disabled) {
191
+ background: #f3f4f6;
192
+ }
193
+
194
+ .timeline-scrubber__btn:disabled {
195
+ cursor: not-allowed;
196
+ }
197
+
198
+ .timeline-scrubber__btn--speed {
199
+ min-width: 3em;
200
+ font-variant-numeric: tabular-nums;
201
+ }
202
+
203
+ .timeline-scrubber__btn--speed.is-locked {
204
+ opacity: 0.5;
205
+ }
206
+
207
+ .timeline-scrubber__currtime {
208
+ font-family: ui-monospace, 'SF Mono', Menlo, monospace;
209
+ font-size: 12px;
210
+ color: #111;
211
+ min-width: 4em;
212
+ text-align: right;
213
+ }
214
+ </style>
@@ -0,0 +1,32 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type TimelineSegment, type TimelineViewWindow } from './TimelineTrack.svelte';
3
+ interface Props {
4
+ duration: number;
5
+ currentTime?: number;
6
+ isPlaying?: boolean;
7
+ speed?: number;
8
+ /** Available speed presets. Default: [1, 2, 4, 8, 16]. */
9
+ speedOptions?: number[];
10
+ /** Render speed as disabled / muted. Use when video playback overrides it. */
11
+ speedLocked?: boolean;
12
+ /** Title/tooltip shown when `speedLocked` is true. */
13
+ speedLockedReason?: string;
14
+ /** Optional zoomed view window; bindable. */
15
+ viewWindow?: TimelineViewWindow;
16
+ segments?: TimelineSegment[];
17
+ /** Format `currentTime` and `duration` for display. Default: mm:ss. */
18
+ formatTime?: (seconds: number) => string;
19
+ onSeek?: (time: number) => void;
20
+ onPlayToggle?: (nowPlaying: boolean) => void;
21
+ onSpeedChange?: (speed: number) => void;
22
+ onSegmentClick?: (segment: TimelineSegment) => void;
23
+ /** Snippet rendered above the hovered time, e.g. for a turn-preview tooltip. */
24
+ hoverIndicator?: Snippet<[{
25
+ time: number;
26
+ left: number;
27
+ }]>;
28
+ class?: string;
29
+ }
30
+ declare const TimelineScrubber: import("svelte").Component<Props, {}, "currentTime" | "viewWindow" | "isPlaying" | "speed">;
31
+ type TimelineScrubber = ReturnType<typeof TimelineScrubber>;
32
+ export default TimelineScrubber;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import TimelineScrubber from './TimelineScrubber.svelte';
4
+ describe('<TimelineScrubber>', () => {
5
+ it('shows the play icon when paused and pause when playing', () => {
6
+ const { container: paused } = render(TimelineScrubber, {
7
+ props: { duration: 60, currentTime: 0, isPlaying: false }
8
+ });
9
+ expect(paused.querySelector('[aria-label="Play"]')).not.toBeNull();
10
+ const { container: playing } = render(TimelineScrubber, {
11
+ props: { duration: 60, currentTime: 0, isPlaying: true }
12
+ });
13
+ expect(playing.querySelector('[aria-label="Pause"]')).not.toBeNull();
14
+ });
15
+ it('clicking the play button fires onPlayToggle with the new state', async () => {
16
+ const onPlayToggle = vi.fn();
17
+ const { container } = render(TimelineScrubber, {
18
+ props: { duration: 60, isPlaying: false, onPlayToggle }
19
+ });
20
+ const btn = container.querySelector('[aria-label="Play"]');
21
+ await fireEvent.click(btn);
22
+ expect(onPlayToggle).toHaveBeenCalledWith(true);
23
+ });
24
+ it('cycles through the default speed presets on each speed-button click', async () => {
25
+ const onSpeedChange = vi.fn();
26
+ const { container } = render(TimelineScrubber, {
27
+ props: { duration: 60, speed: 1, onSpeedChange }
28
+ });
29
+ const btn = container.querySelector('.timeline-scrubber__btn--speed');
30
+ await fireEvent.click(btn);
31
+ expect(onSpeedChange).toHaveBeenLastCalledWith(2);
32
+ });
33
+ it('speedLocked disables the speed button and surfaces the reason via title', () => {
34
+ const { container } = render(TimelineScrubber, {
35
+ props: {
36
+ duration: 60,
37
+ speed: 8,
38
+ speedLocked: true,
39
+ speedLockedReason: 'Video is playing'
40
+ }
41
+ });
42
+ const btn = container.querySelector('.timeline-scrubber__btn--speed');
43
+ expect(btn.disabled).toBe(true);
44
+ expect(btn.classList.contains('is-locked')).toBe(true);
45
+ expect(btn.getAttribute('title')).toContain('Video is playing');
46
+ });
47
+ it('clicking a locked speed button does NOT emit onSpeedChange', async () => {
48
+ const onSpeedChange = vi.fn();
49
+ const { container } = render(TimelineScrubber, {
50
+ props: { duration: 60, speed: 2, speedLocked: true, onSpeedChange }
51
+ });
52
+ const btn = container.querySelector('.timeline-scrubber__btn--speed');
53
+ await fireEvent.click(btn);
54
+ expect(onSpeedChange).not.toHaveBeenCalled();
55
+ });
56
+ it('uses the custom formatTime prop for both edge labels and the current-time readout', () => {
57
+ const formatTime = (s) => `[${Math.round(s)}s]`;
58
+ const { container } = render(TimelineScrubber, {
59
+ props: { duration: 120, currentTime: 42, formatTime }
60
+ });
61
+ const text = container.textContent ?? '';
62
+ expect(text).toContain('[0s]');
63
+ expect(text).toContain('[120s]');
64
+ expect(text).toContain('[42s]');
65
+ });
66
+ });