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,129 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Layout shell for canvas-driven apps.
|
|
6
|
+
*
|
|
7
|
+
* Every multi-panel p5 app ends up with the same picture: a canvas filling
|
|
8
|
+
* the bulk of the viewport, one or more toolbars anchored to its edges,
|
|
9
|
+
* and overlays (tooltips, FPS readouts, legends) painted on top. Each app
|
|
10
|
+
* reinvents the flex math, the ResizeObserver coordination (so the canvas
|
|
11
|
+
* doesn't thrash when a toolbar's height changes), and the z-index/pointer
|
|
12
|
+
* discipline for overlays that must *not* swallow canvas input.
|
|
13
|
+
*
|
|
14
|
+
* `<CanvasFrame>` is that shell. It exposes snippet slots for each region
|
|
15
|
+
* and otherwise stays out of the way:
|
|
16
|
+
*
|
|
17
|
+
* - `canvas` (required): render your `<Sketch>` / `<P5Canvas>` here.
|
|
18
|
+
* - `top` / `bottom` / `leftRail` / `rightRail` (optional): chrome docked
|
|
19
|
+
* to each edge. Natural content height/width; does not fight the canvas
|
|
20
|
+
* for flex space.
|
|
21
|
+
* - `overlay` (optional): absolutely positioned over the canvas region.
|
|
22
|
+
* `pointer-events: none` by default so it doesn't block canvas input;
|
|
23
|
+
* individual children can opt in with `pointer-events: auto`.
|
|
24
|
+
*
|
|
25
|
+
* The frame itself fills whatever parent it's mounted in — typically
|
|
26
|
+
* `height: 100vh` or a flex child. It assumes a bounded height from the
|
|
27
|
+
* parent; inside it, the canvas region flexes to consume whatever remains
|
|
28
|
+
* after the toolbars.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```svelte
|
|
32
|
+
* <div style="height: 100vh">
|
|
33
|
+
* <CanvasFrame>
|
|
34
|
+
* {#snippet top()}<MyMenuBar />{/snippet}
|
|
35
|
+
* {#snippet bottom()}<MyTimeline /><MySpeakerList />{/snippet}
|
|
36
|
+
* {#snippet canvas()}<Sketch {sketch} bind:instance />{/snippet}
|
|
37
|
+
* {#snippet overlay()}<FPSMonitor {instance} />{/snippet}
|
|
38
|
+
* </CanvasFrame>
|
|
39
|
+
* </div>
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
interface Props {
|
|
43
|
+
canvas: Snippet;
|
|
44
|
+
top?: Snippet;
|
|
45
|
+
bottom?: Snippet;
|
|
46
|
+
leftRail?: Snippet;
|
|
47
|
+
rightRail?: Snippet;
|
|
48
|
+
overlay?: Snippet;
|
|
49
|
+
class?: string;
|
|
50
|
+
style?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let {
|
|
54
|
+
canvas,
|
|
55
|
+
top,
|
|
56
|
+
bottom,
|
|
57
|
+
leftRail,
|
|
58
|
+
rightRail,
|
|
59
|
+
overlay,
|
|
60
|
+
class: className = '',
|
|
61
|
+
style
|
|
62
|
+
}: Props = $props();
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<div class="canvas-frame {className}" {style}>
|
|
66
|
+
{#if top}
|
|
67
|
+
<div class="canvas-frame__region canvas-frame__top">{@render top()}</div>
|
|
68
|
+
{/if}
|
|
69
|
+
<div class="canvas-frame__middle">
|
|
70
|
+
{#if leftRail}
|
|
71
|
+
<div class="canvas-frame__region canvas-frame__rail">{@render leftRail()}</div>
|
|
72
|
+
{/if}
|
|
73
|
+
<div class="canvas-frame__stage">
|
|
74
|
+
{@render canvas()}
|
|
75
|
+
{#if overlay}
|
|
76
|
+
<div class="canvas-frame__overlay">{@render overlay()}</div>
|
|
77
|
+
{/if}
|
|
78
|
+
</div>
|
|
79
|
+
{#if rightRail}
|
|
80
|
+
<div class="canvas-frame__region canvas-frame__rail">{@render rightRail()}</div>
|
|
81
|
+
{/if}
|
|
82
|
+
</div>
|
|
83
|
+
{#if bottom}
|
|
84
|
+
<div class="canvas-frame__region canvas-frame__bottom">{@render bottom()}</div>
|
|
85
|
+
{/if}
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<style>
|
|
89
|
+
.canvas-frame {
|
|
90
|
+
display: flex;
|
|
91
|
+
flex-direction: column;
|
|
92
|
+
width: 100%;
|
|
93
|
+
height: 100%;
|
|
94
|
+
min-width: 0;
|
|
95
|
+
min-height: 0;
|
|
96
|
+
overflow: hidden;
|
|
97
|
+
position: relative;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.canvas-frame__region {
|
|
101
|
+
flex: 0 0 auto;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.canvas-frame__middle {
|
|
105
|
+
display: flex;
|
|
106
|
+
flex: 1 1 auto;
|
|
107
|
+
min-width: 0;
|
|
108
|
+
min-height: 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.canvas-frame__rail {
|
|
112
|
+
min-height: 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.canvas-frame__stage {
|
|
116
|
+
flex: 1 1 auto;
|
|
117
|
+
min-width: 0;
|
|
118
|
+
min-height: 0;
|
|
119
|
+
position: relative;
|
|
120
|
+
overflow: hidden;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.canvas-frame__overlay {
|
|
124
|
+
position: absolute;
|
|
125
|
+
inset: 0;
|
|
126
|
+
pointer-events: none;
|
|
127
|
+
z-index: 10;
|
|
128
|
+
}
|
|
129
|
+
</style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* Layout shell for canvas-driven apps.
|
|
4
|
+
*
|
|
5
|
+
* Every multi-panel p5 app ends up with the same picture: a canvas filling
|
|
6
|
+
* the bulk of the viewport, one or more toolbars anchored to its edges,
|
|
7
|
+
* and overlays (tooltips, FPS readouts, legends) painted on top. Each app
|
|
8
|
+
* reinvents the flex math, the ResizeObserver coordination (so the canvas
|
|
9
|
+
* doesn't thrash when a toolbar's height changes), and the z-index/pointer
|
|
10
|
+
* discipline for overlays that must *not* swallow canvas input.
|
|
11
|
+
*
|
|
12
|
+
* `<CanvasFrame>` is that shell. It exposes snippet slots for each region
|
|
13
|
+
* and otherwise stays out of the way:
|
|
14
|
+
*
|
|
15
|
+
* - `canvas` (required): render your `<Sketch>` / `<P5Canvas>` here.
|
|
16
|
+
* - `top` / `bottom` / `leftRail` / `rightRail` (optional): chrome docked
|
|
17
|
+
* to each edge. Natural content height/width; does not fight the canvas
|
|
18
|
+
* for flex space.
|
|
19
|
+
* - `overlay` (optional): absolutely positioned over the canvas region.
|
|
20
|
+
* `pointer-events: none` by default so it doesn't block canvas input;
|
|
21
|
+
* individual children can opt in with `pointer-events: auto`.
|
|
22
|
+
*
|
|
23
|
+
* The frame itself fills whatever parent it's mounted in — typically
|
|
24
|
+
* `height: 100vh` or a flex child. It assumes a bounded height from the
|
|
25
|
+
* parent; inside it, the canvas region flexes to consume whatever remains
|
|
26
|
+
* after the toolbars.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```svelte
|
|
30
|
+
* <div style="height: 100vh">
|
|
31
|
+
* <CanvasFrame>
|
|
32
|
+
* {#snippet top()}<MyMenuBar />{/snippet}
|
|
33
|
+
* {#snippet bottom()}<MyTimeline /><MySpeakerList />{/snippet}
|
|
34
|
+
* {#snippet canvas()}<Sketch {sketch} bind:instance />{/snippet}
|
|
35
|
+
* {#snippet overlay()}<FPSMonitor {instance} />{/snippet}
|
|
36
|
+
* </CanvasFrame>
|
|
37
|
+
* </div>
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
interface Props {
|
|
41
|
+
canvas: Snippet;
|
|
42
|
+
top?: Snippet;
|
|
43
|
+
bottom?: Snippet;
|
|
44
|
+
leftRail?: Snippet;
|
|
45
|
+
rightRail?: Snippet;
|
|
46
|
+
overlay?: Snippet;
|
|
47
|
+
class?: string;
|
|
48
|
+
style?: string;
|
|
49
|
+
}
|
|
50
|
+
declare const CanvasFrame: import("svelte").Component<Props, {}, "">;
|
|
51
|
+
type CanvasFrame = ReturnType<typeof CanvasFrame>;
|
|
52
|
+
export default CanvasFrame;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render } from '@testing-library/svelte';
|
|
3
|
+
import { createRawSnippet } from 'svelte';
|
|
4
|
+
import CanvasFrame from './CanvasFrame.svelte';
|
|
5
|
+
const snippet = (html) => createRawSnippet(() => ({ render: () => html }));
|
|
6
|
+
describe('<CanvasFrame>', () => {
|
|
7
|
+
it('renders the required canvas snippet inside the stage', () => {
|
|
8
|
+
const { container } = render(CanvasFrame, {
|
|
9
|
+
canvas: snippet('<div data-testid="canvas">CANVAS</div>')
|
|
10
|
+
});
|
|
11
|
+
const stage = container.querySelector('.canvas-frame__stage');
|
|
12
|
+
expect(stage).not.toBeNull();
|
|
13
|
+
expect(stage?.textContent).toContain('CANVAS');
|
|
14
|
+
});
|
|
15
|
+
it('omits optional regions when their snippets are absent', () => {
|
|
16
|
+
const { container } = render(CanvasFrame, {
|
|
17
|
+
canvas: snippet('<div>c</div>')
|
|
18
|
+
});
|
|
19
|
+
expect(container.querySelector('.canvas-frame__top')).toBeNull();
|
|
20
|
+
expect(container.querySelector('.canvas-frame__bottom')).toBeNull();
|
|
21
|
+
expect(container.querySelector('.canvas-frame__rail')).toBeNull();
|
|
22
|
+
expect(container.querySelector('.canvas-frame__overlay')).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
it('renders top, bottom, leftRail, rightRail when provided', () => {
|
|
25
|
+
const { container } = render(CanvasFrame, {
|
|
26
|
+
canvas: snippet('<div>c</div>'),
|
|
27
|
+
top: snippet('<span>T</span>'),
|
|
28
|
+
bottom: snippet('<span>B</span>'),
|
|
29
|
+
leftRail: snippet('<span>L</span>'),
|
|
30
|
+
rightRail: snippet('<span>R</span>')
|
|
31
|
+
});
|
|
32
|
+
expect(container.querySelector('.canvas-frame__top')?.textContent).toBe('T');
|
|
33
|
+
expect(container.querySelector('.canvas-frame__bottom')?.textContent).toBe('B');
|
|
34
|
+
const rails = container.querySelectorAll('.canvas-frame__rail');
|
|
35
|
+
expect(rails).toHaveLength(2);
|
|
36
|
+
expect(rails[0]?.textContent).toBe('L');
|
|
37
|
+
expect(rails[1]?.textContent).toBe('R');
|
|
38
|
+
});
|
|
39
|
+
it('overlay region renders with the dedicated class that applies pointer-events:none', () => {
|
|
40
|
+
// happy-dom doesn't resolve Svelte's scoped stylesheet cascade, so we
|
|
41
|
+
// assert on the marker class instead of the computed value. The actual
|
|
42
|
+
// CSS is covered by a Playwright smoke pass on docs/examples.
|
|
43
|
+
const { container } = render(CanvasFrame, {
|
|
44
|
+
canvas: snippet('<div>c</div>'),
|
|
45
|
+
overlay: snippet('<div>o</div>')
|
|
46
|
+
});
|
|
47
|
+
const overlay = container.querySelector('.canvas-frame__overlay');
|
|
48
|
+
expect(overlay).not.toBeNull();
|
|
49
|
+
});
|
|
50
|
+
it('forwards class and style to the root container', () => {
|
|
51
|
+
const { container } = render(CanvasFrame, {
|
|
52
|
+
canvas: snippet('<div>c</div>'),
|
|
53
|
+
class: 'extra-class',
|
|
54
|
+
style: 'background: red;'
|
|
55
|
+
});
|
|
56
|
+
const root = container.querySelector('.canvas-frame');
|
|
57
|
+
expect(root?.classList.contains('extra-class')).toBe(true);
|
|
58
|
+
expect(root.style.background).toBe('red');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -72,36 +72,59 @@
|
|
|
72
72
|
let size = $state(untrack(() => ({ w: typeof width === 'number' ? width : 480, h: 40 })));
|
|
73
73
|
let rootEl: HTMLDivElement | null = $state(null);
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
75
|
+
// Clamp `pos` back inside whatever sandbox `constrained` points at.
|
|
76
|
+
// Runs on drag end (drag might overshoot during pointer-move) AND on
|
|
77
|
+
// any resize of the sandbox (window → viewport mode, parent element →
|
|
78
|
+
// parent mode) so the window snaps back into view when its container
|
|
79
|
+
// shrinks underneath it.
|
|
80
|
+
function clamp() {
|
|
81
|
+
if (typeof window === 'undefined' || !rootEl) return;
|
|
82
|
+
|
|
83
|
+
if (constrained === 'parent') {
|
|
84
|
+
// Position is relative to the parent's padding-box, so bounds
|
|
85
|
+
// are [0, parentSize - windowSize]. Falls through to no-op if
|
|
86
|
+
// the window is somehow larger than the parent (Math.max(0, ...)).
|
|
87
|
+
const parent = rootEl.parentElement;
|
|
88
|
+
if (!parent) return;
|
|
89
|
+
const r = parent.getBoundingClientRect();
|
|
90
|
+
const maxX = Math.max(0, r.width - size.w);
|
|
91
|
+
const maxY = Math.max(0, r.height - size.h);
|
|
92
|
+
pos = {
|
|
93
|
+
x: Math.max(0, Math.min(maxX, pos.x)),
|
|
94
|
+
y: Math.max(0, Math.min(maxY, pos.y))
|
|
95
|
+
};
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (constrained === 'viewport') {
|
|
100
|
+
// Never allow the titlebar above the viewport top - that's the
|
|
101
|
+
// "unreachable" case. Allow partial horizontal overflow so narrow
|
|
102
|
+
// screens don't trap wide windows, but always keep `minVisible`
|
|
103
|
+
// pixels of the window on-screen for a grab handle.
|
|
104
|
+
const vw = window.innerWidth;
|
|
105
|
+
const vh = window.innerHeight;
|
|
106
|
+
const minX = -(size.w - minVisible);
|
|
107
|
+
const maxX = vw - minVisible;
|
|
108
|
+
const minY = 0;
|
|
109
|
+
const maxY = vh - minVisible;
|
|
110
|
+
pos = {
|
|
111
|
+
x: Math.max(minX, Math.min(maxX, pos.x)),
|
|
112
|
+
y: Math.max(minY, Math.min(maxY, pos.y))
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// 'none': no clamping.
|
|
95
116
|
}
|
|
96
117
|
|
|
97
118
|
// Keep size fresh (used for clamp math) - ResizeObserver on our own
|
|
98
|
-
// root element catches CSS resize-from-corner too.
|
|
119
|
+
// root element catches CSS resize-from-corner too. We also re-clamp
|
|
120
|
+
// after each size change so corner-resizing past a bound snaps back.
|
|
99
121
|
$effect(() => {
|
|
100
122
|
if (!rootEl) return;
|
|
101
123
|
const el = rootEl;
|
|
102
124
|
const ro = new ResizeObserver(() => {
|
|
103
125
|
const r = el.getBoundingClientRect();
|
|
104
126
|
size = { w: r.width, h: r.height };
|
|
127
|
+
clamp();
|
|
105
128
|
});
|
|
106
129
|
ro.observe(el);
|
|
107
130
|
// seed
|
|
@@ -110,15 +133,30 @@
|
|
|
110
133
|
return () => ro.disconnect();
|
|
111
134
|
});
|
|
112
135
|
|
|
113
|
-
// Re-clamp on window resize -
|
|
114
|
-
//
|
|
136
|
+
// Re-clamp on window resize - the "snap back when the viewport shrinks"
|
|
137
|
+
// case. Harmless in parent mode (clamp() handles parent bounds too), so
|
|
138
|
+
// we keep a single global listener instead of branching.
|
|
115
139
|
$effect(() => {
|
|
116
140
|
if (typeof window === 'undefined') return;
|
|
117
|
-
const onResize = () =>
|
|
141
|
+
const onResize = () => clamp();
|
|
118
142
|
window.addEventListener('resize', onResize);
|
|
119
143
|
return () => window.removeEventListener('resize', onResize);
|
|
120
144
|
});
|
|
121
145
|
|
|
146
|
+
// Parent mode: watch the parent element's size too. A flex/grid row
|
|
147
|
+
// resizing around us wouldn't trigger `window.resize`, so without this
|
|
148
|
+
// the window can end up stranded outside a shrunken container until the
|
|
149
|
+
// user nudges it.
|
|
150
|
+
$effect(() => {
|
|
151
|
+
if (typeof window === 'undefined') return;
|
|
152
|
+
if (constrained !== 'parent' || !rootEl) return;
|
|
153
|
+
const parent = rootEl.parentElement;
|
|
154
|
+
if (!parent) return;
|
|
155
|
+
const ro = new ResizeObserver(() => clamp());
|
|
156
|
+
ro.observe(parent);
|
|
157
|
+
return () => ro.disconnect();
|
|
158
|
+
});
|
|
159
|
+
|
|
122
160
|
// The neodrag `position` plugin takes a `current: {x, y}` and forces
|
|
123
161
|
// the element to that transform. Wrapped in a Compartment so updates
|
|
124
162
|
// to `pos` re-run just this plugin (not the whole plugin list).
|
|
@@ -132,7 +170,7 @@
|
|
|
132
170
|
onDrag: ({ offset }) => {
|
|
133
171
|
pos = { x: offset.x, y: offset.y };
|
|
134
172
|
},
|
|
135
|
-
onDragEnd: () =>
|
|
173
|
+
onDragEnd: () => clamp()
|
|
136
174
|
}),
|
|
137
175
|
...(constrained === 'viewport' ? [bounds(BoundsFrom.viewport())] : []),
|
|
138
176
|
...(constrained === 'parent' ? [bounds(BoundsFrom.parent())] : []),
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* A single entity (speaker, actor, track, class, series, etc.) rendered
|
|
4
|
+
* as a toggle button in the list. Consumers supply a flat array; optional
|
|
5
|
+
* `group` buckets entities into labeled sections.
|
|
6
|
+
*/
|
|
7
|
+
export interface Entity {
|
|
8
|
+
/** Stable id. Used in onToggle/onColorChange callbacks. */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Human-readable label shown on the button. */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Color swatch. Any CSS color string. */
|
|
13
|
+
color: string;
|
|
14
|
+
/** Default: true. When false, the entity renders dimmed. */
|
|
15
|
+
visible?: boolean;
|
|
16
|
+
/** Optional group label. Entities with the same `group` are rendered together. */
|
|
17
|
+
group?: string;
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<script lang="ts">
|
|
22
|
+
// Entity visibility/color toggle panel.
|
|
23
|
+
//
|
|
24
|
+
// Canvas apps that render N separately-colored series (speakers,
|
|
25
|
+
// actors, clusters, channels) nearly always grow a panel like this:
|
|
26
|
+
// a row of buttons with a colored swatch and a label, each toggling
|
|
27
|
+
// the corresponding entity's visibility in the viz. Optional color
|
|
28
|
+
// editing. Optional grouping (family, cluster). Optional overflow
|
|
29
|
+
// expander once the list exceeds a threshold.
|
|
30
|
+
//
|
|
31
|
+
// The list binds `entities[].visible` back via callbacks rather than
|
|
32
|
+
// by mutating the prop in place — consumers keep their own authoritative
|
|
33
|
+
// state and this component stays stateless.
|
|
34
|
+
//
|
|
35
|
+
// See the package README for a usage example.
|
|
36
|
+
|
|
37
|
+
interface Props {
|
|
38
|
+
/** The list of entities. */
|
|
39
|
+
entities: Entity[];
|
|
40
|
+
/** Called when a visibility toggle is clicked. */
|
|
41
|
+
onToggle?: (id: string, visible: boolean) => void;
|
|
42
|
+
/** When provided, a native color input is shown and clicking the swatch opens it. */
|
|
43
|
+
onColorChange?: (id: string, color: string) => void;
|
|
44
|
+
/** Max entities to show before collapsing the remainder behind a "+N more" expander. */
|
|
45
|
+
maxVisible?: number;
|
|
46
|
+
/** Optional heading above the list. */
|
|
47
|
+
heading?: string;
|
|
48
|
+
class?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let {
|
|
52
|
+
entities,
|
|
53
|
+
onToggle,
|
|
54
|
+
onColorChange,
|
|
55
|
+
maxVisible,
|
|
56
|
+
heading,
|
|
57
|
+
class: className = ''
|
|
58
|
+
}: Props = $props();
|
|
59
|
+
|
|
60
|
+
let expanded = $state(false);
|
|
61
|
+
|
|
62
|
+
const visibleEntities = $derived(
|
|
63
|
+
expanded || maxVisible === undefined ? entities : entities.slice(0, maxVisible)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const groupKeys = $derived.by(() => {
|
|
67
|
+
const keys: string[] = [];
|
|
68
|
+
for (const e of visibleEntities) {
|
|
69
|
+
const k = e.group ?? '';
|
|
70
|
+
if (!keys.includes(k)) keys.push(k);
|
|
71
|
+
}
|
|
72
|
+
return keys;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const hasGroupBoundaries = $derived(groupKeys.some((k) => k !== ''));
|
|
76
|
+
|
|
77
|
+
const hiddenCount = $derived(
|
|
78
|
+
maxVisible === undefined || expanded ? 0 : Math.max(0, entities.length - maxVisible)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
function entitiesInGroup(key: string): Entity[] {
|
|
82
|
+
return visibleEntities.filter((e) => (e.group ?? '') === key);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isVisible(e: Entity) {
|
|
86
|
+
return e.visible !== false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function handleToggle(e: Entity) {
|
|
90
|
+
onToggle?.(e.id, !isVisible(e));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function handleColorInput(e: Entity, event: Event) {
|
|
94
|
+
const target = event.currentTarget as HTMLInputElement;
|
|
95
|
+
onColorChange?.(e.id, target.value);
|
|
96
|
+
}
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<div class="entity-toggle-list {className}" data-has-groups={hasGroupBoundaries}>
|
|
100
|
+
{#if heading}
|
|
101
|
+
<div class="entity-toggle-list__heading">{heading}</div>
|
|
102
|
+
{/if}
|
|
103
|
+
|
|
104
|
+
{#each groupKeys as key (key)}
|
|
105
|
+
{#if hasGroupBoundaries && key}
|
|
106
|
+
<div class="entity-toggle-list__group-label">{key}</div>
|
|
107
|
+
{/if}
|
|
108
|
+
<div class="entity-toggle-list__row">
|
|
109
|
+
{#each entitiesInGroup(key) as entity (entity.id)}
|
|
110
|
+
<div class="entity-toggle-list__item" class:is-hidden={!isVisible(entity)}>
|
|
111
|
+
{#if onColorChange}
|
|
112
|
+
<label
|
|
113
|
+
class="entity-toggle-list__swatch-wrap"
|
|
114
|
+
aria-label="Change color for {entity.label}"
|
|
115
|
+
>
|
|
116
|
+
<span class="entity-toggle-list__swatch" style:background-color={entity.color}></span>
|
|
117
|
+
<input
|
|
118
|
+
type="color"
|
|
119
|
+
class="entity-toggle-list__color-input"
|
|
120
|
+
value={entity.color}
|
|
121
|
+
oninput={(e) => handleColorInput(entity, e)}
|
|
122
|
+
/>
|
|
123
|
+
</label>
|
|
124
|
+
{:else}
|
|
125
|
+
<span
|
|
126
|
+
class="entity-toggle-list__swatch"
|
|
127
|
+
style:background-color={entity.color}
|
|
128
|
+
aria-hidden="true"
|
|
129
|
+
></span>
|
|
130
|
+
{/if}
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
class="entity-toggle-list__label"
|
|
134
|
+
aria-pressed={isVisible(entity)}
|
|
135
|
+
title={isVisible(entity) ? `Hide ${entity.label}` : `Show ${entity.label}`}
|
|
136
|
+
onclick={() => handleToggle(entity)}
|
|
137
|
+
>
|
|
138
|
+
{entity.label}
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
{/each}
|
|
142
|
+
</div>
|
|
143
|
+
{/each}
|
|
144
|
+
|
|
145
|
+
{#if hiddenCount > 0}
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
class="entity-toggle-list__expand"
|
|
149
|
+
onclick={() => (expanded = true)}
|
|
150
|
+
title="Show {hiddenCount} more"
|
|
151
|
+
>
|
|
152
|
+
+{hiddenCount} more
|
|
153
|
+
</button>
|
|
154
|
+
{:else if expanded && maxVisible !== undefined && entities.length > maxVisible}
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
class="entity-toggle-list__expand"
|
|
158
|
+
onclick={() => (expanded = false)}
|
|
159
|
+
title="Collapse"
|
|
160
|
+
>
|
|
161
|
+
show less
|
|
162
|
+
</button>
|
|
163
|
+
{/if}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<style>
|
|
167
|
+
.entity-toggle-list {
|
|
168
|
+
display: flex;
|
|
169
|
+
flex-direction: column;
|
|
170
|
+
gap: 6px;
|
|
171
|
+
font:
|
|
172
|
+
12px/1.3 system-ui,
|
|
173
|
+
-apple-system,
|
|
174
|
+
Segoe UI,
|
|
175
|
+
sans-serif;
|
|
176
|
+
|
|
177
|
+
--entity-swatch-size: 12px;
|
|
178
|
+
--entity-btn-bg: transparent;
|
|
179
|
+
--entity-btn-bg-hover: rgba(0, 0, 0, 0.05);
|
|
180
|
+
--entity-btn-fg: #111;
|
|
181
|
+
--entity-btn-fg-hidden: #9ca3af;
|
|
182
|
+
--entity-heading-fg: #6b7280;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.entity-toggle-list__heading,
|
|
186
|
+
.entity-toggle-list__group-label {
|
|
187
|
+
font-size: 11px;
|
|
188
|
+
font-weight: 600;
|
|
189
|
+
text-transform: uppercase;
|
|
190
|
+
letter-spacing: 0.04em;
|
|
191
|
+
color: var(--entity-heading-fg);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.entity-toggle-list__row {
|
|
195
|
+
display: flex;
|
|
196
|
+
flex-wrap: wrap;
|
|
197
|
+
gap: 4px 8px;
|
|
198
|
+
align-items: center;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.entity-toggle-list__item {
|
|
202
|
+
display: inline-flex;
|
|
203
|
+
align-items: center;
|
|
204
|
+
gap: 6px;
|
|
205
|
+
padding: 2px 6px 2px 4px;
|
|
206
|
+
border-radius: 4px;
|
|
207
|
+
background: var(--entity-btn-bg);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.entity-toggle-list__item:hover {
|
|
211
|
+
background: var(--entity-btn-bg-hover);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.entity-toggle-list__item.is-hidden .entity-toggle-list__label {
|
|
215
|
+
color: var(--entity-btn-fg-hidden);
|
|
216
|
+
text-decoration: line-through;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.entity-toggle-list__item.is-hidden .entity-toggle-list__swatch {
|
|
220
|
+
opacity: 0.3;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.entity-toggle-list__swatch {
|
|
224
|
+
display: inline-block;
|
|
225
|
+
width: var(--entity-swatch-size);
|
|
226
|
+
height: var(--entity-swatch-size);
|
|
227
|
+
border-radius: 50%;
|
|
228
|
+
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
229
|
+
flex: 0 0 auto;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.entity-toggle-list__swatch-wrap {
|
|
233
|
+
position: relative;
|
|
234
|
+
display: inline-flex;
|
|
235
|
+
cursor: pointer;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.entity-toggle-list__color-input {
|
|
239
|
+
position: absolute;
|
|
240
|
+
inset: 0;
|
|
241
|
+
opacity: 0;
|
|
242
|
+
cursor: pointer;
|
|
243
|
+
width: 100%;
|
|
244
|
+
height: 100%;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.entity-toggle-list__label {
|
|
248
|
+
background: none;
|
|
249
|
+
border: none;
|
|
250
|
+
padding: 0;
|
|
251
|
+
margin: 0;
|
|
252
|
+
font: inherit;
|
|
253
|
+
color: var(--entity-btn-fg);
|
|
254
|
+
cursor: pointer;
|
|
255
|
+
text-align: left;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.entity-toggle-list__expand {
|
|
259
|
+
align-self: flex-start;
|
|
260
|
+
background: none;
|
|
261
|
+
border: none;
|
|
262
|
+
padding: 0;
|
|
263
|
+
color: var(--entity-heading-fg);
|
|
264
|
+
font: inherit;
|
|
265
|
+
font-size: 11px;
|
|
266
|
+
text-decoration: underline;
|
|
267
|
+
cursor: pointer;
|
|
268
|
+
}
|
|
269
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single entity (speaker, actor, track, class, series, etc.) rendered
|
|
3
|
+
* as a toggle button in the list. Consumers supply a flat array; optional
|
|
4
|
+
* `group` buckets entities into labeled sections.
|
|
5
|
+
*/
|
|
6
|
+
export interface Entity {
|
|
7
|
+
/** Stable id. Used in onToggle/onColorChange callbacks. */
|
|
8
|
+
id: string;
|
|
9
|
+
/** Human-readable label shown on the button. */
|
|
10
|
+
label: string;
|
|
11
|
+
/** Color swatch. Any CSS color string. */
|
|
12
|
+
color: string;
|
|
13
|
+
/** Default: true. When false, the entity renders dimmed. */
|
|
14
|
+
visible?: boolean;
|
|
15
|
+
/** Optional group label. Entities with the same `group` are rendered together. */
|
|
16
|
+
group?: string;
|
|
17
|
+
}
|
|
18
|
+
interface Props {
|
|
19
|
+
/** The list of entities. */
|
|
20
|
+
entities: Entity[];
|
|
21
|
+
/** Called when a visibility toggle is clicked. */
|
|
22
|
+
onToggle?: (id: string, visible: boolean) => void;
|
|
23
|
+
/** When provided, a native color input is shown and clicking the swatch opens it. */
|
|
24
|
+
onColorChange?: (id: string, color: string) => void;
|
|
25
|
+
/** Max entities to show before collapsing the remainder behind a "+N more" expander. */
|
|
26
|
+
maxVisible?: number;
|
|
27
|
+
/** Optional heading above the list. */
|
|
28
|
+
heading?: string;
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
declare const EntityToggleList: import("svelte").Component<Props, {}, "">;
|
|
32
|
+
type EntityToggleList = ReturnType<typeof EntityToggleList>;
|
|
33
|
+
export default EntityToggleList;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|