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.
- package/LICENSE +21 -0
- package/README.md +300 -0
- package/dist/HorizonLayout.svelte +798 -0
- package/dist/HorizonLayout.svelte.d.ts +49 -0
- package/dist/LayoutNode.svelte +98 -0
- package/dist/LayoutNode.svelte.d.ts +28 -0
- package/dist/PopoutContent.svelte +25 -0
- package/dist/PopoutContent.svelte.d.ts +10 -0
- package/dist/Split.svelte +197 -0
- package/dist/Split.svelte.d.ts +28 -0
- package/dist/TabGroup.svelte +226 -0
- package/dist/TabGroup.svelte.d.ts +22 -0
- package/dist/horizon-layout.css +459 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/internal-types.d.ts +8 -0
- package/dist/internal-types.js +1 -0
- package/dist/internal-utils.d.ts +6 -0
- package/dist/internal-utils.js +25 -0
- package/dist/types.d.ts +43 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +15 -0
- package/dist/utils.js +248 -0
- package/package.json +59 -0
|
@@ -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;
|