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.
- 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 +21 -6
- 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/dist/windowManager.svelte.js +1 -1
- package/package.json +15 -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
|
+
});
|
|
@@ -56,14 +56,14 @@
|
|
|
56
56
|
return typeof v === 'number' ? `${v}px` : v;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
// Focus management
|
|
59
|
+
// Focus management - each window gets a reactive z-index slot shared
|
|
60
60
|
// across every DraggableWindow on the page. Clicking brings it to top.
|
|
61
61
|
const win = registerWindow();
|
|
62
62
|
|
|
63
63
|
// Live position. Neodrag's `position` plugin drives the element via
|
|
64
64
|
// transform; we update this state from onDrag AND on resize/clamp,
|
|
65
65
|
// and the Compartment re-evaluates to push changes back into neodrag.
|
|
66
|
-
// `untrack` prevents these props from forming a reactive dep
|
|
66
|
+
// `untrack` prevents these props from forming a reactive dep - they're
|
|
67
67
|
// *initial* values only; later position comes from user drag.
|
|
68
68
|
let pos = $state(untrack(() => ({ x: initialX, y: initialY })));
|
|
69
69
|
|
|
@@ -74,9 +74,13 @@
|
|
|
74
74
|
|
|
75
75
|
function clampToViewport() {
|
|
76
76
|
if (typeof window === 'undefined') return;
|
|
77
|
+
// When constrained to a parent, neodrag's `bounds(BoundsFrom.parent())`
|
|
78
|
+
// already keeps the window inside; viewport math here would be wrong
|
|
79
|
+
// because positions are relative to the parent, not the viewport.
|
|
80
|
+
if (constrained === 'parent') return;
|
|
77
81
|
const vw = window.innerWidth;
|
|
78
82
|
const vh = window.innerHeight;
|
|
79
|
-
// Never allow the titlebar above the viewport top
|
|
83
|
+
// Never allow the titlebar above the viewport top - that's the
|
|
80
84
|
// "unreachable" case. Allow partial horizontal overflow so narrow
|
|
81
85
|
// screens don't trap wide windows, but always keep `minVisible`
|
|
82
86
|
// pixels of the window on-screen for a grab handle.
|
|
@@ -90,7 +94,7 @@
|
|
|
90
94
|
};
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
// Keep size fresh (used for clamp math)
|
|
97
|
+
// Keep size fresh (used for clamp math) - ResizeObserver on our own
|
|
94
98
|
// root element catches CSS resize-from-corner too.
|
|
95
99
|
$effect(() => {
|
|
96
100
|
if (!rootEl) return;
|
|
@@ -106,7 +110,7 @@
|
|
|
106
110
|
return () => ro.disconnect();
|
|
107
111
|
});
|
|
108
112
|
|
|
109
|
-
// Re-clamp on window resize
|
|
113
|
+
// Re-clamp on window resize - this is the "snap back in when the
|
|
110
114
|
// viewport shrinks" case.
|
|
111
115
|
$effect(() => {
|
|
112
116
|
if (typeof window === 'undefined') return;
|
|
@@ -139,6 +143,8 @@
|
|
|
139
143
|
<div
|
|
140
144
|
bind:this={rootEl}
|
|
141
145
|
class="draggable-window"
|
|
146
|
+
class:dw-fixed={constrained !== 'parent'}
|
|
147
|
+
class:dw-absolute={constrained === 'parent'}
|
|
142
148
|
style:width={toCssSize(width)}
|
|
143
149
|
style:height={toCssSize(height)}
|
|
144
150
|
style:min-width={toCssSize(minWidth)}
|
|
@@ -162,7 +168,6 @@
|
|
|
162
168
|
|
|
163
169
|
<style>
|
|
164
170
|
.draggable-window {
|
|
165
|
-
position: fixed;
|
|
166
171
|
top: 0;
|
|
167
172
|
left: 0;
|
|
168
173
|
display: flex;
|
|
@@ -177,6 +182,16 @@
|
|
|
177
182
|
/* Native resize from the bottom-right corner. */
|
|
178
183
|
resize: both;
|
|
179
184
|
}
|
|
185
|
+
/* Default: float above the viewport (suits constrained="viewport" or "none"). */
|
|
186
|
+
.dw-fixed {
|
|
187
|
+
position: fixed;
|
|
188
|
+
}
|
|
189
|
+
/* When constrained to a parent, position absolute inside that parent so
|
|
190
|
+
the visual position matches the drag bounds. The parent must be a
|
|
191
|
+
positioning context (`position: relative` or similar). */
|
|
192
|
+
.dw-absolute {
|
|
193
|
+
position: absolute;
|
|
194
|
+
}
|
|
180
195
|
.dw-titlebar {
|
|
181
196
|
display: flex;
|
|
182
197
|
align-items: center;
|
|
@@ -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 {};
|