svelte-tiler 0.2.0 → 0.3.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.
@@ -1,5 +1,5 @@
1
1
  export type ConstraintUnit = 'px' | 'weight' | '%';
2
- export type ConstraintType = 'maxSize' | 'minSize';
2
+ export type ConstraintType = 'maxSize' | 'minSize' | 'collapsedSize';
3
3
  export interface Constraint {
4
4
  type: ConstraintType;
5
5
  unit: ConstraintUnit;
@@ -6,12 +6,14 @@ const UNIT_TO_TOTAL_SIZE = {
6
6
  const CONSTRAINT_COMBINATOR = {
7
7
  maxSize: Math.min,
8
8
  minSize: Math.max,
9
+ collapsedSize: Math.max,
9
10
  };
10
11
  // TODO: Handle 0 total sizes
11
12
  export function normalize(options) {
12
13
  const result = {
13
14
  maxSize: Infinity,
14
15
  minSize: 0,
16
+ collapsedSize: -1,
15
17
  };
16
18
  const targetTotal = options[UNIT_TO_TOTAL_SIZE[options.targetUnit]];
17
19
  for (const constraint of options.constraints) {
@@ -2,7 +2,7 @@
2
2
  import { createContext, type Snippet } from 'svelte';
3
3
 
4
4
  import type { Registry } from '../shared/registry.js';
5
- import type { TileProps, Tiles } from '../model.js';
5
+ import type { Tile, TileProps, Tiles } from '../model.js';
6
6
 
7
7
  declare module '../model.js' {
8
8
  interface TileRegistry {
@@ -14,7 +14,7 @@
14
14
 
15
15
  type LeafContext<N extends string = string> = Registry<
16
16
  N,
17
- Snippet<[Tiles['leaf']]> | undefined
17
+ Snippet<[Tiles['leaf'], number, Tile | undefined]> | undefined
18
18
  >;
19
19
 
20
20
  const [getContext, setContext] = createContext<LeafContext>();
@@ -39,7 +39,7 @@
39
39
  <script lang="ts">
40
40
  const leafCtx = getContext();
41
41
 
42
- let { tile = $bindable() }: TileProps<'leaf'> = $props();
42
+ let { tile = $bindable(), index, parent }: TileProps<'leaf'> = $props();
43
43
  </script>
44
44
 
45
- {@render leafCtx.get(tile.name)?.(tile)}
45
+ {@render leafCtx.get(tile.name)?.(tile, index, parent)}
@@ -1,6 +1,6 @@
1
1
  import { type Snippet } from 'svelte';
2
2
  import type { Registry } from '../shared/registry.js';
3
- import type { TileProps, Tiles } from '../model.js';
3
+ import type { Tile, TileProps, Tiles } from '../model.js';
4
4
  declare module '../model.js' {
5
5
  interface TileRegistry {
6
6
  leaf: {
@@ -8,7 +8,7 @@ declare module '../model.js' {
8
8
  };
9
9
  }
10
10
  }
11
- type LeafContext<N extends string = string> = Registry<N, Snippet<[Tiles['leaf']]> | undefined>;
11
+ type LeafContext<N extends string = string> = Registry<N, Snippet<[Tiles['leaf'], number, Tile | undefined]> | undefined>;
12
12
  export declare function setup<N extends string>(leafs: LeafContext<N>): (name: N) => Tiles["leaf"];
13
13
  export declare function onRemoveChild(): void;
14
14
  export declare function onClear(): void;
@@ -130,6 +130,28 @@
130
130
  weights,
131
131
  });
132
132
  }
133
+
134
+ interface SplitAPI {
135
+ isCollapsed: (index: number) => boolean;
136
+ collapse: (index: number) => boolean;
137
+ expand: (index: number) => boolean;
138
+ }
139
+
140
+ const API = new Map<string, SplitAPI>();
141
+
142
+ function bind<M extends keyof SplitAPI>(method: M) {
143
+ return (splitId: string, ...args: Parameters<SplitAPI[M]>) => {
144
+ const api = API.get(splitId);
145
+ if (!api) {
146
+ throw new Error(`Unable to find split with id: "${splitId}"`);
147
+ }
148
+ return api[method].apply(api, args);
149
+ };
150
+ }
151
+
152
+ export const isCollapsed = bind('isCollapsed');
153
+ export const collapse = bind('collapse');
154
+ export const expand = bind('expand');
133
155
  </script>
134
156
 
135
157
  <script lang="ts">
@@ -147,19 +169,91 @@
147
169
  let resizerEl: HTMLElement;
148
170
 
149
171
  const isRow = $derived(tile.direction === 'row');
150
- let currentDir = 0;
151
- let lastDir = 0;
152
- let startPos = 0;
153
- let previousPos = 0;
154
- let containerSize = 0;
172
+ let posDiff = 0;
173
+ let totalSizePx = 0;
155
174
  let remaining = 0;
156
175
  let totalWeight = 0;
157
176
  let len = 0;
158
177
  let constraints: NormalizedConstraints[] = [];
159
178
 
160
- let lastWeights: number[] = [];
161
179
  let nextLayout: number[] = [];
162
180
 
181
+ function applyNextLayout() {
182
+ for (let j = 0; j < len; j++) {
183
+ tile.weights[j] = nextLayout[j];
184
+ }
185
+ }
186
+
187
+ function sumOf(arr: number[]) {
188
+ let s = 0;
189
+ for (let i = 0; i < arr.length; i++) {
190
+ s += arr[i];
191
+ }
192
+ return s;
193
+ }
194
+
195
+ function getContainerSizePx() {
196
+ return (
197
+ (isRow ? splitEl.clientWidth : splitEl.clientHeight) -
198
+ (len - 1) * tile.gapPx
199
+ );
200
+ }
201
+
202
+ function expand(weight: number, j: number) {
203
+ const { maxSize, minSize, collapsedSize } = constraints[j];
204
+ if (collapsedSize >= 0 && weight < minSize) {
205
+ const snapThreshold = collapsedSize + (minSize - collapsedSize) * 0.5;
206
+ if (snapThreshold < weight + remaining) {
207
+ nextLayout[j] = minSize;
208
+ remaining -= minSize - weight;
209
+ weight = minSize;
210
+ } else {
211
+ return;
212
+ }
213
+ }
214
+ if (remaining > 0 && weight < maxSize) {
215
+ const available = maxSize - weight;
216
+ if (available < remaining) {
217
+ nextLayout[j] = maxSize;
218
+ remaining -= available;
219
+ } else {
220
+ nextLayout[j] = weight + remaining;
221
+ remaining = 0;
222
+ }
223
+ }
224
+ }
225
+
226
+ function shrink(weight: number, j: number) {
227
+ const { minSize, collapsedSize } = constraints[j];
228
+ if (weight > minSize) {
229
+ const available = weight - minSize;
230
+ if (available < remaining) {
231
+ nextLayout[j] = minSize;
232
+ remaining -= available;
233
+ } else {
234
+ nextLayout[j] = weight - remaining;
235
+ remaining = 0;
236
+ }
237
+ }
238
+ if (minSize > 0 && collapsedSize >= 0 && nextLayout[j] <= minSize) {
239
+ const required = minSize * 0.5;
240
+ if (required < remaining) {
241
+ remaining -= nextLayout[j] - collapsedSize;
242
+ nextLayout[j] = collapsedSize;
243
+ }
244
+ }
245
+ }
246
+
247
+ function normalizeConstraints(constraints: Constraint[]) {
248
+ return normalize({
249
+ constraints,
250
+ targetUnit: 'weight',
251
+ totalSizePercent: 100,
252
+ totalSizePx,
253
+ totalWeight,
254
+ });
255
+ }
256
+
163
257
  class DraggableResizer extends Draggable {
164
258
  #index = 0;
165
259
 
@@ -168,72 +262,44 @@
168
262
  this.#index = index;
169
263
  }
170
264
 
171
- protected onStart(e: PointerEvent, el: HTMLElement): void {
265
+ protected onStart(_: PointerEvent, el: HTMLElement): void {
172
266
  resizerEl = el;
173
- currentDir = 0;
174
- lastDir = 0;
175
- startPos = isRow ? e.pageX : e.pageY;
176
- previousPos = startPos;
177
- this.syncWeights();
178
- remaining = 0;
179
- totalWeight = tile.weights.reduce((a, b) => a + b);
180
- len = tile.weights.length;
181
-
182
- containerSize =
183
- (isRow ? splitEl.clientWidth : splitEl.clientHeight) -
184
- (len - 1) * tile.gapPx;
185
- constraints = tile.constraints.map((constraints) =>
186
- normalize({
187
- constraints,
188
- targetUnit: 'weight',
189
- totalSizePercent: 100,
190
- totalSizePx: containerSize,
191
- totalWeight: totalWeight,
192
- })
193
- );
267
+ totalSizePx = getContainerSizePx();
268
+ nextLayout = $state.snapshot(tile.weights);
269
+ totalWeight = sumOf(nextLayout);
270
+ len = nextLayout.length;
271
+ constraints = tile.constraints.map(normalizeConstraints);
194
272
  }
195
273
 
196
274
  protected onMove(e: PointerEvent) {
197
275
  const currentPos = isRow ? e.pageX : e.pageY;
198
- currentDir = Math.sign(currentPos - previousPos);
199
- if (currentDir === 0) {
276
+ const rect = resizerEl.getBoundingClientRect();
277
+ const lastPos = isRow
278
+ ? rect.x + rect.width / 2
279
+ : rect.y + rect.height / 2;
280
+ posDiff = currentPos - lastPos;
281
+ if (almostEqual(posDiff, 0)) {
200
282
  return;
201
283
  }
202
- const resizerRect = resizerEl.getBoundingClientRect();
203
- if (
204
- isRow
205
- ? currentDir < 0
206
- ? currentPos < resizerRect.right
207
- : currentPos > resizerRect.left
208
- : currentDir < 0
209
- ? currentPos < resizerRect.bottom
210
- : currentPos > resizerRect.top
211
- ) {
212
- if (currentDir !== lastDir) {
213
- startPos = currentPos;
214
- this.syncWeights();
215
- lastDir = currentDir;
284
+
285
+ const deltaWeight = Math.abs((posDiff * totalWeight) / totalSizePx);
286
+ if (deltaWeight > 0) {
287
+ remaining = deltaWeight;
288
+ this.adjustBy(shrink);
289
+ remaining = deltaWeight - remaining;
290
+ if (remaining > 0) {
291
+ posDiff *= -1;
292
+ this.adjustBy(expand);
216
293
  }
217
- const deltaWeight = Math.abs(
218
- ((currentPos - startPos) * totalWeight) / containerSize
219
- );
220
- if (deltaWeight > 0) {
221
- remaining = deltaWeight;
222
- this.adjustBy('shrink');
223
- remaining = deltaWeight - remaining;
224
- if (remaining > 0) {
225
- currentDir *= -1;
226
- this.adjustBy('expand');
227
- }
228
- const total = nextLayout.reduce((a, b) => a + b);
229
- if (almostEqual(totalWeight, total)) {
230
- for (let j = 0; j < len; j++) {
231
- tile.weights[j] = nextLayout[j];
232
- }
233
- }
294
+ if (remaining < 0) {
295
+ posDiff *= -1;
296
+ remaining = Math.abs(totalWeight - sumOf(nextLayout));
297
+ this.adjustBy(shrink, nextLayout);
298
+ }
299
+ if (almostEqual(totalWeight, sumOf(nextLayout))) {
300
+ applyNextLayout();
234
301
  }
235
302
  }
236
- previousPos = currentPos;
237
303
  }
238
304
 
239
305
  protected onStop() {
@@ -242,55 +308,132 @@
242
308
  }
243
309
  }
244
310
 
245
- private expand(j: number) {
246
- const weight = lastWeights[j];
247
- const maxWeight = constraints[j].maxSize;
248
- if (weight < maxWeight) {
249
- const available = maxWeight - weight;
250
- if (available < remaining) {
251
- nextLayout[j] = maxWeight;
252
- remaining -= available;
253
- } else {
254
- nextLayout[j] = weight + remaining;
255
- remaining = 0;
256
- }
257
- }
258
- }
259
-
260
- private shrink(j: number) {
261
- const minWeight = constraints[j].minSize;
262
- const weight = lastWeights[j];
263
- if (weight > minWeight) {
264
- const available = weight - minWeight;
265
- if (available < remaining) {
266
- nextLayout[j] = minWeight;
267
- remaining -= available;
268
- } else {
269
- nextLayout[j] = weight - remaining;
270
- remaining = 0;
271
- }
272
- }
273
- }
274
-
275
- private adjustBy(adjust: 'expand' | 'shrink') {
276
- if (currentDir < 0) {
311
+ private adjustBy(
312
+ adjust: (weight: number, index: number) => void,
313
+ layout = tile.weights
314
+ ) {
315
+ if (posDiff < 0) {
277
316
  let j = this.#index - 1;
278
317
  while (j >= 0 && remaining > 0) {
279
- this[adjust](j--);
318
+ adjust(layout[j], j--);
280
319
  }
281
320
  } else {
282
321
  let j = this.#index;
283
322
  while (j < len && remaining > 0) {
284
- this[adjust](j++);
323
+ adjust(layout[j], j++);
285
324
  }
286
325
  }
287
326
  }
327
+ }
328
+
329
+ function isChildCollapsed(index: number) {
330
+ return tile.weights[index] <= constraints[index].collapsedSize;
331
+ }
332
+
333
+ let indexes = $derived(tile.weights.map((_, i) => i));
334
+ // TODO: Implement the ability to collapse/expand
335
+ function redistributeWeight(pivotIndex: number, delta: number) {
336
+ let remaining = Math.abs(delta);
337
+ const isGrow = delta > 0;
338
+ const isCandidate = (i: number) =>
339
+ isGrow
340
+ ? nextLayout[i] < constraints[i].maxSize
341
+ : nextLayout[i] > constraints[i].minSize;
342
+
343
+ let candidateIndexes = indexes.filter(
344
+ (i) => i !== pivotIndex && isCandidate(i)
345
+ );
346
+
347
+ while (remaining > 0 && candidateIndexes.length > 0) {
348
+ const totalWeight = sumOf(candidateIndexes.map((i) => nextLayout[i]));
349
+ let consumed = 0;
288
350
 
289
- private syncWeights() {
290
- lastWeights = $state.snapshot(tile.weights);
291
- nextLayout = lastWeights.slice();
351
+ for (const candidateIndex of candidateIndexes) {
352
+ const share = remaining * (nextLayout[candidateIndex] / totalWeight);
353
+
354
+ const capacity = isGrow
355
+ ? constraints[candidateIndex].maxSize - nextLayout[candidateIndex]
356
+ : nextLayout[candidateIndex] - constraints[candidateIndex].minSize;
357
+
358
+ const applied = Math.min(share, capacity);
359
+
360
+ nextLayout[candidateIndex] += isGrow ? applied : -applied;
361
+ consumed += applied;
362
+ }
363
+
364
+ remaining -= consumed;
365
+
366
+ candidateIndexes = candidateIndexes.filter(isCandidate);
292
367
  }
368
+
369
+ return remaining;
293
370
  }
371
+
372
+ let lastWeights = $derived(
373
+ new Array<number | undefined>(tile.weights.length)
374
+ );
375
+
376
+ $effect(() => {
377
+ const id = tile.id;
378
+ API.set(id, {
379
+ isCollapsed(index) {
380
+ totalWeight = sumOf(tile.weights);
381
+ totalSizePx = getContainerSizePx();
382
+ constraints[index] = normalizeConstraints(tile.constraints[index]);
383
+ return isChildCollapsed(index);
384
+ },
385
+ collapse(index) {
386
+ nextLayout = $state.snapshot(tile.weights);
387
+ totalWeight = sumOf(nextLayout);
388
+ len = nextLayout.length;
389
+ totalSizePx = getContainerSizePx();
390
+ constraints = tile.constraints.map(normalizeConstraints);
391
+ if (isChildCollapsed(index)) {
392
+ return false;
393
+ }
394
+ const rem = redistributeWeight(
395
+ index,
396
+ nextLayout[index] - constraints[index].collapsedSize
397
+ );
398
+ if (rem > 0) {
399
+ return false;
400
+ }
401
+ lastWeights[index] = nextLayout[index];
402
+ nextLayout[index] = constraints[index].collapsedSize;
403
+ applyNextLayout();
404
+ return true;
405
+ },
406
+ expand(index) {
407
+ nextLayout = $state.snapshot(tile.weights);
408
+ totalWeight = sumOf(nextLayout);
409
+ len = nextLayout.length;
410
+ totalSizePx = getContainerSizePx();
411
+ constraints = tile.constraints.map(normalizeConstraints);
412
+ if (!isChildCollapsed(index)) {
413
+ return false;
414
+ }
415
+ const lastWeight = lastWeights[index] ?? constraints[index].minSize;
416
+ let delta = constraints[index].collapsedSize - lastWeight;
417
+ let rem = redistributeWeight(index, delta);
418
+ if (rem > 0) {
419
+ delta += rem;
420
+ if (delta < 0) {
421
+ nextLayout = $state.snapshot(tile.weights);
422
+ redistributeWeight(index, delta);
423
+ } else {
424
+ return false;
425
+ }
426
+ }
427
+ nextLayout[index] = lastWeight;
428
+ lastWeights[index] = undefined;
429
+ applyNextLayout();
430
+ return true;
431
+ },
432
+ });
433
+ return () => {
434
+ API.delete(id);
435
+ };
436
+ });
294
437
  </script>
295
438
 
296
439
  <div
@@ -37,6 +37,9 @@ export declare function setup<R extends string>(ctx: SplitContext<R>): (options:
37
37
  export declare function onRemoveChild(ctx: TilerContext, tile: Tiles['split'], i: number): void;
38
38
  export declare function onClear(_ctx: TilerContext, _tile: Tiles['split']): void;
39
39
  export declare function onInsert(_ctx: TilerContext, tile: Tiles['split'], index: number, { children, constraints, weights, }: TileInsertData<'split'>): void;
40
+ export declare const isCollapsed: (splitId: string, index: number) => boolean;
41
+ export declare const collapse: (splitId: string, index: number) => boolean;
42
+ export declare const expand: (splitId: string, index: number) => boolean;
40
43
  declare const Split: import("svelte").Component<TileProps<"split">, {}, "tile">;
41
44
  type Split = ReturnType<typeof Split>;
42
45
  export default Split;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-tiler",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A small, unstyled library for building tiling user interfaces.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/x0k/svelte-tiler#readme",
@@ -46,30 +46,30 @@
46
46
  "devDependencies": {
47
47
  "@changesets/changelog-github": "^0.5.2",
48
48
  "@changesets/cli": "^2.29.8",
49
- "@eslint/compat": "^2.0.1",
49
+ "@eslint/compat": "^2.0.2",
50
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",
51
+ "@iconify-json/codicon": "^1.2.43",
52
+ "@iconify-json/material-icon-theme": "^1.2.50",
53
+ "@shikijs/langs": "^3.22.0",
54
+ "@shikijs/themes": "^3.22.0",
55
55
  "@sveltejs/adapter-static": "^3.0.10",
56
- "@sveltejs/kit": "^2.50.1",
56
+ "@sveltejs/kit": "^2.50.2",
57
57
  "@sveltejs/package": "^2.5.7",
58
58
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
59
- "@types/node": "^24.10.9",
59
+ "@types/node": "^24.10.12",
60
60
  "@vitest/browser-playwright": "^4.0.18",
61
61
  "eslint": "^9.39.2",
62
62
  "eslint-config-prettier": "^10.1.8",
63
63
  "eslint-plugin-svelte": "^3.14.0",
64
- "globals": "^17.2.0",
64
+ "globals": "^17.3.0",
65
65
  "marked": "^17.0.1",
66
- "playwright": "^1.58.0",
66
+ "playwright": "^1.58.2",
67
67
  "prettier": "^3.8.1",
68
68
  "prettier-plugin-svelte": "^3.4.1",
69
69
  "publint": "^0.3.17",
70
- "shiki": "^3.21.0",
71
- "svelte": "^5.49.1",
72
- "svelte-check": "^4.3.5",
70
+ "shiki": "^3.22.0",
71
+ "svelte": "^5.50.0",
72
+ "svelte-check": "^4.3.6",
73
73
  "typescript": "^5.9.3",
74
74
  "typescript-eslint": "^8.54.0",
75
75
  "unplugin-icons": "^23.0.1",