npm.io
1.0.2 • Published 5h ago

horizon-layout

Licence
MIT
Version
1.0.2
Deps
0
Size
90 kB
Vulns
0
Weekly
0
Stars
1

HorizonLayout

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.


Quick start

<script lang="ts">
	import { HorizonLayout } from 'horizon-layout';
	import { SvelteMap } from 'svelte/reactivity';
	import type { LayoutConfig } from 'horizon-layout';

	const views = new SvelteMap([
		['editor', { title: 'Editor', snippet: editorSnippet }],
		['preview', { title: 'Preview', snippet: previewSnippet }]
	]);

	let config = $state<LayoutConfig>({
		root: {
			direction: 'horizontal',
			views: [
				{ tabs: ['editor'], activeTabIndex: 0 },
				{ tabs: ['preview'], activeTabIndex: 0 }
			],
			splitPoints: [0.5]
		}
	});
</script>

{#snippet editorSnippet()}<MyEditor />{/snippet}
{#snippet previewSnippet()}<MyPreview />{/snippet}

<HorizonLayout bind:config {views} />

LayoutConfig

The entire layout state is a plain LayoutConfig object. Bind it to keep it in sync, or persist it to localStorage/a server.

interface LayoutConfig {
	root?: NodeConfig; // omit (or set to undefined) to render an empty layout
	maximizedView?: Id; // when set, this view fills the whole container
	popouts?: Id[]; // views opened in detached browser tabs
}
NodeConfig

A node is either a split or a tab group.

SplitConfig
interface SplitConfig {
	direction: 'horizontal' | 'vertical';
	views: [NodeConfig, NodeConfig, ...NodeConfig[]]; // at least two children
	splitPoints: [number, ...number[]]; // length === views.length - 1
}

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.

TabGroupConfig
interface TabGroupConfig {
	tabs: [Id, ...Id[]]; // at least one tab
	activeTabIndex: number;
}

View

interface View {
	title: string;
	snippet: Snippet; // panel body
	tabControls?: Snippet<[Id]>[]; // optional controls rendered inside the tab, receives the viewId as argument
}

Pass views as a SvelteMap<Id, View>. The map is reactive.


HorizonLayout props

Prop Type Default Description
config LayoutConfig (required) Bindable layout state.
views SvelteMap<Id, View> (required) Map from view id to its descriptor.
tabgroupControls Snippet<[Id]>[] [] Snippets rendered in every tab-group toolbar (receives active viewId).
disableResizeSplits boolean false Prevent the user from dragging resizers.
disableDragAndDrop boolean false Prevent tab drag-and-drop.
showSplitRatio boolean false Show the current split ratio as a label while resizing.
minWidthRatio number 0.1 Minimum pane width as a fraction of its split container.
minHeightRatio number 0.2 Minimum pane height as a fraction of its split container.
maxDepth number 6 Maximum nesting depth (prevents infinite subdivision).
hideTabBar boolean false Hide the tab bar.
keyboardControls KeyboardControls (see below) Override built-in keyboard shortcuts.
formatRatio (n: number) => string "50.00%" Format a split ratio for display.
formatRatioForAria (n: number) => number 50.00 Format a split ratio for aria-valuenow.
onPopoutClose (id: Id) => void Called when a popout window is closed by the user.
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.
baseClass string "horizon-layout" Root CSS class and BEM prefix for all generated elements.
skipValidation boolean false Skip config validation on every update (use after pre-validating with validateConfig).

CSS classes

HorizonLayout ships no styles. All elements receive BEM classes derived from baseClass ("horizon-layout" by default).

Class Element
.horizon-layout Root container
.horizon-layout__content Content wrapper (or maximised view wrapper)
.horizon-layout__content--maximized Added when a view is maximised
.horizon-layout__popout Popout window root element
.horizon-layout-split A split container
.horizon-layout-split--horizontal / --vertical Direction modifier
.horizon-layout-split__pane Individual pane inside a split
.horizon-layout-split__pane--horizontal / --vertical Direction modifier on each pane
.horizon-layout-split__resizer Drag handle between panes
.horizon-layout-split__resizer--horizontal / --vertical Direction modifier on resizer container
.horizon-layout-split__resizer--active Added while the resizer is focused/dragged
.horizon-layout-split__resizer-handle Focusable slider handle inside the resizer
.horizon-layout-split__resizer-handle--horizontal / --vertical Direction modifier on handle
.horizon-layout-split__resizer-handle--active Added while dragging
.horizon-layout-split__ratio Floating ratio label (visible when showSplitRatio)
.horizon-layout-split__ratio--horizontal / --vertical Direction modifier on ratio label
.horizon-layout-split__ratio--active Added while the matching resizer is active
.horizon-layout-tabgroup A tab group (<section>)
.horizon-layout-tabgroup__tab-bar Tab bar row
.horizon-layout-tabgroup__tabs Tab list (role="tablist")
.horizon-layout-tabgroup__tab Individual tab
.horizon-layout-tabgroup__tab--active Active tab modifier
.horizon-layout-tabgroup__tab--hover Added on tab hover
.horizon-layout-tabgroup__tab-accent Colored top accent bar on the active tab
.horizon-layout-tabgroup__tab-active-fill Bottom fill that blends the active tab into the content area
.horizon-layout-tabgroup__tab-title Tab label text
.horizon-layout-tabgroup__tab-controls Container for per-tab snippets
.horizon-layout-tabgroup__tab-control Individual per-tab control wrapper
.horizon-layout-tabgroup__tab-drop-hint Visible insertion marker before/between/after tabs
.horizon-layout-tabgroup__tab-drop-hint--{n} Numeric index modifier (0 … tabs.length)
.horizon-layout-tabgroup__tab-drop-hint--end Modifier for the trailing insertion marker
.horizon-layout-tabgroup__tab-drop-hint--hover Added when a tab insertion marker is active
.horizon-layout-tabgroup__tab-drop-zone Invisible tab insertion hit target
.horizon-layout-tabgroup__tab-drop-zone--{n} Numeric index modifier (0 … tabs.length)
.horizon-layout-tabgroup__tab-drop-zone--end Modifier for the trailing hit target
.horizon-layout-tabgroup__controls Container for shared tab-group snippets
.horizon-layout-tabgroup__control Individual shared control wrapper
.horizon-layout-tabgroup__body Content + drop-zone wrapper
.horizon-layout-tabgroup__content Active view content area
.horizon-layout-tabgroup__drop-zones Drop-zone overlay (visible during drag)
.horizon-layout-tabgroup__drop-hint Visible pane split hint for the active drop side
.horizon-layout-tabgroup__drop-hint--top/right/bottom/left Drop hint side modifier
.horizon-layout-tabgroup__drop-zone Invisible pane split hit target
.horizon-layout-tabgroup__drop-zone--top/right/bottom/left Drop-zone side modifier

Keyboard shortcuts

Resizer (role="slider")
Keys Action
/ Move resizer −1 %
/ Move resizer +1 %
Shift + ←/↑ Move −10 %
Shift + →/↓ Move +10 %
Ctrl + ←/↑ Move −0.1 %
Ctrl + →/↓ Move +0.1 %
Alt + ←/↑ Move −0.01 %
Alt + →/↓ Move +0.01 %
Home Snap to the minimum valid position
End Snap to the maximum valid position
Escape Unselect/Blur the resizer
Tab group (role="tablist")
Keys Action
/ Previous tab
/ Next tab
Home / End First / last tab
Shift + ←/↑ Move active tab left/up within group
Shift + →/↓ Move active tab right/down within group
Shift + Home/End Move active tab to start/end of group
Ctrl + ←/↑/→/↓ Move active tab to the nearest group in that direction
Ctrl + Home/End Move active tab to the first/last group
Alt + ←/↑/→/↓ Split active tab out into a new pane in that direction
Escape Unselect/Blur the tab list

Pass keyboardControls to override any subset of these.


Custom keyboard controls

import type { KeyboardControls } from 'horizon-layout';

const keyboardControls: KeyboardControls = {
	tabGroupControls: [
		{
			shortcuts: [{ modifier: 'ctrl', key: 'w' }],
			action: (tabGroup) => closeActiveTab(tabGroup)
		}
	]
	// omit splitControls to keep the defaults
};

Both arrays replace their respective defaults entirely when provided.


parseLayoutConfig

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.

import { parseLayoutConfig } from 'horizon-layout';

const raw = localStorage.getItem('layout');
if (raw) {
	try {
		config = parseLayoutConfig(JSON.parse(raw));
	} catch (e) {
		console.warn('Invalid saved layout:', e.message);
	}
}

parseLayoutConfig only checks the structure, it does not validate that the config is valid. Run validateConfig afterwards if you need that guarantee.


validateConfig

Validate a LayoutConfig before passing it to HorizonLayout. Throws an Error describing the first problem found if the config is invalid, and returns nothing.

import { validateConfig } from 'horizon-layout';

try {
	validateConfig(config, views, {
		minWidthRatio: 0.1, // optional, matches your HorizonLayout prop
		minHeightRatio: 0.2 // optional, matches your HorizonLayout prop
	});
	// safe to pass to HorizonLayout, optionally with skipValidation
} catch (e) {
	console.error(e.message);
}

Pass skipValidation to HorizonLayout once you have confirmed the config is valid, to avoid the redundant re-check on every update:

<HorizonLayout bind:config {views} skipValidation />

Popout windows

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.

// Open a popout programmatically:
config.popouts = [...(config.popouts ?? []), 'myViewId'];
Blocked popouts

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.

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.

To handle blocked popouts yourself — for example to show a notification or decide where to place the tab — provide onPopoutBlocked:

<HorizonLayout
	bind:config
	{views}
	onPopoutBlocked={(id) => {
		toast.warning(`"${views.get(id)?.title}" could not open in a new window.`);
		config.root = insertTabSomewhere(config.root, id);
	}}
/>

Keywords