1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
<script lang="ts">
import { type Snippet, untrack } from "svelte";
import {
addPanel,
initialPanels,
type PanelsState,
removePanel,
selectKind,
} from "../logic/panels";
interface ViewKind {
readonly id: string;
readonly label: string;
}
let {
kinds,
content,
initial,
onChange,
}: {
/** The view kinds offered in every panel's dropdown. */
kinds: readonly ViewKind[];
/** Renders a panel body for the given (non-null) view-kind id. */
content: Snippet<[string]>;
/** Optional seed of panel kinds; defaults to one panel of the first kind. */
initial?: readonly (string | null)[];
/** Called whenever the panel layout changes (add/remove/select). */
onChange?: (kinds: readonly (string | null)[]) => void;
} = $props();
// Local UI composition state, owned by this unit and folded through the pure
// reducer — never reached from elsewhere (no ambient store). Seeded ONCE from
// the props (untrack makes that one-time read explicit, not reactive).
let state = $state<PanelsState>(
untrack(() => initialPanels(initial ?? [kinds[0]?.id ?? null])),
);
function notify(): void {
onChange?.(state.panels.map((p) => p.kind));
}
</script>
<div class="flex min-h-0 flex-col gap-2">
{#each state.panels as panel, idx (panel.id)}
<div class="flex flex-col rounded-lg bg-base-200 p-3">
<div class="flex items-center gap-1">
<select
class="select select-bordered select-sm flex-1"
aria-label="Select a view"
value={panel.kind ?? ""}
onchange={(e) => {
const v = e.currentTarget.value;
state = selectKind(state, panel.id, v === "" ? null : v);
notify();
}}
>
<option value="" disabled>Select a view</option>
{#each kinds as kind (kind.id)}
<option value={kind.id}>{kind.label}</option>
{/each}
</select>
{#if idx > 0}
<button
type="button"
class="btn btn-square btn-ghost btn-sm shrink-0"
aria-label="Remove view"
onclick={() => {
state = removePanel(state, panel.id);
notify();
}}
>
✕
</button>
{/if}
</div>
{#if panel.kind !== null}
<div class="mt-2">
{@render content(panel.kind)}
</div>
{/if}
</div>
{/each}
<button
type="button"
class="btn w-full border-none bg-base-200 text-lg hover:bg-base-300"
aria-label="Add view"
onclick={() => {
state = addPanel(state);
notify();
}}
>
+
</button>
</div>
|