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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
|
<script lang="ts">
/**
* A flex row/column of child subtrees. Child sizing comes from the tree:
* `size` → flex-grow (+ optional `basis`), and `collapsible` children get
* today's EXACT sidebar behavior — width/translate transition, `lg:relative`
* in-flow push vs `max-lg:absolute` overlay with a backdrop. Collapse state
* is chrome (per-node-id), owned by the injected collapse store.
*/
import type { LayoutOp, Path, SplitNode, TileMarker } from "@dispatch/layout-contract";
import type { CollapseStore } from "../logic/collapse";
import { markerStyle } from "../logic/marker";
import TileNode from "./TileNode.svelte";
let {
node,
path,
ports,
collapse,
onEdit,
}: {
node: SplitNode;
path: Path;
ports?: unknown;
collapse: CollapseStore;
onEdit: (ops: readonly LayoutOp[]) => void;
} = $props();
const state = $derived($collapse);
/** Collapse state key: the node's id when present, else its path (stable enough for chrome). */
function keyOf(i: number, id: string | undefined): string {
return id ?? [...path, i].map((s) => String(s)).join(":");
}
function isOpen(i: number, id: string | undefined): boolean {
const key = keyOf(i, id);
return state.openByNode[key] ?? state.defaultOpen;
}
/**
* The scroll container's inline style: the fixed sidebar width + slide
* transform + any diagnostic marker (inset) — none when unmarked.
*
* The marker goes on THIS div, not the aside: the inner div paints an
* OPAQUE full-height background (bg-base-100) that would cover an outline
* drawn on the aside. This div's own box never moves — only its contents
* scroll — and overflow-y-auto clips descendants, never an element's own
* outline, so the marker frames exactly the visible sidebar.
*/
function scrollViewportStyle(
basis: number | undefined,
open: boolean,
marker: TileMarker | undefined,
): string {
const parts = [`width: ${basis ?? 320}px`, `transform: translateX(${open ? "0" : "100%"})`];
const markerCss = markerStyle(marker);
if (markerCss !== undefined) parts.push(markerCss);
return parts.join("; ");
}
</script>
{#if node.dir === "col"}
<div class="flex min-h-0 w-full flex-col" style={markerStyle(node.marker)}>
{#each node.children as child, i (i)}
{@const key = keyOf(i, child.node.id)}
<div
class="min-h-0 min-w-0"
style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")}
>
<TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
</div>
{/each}
</div>
{:else}
<div class="flex min-h-0 h-full flex-row" style={markerStyle(node.marker)}>
{#each node.children as child, i (i)}
{@const key = keyOf(i, child.node.id)}
{#if child.collapsible}
{@const open = isOpen(i, child.node.id)}
<!--
A diagnostic marker on a collapsible child is drawn on the SCROLL
CONTAINER below — the sidebar's fixed, opaquely-painted viewport —
NOT on the child's own root element: that root renders INSIDE the
scroll container, so an outline there would wrap the full
scrollable content and scroll along with it instead of framing the
visible panel.
-->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<aside
class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative"
style="width: {open ? (child.basis ?? 320) : 0}px"
data-testid="collapsible-child"
data-open={open}
>
<div
class="flex h-full flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 transition-transform duration-300 ease-out"
style={scrollViewportStyle(child.basis, open, child.node.marker)}
data-testid="sidebar-scroll-viewport"
>
<TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
</div>
</aside>
{#if open}
<!-- Backdrop: only on narrow screens (overlay mode), click to close. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-20 bg-black/30 lg:hidden"
role="button"
tabindex="0"
aria-label="Close sidebar"
onclick={() => collapse.setOpen(key, false)}
onkeydown={(e) => {
if (e.key === "Escape" || e.key === "Enter") collapse.setOpen(key, false);
}}
></div>
{/if}
{:else}
<div
class="min-h-0 min-w-0"
style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")}
>
<TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
</div>
{/if}
{/each}
</div>
{/if}
|