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
128
129
130
|
<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 -->
<!-- Viewport padding: top/right/bottom always; LEFT only in overlay mode
(< lg) — inline (≥ lg) the chat column's gutter already separates the
views, but the overlay's left edge is the panel's own edge. -->
<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 max-lg:pl-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}
|