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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Horizon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,300 @@
1
+ # HorizonLayout
2
+
3
+ ### A headless, fully keyboard-accessible docking layout for Svelte 5. Users can drag tabs between panes, resize splits, and pop views out into separate browser windows, all described by a plain serialisable config object that you bind to and persist however you like.
4
+
5
+ ---
6
+
7
+ ## Quick start
8
+
9
+ ```svelte
10
+ <script lang="ts">
11
+ import { HorizonLayout } from 'horizon-layout';
12
+ import { SvelteMap } from 'svelte/reactivity';
13
+ import type { LayoutConfig } from 'horizon-layout';
14
+
15
+ const views = new SvelteMap([
16
+ ['editor', { title: 'Editor', snippet: editorSnippet }],
17
+ ['preview', { title: 'Preview', snippet: previewSnippet }]
18
+ ]);
19
+
20
+ let config = $state<LayoutConfig>({
21
+ root: {
22
+ direction: 'horizontal',
23
+ views: [
24
+ { tabs: ['editor'], activeTabIndex: 0 },
25
+ { tabs: ['preview'], activeTabIndex: 0 }
26
+ ],
27
+ splitPoints: [0.5]
28
+ }
29
+ });
30
+ </script>
31
+
32
+ {#snippet editorSnippet()}<MyEditor />{/snippet}
33
+ {#snippet previewSnippet()}<MyPreview />{/snippet}
34
+
35
+ <HorizonLayout bind:config {views} />
36
+ ```
37
+
38
+ ---
39
+
40
+ ## `LayoutConfig`
41
+
42
+ The entire layout state is a plain `LayoutConfig` object. Bind it to keep it in sync, or persist it to `localStorage`/a server.
43
+
44
+ ```ts
45
+ interface LayoutConfig {
46
+ root?: NodeConfig; // omit (or set to undefined) to render an empty layout
47
+ maximizedView?: Id; // when set, this view fills the whole container
48
+ popouts?: Id[]; // views opened in detached browser tabs
49
+ }
50
+ ```
51
+
52
+ ### `NodeConfig`
53
+
54
+ A node is either a **split** or a **tab group**.
55
+
56
+ #### `SplitConfig`
57
+
58
+ ```ts
59
+ interface SplitConfig {
60
+ direction: 'horizontal' | 'vertical';
61
+ views: [NodeConfig, NodeConfig, ...NodeConfig[]]; // at least two children
62
+ splitPoints: [number, ...number[]]; // length === views.length - 1
63
+ }
64
+ ```
65
+
66
+ `splitPoints` are fractions `[0, 1]` of the container's width (`horizontal`) or height (`vertical`). For example, `[0.33, 0.66]` divides the container into three equal thirds.
67
+
68
+ #### `TabGroupConfig`
69
+
70
+ ```ts
71
+ interface TabGroupConfig {
72
+ tabs: [Id, ...Id[]]; // at least one tab
73
+ activeTabIndex: number;
74
+ }
75
+ ```
76
+
77
+ ---
78
+
79
+ ## `View`
80
+
81
+ ```ts
82
+ interface View {
83
+ title: string;
84
+ snippet: Snippet; // panel body
85
+ tabControls?: Snippet<[Id]>[]; // optional controls rendered inside the tab, receives the viewId as argument
86
+ }
87
+ ```
88
+
89
+ Pass views as a `SvelteMap<Id, View>`. The map is reactive.
90
+
91
+ ---
92
+
93
+ ## `HorizonLayout` props
94
+
95
+ | Prop | Type | Default | Description |
96
+ | --------------------- | ----------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
97
+ | `config` | `LayoutConfig` | _(required)_ | Bindable layout state. |
98
+ | `views` | `SvelteMap<Id, View>` | _(required)_ | Map from view id to its descriptor. |
99
+ | `tabgroupControls` | `Snippet<[Id]>[]` | `[]` | Snippets rendered in every tab-group toolbar (receives active viewId). |
100
+ | `disableResizeSplits` | `boolean` | `false` | Prevent the user from dragging resizers. |
101
+ | `disableDragAndDrop` | `boolean` | `false` | Prevent tab drag-and-drop. |
102
+ | `showSplitRatio` | `boolean` | `false` | Show the current split ratio as a label while resizing. |
103
+ | `minWidthRatio` | `number` | `0.1` | Minimum pane width as a fraction of its split container. |
104
+ | `minHeightRatio` | `number` | `0.2` | Minimum pane height as a fraction of its split container. |
105
+ | `maxDepth` | `number` | `6` | Maximum nesting depth (prevents infinite subdivision). |
106
+ | `hideTabBar` | `boolean` | `false` | Hide the tab bar. |
107
+ | `keyboardControls` | `KeyboardControls` | _(see below)_ | Override built-in keyboard shortcuts. |
108
+ | `formatRatio` | `(n: number) => string` | `"50.00%"` | Format a split ratio for display. |
109
+ | `formatRatioForAria` | `(n: number) => number` | `50.00` | Format a split ratio for `aria-valuenow`. |
110
+ | `onPopoutClose` | `(id: Id) => void` | — | Called when a popout window is closed by the user. |
111
+ | `onPopoutBlocked` | `(id: Id) => void` | — | Called when the browser blocks a popout after the fallback timeout. If omitted, the tab is moved into the main layout automatically. |
112
+ | `baseClass` | `string` | `"horizon-layout"` | Root CSS class and BEM prefix for all generated elements. |
113
+ | `skipValidation` | `boolean` | `false` | Skip config validation on every update (use after pre-validating with `validateConfig`). |
114
+
115
+ ---
116
+
117
+ ## CSS classes
118
+
119
+ HorizonLayout ships no styles. All elements receive BEM classes derived from `baseClass` (`"horizon-layout"` by default).
120
+
121
+ | Class | Element |
122
+ | ------------------------------------------------------------------ | ------------------------------------------------------------ |
123
+ | `.horizon-layout` | Root container |
124
+ | `.horizon-layout__content` | Content wrapper (or maximised view wrapper) |
125
+ | `.horizon-layout__content--maximized` | Added when a view is maximised |
126
+ | `.horizon-layout__popout` | Popout window root element |
127
+ | `.horizon-layout-split` | A split container |
128
+ | `.horizon-layout-split--horizontal` / `--vertical` | Direction modifier |
129
+ | `.horizon-layout-split__pane` | Individual pane inside a split |
130
+ | `.horizon-layout-split__pane--horizontal` / `--vertical` | Direction modifier on each pane |
131
+ | `.horizon-layout-split__resizer` | Drag handle between panes |
132
+ | `.horizon-layout-split__resizer--horizontal` / `--vertical` | Direction modifier on resizer container |
133
+ | `.horizon-layout-split__resizer--active` | Added while the resizer is focused/dragged |
134
+ | `.horizon-layout-split__resizer-handle` | Focusable slider handle inside the resizer |
135
+ | `.horizon-layout-split__resizer-handle--horizontal` / `--vertical` | Direction modifier on handle |
136
+ | `.horizon-layout-split__resizer-handle--active` | Added while dragging |
137
+ | `.horizon-layout-split__ratio` | Floating ratio label (visible when `showSplitRatio`) |
138
+ | `.horizon-layout-split__ratio--horizontal` / `--vertical` | Direction modifier on ratio label |
139
+ | `.horizon-layout-split__ratio--active` | Added while the matching resizer is active |
140
+ | `.horizon-layout-tabgroup` | A tab group (`<section>`) |
141
+ | `.horizon-layout-tabgroup__tab-bar` | Tab bar row |
142
+ | `.horizon-layout-tabgroup__tabs` | Tab list (`role="tablist"`) |
143
+ | `.horizon-layout-tabgroup__tab` | Individual tab |
144
+ | `.horizon-layout-tabgroup__tab--active` | Active tab modifier |
145
+ | `.horizon-layout-tabgroup__tab--hover` | Added on tab hover |
146
+ | `.horizon-layout-tabgroup__tab-accent` | Colored top accent bar on the active tab |
147
+ | `.horizon-layout-tabgroup__tab-active-fill` | Bottom fill that blends the active tab into the content area |
148
+ | `.horizon-layout-tabgroup__tab-title` | Tab label text |
149
+ | `.horizon-layout-tabgroup__tab-controls` | Container for per-tab snippets |
150
+ | `.horizon-layout-tabgroup__tab-control` | Individual per-tab control wrapper |
151
+ | `.horizon-layout-tabgroup__tab-drop-hint` | Visible insertion marker before/between/after tabs |
152
+ | `.horizon-layout-tabgroup__tab-drop-hint--{n}` | Numeric index modifier (0 … tabs.length) |
153
+ | `.horizon-layout-tabgroup__tab-drop-hint--end` | Modifier for the trailing insertion marker |
154
+ | `.horizon-layout-tabgroup__tab-drop-hint--hover` | Added when a tab insertion marker is active |
155
+ | `.horizon-layout-tabgroup__tab-drop-zone` | Invisible tab insertion hit target |
156
+ | `.horizon-layout-tabgroup__tab-drop-zone--{n}` | Numeric index modifier (0 … tabs.length) |
157
+ | `.horizon-layout-tabgroup__tab-drop-zone--end` | Modifier for the trailing hit target |
158
+ | `.horizon-layout-tabgroup__controls` | Container for shared tab-group snippets |
159
+ | `.horizon-layout-tabgroup__control` | Individual shared control wrapper |
160
+ | `.horizon-layout-tabgroup__body` | Content + drop-zone wrapper |
161
+ | `.horizon-layout-tabgroup__content` | Active view content area |
162
+ | `.horizon-layout-tabgroup__drop-zones` | Drop-zone overlay (visible during drag) |
163
+ | `.horizon-layout-tabgroup__drop-hint` | Visible pane split hint for the active drop side |
164
+ | `.horizon-layout-tabgroup__drop-hint--top/right/bottom/left` | Drop hint side modifier |
165
+ | `.horizon-layout-tabgroup__drop-zone` | Invisible pane split hit target |
166
+ | `.horizon-layout-tabgroup__drop-zone--top/right/bottom/left` | Drop-zone side modifier |
167
+
168
+ ---
169
+
170
+ ## Keyboard shortcuts
171
+
172
+ ### Resizer (`role="slider"`)
173
+
174
+ | Keys | Action |
175
+ | ------------- | ---------------------------------- |
176
+ | `←` / `↑` | Move resizer −1 % |
177
+ | `→` / `↓` | Move resizer +1 % |
178
+ | `Shift + ←/↑` | Move −10 % |
179
+ | `Shift + →/↓` | Move +10 % |
180
+ | `Ctrl + ←/↑` | Move −0.1 % |
181
+ | `Ctrl + →/↓` | Move +0.1 % |
182
+ | `Alt + ←/↑` | Move −0.01 % |
183
+ | `Alt + →/↓` | Move +0.01 % |
184
+ | `Home` | Snap to the minimum valid position |
185
+ | `End` | Snap to the maximum valid position |
186
+ | `Escape` | Unselect/Blur the resizer |
187
+
188
+ ### Tab group (`role="tablist"`)
189
+
190
+ | Keys | Action |
191
+ | ------------------ | ------------------------------------------------------ |
192
+ | `←` / `↑` | Previous tab |
193
+ | `→` / `↓` | Next tab |
194
+ | `Home` / `End` | First / last tab |
195
+ | `Shift + ←/↑` | Move active tab left/up within group |
196
+ | `Shift + →/↓` | Move active tab right/down within group |
197
+ | `Shift + Home/End` | Move active tab to start/end of group |
198
+ | `Ctrl + ←/↑/→/↓` | Move active tab to the nearest group in that direction |
199
+ | `Ctrl + Home/End` | Move active tab to the first/last group |
200
+ | `Alt + ←/↑/→/↓` | Split active tab out into a new pane in that direction |
201
+ | `Escape` | Unselect/Blur the tab list |
202
+
203
+ Pass `keyboardControls` to override any subset of these.
204
+
205
+ ---
206
+
207
+ ## Custom keyboard controls
208
+
209
+ ```ts
210
+ import type { KeyboardControls } from 'horizon-layout';
211
+
212
+ const keyboardControls: KeyboardControls = {
213
+ tabGroupControls: [
214
+ {
215
+ shortcuts: [{ modifier: 'ctrl', key: 'w' }],
216
+ action: (tabGroup) => closeActiveTab(tabGroup)
217
+ }
218
+ ]
219
+ // omit splitControls to keep the defaults
220
+ };
221
+ ```
222
+
223
+ Both arrays replace their respective defaults entirely when provided.
224
+
225
+ ---
226
+
227
+ ## `parseLayoutConfig`
228
+
229
+ Parse an unknown value (e.g. from `JSON.parse`) into a typed `LayoutConfig`. Throws a descriptive error if the value does not match the expected shape. Unknown extra fields are ignored.
230
+
231
+ ```ts
232
+ import { parseLayoutConfig } from 'horizon-layout';
233
+
234
+ const raw = localStorage.getItem('layout');
235
+ if (raw) {
236
+ try {
237
+ config = parseLayoutConfig(JSON.parse(raw));
238
+ } catch (e) {
239
+ console.warn('Invalid saved layout:', e.message);
240
+ }
241
+ }
242
+ ```
243
+
244
+ `parseLayoutConfig` only checks the structure, it does not validate that the config is valid. Run `validateConfig` afterwards if you need that guarantee.
245
+
246
+ ---
247
+
248
+ ## `validateConfig`
249
+
250
+ Validate a `LayoutConfig` before passing it to `HorizonLayout`. Throws an `Error` describing the first problem found if the config is invalid, and returns nothing.
251
+
252
+ ```ts
253
+ import { validateConfig } from 'horizon-layout';
254
+
255
+ try {
256
+ validateConfig(config, views, {
257
+ minWidthRatio: 0.1, // optional, matches your HorizonLayout prop
258
+ minHeightRatio: 0.2 // optional, matches your HorizonLayout prop
259
+ });
260
+ // safe to pass to HorizonLayout, optionally with skipValidation
261
+ } catch (e) {
262
+ console.error(e.message);
263
+ }
264
+ ```
265
+
266
+ Pass `skipValidation` to `HorizonLayout` once you have confirmed the config is valid, to avoid the redundant re-check on every update:
267
+
268
+ ```svelte
269
+ <HorizonLayout bind:config {views} skipValidation />
270
+ ```
271
+
272
+ ---
273
+
274
+ ## Popout windows
275
+
276
+ Add a view id to `config.popouts` and HorizonLayout opens a new browser window with that view's content. The host page's stylesheets are mirrored into the window automatically. When the user closes the window, `onPopoutClose` is called and the id is removed from `config.popouts`.
277
+
278
+ ```ts
279
+ // Open a popout programmatically:
280
+ config.popouts = [...(config.popouts ?? []), 'myViewId'];
281
+ ```
282
+
283
+ ### Blocked popouts
284
+
285
+ Some browsers (notably Firefox) silently block `window.open` when popups are not permitted by the user. This is particularly relevant when loading a saved config that already contains `popouts` entries — the tab would otherwise be stuck: neither visible in the main layout nor able to open in a new window.
286
+
287
+ HorizonLayout handles this automatically. If a popout cannot be opened within ~500 ms it is treated as definitively blocked. The default fallback appends the tab to the last tab group in the layout (or creates a new root if the layout is empty), and updates `config` accordingly.
288
+
289
+ To handle blocked popouts yourself — for example to show a notification or decide where to place the tab — provide `onPopoutBlocked`:
290
+
291
+ ```svelte
292
+ <HorizonLayout
293
+ bind:config
294
+ {views}
295
+ onPopoutBlocked={(id) => {
296
+ toast.warning(`"${views.get(id)?.title}" could not open in a new window.`);
297
+ config.root = insertTabSomewhere(config.root, id);
298
+ }}
299
+ />
300
+ ```