horizon-layout 1.0.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.
@@ -0,0 +1,49 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { Id, KeyboardControls, LayoutConfig, View } from './types.ts';
3
+ import { SvelteMap } from 'svelte/reactivity';
4
+ type $$ComponentProps = {
5
+ /** The layout configuration. Bind this to keep it in sync with user interactions. */
6
+ config: LayoutConfig;
7
+ /** Map from view Id to its View descriptor. */
8
+ views: SvelteMap<Id, View>;
9
+ /** Snippets rendered in every tab-group's toolbar (e.g. a maximise button). */
10
+ tabgroupControls?: Snippet<[Id]>[];
11
+ /** Prevent the user from dragging split dividers. */
12
+ disableResizeSplits?: boolean;
13
+ /** Prevent drag-and-drop tab rearrangement. */
14
+ disableDragAndDrop?: boolean;
15
+ /** Show the current split ratio as a floating label while resizing. */
16
+ showSplitRatio?: boolean;
17
+ /** Minimum allowed width as a fraction of the split container. */
18
+ minWidthRatio?: number;
19
+ /** Minimum allowed height as a fraction of the split container. */
20
+ minHeightRatio?: number;
21
+ /** Maximum nesting depth for splits (prevents infinite subdivision). */
22
+ maxDepth?: number;
23
+ /** Hide the tab bar entirely (useful when every group has exactly one tab). */
24
+ hideTabBar?: boolean;
25
+ /** Override the default keyboard shortcuts for splits and tab groups. */
26
+ keyboardControls?: KeyboardControls;
27
+ /** Format a split ratio as a display string. Default: "50.00%". */
28
+ formatRatio?: (ratio: number) => string;
29
+ /** Format a split ratio as a number for aria-valuenow. Default: 50.00. */
30
+ formatRatioForAria?: (ratio: number) => number;
31
+ /** Called when a popout window is closed by the user. */
32
+ onPopoutClose?: (viewId: Id) => void;
33
+ /**
34
+ * Called when the browser blocks a popout window after the fallback timeout.
35
+ * If not provided, the blocked tab is automatically moved into the main layout.
36
+ */
37
+ onPopoutBlocked?: (id: Id) => void;
38
+ /** Root CSS class applied to the layout element and used as a BEM prefix. */
39
+ baseClass?: string;
40
+ /**
41
+ * Skip config validation on every update. Set this when you have already
42
+ * validated the config yourself (e.g. with validateConfig) and want to avoid
43
+ * the redundant check.
44
+ */
45
+ skipValidation?: boolean;
46
+ };
47
+ declare const HorizonLayout: import("svelte").Component<$$ComponentProps, {}, "config">;
48
+ type HorizonLayout = ReturnType<typeof HorizonLayout>;
49
+ export default HorizonLayout;
@@ -0,0 +1,98 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type {
4
+ Id,
5
+ KeyboardControls,
6
+ SplitConfig,
7
+ View,
8
+ TabGroupConfig,
9
+ NodeConfig
10
+ } from './types.js';
11
+ import { nodeConfigType } from './utils.js';
12
+ import HorizonSplit from './Split.svelte';
13
+ import HorizonTabGroup from './TabGroup.svelte';
14
+ import { SvelteMap } from 'svelte/reactivity';
15
+ import type { DropTarget } from './internal-types.js';
16
+
17
+ let {
18
+ config = $bindable(),
19
+ views,
20
+ tabgroupControls,
21
+ disableResizeSplits,
22
+ disableDragAndDrop,
23
+ isDragging,
24
+ onStartTabDrag,
25
+ onHoverEnter,
26
+ onHoverExit,
27
+ canDrop,
28
+ remainingDepth,
29
+ showSplitRatio,
30
+ minWidthRatio,
31
+ minHeightRatio,
32
+ hideTabBar,
33
+ keyboardControls,
34
+ formatRatio,
35
+ formatRatioForAria,
36
+ baseClass
37
+ }: {
38
+ config: NodeConfig;
39
+ views: SvelteMap<Id, View>;
40
+ tabgroupControls: Snippet<[Id]>[];
41
+ disableResizeSplits: boolean;
42
+ disableDragAndDrop: boolean;
43
+ onStartTabDrag: (event: DragEvent, tabGroup: TabGroupConfig, tabId: Id) => void;
44
+ onHoverEnter: (tabGroup: TabGroupConfig, target: DropTarget) => void;
45
+ onHoverExit: (tabGroup: TabGroupConfig) => void;
46
+ canDrop: (tabGroup: TabGroupConfig, target: DropTarget) => boolean;
47
+ isDragging: boolean;
48
+ remainingDepth: number;
49
+ showSplitRatio: boolean;
50
+ minWidthRatio: number;
51
+ minHeightRatio: number;
52
+ hideTabBar: boolean;
53
+ keyboardControls: KeyboardControls;
54
+ formatRatio: (ratio: number) => string;
55
+ formatRatioForAria: (ratio: number) => number;
56
+ baseClass: string;
57
+ } = $props();
58
+ </script>
59
+
60
+ {#if nodeConfigType(config) === 'split'}
61
+ <HorizonSplit
62
+ bind:config={config as SplitConfig}
63
+ {views}
64
+ {tabgroupControls}
65
+ {disableResizeSplits}
66
+ {disableDragAndDrop}
67
+ {isDragging}
68
+ {onStartTabDrag}
69
+ {onHoverEnter}
70
+ {onHoverExit}
71
+ {canDrop}
72
+ remainingDepth={remainingDepth - 1}
73
+ {showSplitRatio}
74
+ {minWidthRatio}
75
+ {minHeightRatio}
76
+ {hideTabBar}
77
+ {keyboardControls}
78
+ {formatRatio}
79
+ {formatRatioForAria}
80
+ {baseClass}
81
+ ></HorizonSplit>
82
+ {:else}
83
+ <HorizonTabGroup
84
+ bind:config={config as TabGroupConfig}
85
+ {views}
86
+ disableDrag={disableDragAndDrop}
87
+ {isDragging}
88
+ {onStartTabDrag}
89
+ {onHoverEnter}
90
+ {onHoverExit}
91
+ {canDrop}
92
+ {remainingDepth}
93
+ {hideTabBar}
94
+ controls={tabgroupControls}
95
+ keyboardControls={keyboardControls.tabGroupControls ?? []}
96
+ baseClass={`${baseClass}-tabgroup`}
97
+ ></HorizonTabGroup>
98
+ {/if}
@@ -0,0 +1,28 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { Id, KeyboardControls, View, TabGroupConfig, NodeConfig } from './types.ts';
3
+ import { SvelteMap } from 'svelte/reactivity';
4
+ import type { DropTarget } from './internal-types.ts';
5
+ type $$ComponentProps = {
6
+ config: NodeConfig;
7
+ views: SvelteMap<Id, View>;
8
+ tabgroupControls: Snippet<[Id]>[];
9
+ disableResizeSplits: boolean;
10
+ disableDragAndDrop: boolean;
11
+ onStartTabDrag: (event: DragEvent, tabGroup: TabGroupConfig, tabId: Id) => void;
12
+ onHoverEnter: (tabGroup: TabGroupConfig, target: DropTarget) => void;
13
+ onHoverExit: (tabGroup: TabGroupConfig) => void;
14
+ canDrop: (tabGroup: TabGroupConfig, target: DropTarget) => boolean;
15
+ isDragging: boolean;
16
+ remainingDepth: number;
17
+ showSplitRatio: boolean;
18
+ minWidthRatio: number;
19
+ minHeightRatio: number;
20
+ hideTabBar: boolean;
21
+ keyboardControls: KeyboardControls;
22
+ formatRatio: (ratio: number) => string;
23
+ formatRatioForAria: (ratio: number) => number;
24
+ baseClass: string;
25
+ };
26
+ declare const LayoutNode: import("svelte").Component<$$ComponentProps, {}, "config">;
27
+ type LayoutNode = ReturnType<typeof LayoutNode>;
28
+ export default LayoutNode;
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ import type { View } from './types.js';
3
+
4
+ let {
5
+ popout,
6
+ title,
7
+ view,
8
+ baseClass
9
+ }: {
10
+ popout: Window;
11
+ title: string;
12
+ view: View;
13
+ baseClass: string;
14
+ } = $props();
15
+
16
+ $effect(() => {
17
+ popout.document.title = title;
18
+ });
19
+ </script>
20
+
21
+ <main class="{baseClass} {baseClass}__popout">
22
+ <div class="{baseClass}__content">
23
+ {@render view.snippet()}
24
+ </div>
25
+ </main>
@@ -0,0 +1,10 @@
1
+ import type { View } from './types.ts';
2
+ type $$ComponentProps = {
3
+ popout: Window;
4
+ title: string;
5
+ view: View;
6
+ baseClass: string;
7
+ };
8
+ declare const PopoutContent: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type PopoutContent = ReturnType<typeof PopoutContent>;
10
+ export default PopoutContent;
@@ -0,0 +1,197 @@
1
+ <script lang="ts">
2
+ import type { SvelteMap } from 'svelte/reactivity';
3
+ import type { View, Id, SplitConfig, KeyboardControls, TabGroupConfig } from './types.js';
4
+ import { getModifier } from './internal-utils.js';
5
+ import HorizonLayoutNode from './LayoutNode.svelte';
6
+ import { type Snippet } from 'svelte';
7
+ import type { DropTarget } from './internal-types.js';
8
+
9
+ let {
10
+ config = $bindable(),
11
+ views,
12
+ tabgroupControls,
13
+ disableResizeSplits,
14
+ disableDragAndDrop,
15
+ isDragging,
16
+ onStartTabDrag,
17
+ onHoverEnter,
18
+ onHoverExit,
19
+ canDrop,
20
+ remainingDepth,
21
+ showSplitRatio,
22
+ minWidthRatio,
23
+ minHeightRatio,
24
+ hideTabBar,
25
+ keyboardControls,
26
+ formatRatio,
27
+ formatRatioForAria,
28
+ baseClass
29
+ }: {
30
+ config: SplitConfig;
31
+ views: SvelteMap<Id, View>;
32
+ tabgroupControls: Snippet<[Id]>[];
33
+ disableResizeSplits: boolean;
34
+ disableDragAndDrop: boolean;
35
+ isDragging: boolean;
36
+ onStartTabDrag: (event: DragEvent, tabGroup: TabGroupConfig, tabId: Id) => void;
37
+ onHoverEnter: (tabGroup: TabGroupConfig, target: DropTarget) => void;
38
+ onHoverExit: (tabGroup: TabGroupConfig) => void;
39
+ canDrop: (tabGroup: TabGroupConfig, target: DropTarget) => boolean;
40
+ remainingDepth: number;
41
+ showSplitRatio: boolean;
42
+ minWidthRatio: number;
43
+ minHeightRatio: number;
44
+ hideTabBar: boolean;
45
+ keyboardControls: KeyboardControls;
46
+ formatRatio: (ratio: number) => string;
47
+ formatRatioForAria: (ratio: number) => number;
48
+ baseClass: string;
49
+ } = $props();
50
+
51
+ let splitContainer = $state<HTMLElement | null>(null);
52
+
53
+ let activeResizer = $state<number | null>(null);
54
+
55
+ let isHorizontal = $derived(config.direction === 'horizontal');
56
+ let minRatio = $derived(isHorizontal ? minWidthRatio : minHeightRatio);
57
+
58
+ let posConstraints = $derived(
59
+ config.splitPoints.map((_, i) => ({
60
+ min: (config.splitPoints[i - 1] ?? 0) + minRatio,
61
+ max: (config.splitPoints[i + 1] ?? 1) - minRatio
62
+ }))
63
+ );
64
+
65
+ let splitRatioStyles = $derived(
66
+ config.splitPoints.map((_, i) => {
67
+ const ratio = config.splitPoints[i] ?? 0;
68
+ return isHorizontal ? `left: ${ratio * 100}%;` : `top: ${ratio * 100}%;`;
69
+ })
70
+ );
71
+
72
+ function startResize(event: PointerEvent, index: number) {
73
+ event.preventDefault();
74
+ event.stopPropagation();
75
+ if (disableResizeSplits) return;
76
+
77
+ const target = event.currentTarget as HTMLElement;
78
+ target.setPointerCapture(event.pointerId);
79
+ activeResizer = index;
80
+
81
+ const rect = splitContainer!.getBoundingClientRect();
82
+
83
+ function move(event: PointerEvent) {
84
+ const ratio = isHorizontal
85
+ ? (event.clientX - rect.left) / rect.width
86
+ : (event.clientY - rect.top) / rect.height;
87
+ const { min, max } = posConstraints[index]!;
88
+ const clamped = Number(Math.min(Math.max(ratio, min), max).toFixed(4));
89
+ if (clamped !== config.splitPoints[index]) config.splitPoints[index] = clamped;
90
+ }
91
+
92
+ function stop(event: PointerEvent) {
93
+ activeResizer = null;
94
+ if (target.hasPointerCapture(event.pointerId)) {
95
+ target.releasePointerCapture(event.pointerId);
96
+ }
97
+ target.removeEventListener('pointermove', move);
98
+ target.removeEventListener('pointerup', stop);
99
+ target.removeEventListener('pointercancel', stop);
100
+ }
101
+
102
+ target.addEventListener('pointermove', move);
103
+ target.addEventListener('pointerup', stop);
104
+ target.addEventListener('pointercancel', stop);
105
+ }
106
+
107
+ function handleKeyDown(event: KeyboardEvent, index: number) {
108
+ const modifier = getModifier(event);
109
+ for (const control of keyboardControls.splitControls ?? []) {
110
+ for (const shortcut of control.shortcuts) {
111
+ if (modifier === (shortcut.modifier ?? null) && event.key === shortcut.key) {
112
+ event.preventDefault();
113
+ event.stopPropagation();
114
+ control.action({ config, index }, event);
115
+ return;
116
+ }
117
+ }
118
+ }
119
+ }
120
+ </script>
121
+
122
+ <div
123
+ class="{baseClass}-split {baseClass}-split--{config.direction}"
124
+ style="display: flex !important;"
125
+ bind:this={splitContainer}
126
+ >
127
+ {#each config.views as view, i (view)}
128
+ {@const splitPoint = config.splitPoints[i]}
129
+ <div
130
+ class="{baseClass}-split__pane {baseClass}-split__pane--{config.direction}"
131
+ style={`flex: ${(splitPoint ?? 1) - (config.splitPoints[i - 1] ?? 0)} 1 0 !important;`}
132
+ >
133
+ <HorizonLayoutNode
134
+ bind:config={config.views[i]!}
135
+ {views}
136
+ {tabgroupControls}
137
+ {disableResizeSplits}
138
+ {disableDragAndDrop}
139
+ {onStartTabDrag}
140
+ {onHoverEnter}
141
+ {onHoverExit}
142
+ {canDrop}
143
+ {isDragging}
144
+ {remainingDepth}
145
+ {showSplitRatio}
146
+ {minWidthRatio}
147
+ {minHeightRatio}
148
+ {hideTabBar}
149
+ {keyboardControls}
150
+ {formatRatio}
151
+ {formatRatioForAria}
152
+ {baseClass}
153
+ ></HorizonLayoutNode>
154
+ </div>
155
+
156
+ {#if i < config.splitPoints.length}
157
+ {@const isActive = activeResizer === i}
158
+ {@const constraints = posConstraints[i]!}
159
+ <div
160
+ class="{baseClass}-split__resizer {baseClass}-split__resizer--{config.direction}{isActive
161
+ ? ` ${baseClass}-split__resizer--active`
162
+ : ''}"
163
+ >
164
+ <span
165
+ class="{baseClass}-split__resizer-handle {baseClass}-split__resizer-handle--{config.direction}{isActive
166
+ ? ` ${baseClass}-split__resizer-handle--active`
167
+ : ''}"
168
+ role="slider"
169
+ tabindex={0}
170
+ aria-label="Resize pane {i + 1}"
171
+ aria-orientation={config.direction}
172
+ aria-valuemin={formatRatioForAria(constraints.min)}
173
+ aria-valuemax={formatRatioForAria(constraints.max)}
174
+ aria-valuenow={formatRatioForAria(splitPoint!)}
175
+ aria-valuetext={formatRatio(splitPoint!)}
176
+ onpointerdown={(event) => startResize(event, i)}
177
+ onfocus={() => {
178
+ if (!disableResizeSplits) activeResizer = i;
179
+ }}
180
+ onblur={() => (activeResizer = null)}
181
+ onkeydown={(event) => handleKeyDown(event, i)}
182
+ ></span>
183
+ </div>
184
+
185
+ {#if showSplitRatio}
186
+ <div
187
+ class="{baseClass}-split__ratio {baseClass}-split__ratio--{config.direction}{isActive
188
+ ? ` ${baseClass}-split__ratio--active`
189
+ : ''}"
190
+ style={splitRatioStyles[i]}
191
+ >
192
+ {formatRatio(splitPoint!)}
193
+ </div>
194
+ {/if}
195
+ {/if}
196
+ {/each}
197
+ </div>
@@ -0,0 +1,28 @@
1
+ import type { SvelteMap } from 'svelte/reactivity';
2
+ import type { View, Id, SplitConfig, KeyboardControls, TabGroupConfig } from './types.ts';
3
+ import { type Snippet } from 'svelte';
4
+ import type { DropTarget } from './internal-types.ts';
5
+ type $$ComponentProps = {
6
+ config: SplitConfig;
7
+ views: SvelteMap<Id, View>;
8
+ tabgroupControls: Snippet<[Id]>[];
9
+ disableResizeSplits: boolean;
10
+ disableDragAndDrop: boolean;
11
+ isDragging: boolean;
12
+ onStartTabDrag: (event: DragEvent, tabGroup: TabGroupConfig, tabId: Id) => void;
13
+ onHoverEnter: (tabGroup: TabGroupConfig, target: DropTarget) => void;
14
+ onHoverExit: (tabGroup: TabGroupConfig) => void;
15
+ canDrop: (tabGroup: TabGroupConfig, target: DropTarget) => boolean;
16
+ remainingDepth: number;
17
+ showSplitRatio: boolean;
18
+ minWidthRatio: number;
19
+ minHeightRatio: number;
20
+ hideTabBar: boolean;
21
+ keyboardControls: KeyboardControls;
22
+ formatRatio: (ratio: number) => string;
23
+ formatRatioForAria: (ratio: number) => number;
24
+ baseClass: string;
25
+ };
26
+ declare const Split: import("svelte").Component<$$ComponentProps, {}, "config">;
27
+ type Split = ReturnType<typeof Split>;
28
+ export default Split;
@@ -0,0 +1,226 @@
1
+ <script lang="ts">
2
+ import type { Id, KeyboardControl, TabGroupConfig, View } from './types.js';
3
+ import { onDestroy, untrack, type Snippet } from 'svelte';
4
+ import { getModifier } from './internal-utils.js';
5
+ import type { SvelteMap } from 'svelte/reactivity';
6
+ import type { DropSide, DropTarget } from './internal-types.js';
7
+
8
+ let {
9
+ config = $bindable(),
10
+ views,
11
+ disableDrag,
12
+ isDragging,
13
+ onStartTabDrag,
14
+ onHoverEnter,
15
+ onHoverExit,
16
+ canDrop,
17
+ remainingDepth,
18
+ hideTabBar,
19
+ controls,
20
+ keyboardControls,
21
+ baseClass
22
+ }: {
23
+ config: TabGroupConfig;
24
+ views: SvelteMap<Id, View>;
25
+ disableDrag: boolean;
26
+ isDragging: boolean;
27
+ onStartTabDrag: (event: DragEvent, tabGroup: TabGroupConfig, tabId: Id) => void;
28
+ onHoverEnter: (tabGroup: TabGroupConfig, target: DropTarget) => void;
29
+ onHoverExit: (tabGroup: TabGroupConfig) => void;
30
+ canDrop: (tabGroup: TabGroupConfig, target: DropTarget) => boolean;
31
+ remainingDepth: number;
32
+ hideTabBar: boolean;
33
+ controls: Snippet<[Id]>[];
34
+ keyboardControls: KeyboardControl<TabGroupConfig>[];
35
+ baseClass: string;
36
+ } = $props();
37
+
38
+ function handleKeyDown(event: KeyboardEvent) {
39
+ const modifier = getModifier(event);
40
+ for (const control of keyboardControls) {
41
+ for (const shortcut of control.shortcuts) {
42
+ if (modifier === (shortcut.modifier ?? null) && event.key === shortcut.key) {
43
+ event.preventDefault();
44
+ event.stopPropagation();
45
+ control.action(config, event);
46
+ return;
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ function startTabDrag(event: DragEvent, tabId: Id) {
53
+ if (!event.dataTransfer) return;
54
+ event.stopPropagation();
55
+ event.dataTransfer.dropEffect = 'move';
56
+ const currentTarget = event.currentTarget as HTMLElement;
57
+ event.dataTransfer.setDragImage(
58
+ currentTarget,
59
+ currentTarget.offsetWidth / 2,
60
+ currentTarget.offsetHeight / 2
61
+ );
62
+ onStartTabDrag(event, config, tabId);
63
+ }
64
+
65
+ // Track which target (if any) we are currently hovering over and which config
66
+ // object we reported to the parent, so we can reliably fire the exit callback
67
+ // even if the component's config reference changes mid-drag.
68
+ let hoverData: {
69
+ target: DropTarget;
70
+ reportedConfig: TabGroupConfig;
71
+ } | null = $state(null);
72
+
73
+ function handleHoverExit() {
74
+ if (!hoverData) return;
75
+ const reported = hoverData.reportedConfig;
76
+ hoverData = null;
77
+ onHoverExit(reported);
78
+ }
79
+
80
+ function handleHoverEnter(target: DropTarget) {
81
+ if (!isDragging || !canDrop(config, target)) {
82
+ handleHoverExit();
83
+ return;
84
+ }
85
+
86
+ if (
87
+ hoverData &&
88
+ hoverData.reportedConfig === config &&
89
+ hoverData.target.side === target.side &&
90
+ hoverData.target.tabIndex === target.tabIndex
91
+ ) {
92
+ return;
93
+ }
94
+
95
+ handleHoverExit();
96
+ hoverData = { target, reportedConfig: config };
97
+ onHoverEnter(config, target);
98
+ }
99
+
100
+ function handleDragOver(event: DragEvent, target: DropTarget) {
101
+ event.preventDefault();
102
+ event.stopPropagation();
103
+ event.dataTransfer!.dropEffect = 'move';
104
+ handleHoverEnter(target);
105
+ }
106
+
107
+ function handleDragLeave(event: DragEvent) {
108
+ event.preventDefault();
109
+ event.stopPropagation();
110
+ handleHoverExit();
111
+ }
112
+
113
+ // Re-emit hover events if config or drag state changes while hovering,
114
+ // so the parent always has up-to-date references.
115
+ $effect(() => {
116
+ void config;
117
+ void isDragging;
118
+ void remainingDepth;
119
+ if (!isDragging) {
120
+ handleHoverExit();
121
+ return;
122
+ }
123
+ const target = untrack(() => hoverData?.target);
124
+ if (target) handleHoverEnter(target);
125
+ });
126
+
127
+ onDestroy(handleHoverExit);
128
+ </script>
129
+
130
+ <section class={baseClass}>
131
+ {#if !hideTabBar}
132
+ <div class="{baseClass}__tab-bar">
133
+ <!-- Tab list - keyboard navigation is handled on the list container. -->
134
+ <div role="tablist" tabindex={0} class="{baseClass}__tabs" onkeydown={handleKeyDown}>
135
+ {#each config.tabs as tabId, index (tabId)}
136
+ {@const tab = views.get(tabId)}
137
+ <div
138
+ class="{baseClass}__tab-drop-hint {baseClass}__tab-drop-hint--{index}{hoverData?.target
139
+ .tabIndex === index
140
+ ? ` ${baseClass}__tab-drop-hint--hover`
141
+ : ''}"
142
+ aria-hidden="true"
143
+ ></div>
144
+ {#if isDragging}
145
+ <div
146
+ class="{baseClass}__tab-drop-zone {baseClass}__tab-drop-zone--{index}"
147
+ aria-hidden="true"
148
+ ondragenter={(event) => handleDragOver(event, { tabIndex: index })}
149
+ ondragover={(event) => handleDragOver(event, { tabIndex: index })}
150
+ ondragleave={handleDragLeave}
151
+ ></div>
152
+ {/if}
153
+ <div
154
+ role="tab"
155
+ class="{baseClass}__tab{index === config.activeTabIndex
156
+ ? ` ${baseClass}__tab--active`
157
+ : ''}"
158
+ tabindex={-1}
159
+ draggable={!disableDrag}
160
+ ondragstart={(event) => startTabDrag(event, tabId)}
161
+ onclick={() => (config.activeTabIndex = index)}
162
+ onkeydown={null}
163
+ >
164
+ <span class="{baseClass}__tab-title">{tab?.title}</span>
165
+ <div class="{baseClass}__tab-controls">
166
+ {#each tab?.tabControls ?? [] as control (control)}
167
+ <span class="{baseClass}__tab-control">
168
+ {@render control(tabId)}
169
+ </span>
170
+ {/each}
171
+ </div>
172
+ </div>
173
+ {/each}
174
+ <div
175
+ class="{baseClass}__tab-drop-hint {baseClass}__tab-drop-hint--{config.tabs
176
+ .length} {baseClass}__tab-drop-hint--end{hoverData?.target.tabIndex ===
177
+ config.tabs.length
178
+ ? ` ${baseClass}__tab-drop-hint--hover`
179
+ : ''}"
180
+ aria-hidden="true"
181
+ ></div>
182
+ {#if isDragging}
183
+ <div
184
+ class="{baseClass}__tab-drop-zone {baseClass}__tab-drop-zone--{config.tabs
185
+ .length} {baseClass}__tab-drop-zone--end"
186
+ aria-hidden="true"
187
+ ondragenter={(event) => handleDragOver(event, { tabIndex: config.tabs.length })}
188
+ ondragover={(event) => handleDragOver(event, { tabIndex: config.tabs.length })}
189
+ ondragleave={handleDragLeave}
190
+ ></div>
191
+ {/if}
192
+ </div>
193
+
194
+ <div class="{baseClass}__controls">
195
+ {#each controls as control (control)}
196
+ <div class="{baseClass}__control">
197
+ {@render control(config.tabs[config.activeTabIndex]!)}
198
+ </div>
199
+ {/each}
200
+ </div>
201
+ </div>
202
+ {/if}
203
+
204
+ <div class="{baseClass}__body">
205
+ <div class="{baseClass}__content">
206
+ {@render views.get(config.tabs[config.activeTabIndex]!)?.snippet()}
207
+ </div>
208
+ </div>
209
+
210
+ {#if isDragging}
211
+ <div class="{baseClass}__drop-zones">
212
+ {#each ['top', 'right', 'bottom', 'left'] as side (side)}
213
+ <div
214
+ class="{baseClass}__drop-zone {baseClass}__drop-zone--{side}"
215
+ role="region"
216
+ ondragenter={(event) => handleDragOver(event, { side: side as DropSide })}
217
+ ondragover={(event) => handleDragOver(event, { side: side as DropSide })}
218
+ ondragleave={handleDragLeave}
219
+ ></div>
220
+ {/each}
221
+ </div>
222
+ {#if hoverData?.target.side}
223
+ <div class="{baseClass}__drop-hint {baseClass}__drop-hint--{hoverData.target.side}"></div>
224
+ {/if}
225
+ {/if}
226
+ </section>
@@ -0,0 +1,22 @@
1
+ import type { Id, KeyboardControl, TabGroupConfig, View } from './types.ts';
2
+ import { type Snippet } from 'svelte';
3
+ import type { SvelteMap } from 'svelte/reactivity';
4
+ import type { DropTarget } from './internal-types.ts';
5
+ type $$ComponentProps = {
6
+ config: TabGroupConfig;
7
+ views: SvelteMap<Id, View>;
8
+ disableDrag: boolean;
9
+ isDragging: boolean;
10
+ onStartTabDrag: (event: DragEvent, tabGroup: TabGroupConfig, tabId: Id) => void;
11
+ onHoverEnter: (tabGroup: TabGroupConfig, target: DropTarget) => void;
12
+ onHoverExit: (tabGroup: TabGroupConfig) => void;
13
+ canDrop: (tabGroup: TabGroupConfig, target: DropTarget) => boolean;
14
+ remainingDepth: number;
15
+ hideTabBar: boolean;
16
+ controls: Snippet<[Id]>[];
17
+ keyboardControls: KeyboardControl<TabGroupConfig>[];
18
+ baseClass: string;
19
+ };
20
+ declare const TabGroup: import("svelte").Component<$$ComponentProps, {}, "config">;
21
+ type TabGroup = ReturnType<typeof TabGroup>;
22
+ export default TabGroup;