quilt-vanilla 0.1.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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/dist/action-icons.d.ts +18 -0
  4. package/dist/action-icons.d.ts.map +1 -0
  5. package/dist/action-icons.js +36 -0
  6. package/dist/action-icons.js.map +1 -0
  7. package/dist/chrome-icons.d.ts +3 -0
  8. package/dist/chrome-icons.d.ts.map +1 -0
  9. package/dist/chrome-icons.js +32 -0
  10. package/dist/chrome-icons.js.map +1 -0
  11. package/dist/corners.d.ts +8 -0
  12. package/dist/corners.d.ts.map +1 -0
  13. package/dist/corners.js +205 -0
  14. package/dist/corners.js.map +1 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +5 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/lifetime.d.ts +10 -0
  20. package/dist/lifetime.d.ts.map +1 -0
  21. package/dist/lifetime.js +44 -0
  22. package/dist/lifetime.js.map +1 -0
  23. package/dist/menu.d.ts +10 -0
  24. package/dist/menu.d.ts.map +1 -0
  25. package/dist/menu.js +356 -0
  26. package/dist/menu.js.map +1 -0
  27. package/dist/panes.d.ts +13 -0
  28. package/dist/panes.d.ts.map +1 -0
  29. package/dist/panes.js +58 -0
  30. package/dist/panes.js.map +1 -0
  31. package/dist/picker.d.ts +6 -0
  32. package/dist/picker.d.ts.map +1 -0
  33. package/dist/picker.js +112 -0
  34. package/dist/picker.js.map +1 -0
  35. package/dist/registry.d.ts +25 -0
  36. package/dist/registry.d.ts.map +1 -0
  37. package/dist/registry.js +39 -0
  38. package/dist/registry.js.map +1 -0
  39. package/dist/renderer.d.ts +3 -0
  40. package/dist/renderer.d.ts.map +1 -0
  41. package/dist/renderer.js +570 -0
  42. package/dist/renderer.js.map +1 -0
  43. package/dist/resize.d.ts +7 -0
  44. package/dist/resize.d.ts.map +1 -0
  45. package/dist/resize.js +56 -0
  46. package/dist/resize.js.map +1 -0
  47. package/dist/shortcuts.d.ts +6 -0
  48. package/dist/shortcuts.d.ts.map +1 -0
  49. package/dist/shortcuts.js +64 -0
  50. package/dist/shortcuts.js.map +1 -0
  51. package/dist/styles.css +842 -0
  52. package/dist/tab-bar.d.ts +6 -0
  53. package/dist/tab-bar.d.ts.map +1 -0
  54. package/dist/tab-bar.js +336 -0
  55. package/dist/tab-bar.js.map +1 -0
  56. package/dist/tab-tooltip.d.ts +4 -0
  57. package/dist/tab-tooltip.d.ts.map +1 -0
  58. package/dist/tab-tooltip.js +95 -0
  59. package/dist/tab-tooltip.js.map +1 -0
  60. package/dist/tabs.d.ts +15 -0
  61. package/dist/tabs.d.ts.map +1 -0
  62. package/dist/tabs.js +126 -0
  63. package/dist/tabs.js.map +1 -0
  64. package/dist/theme.d.ts +206 -0
  65. package/dist/theme.d.ts.map +1 -0
  66. package/dist/theme.js +166 -0
  67. package/dist/theme.js.map +1 -0
  68. package/dist/types.d.ts +66 -0
  69. package/dist/types.d.ts.map +1 -0
  70. package/dist/types.js +2 -0
  71. package/dist/types.js.map +1 -0
  72. package/dist/windows.d.ts +23 -0
  73. package/dist/windows.d.ts.map +1 -0
  74. package/dist/windows.js +247 -0
  75. package/dist/windows.js.map +1 -0
  76. package/package.json +50 -0
  77. package/src/action-icons.ts +40 -0
  78. package/src/chrome-icons.ts +30 -0
  79. package/src/corners.ts +259 -0
  80. package/src/index.ts +27 -0
  81. package/src/lifetime.ts +46 -0
  82. package/src/menu.ts +432 -0
  83. package/src/panes.ts +76 -0
  84. package/src/picker.ts +114 -0
  85. package/src/registry.ts +46 -0
  86. package/src/renderer.ts +654 -0
  87. package/src/resize.ts +65 -0
  88. package/src/shortcuts.ts +90 -0
  89. package/src/styles.css +842 -0
  90. package/src/tab-bar.ts +382 -0
  91. package/src/tab-tooltip.ts +96 -0
  92. package/src/tabs.ts +129 -0
  93. package/src/theme.ts +211 -0
  94. package/src/types.ts +67 -0
  95. package/src/windows.ts +244 -0
package/src/menu.ts ADDED
@@ -0,0 +1,432 @@
1
+ import { actionIcon } from './action-icons.js';
2
+ import type { ActionIcon } from './action-icons.js';
3
+ import { fillTabPicker } from './picker.js';
4
+ import { findNode, findParent, groups, paneIds } from 'quilt-core';
5
+ import type { Group, Pane } from 'quilt-core';
6
+ import type { LayoutOptions } from './types.js';
7
+ import type { Windows } from './windows.js';
8
+ import { el, Scope } from './lifetime.js';
9
+ export function createPaneMenu(
10
+ root: HTMLElement,
11
+ options: LayoutOptions,
12
+ windows: Pick<Windows, 'pending' | 'open'>,
13
+ refresh: () => void,
14
+ report: (e: unknown) => void,
15
+ ) {
16
+ const doc = root.ownerDocument,
17
+ win = doc.defaultView!;
18
+ let current: Scope | undefined;
19
+ const persistentPickers = new Map<string, Scope>();
20
+ const act = (fn: () => void) => {
21
+ try {
22
+ fn();
23
+ } catch (error) {
24
+ report(error);
25
+ }
26
+ };
27
+ function button(text: string, title: string, action: () => void) {
28
+ const b = el(doc, 'button', 'layouts-button', text);
29
+ b.type = 'button';
30
+ b.title = title;
31
+ b.setAttribute('aria-label', title);
32
+ b.onclick = () => act(action);
33
+ return b;
34
+ }
35
+ function open(
36
+ anchor: HTMLElement,
37
+ pane: Pane | undefined,
38
+ group: Group,
39
+ direction?: 'left' | 'right' | 'top' | 'bottom',
40
+ ratio = 0.5,
41
+ groupActions = false,
42
+ addTab = false,
43
+ ) {
44
+ current?.dispose();
45
+ const latestGroup = findNode(options.store.getSnapshot().root, group.id);
46
+ if (latestGroup?.kind !== 'group') return;
47
+ group = latestGroup;
48
+ const local = new Scope();
49
+ current = local;
50
+ const dialog = el(doc, 'dialog', 'layouts-menu');
51
+ dialog.tabIndex = -1;
52
+ dialog.autofocus = true;
53
+ dialog.setAttribute(
54
+ 'aria-label',
55
+ pane && !groupActions ? `${pane.title} actions` : 'Empty pane actions',
56
+ );
57
+ local.add(() => dialog.remove());
58
+ const add = (icon: ActionIcon, label: string, enabled: boolean, fn: () => void) => {
59
+ const b = button(label, label, () => {
60
+ local.dispose();
61
+ act(fn);
62
+ });
63
+ const glyph = el(doc, 'span', 'layouts-tab-icon');
64
+ glyph.setAttribute('aria-hidden', 'true');
65
+ act(() => glyph.append(actionIcon(doc, icon, options)));
66
+ b.replaceChildren(glyph, el(doc, 'span', '', label.replace(/^\+ /, '')));
67
+ b.disabled = !enabled;
68
+ dialog.append(b);
69
+ return b;
70
+ };
71
+ const allowed = (cap: 'split' | 'join' | 'move') =>
72
+ group.panes.every((id) => options.store.can(id, cap));
73
+ const available = Boolean(options.tabs?.list().length);
74
+ const create = (axis?: 'horizontal' | 'vertical', before = false) => {
75
+ if (!axis && !pane && !options.tabs) return;
76
+ let destination = group;
77
+ if (axis && (options.tabs || !pane)) {
78
+ const id = options.store.split(group.id, axis, null, { source: 'user', before, ratio });
79
+ destination = findNode(options.store.getSnapshot().root, id) as Group;
80
+ refresh();
81
+ if (!options.tabs) return;
82
+ }
83
+ const commit = (fresh: Pane) => options.store.add(fresh, destination.id, { source: 'user' });
84
+ if (options.tabs) {
85
+ if ((axis || !group.panes.length) && options.store.getAutoCollapse() === 'disabled') {
86
+ if (persistentPickers.has(destination.id)) {
87
+ const region = Array.from(root.querySelectorAll<HTMLElement>('[data-node-id]')).find(
88
+ (element) => element.dataset.nodeId === destination.id,
89
+ );
90
+ region?.querySelector<HTMLInputElement>('.layouts-picker-search')?.focus();
91
+ return;
92
+ }
93
+ const region = Array.from(root.querySelectorAll<HTMLElement>('[data-node-id]')).find(
94
+ (element) => element.dataset.nodeId === destination.id,
95
+ );
96
+ if (!region) return;
97
+ const pickerScope = new Scope();
98
+ persistentPickers.set(destination.id, pickerScope);
99
+ const picker = el(doc, 'div', 'layouts-menu layouts-picker layouts-picker-persistent');
100
+ picker.setAttribute('role', 'dialog');
101
+ region.append(picker);
102
+ pickerScope.listen(
103
+ doc,
104
+ 'pointerdown',
105
+ (event) => {
106
+ if (!event.composedPath().includes(region)) pickerScope.dispose();
107
+ },
108
+ { capture: true },
109
+ );
110
+ pickerScope.add(() => {
111
+ picker.remove();
112
+ persistentPickers.delete(destination.id);
113
+ });
114
+ pickerScope.add(
115
+ options.store.subscribe(() => {
116
+ const group = findNode(options.store.getSnapshot().root, destination.id);
117
+ if (group?.kind !== 'group' || group.panes.length) pickerScope.dispose();
118
+ }),
119
+ );
120
+ fillTabPicker(picker, pickerScope, options, pane, destination, commit, report, true);
121
+ return;
122
+ }
123
+ const pickerScope = new Scope();
124
+ current = pickerScope;
125
+ const picker = el(doc, 'dialog', 'layouts-menu layouts-picker');
126
+ if (axis)
127
+ pickerScope.add(() => {
128
+ if (options.store.getAutoCollapse() !== 'disabled')
129
+ options.store.removeEmptyGroup(destination.id);
130
+ });
131
+ pickerScope.add(() => picker.remove());
132
+ pickerScope.listen(picker, 'close', () => pickerScope.dispose());
133
+ root.append(picker);
134
+ pickerScope.listen(picker, 'click', (event) => {
135
+ const e = event as MouseEvent;
136
+ const bounds = picker.getBoundingClientRect();
137
+ if (
138
+ e.target === picker &&
139
+ (e.clientX < bounds.left ||
140
+ e.clientX > bounds.right ||
141
+ e.clientY < bounds.top ||
142
+ e.clientY > bounds.bottom)
143
+ )
144
+ pickerScope.dispose();
145
+ });
146
+ picker.showModal();
147
+ fillTabPicker(picker, pickerScope, options, pane, destination, commit, report);
148
+ const region = Array.from(root.querySelectorAll<HTMLElement>('[data-node-id]')).find(
149
+ (element) => element.dataset.nodeId === destination.id,
150
+ );
151
+ if (region) {
152
+ const position = () => {
153
+ const bounds = region.getBoundingClientRect();
154
+ picker.style.width = `${Math.max(0, Math.min(340, bounds.width - 16))}px`;
155
+ picker.style.maxHeight = `${Math.max(0, bounds.height - 16)}px`;
156
+ picker.style.left = `${bounds.left + (bounds.width - picker.offsetWidth) / 2}px`;
157
+ picker.style.top = `${bounds.top + (bounds.height - picker.offsetHeight) / 2}px`;
158
+ };
159
+ position();
160
+ const observer = new ResizeObserver(position);
161
+ observer.observe(region);
162
+ observer.observe(picker);
163
+ pickerScope.add(() => observer.disconnect());
164
+ pickerScope.listen(win, 'resize', position);
165
+ pickerScope.add(
166
+ options.store.subscribe(() => {
167
+ if (!findNode(options.store.getSnapshot().root, destination.id))
168
+ pickerScope.dispose();
169
+ }),
170
+ );
171
+ }
172
+ }
173
+ };
174
+ if (addTab || (!direction && !group.panes.length && !groupActions)) {
175
+ local.dispose();
176
+ create();
177
+ return;
178
+ }
179
+ if (direction) {
180
+ local.dispose();
181
+ create(
182
+ direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical',
183
+ direction === 'left' || direction === 'top',
184
+ );
185
+ return;
186
+ }
187
+ add('add-tab', '+ Add tab', available && allowed('move'), () => create());
188
+ const canCreate = !group.panes.length || available;
189
+ function submenu(label: string, icon: ActionIcon, enabled = true) {
190
+ const container = el(doc, 'div', 'layouts-submenu');
191
+ const trigger = button(`${label} ▸`, label, () => setOpen(true));
192
+ const glyph = el(doc, 'span', 'layouts-tab-icon');
193
+ glyph.setAttribute('aria-hidden', 'true');
194
+ glyph.append(actionIcon(doc, icon, options));
195
+ trigger.prepend(glyph);
196
+ trigger.disabled = !enabled;
197
+ trigger.setAttribute('aria-haspopup', 'menu');
198
+ trigger.setAttribute('aria-expanded', 'false');
199
+ const flyout = el(doc, 'div', 'layouts-submenu-content');
200
+ flyout.hidden = true;
201
+ flyout.setAttribute('role', 'menu');
202
+ flyout.setAttribute('aria-label', label);
203
+ const setOpen = (open: boolean) => {
204
+ flyout.hidden = !open;
205
+ trigger.setAttribute('aria-expanded', String(open));
206
+ };
207
+ local.listen(container, 'pointerenter', () => {
208
+ if (!trigger.disabled) setOpen(true);
209
+ });
210
+ local.listen(container, 'pointerleave', () => setOpen(false));
211
+ local.listen(trigger, 'keydown', (event) => {
212
+ if ((event as KeyboardEvent).key === 'ArrowRight') {
213
+ event.preventDefault();
214
+ setOpen(true);
215
+ (flyout.firstElementChild as HTMLElement)?.focus();
216
+ }
217
+ });
218
+ local.listen(flyout, 'keydown', (event) => {
219
+ if ((event as KeyboardEvent).key === 'ArrowLeft') {
220
+ event.preventDefault();
221
+ setOpen(false);
222
+ trigger.focus();
223
+ }
224
+ });
225
+ container.dataset.side =
226
+ anchor.getBoundingClientRect().right + 220 > win.innerWidth ? 'left' : 'right';
227
+ container.append(trigger, flyout);
228
+ dialog.append(container);
229
+ return flyout;
230
+ }
231
+ const flyout = submenu('Split', 'split-right', canCreate && allowed('split'));
232
+ for (const [label, axis, before] of [
233
+ ['Split left', 'horizontal', true],
234
+ ['Split right', 'horizontal', false],
235
+ ['Split up', 'vertical', true],
236
+ ['Split down', 'vertical', false],
237
+ ] as const) {
238
+ const option = button(label, label, () => {
239
+ local.dispose();
240
+ create(axis, before);
241
+ });
242
+ const icon = el(doc, 'span', 'layouts-tab-icon');
243
+ icon.setAttribute('aria-hidden', 'true');
244
+ icon.append(actionIcon(doc, axis === 'horizontal' ? 'split-right' : 'split-below', options));
245
+ option.prepend(icon);
246
+ option.setAttribute('role', 'menuitem');
247
+ flyout.append(option);
248
+ }
249
+ const joinParent = findParent(options.store.getSnapshot().root, group.id);
250
+ const join = add(
251
+ 'join',
252
+ 'Join sibling region',
253
+ Boolean(joinParent) && paneIds(joinParent!).every((id) => options.store.can(id, 'join')),
254
+ () => options.store.join(group.id, { source: 'user' }),
255
+ );
256
+ let preview: Scope | undefined;
257
+ const clearPreview = () => {
258
+ preview?.dispose();
259
+ preview = undefined;
260
+ };
261
+ local.add(clearPreview);
262
+ const showPreview = () => {
263
+ clearPreview();
264
+ const layout = options.store.getSnapshot();
265
+ const parent = findParent(layout.root, group.id);
266
+ if (join.disabled || !parent) return;
267
+ const scope = new Scope();
268
+ preview = scope;
269
+ const overlay = el(doc, 'div', 'layouts-corner-overlay');
270
+ overlay.setAttribute('aria-hidden', 'true');
271
+ root.append(overlay);
272
+ scope.add(() => overlay.remove());
273
+ const targetTitle = layout.panes[group.active ?? '']?.title ?? 'This region';
274
+ const regions = groups(parent).map((region) => ({
275
+ region,
276
+ element: Array.from(root.querySelectorAll<HTMLElement>('[data-node-id]')).find(
277
+ (element) => element.dataset.nodeId === region.id && element.closest('.layouts') === root,
278
+ ),
279
+ }));
280
+ const position = () => {
281
+ overlay.replaceChildren();
282
+ for (const { region, element } of regions) {
283
+ if (!element || !element.getClientRects().length) continue;
284
+ const rect = element.getBoundingClientRect();
285
+ const box = el(doc, 'div', 'layouts-corner-preview');
286
+ box.dataset.cornerPreview = region.id === group.id ? 'join-target' : 'join-source';
287
+ Object.assign(box.style, {
288
+ left: `${rect.left}px`,
289
+ top: `${rect.top}px`,
290
+ width: `${rect.width}px`,
291
+ height: `${rect.height}px`,
292
+ });
293
+ box.append(
294
+ el(
295
+ doc,
296
+ 'span',
297
+ 'layouts-corner-label',
298
+ region.id === group.id ? `${targetTitle} keeps all tabs` : `Joins ${targetTitle}`,
299
+ ),
300
+ );
301
+ overlay.append(box);
302
+ }
303
+ };
304
+ position();
305
+ const observer = new win.ResizeObserver(position);
306
+ for (const { element } of regions) if (element) observer.observe(element);
307
+ scope.add(() => observer.disconnect());
308
+ scope.listen(win, 'resize', position);
309
+ scope.listen(doc, 'scroll', position, { capture: true });
310
+ scope.add(options.store.subscribe(clearPreview));
311
+ };
312
+ local.listen(join, 'pointerenter', showPreview);
313
+ local.listen(join, 'pointerleave', clearPreview);
314
+ local.listen(join, 'focus', showPreview);
315
+ local.listen(join, 'blur', clearPreview);
316
+ add(
317
+ options.store.getSnapshot().maximized === group.id ? 'restore' : 'maximize',
318
+ options.store.getSnapshot().maximized === group.id ? 'Restore region' : 'Maximize region',
319
+ true,
320
+ () =>
321
+ options.store.maximize(
322
+ options.store.getSnapshot().maximized === group.id ? null : group.id,
323
+ ),
324
+ );
325
+ if (group.panes.length && pane) {
326
+ add(
327
+ 'popout',
328
+ windows.pending.has(pane.id) ? 'Reopen window' : 'Open in window',
329
+ options.store.can(pane.id, 'popout'),
330
+ () => {
331
+ windows.open(pane.id, windows.pending.get(pane.id));
332
+ refresh();
333
+ },
334
+ );
335
+ }
336
+ const orientation = submenu('Tab orientation', 'tab-orientation');
337
+ for (const [value, label] of [
338
+ [undefined, 'Workspace default'],
339
+ ['top', 'Horizontal'],
340
+ ['left', 'Vertical'],
341
+ ] as const) {
342
+ const selected = group.tabPlacement === value;
343
+ const option = button(label, label, () => {
344
+ options.store.setTabPlacement(group.id, value);
345
+ local.dispose();
346
+ });
347
+ option.setAttribute('role', 'menuitemradio');
348
+ option.setAttribute('aria-checked', String(selected));
349
+ const mark = el(doc, 'span', 'layouts-tab-icon', selected ? '✓' : '');
350
+ mark.setAttribute('aria-hidden', 'true');
351
+ option.prepend(mark);
352
+ orientation.append(option);
353
+ }
354
+ const display = submenu('Tab display', 'tab-orientation');
355
+ for (const [value, label] of [
356
+ [undefined, 'Workspace default'],
357
+ ['automatic', 'Automatic'],
358
+ ['compact', 'Compact'],
359
+ ] as const) {
360
+ const selected = group.tabDisplay === value;
361
+ const option = button(label, label, () => {
362
+ options.store.setTabDisplay(group.id, value);
363
+ local.dispose();
364
+ });
365
+ option.setAttribute('role', 'menuitemradio');
366
+ option.setAttribute('aria-checked', String(selected));
367
+ const mark = el(doc, 'span', 'layouts-tab-icon', selected ? '✓' : '');
368
+ mark.setAttribute('aria-hidden', 'true');
369
+ option.prepend(mark);
370
+ display.append(option);
371
+ }
372
+ if (group.panes.length && pane) {
373
+ add('close', 'Close active tab', options.store.can(pane.id, 'close'), () =>
374
+ options.store.close(pane.id, { source: 'user' }),
375
+ );
376
+ add(
377
+ 'close',
378
+ 'Close pane',
379
+ group.panes.every((id) => options.store.can(id, 'close')),
380
+ () => options.store.closeGroup(group.id, { source: 'user' }),
381
+ );
382
+ } else {
383
+ add('close', 'Close empty pane', options.store.getSnapshot().root.id !== group.id, () =>
384
+ options.store.removeEmptyGroup(group.id),
385
+ );
386
+ }
387
+ add('restore', 'Restore closed tab', options.store.canRestoreClosedTab(), () =>
388
+ options.store.restoreClosedTab(),
389
+ );
390
+ add('cancel', 'Cancel', true, () => {});
391
+ root.append(dialog);
392
+ const rect = anchor.getBoundingClientRect();
393
+ dialog.style.left = `${Math.max(8, Math.min(rect.right - 230, win!.innerWidth - 250))}px`;
394
+ dialog.style.top = `${Math.min(rect.bottom + 4, win!.innerHeight - 180)}px`;
395
+ local.listen(dialog, 'close', () => local.dispose());
396
+ local.listen(dialog, 'click', (event) => {
397
+ if (event.target === dialog) {
398
+ const r = dialog.getBoundingClientRect();
399
+ const e = event as MouseEvent;
400
+ if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom)
401
+ local.dispose();
402
+ }
403
+ });
404
+ dialog.showModal();
405
+ dialog.focus({ preventScroll: true });
406
+ }
407
+ return {
408
+ open,
409
+ addTab(anchor: HTMLElement, group: Group) {
410
+ const layout = options.store.getSnapshot();
411
+ open(
412
+ anchor,
413
+ group.active ? layout.panes[group.active] : undefined,
414
+ group,
415
+ undefined,
416
+ 0.5,
417
+ false,
418
+ true,
419
+ );
420
+ },
421
+ openEmpty(anchor: HTMLElement, group: Group) {
422
+ const source = Object.values(options.store.getSnapshot().panes).find(
423
+ (pane) => pane.header !== false,
424
+ );
425
+ open(anchor, source, group, undefined, 0.5, true);
426
+ },
427
+ dispose() {
428
+ current?.dispose();
429
+ for (const picker of persistentPickers.values()) picker.dispose();
430
+ },
431
+ };
432
+ }
package/src/panes.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { Pane } from 'quilt-core';
2
+ import type { LayoutOptions, PaneView, PaneRenderer } from './types.js';
3
+ import { el } from './lifetime.js';
4
+ export interface MountedPane {
5
+ element: HTMLElement;
6
+ view: PaneView;
7
+ pane: Pane;
8
+ signature: string;
9
+ renderer: PaneRenderer | undefined;
10
+ failed?: boolean;
11
+ dispose(): void;
12
+ }
13
+ export function mountPane(
14
+ doc: Document,
15
+ pane: Pane,
16
+ location: 'main' | 'popout',
17
+ options: LayoutOptions,
18
+ ): MountedPane {
19
+ const win = doc.defaultView;
20
+ if (!win) throw new Error('Pane needs a live document');
21
+ const element = el(doc, 'div', 'layouts-pane');
22
+ element.dataset.paneId = pane.id;
23
+ const renderer = Object.hasOwn(options.renderers, pane.type)
24
+ ? options.renderers[pane.type]
25
+ : undefined;
26
+ let view: PaneView;
27
+ if (renderer)
28
+ view = renderer({
29
+ element,
30
+ document: doc,
31
+ window: win,
32
+ pane,
33
+ state: options.getPaneState?.(pane.id),
34
+ location,
35
+ });
36
+ else {
37
+ element.append(
38
+ el(
39
+ doc,
40
+ 'div',
41
+ 'layouts-placeholder',
42
+ `Unknown pane type: ${pane.type}. Register a renderer and reload the layout.`,
43
+ ),
44
+ );
45
+ view = { dispose() {} };
46
+ }
47
+ const observer = new ResizeObserver((entries) => {
48
+ const rect = entries[0]?.contentRect;
49
+ if (rect) {
50
+ try {
51
+ view.resize?.(rect.width, rect.height);
52
+ } catch (error) {
53
+ options.onError?.(error);
54
+ }
55
+ }
56
+ });
57
+ observer.observe(element);
58
+ let disposed = false;
59
+ return {
60
+ element,
61
+ view,
62
+ pane,
63
+ signature: JSON.stringify(pane),
64
+ renderer,
65
+ dispose() {
66
+ if (disposed) return;
67
+ disposed = true;
68
+ observer.disconnect();
69
+ try {
70
+ view.dispose();
71
+ } finally {
72
+ element.remove();
73
+ }
74
+ },
75
+ };
76
+ }
package/src/picker.ts ADDED
@@ -0,0 +1,114 @@
1
+ import type { Group, Pane } from 'quilt-core';
2
+ import type { LayoutOptions } from './types.js';
3
+ import type { TabRegistration } from './registry.js';
4
+ import { el, Scope } from './lifetime.js';
5
+ let sequence = 0;
6
+ /** Search remains focused while aria-activedescendant tracks keyboard selection. */
7
+ export function fillTabPicker(
8
+ dialog: HTMLElement,
9
+ scope: Scope,
10
+ options: LayoutOptions,
11
+ source: Pane | undefined,
12
+ group: Group,
13
+ choose: (pane: Pane) => void,
14
+ report: (error: unknown) => void,
15
+ persistent = false,
16
+ ) {
17
+ const doc = dialog.ownerDocument;
18
+ const input = el(doc, 'input', 'layouts-picker-search');
19
+ input.type = 'search';
20
+ input.placeholder = 'Search tabs…';
21
+ input.setAttribute('role', 'combobox');
22
+ input.setAttribute('aria-label', 'Search tabs');
23
+ input.setAttribute('aria-expanded', 'true');
24
+ input.setAttribute('aria-autocomplete', 'list');
25
+ const list = el(doc, 'div', 'layouts-picker-list');
26
+ list.id = `layouts-picker-${++sequence}`;
27
+ list.setAttribute('role', 'listbox');
28
+ list.setAttribute('aria-label', 'Available tabs');
29
+ input.setAttribute('aria-controls', list.id);
30
+ const status = el(doc, 'div', 'layouts-picker-status');
31
+ status.setAttribute('role', 'status');
32
+ let matches: readonly TabRegistration[] = [],
33
+ selected = 0;
34
+ function select(entry: TabRegistration) {
35
+ try {
36
+ const pane = entry.create({ source, groupId: group.id });
37
+ if (pane) {
38
+ choose(pane);
39
+ scope.dispose();
40
+ }
41
+ } catch (error) {
42
+ status.textContent = error instanceof Error ? error.message : String(error);
43
+ report(error);
44
+ }
45
+ }
46
+ function paint() {
47
+ list.replaceChildren();
48
+ matches.forEach((entry, index) => {
49
+ const option = el(doc, 'div', 'layouts-picker-option');
50
+ option.id = `${list.id}-${index}`;
51
+ option.setAttribute('role', 'option');
52
+ option.setAttribute('aria-selected', String(index === selected));
53
+ option.setAttribute('aria-label', entry.title);
54
+ if (entry.icon) {
55
+ const icon = el(doc, 'span', 'layouts-tab-icon');
56
+ icon.setAttribute('aria-hidden', 'true');
57
+ try {
58
+ const node = options.renderIcon?.(entry.icon, doc);
59
+ if (node) icon.append(node);
60
+ } catch (error) {
61
+ report(error);
62
+ }
63
+ option.append(icon);
64
+ }
65
+ const text = el(doc, 'span', '');
66
+ text.append(el(doc, 'strong', '', entry.title));
67
+ if (entry.description) text.append(el(doc, 'small', '', entry.description));
68
+ option.append(text);
69
+ option.onmousedown = (e) => e.preventDefault();
70
+ option.onclick = () => select(entry);
71
+ list.append(option);
72
+ });
73
+ if (matches[selected]) {
74
+ input.setAttribute('aria-activedescendant', `${list.id}-${selected}`);
75
+ list.children[selected]?.scrollIntoView({ block: 'nearest' });
76
+ } else input.removeAttribute('aria-activedescendant');
77
+ status.textContent = matches.length ? `${matches.length} tab types` : 'No matching tabs';
78
+ }
79
+ function search() {
80
+ const terms = input.value.toLocaleLowerCase().trim().split(/\s+/);
81
+ matches = (options.tabs?.list() ?? []).filter((entry) => {
82
+ const text = [entry.title, entry.description ?? '', ...(entry.keywords ?? [])]
83
+ .join(' ')
84
+ .toLocaleLowerCase();
85
+ return terms.every((term) => text.includes(term));
86
+ });
87
+ selected = 0;
88
+ paint();
89
+ }
90
+ input.oninput = search;
91
+ input.onkeydown = (e) => {
92
+ if (persistent && e.key === 'Escape') {
93
+ e.preventDefault();
94
+ input.blur();
95
+ } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
96
+ e.preventDefault();
97
+ if (matches.length)
98
+ selected = (selected + (e.key === 'ArrowDown' ? 1 : matches.length - 1)) % matches.length;
99
+ paint();
100
+ } else if (e.key === 'Enter') {
101
+ e.preventDefault();
102
+ const entry = matches[selected];
103
+ if (entry) select(entry);
104
+ }
105
+ };
106
+ const cancel = el(doc, 'button', 'layouts-button', 'Cancel');
107
+ cancel.type = 'button';
108
+ cancel.onclick = () => scope.dispose();
109
+ dialog.replaceChildren(input, list, status, ...(persistent ? [] : [cancel]));
110
+ dialog.setAttribute('aria-label', 'Choose a tab');
111
+ if (options.tabs) scope.add(options.tabs.subscribe(search));
112
+ search();
113
+ input.focus();
114
+ }
@@ -0,0 +1,46 @@
1
+ import type { Pane } from 'quilt-core';
2
+ export interface TabRegistration {
3
+ id: string;
4
+ title: string;
5
+ description?: string;
6
+ icon?: string;
7
+ keywords?: readonly string[];
8
+ /** Source is undefined when creating content in a completely empty workspace.
9
+ * Return a fresh pane with a unique id; undefined cancels creation. */
10
+ create: (context: { source: Pane | undefined; groupId: string }) => Pane | undefined;
11
+ }
12
+ /** Available content types, independent of open pane instances and layout JSON. */
13
+ export class TabRegistry {
14
+ private entries = new Map<string, TabRegistration>();
15
+ private listeners = new Set<() => void>();
16
+ constructor(entries: readonly TabRegistration[] = []) {
17
+ for (const entry of entries) this.register(entry);
18
+ }
19
+ list(): readonly TabRegistration[] {
20
+ return [...this.entries.values()];
21
+ }
22
+ register(entry: TabRegistration): () => void {
23
+ if (!entry.id || this.entries.has(entry.id))
24
+ throw new Error(`Duplicate or empty tab registration: ${entry.id}`);
25
+ const stored = Object.freeze({
26
+ ...entry,
27
+ ...(entry.keywords ? { keywords: Object.freeze([...entry.keywords]) } : {}),
28
+ });
29
+ this.entries.set(entry.id, stored);
30
+ this.emit();
31
+ return () => {
32
+ if (this.entries.get(entry.id) !== stored) return;
33
+ this.entries.delete(entry.id);
34
+ this.emit();
35
+ };
36
+ }
37
+ subscribe(listener: () => void): () => void {
38
+ this.listeners.add(listener);
39
+ return () => {
40
+ this.listeners.delete(listener);
41
+ };
42
+ }
43
+ private emit() {
44
+ for (const listener of this.listeners) listener();
45
+ }
46
+ }