quilt-vanilla 0.2.4 → 0.4.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.
Files changed (74) hide show
  1. package/README.md +35 -39
  2. package/dist/actions.d.ts +14 -0
  3. package/dist/actions.d.ts.map +1 -0
  4. package/dist/actions.js +115 -0
  5. package/dist/actions.js.map +1 -0
  6. package/dist/chrome-size.d.ts +7 -0
  7. package/dist/chrome-size.d.ts.map +1 -0
  8. package/dist/chrome-size.js +86 -0
  9. package/dist/chrome-size.js.map +1 -0
  10. package/dist/close.d.ts.map +1 -1
  11. package/dist/close.js +3 -1
  12. package/dist/close.js.map +1 -1
  13. package/dist/corners.d.ts +2 -1
  14. package/dist/corners.d.ts.map +1 -1
  15. package/dist/corners.js +2 -2
  16. package/dist/corners.js.map +1 -1
  17. package/dist/index.d.ts +6 -4
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/menu.d.ts +9 -3
  22. package/dist/menu.d.ts.map +1 -1
  23. package/dist/menu.js +110 -37
  24. package/dist/menu.js.map +1 -1
  25. package/dist/panes.d.ts.map +1 -1
  26. package/dist/panes.js +84 -52
  27. package/dist/panes.js.map +1 -1
  28. package/dist/registry.d.ts +1 -0
  29. package/dist/registry.d.ts.map +1 -1
  30. package/dist/registry.js +4 -3
  31. package/dist/registry.js.map +1 -1
  32. package/dist/renderer.d.ts.map +1 -1
  33. package/dist/renderer.js +128 -36
  34. package/dist/renderer.js.map +1 -1
  35. package/dist/resize.d.ts +2 -1
  36. package/dist/resize.d.ts.map +1 -1
  37. package/dist/resize.js +2 -2
  38. package/dist/resize.js.map +1 -1
  39. package/dist/shortcut-registration.d.ts +9 -0
  40. package/dist/shortcut-registration.d.ts.map +1 -0
  41. package/dist/shortcut-registration.js +95 -0
  42. package/dist/shortcut-registration.js.map +1 -0
  43. package/dist/shortcuts.d.ts +7 -6
  44. package/dist/shortcuts.d.ts.map +1 -1
  45. package/dist/shortcuts.js +28 -50
  46. package/dist/shortcuts.js.map +1 -1
  47. package/dist/styles.css +74 -9
  48. package/dist/tabs.d.ts.map +1 -1
  49. package/dist/tabs.js +10 -12
  50. package/dist/tabs.js.map +1 -1
  51. package/dist/types.d.ts +50 -13
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/types.js.map +1 -1
  54. package/dist/workspace-api.d.ts +186 -0
  55. package/dist/workspace-api.d.ts.map +1 -0
  56. package/dist/workspace-api.js +409 -0
  57. package/dist/workspace-api.js.map +1 -0
  58. package/package.json +2 -2
  59. package/src/actions.ts +125 -0
  60. package/src/chrome-size.ts +99 -0
  61. package/src/close.ts +6 -1
  62. package/src/corners.ts +2 -1
  63. package/src/index.ts +31 -4
  64. package/src/menu.ts +123 -43
  65. package/src/panes.ts +82 -55
  66. package/src/registry.ts +6 -4
  67. package/src/renderer.ts +181 -54
  68. package/src/resize.ts +2 -1
  69. package/src/shortcut-registration.ts +106 -0
  70. package/src/shortcuts.ts +38 -63
  71. package/src/styles.css +74 -9
  72. package/src/tabs.ts +9 -11
  73. package/src/types.ts +67 -14
  74. package/src/workspace-api.ts +583 -0
package/src/menu.ts CHANGED
@@ -4,20 +4,23 @@ import type { ActionIcon } from './action-icons.js';
4
4
  import { fillTabPicker } from './picker.js';
5
5
  import { findNode, findParent, groups, paneIds } from 'quilt-core';
6
6
  import type { Group, Pane } from 'quilt-core';
7
- import type { ResolvedLayoutOptions } from './types.js';
8
- import type { Windows } from './windows.js';
7
+ import type { ResolvedLayoutOptions, WorkspaceAction, ActionContext } from './types.js';
8
+ import type { createActions } from './actions.js';
9
+ import { registeredShortcuts, formatShortcut, ariaShortcut } from './shortcut-registration.js';
9
10
  import { el, Scope } from './lifetime.js';
10
11
  export function createPaneMenu(
11
12
  root: HTMLElement,
12
13
  options: ResolvedLayoutOptions,
13
- windows: Pick<Windows, 'open'>,
14
14
  refresh: () => void,
15
15
  report: (e: unknown) => void,
16
- requestClose: (id: string, kind?: 'pane' | 'group') => Promise<boolean>,
16
+ actions: ReturnType<typeof createActions>,
17
17
  ) {
18
18
  const doc = root.ownerDocument,
19
19
  win = doc.defaultView!;
20
20
  let current: Scope | undefined;
21
+ let shortcutTarget:
22
+ { dialog: HTMLDialogElement; context: ActionContext; close(): void } | undefined;
23
+ let refreshShortcuts = () => {};
21
24
  const persistentPickers = new Map<string, Scope>();
22
25
  const act = (fn: () => void) => {
23
26
  try {
@@ -26,11 +29,10 @@ export function createPaneMenu(
26
29
  report(error);
27
30
  }
28
31
  };
29
- function button(text: string, title: string, action: () => void) {
32
+ function button(text: string, label: string, action: () => void) {
30
33
  const b = el(doc, 'button', 'layouts-button', text);
31
34
  b.type = 'button';
32
- b.title = title;
33
- b.setAttribute('aria-label', title);
35
+ b.setAttribute('aria-label', label);
34
36
  b.onclick = () => act(action);
35
37
  return b;
36
38
  }
@@ -59,17 +61,38 @@ export function createPaneMenu(
59
61
  : message(options, 'Empty pane actions'),
60
62
  );
61
63
  local.add(() => dialog.remove());
62
- const add = (icon: ActionIcon, label: string, enabled: boolean, fn: () => void) => {
64
+ const context = {
65
+ groupId: group.id,
66
+ ...(pane && group.panes.includes(pane.id) ? { paneId: pane.id } : {}),
67
+ };
68
+ const hints: { element: HTMLButtonElement; action: WorkspaceAction }[] = [];
69
+ const bindAction = (element: HTMLButtonElement, action: WorkspaceAction) => {
70
+ hints.push({ element, action });
71
+ element.disabled = !actions.canExecuteAction(action, context);
72
+ element.onclick = () => {
73
+ local.dispose();
74
+ void actions.executeAction(action, context);
75
+ };
76
+ };
77
+ const add = (
78
+ icon: ActionIcon,
79
+ label: string,
80
+ enabled: boolean,
81
+ fn: (() => void) | WorkspaceAction,
82
+ target: HTMLElement = dialog,
83
+ ) => {
63
84
  const b = button(label, label, () => {
64
85
  local.dispose();
65
- act(fn);
86
+ if (typeof fn === 'function') act(fn);
66
87
  });
67
88
  const glyph = el(doc, 'span', 'layouts-tab-icon');
68
89
  glyph.setAttribute('aria-hidden', 'true');
69
90
  act(() => glyph.append(actionIcon(doc, icon, options)));
70
91
  b.replaceChildren(glyph, el(doc, 'span', '', label.replace(/^\+ /, '')));
71
92
  b.disabled = !enabled;
72
- dialog.append(b);
93
+ if (typeof fn === 'string') bindAction(b, fn);
94
+ if (target !== dialog) b.setAttribute('role', 'menuitem');
95
+ target.append(b);
73
96
  return b;
74
97
  };
75
98
  const allowed = (cap: 'split' | 'join' | 'move') =>
@@ -188,7 +211,7 @@ export function createPaneMenu(
188
211
  );
189
212
  return;
190
213
  }
191
- add('add-tab', message(options, '+ Add tab'), available && allowed('move'), () => create());
214
+ add('add-tab', message(options, '+ Add tab'), available && allowed('move'), 'addTab');
192
215
  const canCreate = !group.panes.length || available;
193
216
  const flyouts: { trigger: HTMLElement; flyout: HTMLElement }[] = [];
194
217
  let positionMenus = () => {};
@@ -207,7 +230,14 @@ export function createPaneMenu(
207
230
  flyout.setAttribute('role', 'menu');
208
231
  flyout.setAttribute('aria-label', label);
209
232
  flyouts.push({ trigger, flyout });
233
+ let closeTimer: number | undefined;
234
+ const cancelClose = () => {
235
+ win.clearTimeout(closeTimer);
236
+ closeTimer = undefined;
237
+ };
238
+ local.add(cancelClose);
210
239
  const setOpen = (open: boolean) => {
240
+ cancelClose();
211
241
  if (open) {
212
242
  for (const other of flyouts) {
213
243
  other.flyout.hidden = true;
@@ -221,7 +251,11 @@ export function createPaneMenu(
221
251
  local.listen(container, 'pointerenter', () => {
222
252
  if (!trigger.disabled) setOpen(true);
223
253
  });
224
- local.listen(container, 'pointerleave', () => setOpen(false));
254
+ // Allow the pointer to cross the dialog padding or move diagonally into the flyout.
255
+ local.listen(container, 'pointerleave', () => {
256
+ cancelClose();
257
+ closeTimer = win.setTimeout(() => setOpen(false), 300);
258
+ });
225
259
  local.listen(trigger, 'keydown', (event) => {
226
260
  if ((event as KeyboardEvent).key === 'ArrowRight') {
227
261
  event.preventDefault();
@@ -241,16 +275,14 @@ export function createPaneMenu(
241
275
  return flyout;
242
276
  }
243
277
  const flyout = submenu(message(options, 'Split'), 'split-right', canCreate && allowed('split'));
244
- for (const [label, axis, before] of [
245
- [message(options, 'Split left'), 'horizontal', true],
246
- [message(options, 'Split right'), 'horizontal', false],
247
- [message(options, 'Split up'), 'vertical', true],
248
- [message(options, 'Split down'), 'vertical', false],
278
+ for (const [label, axis, action] of [
279
+ [message(options, 'Split left'), 'horizontal', 'splitLeft'],
280
+ [message(options, 'Split right'), 'horizontal', 'splitRight'],
281
+ [message(options, 'Split up'), 'vertical', 'splitUp'],
282
+ [message(options, 'Split down'), 'vertical', 'splitDown'],
249
283
  ] as const) {
250
- const option = button(label, label, () => {
251
- local.dispose();
252
- create(axis, before);
253
- });
284
+ const option = button(label, label, () => {});
285
+ bindAction(option, action);
254
286
  const icon = el(doc, 'span', 'layouts-tab-icon');
255
287
  icon.setAttribute('aria-hidden', 'true');
256
288
  icon.append(actionIcon(doc, axis === 'horizontal' ? 'split-right' : 'split-below', options));
@@ -263,7 +295,7 @@ export function createPaneMenu(
263
295
  'join',
264
296
  message(options, 'Join sibling region'),
265
297
  Boolean(joinParent) && paneIds(joinParent!).every((id) => options.store.can(id, 'join')),
266
- () => options.store.join(group.id, { source: 'user' }),
298
+ 'joinSiblingRegion',
267
299
  );
268
300
  let preview: Scope | undefined;
269
301
  const clearPreview = () => {
@@ -334,20 +366,14 @@ export function createPaneMenu(
334
366
  ? message(options, 'Restore region')
335
367
  : message(options, 'Maximize region'),
336
368
  true,
337
- () =>
338
- options.store.maximize(
339
- options.store.getSnapshot().maximized === group.id ? null : group.id,
340
- ),
369
+ 'maximize',
341
370
  );
342
371
  if (group.panes.length && pane && options.popouts !== false) {
343
372
  add(
344
373
  'popout',
345
374
  message(options, 'Open in window'),
346
375
  options.store.can(pane.id, 'popout'),
347
- () => {
348
- void windows.open(pane.id);
349
- refresh();
350
- },
376
+ 'popout',
351
377
  );
352
378
  }
353
379
  const orientation = submenu(message(options, 'Tab orientation'), 'tab-orientation');
@@ -357,10 +383,15 @@ export function createPaneMenu(
357
383
  ['left', message(options, 'Vertical')],
358
384
  ] as const) {
359
385
  const selected = group.tabPlacement === value;
360
- const option = button(label, label, () => {
361
- options.store.setTabPlacement(group.id, value);
362
- local.dispose();
363
- });
386
+ const option = button(label, label, () => {});
387
+ bindAction(
388
+ option,
389
+ value === 'top'
390
+ ? 'tabOrientationHorizontal'
391
+ : value === 'left'
392
+ ? 'tabOrientationVertical'
393
+ : 'tabOrientationDefault',
394
+ );
364
395
  option.setAttribute('role', 'menuitemradio');
365
396
  option.setAttribute('aria-checked', String(selected));
366
397
  const mark = el(doc, 'span', 'layouts-tab-icon', selected ? '✓' : '');
@@ -375,10 +406,15 @@ export function createPaneMenu(
375
406
  ['compact', message(options, 'Compact')],
376
407
  ] as const) {
377
408
  const selected = group.tabDisplay === value;
378
- const option = button(label, label, () => {
379
- options.store.setTabDisplay(group.id, value);
380
- local.dispose();
381
- });
409
+ const option = button(label, label, () => {});
410
+ bindAction(
411
+ option,
412
+ value === 'automatic'
413
+ ? 'tabDisplayAutomatic'
414
+ : value === 'compact'
415
+ ? 'tabDisplayCompact'
416
+ : 'tabDisplayDefault',
417
+ );
382
418
  option.setAttribute('role', 'menuitemradio');
383
419
  option.setAttribute('aria-checked', String(selected));
384
420
  const mark = el(doc, 'span', 'layouts-tab-icon', selected ? '✓' : '');
@@ -387,31 +423,34 @@ export function createPaneMenu(
387
423
  display.append(option);
388
424
  }
389
425
  if (group.panes.length && pane) {
426
+ const close = submenu(message(options, 'Close'), 'close');
390
427
  add(
391
428
  'close',
392
429
  message(options, 'Close active tab'),
393
430
  options.store.can(pane.id, 'close'),
394
- () => void requestClose(pane.id),
431
+ 'closeActiveTab',
432
+ close,
395
433
  );
396
434
  add(
397
435
  'close',
398
436
  message(options, 'Close pane'),
399
437
  group.panes.every((id) => options.store.can(id, 'close')),
400
- () => void requestClose(group.id, 'group'),
438
+ 'closePane',
439
+ close,
401
440
  );
402
441
  } else {
403
442
  add(
404
443
  'close',
405
444
  message(options, 'Close empty pane'),
406
445
  options.store.getSnapshot().root.id !== group.id,
407
- () => options.store.removeEmptyGroup(group.id),
446
+ 'closeEmptyPane',
408
447
  );
409
448
  }
410
449
  add(
411
450
  'restore',
412
451
  message(options, 'Restore closed tab'),
413
452
  options.store.canRestoreClosedTab(),
414
- () => options.store.restoreClosedTab(),
453
+ 'restoreClosedTab',
415
454
  );
416
455
  add('cancel', message(options, 'Cancel'), true, () => {});
417
456
  root.append(dialog);
@@ -462,8 +501,47 @@ export function createPaneMenu(
462
501
  local.dispose();
463
502
  }
464
503
  });
504
+ const updateHints = () => {
505
+ const registrations = registeredShortcuts(options);
506
+ for (const { element, action } of hints) {
507
+ element.querySelector('.layouts-shortcut')?.remove();
508
+ element.removeAttribute('aria-keyshortcuts');
509
+ element.disabled = !actions.canExecuteAction(action, context);
510
+ const label = element.getAttribute('aria-label') ?? '';
511
+ const registration = registrations.find((entry) => entry.action === action);
512
+ element.removeAttribute('title');
513
+ if (!registration) continue;
514
+ const labels = registration.bindings.map((binding) => {
515
+ try {
516
+ return options.formatShortcut?.(binding) ?? formatShortcut(binding);
517
+ } catch (error) {
518
+ report(error);
519
+ return formatShortcut(binding);
520
+ }
521
+ });
522
+ const badge = el(doc, 'kbd', 'layouts-shortcut', labels[0]);
523
+ badge.setAttribute('aria-hidden', 'true');
524
+ element.append(badge);
525
+ element.title = `${label} (${labels.join(' / ')})`;
526
+ if (!element.disabled)
527
+ element.setAttribute(
528
+ 'aria-keyshortcuts',
529
+ registration.bindings.map(ariaShortcut).join(' '),
530
+ );
531
+ }
532
+ positionMenus();
533
+ };
534
+ refreshShortcuts = updateHints;
535
+ local.add(() => {
536
+ if (refreshShortcuts === updateHints) refreshShortcuts = () => {};
537
+ });
538
+ const target = { dialog, context, close: () => local.dispose() };
539
+ shortcutTarget = target;
540
+ local.add(() => {
541
+ if (shortcutTarget === target) shortcutTarget = undefined;
542
+ });
465
543
  dialog.showModal();
466
- positionMenus();
544
+ updateHints();
467
545
  const observer = new win.ResizeObserver(positionMenus);
468
546
  observer.observe(dialog);
469
547
  observer.observe(anchor);
@@ -478,6 +556,8 @@ export function createPaneMenu(
478
556
  dialog.focus({ preventScroll: true });
479
557
  }
480
558
  return {
559
+ getShortcutTarget: () => shortcutTarget,
560
+ refreshShortcuts: () => refreshShortcuts(),
481
561
  open,
482
562
  addTab(anchor: HTMLElement, group: Group) {
483
563
  const layout = options.store.getSnapshot();
package/src/panes.ts CHANGED
@@ -24,67 +24,94 @@ export function mountPane(
24
24
  const renderer = Object.hasOwn(options.renderers ?? {}, pane.type)
25
25
  ? options.renderers?.[pane.type]
26
26
  : undefined;
27
- let view: PaneView;
27
+ let view: PaneView = { dispose() {} };
28
+ let observer: ResizeObserver | undefined;
29
+ let disposed = false;
30
+ const controller = new (win as Window & typeof globalThis).AbortController();
31
+ const cleanups: (() => void)[] = [];
32
+ const reportCleanup = (error: unknown) => {
33
+ try {
34
+ options.onError?.(error);
35
+ } catch {
36
+ /* Finish teardown even if reporting fails. */
37
+ }
38
+ };
39
+ const run = (callback: () => void) => {
40
+ try {
41
+ callback();
42
+ } catch (error) {
43
+ reportCleanup(error);
44
+ }
45
+ };
46
+ const dispose = () => {
47
+ if (disposed) return;
48
+ disposed = true;
49
+ run(() => controller.abort());
50
+ run(() => observer?.disconnect());
51
+ run(() => view.dispose());
52
+ for (const cleanup of cleanups.splice(0).reverse()) run(cleanup);
53
+ element.remove();
54
+ };
28
55
  let result: MountedPane | undefined;
29
56
  const reportError = (error: unknown) => {
30
57
  if (result) result.failed = true;
31
58
  options.onError?.(error);
32
59
  };
33
- if (renderer)
34
- view = renderer({
35
- element,
36
- reportError,
37
- document: doc,
38
- window: win,
39
- pane,
40
- state: options.getPaneState?.(pane.id),
41
- location,
42
- });
43
- else {
44
- element.append(
45
- el(
46
- doc,
47
- 'div',
48
- 'layouts-placeholder',
49
- message(options, 'Unknown pane type: {type}. Register a renderer to display this pane.', {
50
- type: pane.type,
51
- }),
52
- ),
53
- );
54
- view = { dispose() {} };
55
- }
56
- const observer = new ResizeObserver((entries) => {
57
- const rect = entries[0]?.contentRect;
58
- if (rect) {
59
- try {
60
- view.resize?.(rect.width, rect.height);
61
- } catch (error) {
62
- options.onError?.(error);
63
- }
60
+ try {
61
+ if (renderer)
62
+ view = renderer({
63
+ signal: controller.signal,
64
+ onCleanup(callback) {
65
+ if (disposed) run(callback);
66
+ else cleanups.push(callback);
67
+ },
68
+ element,
69
+ reportError,
70
+ document: doc,
71
+ window: win,
72
+ pane,
73
+ state: options.getPaneState?.(pane.id),
74
+ location,
75
+ }) ?? { dispose() {} };
76
+ else {
77
+ element.append(
78
+ el(
79
+ doc,
80
+ 'div',
81
+ 'layouts-placeholder',
82
+ message(options, 'Unknown pane type: {type}. Register a renderer to display this pane.', {
83
+ type: pane.type,
84
+ }),
85
+ ),
86
+ );
87
+ view = { dispose() {} };
64
88
  }
65
- });
66
- observer.observe(element);
67
- let disposed = false;
68
- result = {
69
- element,
70
- view,
71
- pane,
72
- signature: JSON.stringify(pane),
73
- renderer,
74
- dispose() {
75
- if (disposed) return;
76
- disposed = true;
77
- observer.disconnect();
78
- try {
79
- view.dispose();
80
- } finally {
81
- element.remove();
89
+ observer = new ResizeObserver((entries) => {
90
+ const rect = entries[0]?.contentRect;
91
+ if (rect) {
92
+ try {
93
+ view.resize?.(rect.width, rect.height);
94
+ } catch (error) {
95
+ options.onError?.(error);
96
+ }
82
97
  }
83
- },
84
- };
85
- if (location === 'main')
86
- void view.ready?.catch((error) => {
87
- if (!disposed) reportError(error);
88
98
  });
89
- return result;
99
+ observer.observe(element);
100
+ result = {
101
+ element,
102
+ view,
103
+ pane,
104
+ signature: JSON.stringify(pane),
105
+ renderer,
106
+ dispose,
107
+ };
108
+ if (location === 'main')
109
+ void view.ready?.catch((error) => {
110
+ if (!disposed) reportError(error);
111
+ });
112
+ return result;
113
+ } catch (error) {
114
+ dispose();
115
+ throw error;
116
+ }
90
117
  }
package/src/registry.ts CHANGED
@@ -46,6 +46,11 @@ export class TabRegistry {
46
46
  }
47
47
 
48
48
  let instanceSequence = 0;
49
+ export function createPaneId(): string {
50
+ return (
51
+ globalThis.crypto?.randomUUID?.() ?? `quilt-${Date.now().toString(36)}-${++instanceSequence}`
52
+ );
53
+ }
49
54
 
50
55
  export interface PaneRegistration<T> {
51
56
  type: string;
@@ -90,10 +95,7 @@ export class PaneRegistry<T> {
90
95
  title: stored.title,
91
96
  ...(stored.icon !== undefined ? { icon: stored.icon } : {}),
92
97
  ...supplied,
93
- id:
94
- supplied.id ??
95
- globalThis.crypto?.randomUUID?.() ??
96
- `quilt-${Date.now().toString(36)}-${++instanceSequence}`,
98
+ id: supplied.id ?? createPaneId(),
97
99
  };
98
100
  },
99
101
  });