svelte-p5-components 0.3.0 → 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/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
|
+
});
|
|
@@ -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 {};
|
|
@@ -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
|
+
});
|