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.
@@ -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
- function clampToViewport() {
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;
81
- const vw = window.innerWidth;
82
- const vh = window.innerHeight;
83
- // Never allow the titlebar above the viewport top - that's the
84
- // "unreachable" case. Allow partial horizontal overflow so narrow
85
- // screens don't trap wide windows, but always keep `minVisible`
86
- // pixels of the window on-screen for a grab handle.
87
- const minX = -(size.w - minVisible);
88
- const maxX = vw - minVisible;
89
- const minY = 0;
90
- const maxY = vh - minVisible;
91
- pos = {
92
- x: Math.max(minX, Math.min(maxX, pos.x)),
93
- y: Math.max(minY, Math.min(maxY, pos.y))
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 - this is the "snap back in when the
114
- // viewport shrinks" case.
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 = () => clampToViewport();
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: () => clampToViewport()
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 {};