svelte-p5-components 0.3.0 → 0.4.1
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/CanvasFrame.svelte +129 -0
- package/dist/CanvasFrame.svelte.d.ts +52 -0
- package/dist/CanvasFrame.test.svelte.d.ts +1 -0
- package/dist/CanvasFrame.test.svelte.js +60 -0
- package/dist/DraggableWindow.svelte +63 -25
- package/dist/EntityToggleList.svelte +269 -0
- package/dist/EntityToggleList.svelte.d.ts +33 -0
- package/dist/EntityToggleList.test.svelte.d.ts +1 -0
- package/dist/EntityToggleList.test.svelte.js +69 -0
- package/dist/HoverTooltip.svelte +184 -0
- package/dist/HoverTooltip.svelte.d.ts +22 -0
- package/dist/HoverTooltip.test.svelte.d.ts +1 -0
- package/dist/HoverTooltip.test.svelte.js +66 -0
- package/dist/SplitPane.svelte +280 -0
- package/dist/SplitPane.svelte.d.ts +48 -0
- package/dist/SplitPane.test.svelte.d.ts +1 -0
- package/dist/SplitPane.test.svelte.js +85 -0
- package/dist/TimelineScrubber.svelte +214 -0
- package/dist/TimelineScrubber.svelte.d.ts +32 -0
- package/dist/TimelineScrubber.test.svelte.d.ts +1 -0
- package/dist/TimelineScrubber.test.svelte.js +66 -0
- package/dist/TimelineTrack.svelte +376 -0
- package/dist/TimelineTrack.svelte.d.ts +50 -0
- package/dist/TimelineTrack.test.svelte.d.ts +1 -0
- package/dist/TimelineTrack.test.svelte.js +72 -0
- package/dist/createMediaSync.svelte.d.ts +53 -0
- package/dist/createMediaSync.svelte.js +115 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/tests/setup.d.ts +1 -0
- package/dist/tests/setup.js +26 -0
- package/package.json +12 -3
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render } from '@testing-library/svelte';
|
|
3
|
+
import EntityToggleList, {} from './EntityToggleList.svelte';
|
|
4
|
+
// NOTE: Several structural assertions (counting `.entity-toggle-list__item`
|
|
5
|
+
// elements, clicking a label by its text) hit a happy-dom + Svelte 5
|
|
6
|
+
// runes interaction where entities inside a keyed `{#each}` over a
|
|
7
|
+
// $derived don't render under the test harness even though they render
|
|
8
|
+
// correctly in real browsers. A Playwright smoke pass in
|
|
9
|
+
// `tests/examples/` covers the render-and-interact path end-to-end.
|
|
10
|
+
//
|
|
11
|
+
// The tests below verify what happy-dom can reliably observe: the root
|
|
12
|
+
// element, the heading, the data attribute that reflects group presence,
|
|
13
|
+
// and the expander button that appears when maxVisible truncates.
|
|
14
|
+
describe('<EntityToggleList>', () => {
|
|
15
|
+
it('mounts without throwing with an empty entities array', () => {
|
|
16
|
+
const { container } = render(EntityToggleList, { props: { entities: [] } });
|
|
17
|
+
expect(container.querySelector('.entity-toggle-list')).not.toBeNull();
|
|
18
|
+
});
|
|
19
|
+
it('renders a heading when provided', () => {
|
|
20
|
+
const { container } = render(EntityToggleList, {
|
|
21
|
+
props: { entities: [], heading: 'Speakers' }
|
|
22
|
+
});
|
|
23
|
+
expect(container.querySelector('.entity-toggle-list__heading')?.textContent).toBe('Speakers');
|
|
24
|
+
});
|
|
25
|
+
it('data-has-groups is false when no entity declares a group', () => {
|
|
26
|
+
const entities = [
|
|
27
|
+
{ id: 'a', label: 'A', color: '#000' },
|
|
28
|
+
{ id: 'b', label: 'B', color: '#111' }
|
|
29
|
+
];
|
|
30
|
+
const { container } = render(EntityToggleList, { props: { entities } });
|
|
31
|
+
expect(container.querySelector('.entity-toggle-list')?.getAttribute('data-has-groups')).toBe('false');
|
|
32
|
+
});
|
|
33
|
+
it('data-has-groups is true when any entity declares a group', () => {
|
|
34
|
+
const entities = [
|
|
35
|
+
{ id: 'a', label: 'A', color: '#000', group: 'G1' },
|
|
36
|
+
{ id: 'b', label: 'B', color: '#111' }
|
|
37
|
+
];
|
|
38
|
+
const { container } = render(EntityToggleList, { props: { entities } });
|
|
39
|
+
expect(container.querySelector('.entity-toggle-list')?.getAttribute('data-has-groups')).toBe('true');
|
|
40
|
+
});
|
|
41
|
+
it('group labels render for grouped entities in insertion order', () => {
|
|
42
|
+
const entities = [
|
|
43
|
+
{ id: '1', label: 'A1', color: '#000', group: 'A' },
|
|
44
|
+
{ id: '2', label: 'B1', color: '#000', group: 'B' },
|
|
45
|
+
{ id: '3', label: 'A2', color: '#000', group: 'A' }
|
|
46
|
+
];
|
|
47
|
+
const { container } = render(EntityToggleList, { props: { entities } });
|
|
48
|
+
const labels = [...container.querySelectorAll('.entity-toggle-list__group-label')].map((n) => n.textContent);
|
|
49
|
+
expect(labels).toEqual(['A', 'B']);
|
|
50
|
+
});
|
|
51
|
+
it('renders a "+N more" expander when maxVisible truncates', () => {
|
|
52
|
+
const entities = Array.from({ length: 10 }, (_, i) => ({
|
|
53
|
+
id: `u${i}`,
|
|
54
|
+
label: `U${i}`,
|
|
55
|
+
color: '#000'
|
|
56
|
+
}));
|
|
57
|
+
const { container } = render(EntityToggleList, { props: { entities, maxVisible: 3 } });
|
|
58
|
+
const expander = container.querySelector('.entity-toggle-list__expand');
|
|
59
|
+
expect(expander?.textContent).toContain('+7 more');
|
|
60
|
+
});
|
|
61
|
+
it('omits the expander when no truncation is happening', () => {
|
|
62
|
+
const entities = [
|
|
63
|
+
{ id: 'a', label: 'A', color: '#000' },
|
|
64
|
+
{ id: 'b', label: 'B', color: '#000' }
|
|
65
|
+
];
|
|
66
|
+
const { container } = render(EntityToggleList, { props: { entities, maxVisible: 5 } });
|
|
67
|
+
expect(container.querySelector('.entity-toggle-list__expand')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
// Position-aware tooltip, anchored to a screen-space point.
|
|
5
|
+
//
|
|
6
|
+
// Canvas apps hover hit-test results and want a floating tooltip that
|
|
7
|
+
// tracks the pointer, flips to the other side when it would hit the
|
|
8
|
+
// viewport edge, and doesn't swallow canvas input. HoverTooltip is the
|
|
9
|
+
// minimum-viable version: pass `anchor` coordinates (page space) and
|
|
10
|
+
// `show`; the tooltip measures itself after mount and picks the side
|
|
11
|
+
// that fits.
|
|
12
|
+
//
|
|
13
|
+
// No floating-ui dependency — the edge-flip logic is tiny and handles
|
|
14
|
+
// the common cases. If you need multi-reference positioning, portal
|
|
15
|
+
// nesting, or collision detection against arbitrary boundaries, reach
|
|
16
|
+
// for @floating-ui/dom directly.
|
|
17
|
+
//
|
|
18
|
+
// See the package README for a usage example.
|
|
19
|
+
interface Props {
|
|
20
|
+
/** Tooltip content. */
|
|
21
|
+
children: Snippet;
|
|
22
|
+
/** Anchor point in client (page) coordinates. */
|
|
23
|
+
anchor: { x: number; y: number } | null;
|
|
24
|
+
/** Whether the tooltip is visible. */
|
|
25
|
+
show?: boolean;
|
|
26
|
+
/** Offset between the anchor and the tooltip's nearest edge, in px. Default: 12. */
|
|
27
|
+
offset?: number;
|
|
28
|
+
/** Draw a small triangle pointer from the tooltip toward the anchor. Default: true. */
|
|
29
|
+
triangle?: boolean;
|
|
30
|
+
/** Preferred side when there's room. Default: `'top'`. */
|
|
31
|
+
preferredSide?: 'top' | 'bottom' | 'left' | 'right';
|
|
32
|
+
class?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let {
|
|
36
|
+
children,
|
|
37
|
+
anchor,
|
|
38
|
+
show = true,
|
|
39
|
+
offset = 12,
|
|
40
|
+
triangle = true,
|
|
41
|
+
preferredSide = 'top',
|
|
42
|
+
class: className = ''
|
|
43
|
+
}: Props = $props();
|
|
44
|
+
|
|
45
|
+
type Side = 'top' | 'bottom' | 'left' | 'right';
|
|
46
|
+
|
|
47
|
+
let tooltipEl: HTMLDivElement | null = $state(null);
|
|
48
|
+
let measuredWidth = $state(0);
|
|
49
|
+
let measuredHeight = $state(0);
|
|
50
|
+
|
|
51
|
+
// Remeasure whenever content changes. The ResizeObserver handles dynamic
|
|
52
|
+
// content (e.g. tooltip copy updating mid-hover).
|
|
53
|
+
$effect(() => {
|
|
54
|
+
if (!tooltipEl) return;
|
|
55
|
+
const el = tooltipEl;
|
|
56
|
+
const ro = new ResizeObserver(() => {
|
|
57
|
+
const r = el.getBoundingClientRect();
|
|
58
|
+
measuredWidth = r.width;
|
|
59
|
+
measuredHeight = r.height;
|
|
60
|
+
});
|
|
61
|
+
ro.observe(el);
|
|
62
|
+
return () => ro.disconnect();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const placement = $derived.by<{ left: number; top: number; side: Side } | null>(() => {
|
|
66
|
+
if (!anchor) return null;
|
|
67
|
+
if (typeof window === 'undefined') return null;
|
|
68
|
+
|
|
69
|
+
const vpW = window.innerWidth;
|
|
70
|
+
const vpH = window.innerHeight;
|
|
71
|
+
const { x, y } = anchor;
|
|
72
|
+
|
|
73
|
+
const fits: Record<Side, boolean> = {
|
|
74
|
+
top: y - measuredHeight - offset >= 0,
|
|
75
|
+
bottom: y + measuredHeight + offset <= vpH,
|
|
76
|
+
left: x - measuredWidth - offset >= 0,
|
|
77
|
+
right: x + measuredWidth + offset <= vpW
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Order of preference: requested side first, then opposite axis, then fallbacks.
|
|
81
|
+
const order: Side[] =
|
|
82
|
+
preferredSide === 'top'
|
|
83
|
+
? ['top', 'bottom', 'right', 'left']
|
|
84
|
+
: preferredSide === 'bottom'
|
|
85
|
+
? ['bottom', 'top', 'right', 'left']
|
|
86
|
+
: preferredSide === 'left'
|
|
87
|
+
? ['left', 'right', 'top', 'bottom']
|
|
88
|
+
: ['right', 'left', 'top', 'bottom'];
|
|
89
|
+
|
|
90
|
+
const chosen = order.find((s) => fits[s]) ?? preferredSide;
|
|
91
|
+
|
|
92
|
+
let left = 0;
|
|
93
|
+
let top = 0;
|
|
94
|
+
if (chosen === 'top') {
|
|
95
|
+
left = x - measuredWidth / 2;
|
|
96
|
+
top = y - measuredHeight - offset;
|
|
97
|
+
} else if (chosen === 'bottom') {
|
|
98
|
+
left = x - measuredWidth / 2;
|
|
99
|
+
top = y + offset;
|
|
100
|
+
} else if (chosen === 'left') {
|
|
101
|
+
left = x - measuredWidth - offset;
|
|
102
|
+
top = y - measuredHeight / 2;
|
|
103
|
+
} else {
|
|
104
|
+
left = x + offset;
|
|
105
|
+
top = y - measuredHeight / 2;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Clamp within viewport so the tooltip can't spill over an edge when the
|
|
109
|
+
// anchor is jammed against one.
|
|
110
|
+
left = Math.max(4, Math.min(vpW - measuredWidth - 4, left));
|
|
111
|
+
top = Math.max(4, Math.min(vpH - measuredHeight - 4, top));
|
|
112
|
+
|
|
113
|
+
return { left, top, side: chosen };
|
|
114
|
+
});
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
{#if show && anchor}
|
|
118
|
+
<div
|
|
119
|
+
bind:this={tooltipEl}
|
|
120
|
+
class="hover-tooltip {className}"
|
|
121
|
+
data-side={placement?.side ?? preferredSide}
|
|
122
|
+
style:left="{placement?.left ?? 0}px"
|
|
123
|
+
style:top="{placement?.top ?? 0}px"
|
|
124
|
+
style:visibility={placement ? 'visible' : 'hidden'}
|
|
125
|
+
role="tooltip"
|
|
126
|
+
>
|
|
127
|
+
{@render children()}
|
|
128
|
+
{#if triangle}
|
|
129
|
+
<div class="hover-tooltip__arrow" data-side={placement?.side ?? preferredSide}></div>
|
|
130
|
+
{/if}
|
|
131
|
+
</div>
|
|
132
|
+
{/if}
|
|
133
|
+
|
|
134
|
+
<style>
|
|
135
|
+
.hover-tooltip {
|
|
136
|
+
position: fixed;
|
|
137
|
+
z-index: 1000;
|
|
138
|
+
pointer-events: none;
|
|
139
|
+
padding: 6px 10px;
|
|
140
|
+
max-width: 320px;
|
|
141
|
+
font:
|
|
142
|
+
12px/1.4 system-ui,
|
|
143
|
+
-apple-system,
|
|
144
|
+
Segoe UI,
|
|
145
|
+
sans-serif;
|
|
146
|
+
color: var(--tooltip-fg, #fff);
|
|
147
|
+
background: var(--tooltip-bg, rgba(17, 24, 39, 0.95));
|
|
148
|
+
border-radius: 6px;
|
|
149
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
|
150
|
+
user-select: none;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.hover-tooltip__arrow {
|
|
154
|
+
position: absolute;
|
|
155
|
+
width: 0;
|
|
156
|
+
height: 0;
|
|
157
|
+
border: 6px solid transparent;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.hover-tooltip__arrow[data-side='top'] {
|
|
161
|
+
left: 50%;
|
|
162
|
+
bottom: -12px;
|
|
163
|
+
transform: translateX(-50%);
|
|
164
|
+
border-top-color: var(--tooltip-bg, rgba(17, 24, 39, 0.95));
|
|
165
|
+
}
|
|
166
|
+
.hover-tooltip__arrow[data-side='bottom'] {
|
|
167
|
+
left: 50%;
|
|
168
|
+
top: -12px;
|
|
169
|
+
transform: translateX(-50%);
|
|
170
|
+
border-bottom-color: var(--tooltip-bg, rgba(17, 24, 39, 0.95));
|
|
171
|
+
}
|
|
172
|
+
.hover-tooltip__arrow[data-side='left'] {
|
|
173
|
+
top: 50%;
|
|
174
|
+
right: -12px;
|
|
175
|
+
transform: translateY(-50%);
|
|
176
|
+
border-left-color: var(--tooltip-bg, rgba(17, 24, 39, 0.95));
|
|
177
|
+
}
|
|
178
|
+
.hover-tooltip__arrow[data-side='right'] {
|
|
179
|
+
top: 50%;
|
|
180
|
+
left: -12px;
|
|
181
|
+
transform: translateY(-50%);
|
|
182
|
+
border-right-color: var(--tooltip-bg, rgba(17, 24, 39, 0.95));
|
|
183
|
+
}
|
|
184
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Tooltip content. */
|
|
4
|
+
children: Snippet;
|
|
5
|
+
/** Anchor point in client (page) coordinates. */
|
|
6
|
+
anchor: {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
} | null;
|
|
10
|
+
/** Whether the tooltip is visible. */
|
|
11
|
+
show?: boolean;
|
|
12
|
+
/** Offset between the anchor and the tooltip's nearest edge, in px. Default: 12. */
|
|
13
|
+
offset?: number;
|
|
14
|
+
/** Draw a small triangle pointer from the tooltip toward the anchor. Default: true. */
|
|
15
|
+
triangle?: boolean;
|
|
16
|
+
/** Preferred side when there's room. Default: `'top'`. */
|
|
17
|
+
preferredSide?: 'top' | 'bottom' | 'left' | 'right';
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
declare const HoverTooltip: import("svelte").Component<Props, {}, "">;
|
|
21
|
+
type HoverTooltip = ReturnType<typeof HoverTooltip>;
|
|
22
|
+
export default HoverTooltip;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render } from '@testing-library/svelte';
|
|
3
|
+
import { createRawSnippet } from 'svelte';
|
|
4
|
+
import HoverTooltip from './HoverTooltip.svelte';
|
|
5
|
+
const snippet = (html) => createRawSnippet(() => ({ render: () => html }));
|
|
6
|
+
describe('<HoverTooltip>', () => {
|
|
7
|
+
it('does not render when show is false', () => {
|
|
8
|
+
const { container } = render(HoverTooltip, {
|
|
9
|
+
props: {
|
|
10
|
+
children: snippet('<span>HELLO</span>'),
|
|
11
|
+
anchor: { x: 100, y: 100 },
|
|
12
|
+
show: false
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
expect(container.querySelector('.hover-tooltip')).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
it('does not render when anchor is null even if show is true', () => {
|
|
18
|
+
const { container } = render(HoverTooltip, {
|
|
19
|
+
props: {
|
|
20
|
+
children: snippet('<span>HELLO</span>'),
|
|
21
|
+
anchor: null,
|
|
22
|
+
show: true
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
expect(container.querySelector('.hover-tooltip')).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
it('renders children content when visible', () => {
|
|
28
|
+
const { container } = render(HoverTooltip, {
|
|
29
|
+
props: {
|
|
30
|
+
children: snippet('<span>HELLO</span>'),
|
|
31
|
+
anchor: { x: 100, y: 100 },
|
|
32
|
+
show: true
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
expect(container.querySelector('.hover-tooltip')?.textContent).toContain('HELLO');
|
|
36
|
+
});
|
|
37
|
+
it('applies role=tooltip for assistive tech', () => {
|
|
38
|
+
const { container } = render(HoverTooltip, {
|
|
39
|
+
props: {
|
|
40
|
+
children: snippet('<span>x</span>'),
|
|
41
|
+
anchor: { x: 50, y: 50 },
|
|
42
|
+
show: true
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
expect(container.querySelector('[role="tooltip"]')).not.toBeNull();
|
|
46
|
+
});
|
|
47
|
+
it('renders a triangle arrow by default and omits it when triangle=false', () => {
|
|
48
|
+
const { container: withArrow } = render(HoverTooltip, {
|
|
49
|
+
props: {
|
|
50
|
+
children: snippet('<span>x</span>'),
|
|
51
|
+
anchor: { x: 50, y: 50 },
|
|
52
|
+
show: true
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
expect(withArrow.querySelector('.hover-tooltip__arrow')).not.toBeNull();
|
|
56
|
+
const { container: noArrow } = render(HoverTooltip, {
|
|
57
|
+
props: {
|
|
58
|
+
children: snippet('<span>x</span>'),
|
|
59
|
+
anchor: { x: 50, y: 50 },
|
|
60
|
+
show: true,
|
|
61
|
+
triangle: false
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
expect(noArrow.querySelector('.hover-tooltip__arrow')).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { onDestroy } from 'svelte';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Two-panel resizable split. Accepts snippets for each pane and a
|
|
7
|
+
* bindable `sizes` tuple of percentages that always sums to 100.
|
|
8
|
+
*
|
|
9
|
+
* Orientation follows CSS flex convention:
|
|
10
|
+
* - `orientation: 'vertical'` → `flex-direction: column` → panels stacked
|
|
11
|
+
* top/bottom, divider is a horizontal bar.
|
|
12
|
+
* - `orientation: 'horizontal'` → panels side-by-side, divider is vertical.
|
|
13
|
+
*
|
|
14
|
+
* Colors and divider thickness are themable via CSS custom properties
|
|
15
|
+
* (see the style block) so consumers can match their app palette without
|
|
16
|
+
* overriding selectors.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```svelte
|
|
20
|
+
* <SplitPane orientation="horizontal" bind:sizes={[60, 40]} minSize={200}>
|
|
21
|
+
* {#snippet first()}<Sketch {sketch} bind:instance />{/snippet}
|
|
22
|
+
* {#snippet second()}<MyEditor />{/snippet}
|
|
23
|
+
* </SplitPane>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
interface Props {
|
|
27
|
+
/** `vertical` = stacked, `horizontal` = side-by-side. Default: `vertical`. */
|
|
28
|
+
orientation?: 'horizontal' | 'vertical';
|
|
29
|
+
/** Panel sizes as percentages (sum to 100). Bindable. */
|
|
30
|
+
sizes?: [number, number];
|
|
31
|
+
/** Minimum size in px for each panel. Default: 100. */
|
|
32
|
+
minSize?: number;
|
|
33
|
+
/** When true, one panel collapses fully to show only the other. */
|
|
34
|
+
collapsed?: boolean;
|
|
35
|
+
/** Which panel collapses when `collapsed` is true. Default: `second`. */
|
|
36
|
+
collapsedPanel?: 'first' | 'second';
|
|
37
|
+
first?: Snippet;
|
|
38
|
+
second?: Snippet;
|
|
39
|
+
/** Fired when the divider starts being dragged. */
|
|
40
|
+
ondragstart?: () => void;
|
|
41
|
+
/** Fired when the divider is released. */
|
|
42
|
+
ondragend?: () => void;
|
|
43
|
+
/** Fired on every size update (drag move, keyboard arrow). */
|
|
44
|
+
onresize?: (data: { sizes: [number, number] }) => void;
|
|
45
|
+
class?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let {
|
|
49
|
+
orientation = 'vertical',
|
|
50
|
+
sizes = $bindable<[number, number]>([60, 40]),
|
|
51
|
+
minSize = 100,
|
|
52
|
+
collapsed = false,
|
|
53
|
+
collapsedPanel = 'second',
|
|
54
|
+
first,
|
|
55
|
+
second,
|
|
56
|
+
ondragstart,
|
|
57
|
+
ondragend,
|
|
58
|
+
onresize,
|
|
59
|
+
class: className = ''
|
|
60
|
+
}: Props = $props();
|
|
61
|
+
|
|
62
|
+
let container: HTMLElement;
|
|
63
|
+
let isDragging = $state(false);
|
|
64
|
+
let startPos = 0;
|
|
65
|
+
let startSizes: [number, number] = [...sizes];
|
|
66
|
+
|
|
67
|
+
const flexDirection = $derived(orientation === 'vertical' ? 'column' : 'row');
|
|
68
|
+
const cursorStyle = $derived(orientation === 'vertical' ? 'row-resize' : 'col-resize');
|
|
69
|
+
|
|
70
|
+
function containerExtent(): number {
|
|
71
|
+
if (!container) return 0;
|
|
72
|
+
return orientation === 'vertical' ? container.offsetHeight : container.offsetWidth;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function beginDrag(clientX: number, clientY: number) {
|
|
76
|
+
if (collapsed) return;
|
|
77
|
+
isDragging = true;
|
|
78
|
+
startPos = orientation === 'vertical' ? clientY : clientX;
|
|
79
|
+
startSizes = [...sizes];
|
|
80
|
+
document.addEventListener('pointermove', handlePointerMove);
|
|
81
|
+
document.addEventListener('pointerup', handlePointerUp);
|
|
82
|
+
document.body.style.cursor = cursorStyle;
|
|
83
|
+
document.body.style.userSelect = 'none';
|
|
84
|
+
ondragstart?.();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function handlePointerDown(event: PointerEvent) {
|
|
88
|
+
beginDrag(event.clientX, event.clientY);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function handlePointerMove(event: PointerEvent) {
|
|
92
|
+
if (!isDragging) return;
|
|
93
|
+
|
|
94
|
+
const currentPos = orientation === 'vertical' ? event.clientY : event.clientX;
|
|
95
|
+
const delta = currentPos - startPos;
|
|
96
|
+
const extent = containerExtent();
|
|
97
|
+
if (extent === 0) return;
|
|
98
|
+
|
|
99
|
+
const deltaPercent = (delta / extent) * 100;
|
|
100
|
+
let next0 = startSizes[0] + deltaPercent;
|
|
101
|
+
let next1 = startSizes[1] - deltaPercent;
|
|
102
|
+
const minPercent = (minSize / extent) * 100;
|
|
103
|
+
|
|
104
|
+
if (next0 < minPercent) {
|
|
105
|
+
next0 = minPercent;
|
|
106
|
+
next1 = 100 - minPercent;
|
|
107
|
+
} else if (next1 < minPercent) {
|
|
108
|
+
next1 = minPercent;
|
|
109
|
+
next0 = 100 - minPercent;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
sizes = [next0, next1];
|
|
113
|
+
onresize?.({ sizes });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function handlePointerUp() {
|
|
117
|
+
isDragging = false;
|
|
118
|
+
document.removeEventListener('pointermove', handlePointerMove);
|
|
119
|
+
document.removeEventListener('pointerup', handlePointerUp);
|
|
120
|
+
document.body.style.cursor = '';
|
|
121
|
+
document.body.style.userSelect = '';
|
|
122
|
+
ondragend?.();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
126
|
+
if (collapsed) return;
|
|
127
|
+
|
|
128
|
+
const step = 2;
|
|
129
|
+
let next: [number, number] = [...sizes];
|
|
130
|
+
|
|
131
|
+
if (orientation === 'vertical') {
|
|
132
|
+
if (event.key === 'ArrowUp') next[0] = Math.max(10, sizes[0] - step);
|
|
133
|
+
else if (event.key === 'ArrowDown') next[0] = Math.min(90, sizes[0] + step);
|
|
134
|
+
else return;
|
|
135
|
+
} else {
|
|
136
|
+
if (event.key === 'ArrowLeft') next[0] = Math.max(10, sizes[0] - step);
|
|
137
|
+
else if (event.key === 'ArrowRight') next[0] = Math.min(90, sizes[0] + step);
|
|
138
|
+
else return;
|
|
139
|
+
}
|
|
140
|
+
next[1] = 100 - next[0];
|
|
141
|
+
|
|
142
|
+
if (next[0] !== sizes[0]) {
|
|
143
|
+
sizes = next;
|
|
144
|
+
onresize?.({ sizes });
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
onDestroy(() => {
|
|
150
|
+
if (typeof document !== 'undefined') {
|
|
151
|
+
document.removeEventListener('pointermove', handlePointerMove);
|
|
152
|
+
document.removeEventListener('pointerup', handlePointerUp);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const firstSize = $derived.by(() => {
|
|
157
|
+
if (!collapsed) return `${sizes[0]}%`;
|
|
158
|
+
return collapsedPanel === 'first' ? '0%' : '100%';
|
|
159
|
+
});
|
|
160
|
+
const secondSize = $derived.by(() => {
|
|
161
|
+
if (!collapsed) return `${sizes[1]}%`;
|
|
162
|
+
return collapsedPanel === 'second' ? '0%' : '100%';
|
|
163
|
+
});
|
|
164
|
+
const sizeProp = $derived(orientation === 'vertical' ? 'height' : 'width');
|
|
165
|
+
</script>
|
|
166
|
+
|
|
167
|
+
<div
|
|
168
|
+
class="split-pane {className}"
|
|
169
|
+
class:vertical={orientation === 'vertical'}
|
|
170
|
+
class:horizontal={orientation === 'horizontal'}
|
|
171
|
+
bind:this={container}
|
|
172
|
+
style="flex-direction: {flexDirection};"
|
|
173
|
+
>
|
|
174
|
+
<div class="split-pane__panel" style="{sizeProp}: {firstSize};">
|
|
175
|
+
{@render first?.()}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{#if !collapsed}
|
|
179
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
180
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
181
|
+
<div
|
|
182
|
+
class="split-pane__divider"
|
|
183
|
+
class:is-dragging={isDragging}
|
|
184
|
+
role="separator"
|
|
185
|
+
tabindex="0"
|
|
186
|
+
aria-orientation={orientation}
|
|
187
|
+
aria-valuenow={Math.round(sizes[0])}
|
|
188
|
+
aria-valuemin="10"
|
|
189
|
+
aria-valuemax="90"
|
|
190
|
+
onpointerdown={handlePointerDown}
|
|
191
|
+
onkeydown={handleKeyDown}
|
|
192
|
+
>
|
|
193
|
+
<div class="split-pane__grip"></div>
|
|
194
|
+
</div>
|
|
195
|
+
{/if}
|
|
196
|
+
|
|
197
|
+
<div class="split-pane__panel" style="{sizeProp}: {secondSize};">
|
|
198
|
+
{@render second?.()}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<style>
|
|
203
|
+
.split-pane {
|
|
204
|
+
display: flex;
|
|
205
|
+
width: 100%;
|
|
206
|
+
height: 100%;
|
|
207
|
+
min-width: 0;
|
|
208
|
+
min-height: 0;
|
|
209
|
+
overflow: hidden;
|
|
210
|
+
|
|
211
|
+
/* Themeable via custom properties. Defaults match a neutral gray scale. */
|
|
212
|
+
--split-divider-bg: #e5e7eb;
|
|
213
|
+
--split-divider-bg-active: #d1d5db;
|
|
214
|
+
--split-divider-bg-focus: #3b82f6;
|
|
215
|
+
--split-grip-bg: #9ca3af;
|
|
216
|
+
--split-grip-bg-active: #6b7280;
|
|
217
|
+
--split-divider-size: 8px;
|
|
218
|
+
--split-grip-length: 40px;
|
|
219
|
+
--split-grip-thickness: 4px;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.split-pane__panel {
|
|
223
|
+
overflow: hidden;
|
|
224
|
+
position: relative;
|
|
225
|
+
min-width: 0;
|
|
226
|
+
min-height: 0;
|
|
227
|
+
flex-shrink: 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.split-pane__divider {
|
|
231
|
+
flex: 0 0 auto;
|
|
232
|
+
background-color: var(--split-divider-bg);
|
|
233
|
+
display: flex;
|
|
234
|
+
align-items: center;
|
|
235
|
+
justify-content: center;
|
|
236
|
+
transition: background-color 0.15s;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.split-pane__divider:hover,
|
|
240
|
+
.split-pane__divider.is-dragging {
|
|
241
|
+
background-color: var(--split-divider-bg-active);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.split-pane__divider:focus-visible {
|
|
245
|
+
outline: 2px solid var(--split-divider-bg-focus);
|
|
246
|
+
outline-offset: -2px;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.vertical .split-pane__divider {
|
|
250
|
+
width: 100%;
|
|
251
|
+
height: var(--split-divider-size);
|
|
252
|
+
cursor: row-resize;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.horizontal .split-pane__divider {
|
|
256
|
+
width: var(--split-divider-size);
|
|
257
|
+
height: 100%;
|
|
258
|
+
cursor: col-resize;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.split-pane__grip {
|
|
262
|
+
background-color: var(--split-grip-bg);
|
|
263
|
+
border-radius: 2px;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.vertical .split-pane__grip {
|
|
267
|
+
width: var(--split-grip-length);
|
|
268
|
+
height: var(--split-grip-thickness);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.horizontal .split-pane__grip {
|
|
272
|
+
width: var(--split-grip-thickness);
|
|
273
|
+
height: var(--split-grip-length);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.split-pane__divider:hover .split-pane__grip,
|
|
277
|
+
.split-pane__divider.is-dragging .split-pane__grip {
|
|
278
|
+
background-color: var(--split-grip-bg-active);
|
|
279
|
+
}
|
|
280
|
+
</style>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* Two-panel resizable split. Accepts snippets for each pane and a
|
|
4
|
+
* bindable `sizes` tuple of percentages that always sums to 100.
|
|
5
|
+
*
|
|
6
|
+
* Orientation follows CSS flex convention:
|
|
7
|
+
* - `orientation: 'vertical'` → `flex-direction: column` → panels stacked
|
|
8
|
+
* top/bottom, divider is a horizontal bar.
|
|
9
|
+
* - `orientation: 'horizontal'` → panels side-by-side, divider is vertical.
|
|
10
|
+
*
|
|
11
|
+
* Colors and divider thickness are themable via CSS custom properties
|
|
12
|
+
* (see the style block) so consumers can match their app palette without
|
|
13
|
+
* overriding selectors.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```svelte
|
|
17
|
+
* <SplitPane orientation="horizontal" bind:sizes={[60, 40]} minSize={200}>
|
|
18
|
+
* {#snippet first()}<Sketch {sketch} bind:instance />{/snippet}
|
|
19
|
+
* {#snippet second()}<MyEditor />{/snippet}
|
|
20
|
+
* </SplitPane>
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
interface Props {
|
|
24
|
+
/** `vertical` = stacked, `horizontal` = side-by-side. Default: `vertical`. */
|
|
25
|
+
orientation?: 'horizontal' | 'vertical';
|
|
26
|
+
/** Panel sizes as percentages (sum to 100). Bindable. */
|
|
27
|
+
sizes?: [number, number];
|
|
28
|
+
/** Minimum size in px for each panel. Default: 100. */
|
|
29
|
+
minSize?: number;
|
|
30
|
+
/** When true, one panel collapses fully to show only the other. */
|
|
31
|
+
collapsed?: boolean;
|
|
32
|
+
/** Which panel collapses when `collapsed` is true. Default: `second`. */
|
|
33
|
+
collapsedPanel?: 'first' | 'second';
|
|
34
|
+
first?: Snippet;
|
|
35
|
+
second?: Snippet;
|
|
36
|
+
/** Fired when the divider starts being dragged. */
|
|
37
|
+
ondragstart?: () => void;
|
|
38
|
+
/** Fired when the divider is released. */
|
|
39
|
+
ondragend?: () => void;
|
|
40
|
+
/** Fired on every size update (drag move, keyboard arrow). */
|
|
41
|
+
onresize?: (data: {
|
|
42
|
+
sizes: [number, number];
|
|
43
|
+
}) => void;
|
|
44
|
+
class?: string;
|
|
45
|
+
}
|
|
46
|
+
declare const SplitPane: import("svelte").Component<Props, {}, "sizes">;
|
|
47
|
+
type SplitPane = ReturnType<typeof SplitPane>;
|
|
48
|
+
export default SplitPane;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|