svelte-tiler 0.0.1

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,389 @@
1
+ <script lang="ts" module>
2
+ import { getContext, setContext, type Snippet } from 'svelte';
3
+
4
+ import type { Registry } from '../shared/registry.js';
5
+ import type { Tile, Tiles } from '../model.js';
6
+ import type { TilerContext } from '../context.svelte.js';
7
+
8
+ export type HeadersDirection = Direction | 'none';
9
+
10
+ declare module '../model.js' {
11
+ interface TileRegistry {
12
+ tabs: {
13
+ titles: string[];
14
+ selectedTab: number;
15
+ headersDirection: HeadersDirection;
16
+ actions?: string;
17
+ tabHeader?: string;
18
+ empty?: string;
19
+ };
20
+ }
21
+ }
22
+
23
+ export interface TabsOptions<
24
+ H extends string,
25
+ E extends string,
26
+ A extends string,
27
+ > {
28
+ tabs: [string, Tile][];
29
+ /** @default "none" */
30
+ headersDirection?: HeadersDirection;
31
+ selectedTab?: number;
32
+ actions?: A;
33
+ tabHeader?: H;
34
+ empty?: E;
35
+ }
36
+
37
+ export function create<H extends string, E extends string, A extends string>(
38
+ options: TabsOptions<H, E, A>
39
+ ): Tiles['tabs'] {
40
+ const children: Tile[] = [];
41
+ const titles: string[] = [];
42
+ for (const tab of options.tabs) {
43
+ titles.push(tab[0]);
44
+ children.push(tab[1]);
45
+ }
46
+ return {
47
+ id: crypto.randomUUID(),
48
+ type: 'tabs',
49
+ children,
50
+ titles,
51
+ headersDirection: options.headersDirection ?? 'none',
52
+ selectedTab: options.selectedTab ?? 0,
53
+ actions: options.actions,
54
+ tabHeader: options.tabHeader,
55
+ empty: options.empty,
56
+ };
57
+ }
58
+
59
+ interface SplitOptions {
60
+ type: 'row' | 'column';
61
+ parent: Tile | undefined;
62
+ pivot: Tiles['tabs'];
63
+ offset: number;
64
+ adjacent: Tiles['tabs'];
65
+ }
66
+
67
+ interface TabsContext<
68
+ H extends string = string,
69
+ E extends string = string,
70
+ A extends string = string,
71
+ > {
72
+ createSplit?: (options: SplitOptions) => Tile;
73
+ actions?: Registry<A, Snippet<[Tiles['tabs']]> | undefined>;
74
+ headers?: Registry<
75
+ H,
76
+ Snippet<[Tiles['tabs'], number, Draggable<Tile>]> | undefined
77
+ >;
78
+ empty?: Registry<E, Snippet<[Tiles['tabs']]> | undefined>;
79
+ }
80
+
81
+ const TABS_CONTEXT_KEY = Symbol('tabs-context-key');
82
+
83
+ export function setup<H extends string, E extends string, A extends string>(
84
+ ctx: TabsContext<H, E, A>
85
+ ) {
86
+ setContext(TABS_CONTEXT_KEY, ctx);
87
+ return create<H, E, A>;
88
+ }
89
+
90
+ export function onRemoveChild(
91
+ ctx: TilerContext,
92
+ tile: Tiles['tabs'],
93
+ i: number
94
+ ) {
95
+ if (tile.children.length < 2) {
96
+ ctx.destroy(tile);
97
+ return;
98
+ }
99
+ if (tile.selectedTab >= i) {
100
+ tile.selectedTab = Math.max(0, tile.selectedTab - 1);
101
+ }
102
+ tile.children.splice(i, 1);
103
+ tile.titles.splice(i, 1);
104
+ }
105
+
106
+ export function onClear(_ctx: TilerContext, tile: Tiles['tabs']) {
107
+ if (tile.children.length > 0) {
108
+ tile.selectedTab = -1;
109
+ tile.children.length = 0;
110
+ tile.titles.length = 0;
111
+ }
112
+ }
113
+
114
+ export function insertTabs(
115
+ tile: Tiles['tabs'],
116
+ i: number,
117
+ {
118
+ titles,
119
+ children,
120
+ }: {
121
+ titles: string[];
122
+ children: Tile[];
123
+ }
124
+ ) {
125
+ const newIds = new Set(children.map((c) => c.id));
126
+ let write = 0;
127
+ let shift = 0;
128
+ const c = tile.children;
129
+ const t = tile.titles;
130
+ const l = t.length;
131
+ for (let read = 0; read < l; read++) {
132
+ if (!newIds.has(c[read].id)) {
133
+ t[write] = t[read];
134
+ c[write] = c[read];
135
+ write++;
136
+ } else if (read < i) {
137
+ shift++;
138
+ }
139
+ }
140
+ c.length = write;
141
+ t.length = write;
142
+ i -= shift;
143
+ tile.children.splice(i, 0, ...children);
144
+ tile.titles.splice(i, 0, ...titles);
145
+ tile.selectedTab = i;
146
+ }
147
+ </script>
148
+
149
+ <script lang="ts">
150
+ import {
151
+ DndContext,
152
+ Draggable,
153
+ type DraggableOptions,
154
+ type StopEvent,
155
+ } from '../shared/dnd.svelte.js';
156
+ import {
157
+ getRectParts,
158
+ type Direction,
159
+ type EdgePart,
160
+ } from '../shared/spatial.js';
161
+ import { getTilerContext } from '../context.svelte.js';
162
+ import type { TileProps } from '../model.js';
163
+ import { TileDroppable } from '../dnd.js';
164
+
165
+ let {
166
+ tile = $bindable(),
167
+ parent = $bindable(),
168
+ child,
169
+ }: TileProps<'tabs'> = $props();
170
+
171
+ const ctx = getTilerContext();
172
+ const tabsCtx = getContext<TabsContext | undefined>(TABS_CONTEXT_KEY);
173
+
174
+ const actions = $derived(
175
+ (tile.actions !== undefined && tabsCtx?.actions?.get(tile.actions)) ||
176
+ undefined
177
+ );
178
+ const tabHeader = $derived(
179
+ (tile.tabHeader !== undefined && tabsCtx?.headers?.get(tile.tabHeader)) ||
180
+ defaultTabHeader
181
+ );
182
+ const empty = $derived(
183
+ (tile.empty !== undefined && tabsCtx?.empty?.get(tile.empty)) || undefined
184
+ );
185
+ const edgeRatio = $derived(tabsCtx?.createSplit ? 0.1 : 0);
186
+
187
+ class TabsDroppable extends TileDroppable<Tiles['tabs']> {
188
+ accepts(d: Draggable<Tile>): d is Draggable<Tiles['tabs']> {
189
+ const t = d.data;
190
+ return (
191
+ t?.type === 'tabs' &&
192
+ !(
193
+ t.children.length === 1 &&
194
+ tile.children.length === 1 &&
195
+ t.children[0].id === tile.children[0].id &&
196
+ d instanceof DraggableTab
197
+ )
198
+ );
199
+ }
200
+ }
201
+
202
+ class DroppableSurface extends TabsDroppable {
203
+ protected onDrop(tabs: Tiles['tabs']): void {
204
+ insertTabs(tile, tile.children.length, tabs);
205
+ }
206
+ }
207
+
208
+ class DroppableRect extends TabsDroppable {
209
+ #edgeRatio: number;
210
+ hpart: EdgePart | undefined = $state.raw();
211
+ vpart: EdgePart | undefined = $state.raw();
212
+
213
+ constructor(ctx: DndContext<Tile>, edgeRatio: number) {
214
+ super(ctx, tile.id);
215
+ this.#edgeRatio = edgeRatio;
216
+ }
217
+
218
+ onMove(e: PointerEvent) {
219
+ const rect = this.element!.getBoundingClientRect();
220
+ Object.assign(
221
+ this,
222
+ getRectParts(rect, e.clientX, e.clientY, this.#edgeRatio)
223
+ );
224
+ }
225
+ }
226
+
227
+ class DroppableTab extends DroppableRect {
228
+ #id: string;
229
+ #index: number;
230
+
231
+ constructor(ctx: DndContext<Tile>, id: string, index: number) {
232
+ super(ctx, tile.headersDirection === 'none' ? 0 : 0.5);
233
+ this.#id = id;
234
+ this.#index = index;
235
+ }
236
+
237
+ protected onDrop(tabs: Tiles['tabs'], d: Draggable): void {
238
+ let i = tile.children.findIndex((c) => c.id === this.#id);
239
+ if (i < 0) {
240
+ i = this.#index;
241
+ } else if (
242
+ tile.headersDirection !== 'none'
243
+ ? (tile.headersDirection === 'row' ? this.hpart : this.vpart) ===
244
+ 'end'
245
+ : d instanceof DraggableTab && d.index <= i
246
+ ) {
247
+ i++;
248
+ }
249
+ insertTabs(tile, i, tabs);
250
+ }
251
+ }
252
+
253
+ class DroppableContent extends DroppableRect {
254
+ protected onDrop(tabs: Tiles['tabs'], d: Draggable): void {
255
+ const id = tabs.children[0].id;
256
+ if (this.hpart === 'center' && this.vpart === 'center') {
257
+ let i = tile.children.findIndex((t) => t.id === id);
258
+ if (i < 0 && d instanceof DraggableTab) {
259
+ i = d.index;
260
+ }
261
+ insertTabs(tile, i < 0 ? tile.children.length : i, tabs);
262
+ } else if (tabsCtx?.createSplit) {
263
+ parent = tabsCtx.createSplit({
264
+ parent,
265
+ type:
266
+ this.hpart === 'start' || this.hpart === 'end' ? 'row' : 'column',
267
+ pivot: tile,
268
+ adjacent: tabs,
269
+ offset:
270
+ this.hpart === 'start'
271
+ ? 0
272
+ : this.hpart === 'end'
273
+ ? 1
274
+ : this.vpart === 'start'
275
+ ? 0
276
+ : 1,
277
+ });
278
+ }
279
+ }
280
+ }
281
+
282
+ interface DraggableTabOptions extends DraggableOptions<Tile> {
283
+ index: number;
284
+ }
285
+
286
+ class DraggableTab extends Draggable<Tile> {
287
+ readonly index: number;
288
+
289
+ constructor(ctx: DndContext<Tile>, options: DraggableTabOptions) {
290
+ super(ctx, options);
291
+ this.index = options.index;
292
+ }
293
+
294
+ protected onStop({ reason }: StopEvent): void {
295
+ if (reason !== 'drop') {
296
+ return;
297
+ }
298
+ ctx.removeChild(tile, this.index);
299
+ }
300
+ }
301
+
302
+ function handleKeydown(e: KeyboardEvent & { currentTarget: HTMLElement }) {
303
+ if (e.key === 'Enter' || e.key === ' ') {
304
+ e.preventDefault();
305
+ e.currentTarget.click();
306
+ }
307
+ }
308
+
309
+ const droppableSpacer = $derived(new DroppableSurface(ctx.dnd, tile.id));
310
+ const droppableContent = $derived(new DroppableContent(ctx.dnd, edgeRatio));
311
+ const droppableEmpty = $derived(new DroppableContent(ctx.dnd, edgeRatio));
312
+ </script>
313
+
314
+ {#snippet defaultTabHeader(t: Tiles['tabs'], index: number)}
315
+ {t.titles[index]}
316
+ {/snippet}
317
+
318
+ <div data-tabs>
319
+ {#if tile.children[tile.selectedTab]}
320
+ <div data-tabs-bar>
321
+ <div data-tabs-list>
322
+ {#each tile.children as t, i (t.id)}
323
+ {@const droppable = new DroppableTab(ctx.dnd, t.id, i)}
324
+ {@const draggable = new DraggableTab(ctx.dnd, {
325
+ index: i,
326
+ data: create({
327
+ ...tile,
328
+ tabs: [[tile.titles[i], t]],
329
+ selectedTab: 0,
330
+ }),
331
+ })}
332
+ <div
333
+ data-tabs-header
334
+ {@attach droppable.register}
335
+ {@attach draggable.register}
336
+ role="tab"
337
+ tabindex="0"
338
+ onclick={() => (tile.selectedTab = i)}
339
+ onkeydown={handleKeydown}
340
+ data-dragged={draggable.isDragged}
341
+ data-over={droppable.isOver}
342
+ aria-selected={tile.selectedTab === i}
343
+ data-hpart={droppable.hpart}
344
+ data-vpart={droppable.vpart}
345
+ >
346
+ {@render tabHeader(tile, i, draggable)}
347
+ </div>
348
+ {/each}
349
+ </div>
350
+ <div
351
+ data-tabs-spacer
352
+ {@attach droppableSpacer.register}
353
+ data-over={droppableSpacer.isOver}
354
+ ></div>
355
+ <div data-tabs-actions>
356
+ {@render actions?.(tile)}
357
+ </div>
358
+ </div>
359
+ <div
360
+ data-tabs-content
361
+ {@attach droppableContent.register}
362
+ data-over={droppableContent.isOver}
363
+ data-hpart={droppableContent.hpart}
364
+ data-vpart={droppableContent.vpart}
365
+ >
366
+ {@render child(tile.selectedTab)}
367
+ </div>
368
+ {:else}
369
+ <div
370
+ data-tabs-empty
371
+ {@attach droppableEmpty.register}
372
+ data-over={droppableEmpty.isOver}
373
+ data-hpart={droppableEmpty.hpart}
374
+ data-vpart={droppableEmpty.vpart}
375
+ >
376
+ {@render empty?.(tile)}
377
+ </div>
378
+ {/if}
379
+ </div>
380
+
381
+ <style>
382
+ [data-tabs-header] {
383
+ user-select: none;
384
+ cursor: pointer;
385
+ &[data-dragged='true'] {
386
+ cursor: grabbing;
387
+ }
388
+ }
389
+ </style>
@@ -0,0 +1,53 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { Registry } from '../shared/registry.js';
3
+ import type { Tile, Tiles } from '../model.js';
4
+ import type { TilerContext } from '../context.svelte.js';
5
+ export type HeadersDirection = Direction | 'none';
6
+ declare module '../model.js' {
7
+ interface TileRegistry {
8
+ tabs: {
9
+ titles: string[];
10
+ selectedTab: number;
11
+ headersDirection: HeadersDirection;
12
+ actions?: string;
13
+ tabHeader?: string;
14
+ empty?: string;
15
+ };
16
+ }
17
+ }
18
+ export interface TabsOptions<H extends string, E extends string, A extends string> {
19
+ tabs: [string, Tile][];
20
+ /** @default "none" */
21
+ headersDirection?: HeadersDirection;
22
+ selectedTab?: number;
23
+ actions?: A;
24
+ tabHeader?: H;
25
+ empty?: E;
26
+ }
27
+ export declare function create<H extends string, E extends string, A extends string>(options: TabsOptions<H, E, A>): Tiles['tabs'];
28
+ interface SplitOptions {
29
+ type: 'row' | 'column';
30
+ parent: Tile | undefined;
31
+ pivot: Tiles['tabs'];
32
+ offset: number;
33
+ adjacent: Tiles['tabs'];
34
+ }
35
+ interface TabsContext<H extends string = string, E extends string = string, A extends string = string> {
36
+ createSplit?: (options: SplitOptions) => Tile;
37
+ actions?: Registry<A, Snippet<[Tiles['tabs']]> | undefined>;
38
+ headers?: Registry<H, Snippet<[Tiles['tabs'], number, Draggable<Tile>]> | undefined>;
39
+ empty?: Registry<E, Snippet<[Tiles['tabs']]> | undefined>;
40
+ }
41
+ export declare function setup<H extends string, E extends string, A extends string>(ctx: TabsContext<H, E, A>): (options: TabsOptions<H, E, A>) => Tiles["tabs"];
42
+ export declare function onRemoveChild(ctx: TilerContext, tile: Tiles['tabs'], i: number): void;
43
+ export declare function onClear(_ctx: TilerContext, tile: Tiles['tabs']): void;
44
+ export declare function insertTabs(tile: Tiles['tabs'], i: number, { titles, children, }: {
45
+ titles: string[];
46
+ children: Tile[];
47
+ }): void;
48
+ import { Draggable } from '../shared/dnd.svelte.js';
49
+ import { type Direction } from '../shared/spatial.js';
50
+ import type { TileProps } from '../model.js';
51
+ declare const Tabs: import("svelte").Component<TileProps<"tabs">, {}, "tile" | "parent">;
52
+ type Tabs = ReturnType<typeof Tabs>;
53
+ export default Tabs;
package/package.json ADDED
@@ -0,0 +1,100 @@
1
+ {
2
+ "name": "svelte-tiler",
3
+ "version": "0.0.1",
4
+ "description": "A small, unstyled library for building tiling user interfaces.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/x0k/svelte-tiler#readme",
7
+ "bugs": {
8
+ "url": "https://github.com/x0k/svelte-tiler/issues"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/x0k/svelte-tiler.git"
13
+ },
14
+ "author": "Roman Krasilnikov",
15
+ "files": [
16
+ "dist",
17
+ "!dist/**/*.test.*",
18
+ "!dist/**/*.spec.*"
19
+ ],
20
+ "sideEffects": [
21
+ "**/*.css"
22
+ ],
23
+ "svelte": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "svelte": "./dist/index.js"
30
+ },
31
+ "./shared/*": {
32
+ "types": "./dist/shared/*.d.ts",
33
+ "default": "./dist/shared/*.js"
34
+ },
35
+ "./tiles/*.svelte": {
36
+ "types": "./dist/tiles/*.svelte.d.ts",
37
+ "svelte": "./dist/tiles/*.svelte"
38
+ }
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "peerDependencies": {
44
+ "svelte": "^5.34.8"
45
+ },
46
+ "devDependencies": {
47
+ "@changesets/changelog-github": "^0.5.2",
48
+ "@changesets/cli": "^2.29.8",
49
+ "@eslint/compat": "^2.0.1",
50
+ "@eslint/js": "^9.39.2",
51
+ "@iconify-json/codicon": "^1.2.41",
52
+ "@iconify-json/material-icon-theme": "^1.2.49",
53
+ "@shikijs/langs": "^3.21.0",
54
+ "@shikijs/themes": "^3.21.0",
55
+ "@sveltejs/adapter-static": "^3.0.10",
56
+ "@sveltejs/kit": "^2.50.1",
57
+ "@sveltejs/package": "^2.5.7",
58
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
59
+ "@types/node": "^24.10.9",
60
+ "@vitest/browser-playwright": "^4.0.18",
61
+ "eslint": "^9.39.2",
62
+ "eslint-config-prettier": "^10.1.8",
63
+ "eslint-plugin-svelte": "^3.14.0",
64
+ "globals": "^17.1.0",
65
+ "marked": "^17.0.1",
66
+ "playwright": "^1.58.0",
67
+ "prettier": "^3.8.1",
68
+ "prettier-plugin-svelte": "^3.4.1",
69
+ "publint": "^0.3.17",
70
+ "shiki": "^3.21.0",
71
+ "svelte": "^5.48.3",
72
+ "svelte-check": "^4.3.5",
73
+ "typescript": "^5.9.3",
74
+ "typescript-eslint": "^8.54.0",
75
+ "unplugin-icons": "^23.0.1",
76
+ "vite": "^7.3.1",
77
+ "vite-plugin-devtools-json": "^1.0.0",
78
+ "vitest": "^4.0.18",
79
+ "vitest-browser-svelte": "^2.0.2"
80
+ },
81
+ "keywords": [
82
+ "svelte",
83
+ "tiling",
84
+ "layout"
85
+ ],
86
+ "scripts": {
87
+ "dev": "vite dev",
88
+ "build": "vite build && npm run prepack",
89
+ "preview": "vite preview",
90
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
91
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
92
+ "format": "prettier --write .",
93
+ "lint": "prettier --check . && eslint .",
94
+ "test:unit": "vitest",
95
+ "test": "npm run test:unit -- --run && npm run test:e2e",
96
+ "test:e2e": "playwright test",
97
+ "ci:build": "npm run check && npm run build",
98
+ "ci:version": "changeset version && pnpm install --no-frozen-lockfile"
99
+ }
100
+ }