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.
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/dist/action-icons.d.ts +18 -0
- package/dist/action-icons.d.ts.map +1 -0
- package/dist/action-icons.js +36 -0
- package/dist/action-icons.js.map +1 -0
- package/dist/chrome-icons.d.ts +3 -0
- package/dist/chrome-icons.d.ts.map +1 -0
- package/dist/chrome-icons.js +32 -0
- package/dist/chrome-icons.js.map +1 -0
- package/dist/corners.d.ts +8 -0
- package/dist/corners.d.ts.map +1 -0
- package/dist/corners.js +205 -0
- package/dist/corners.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/lifetime.d.ts +10 -0
- package/dist/lifetime.d.ts.map +1 -0
- package/dist/lifetime.js +44 -0
- package/dist/lifetime.js.map +1 -0
- package/dist/menu.d.ts +10 -0
- package/dist/menu.d.ts.map +1 -0
- package/dist/menu.js +356 -0
- package/dist/menu.js.map +1 -0
- package/dist/panes.d.ts +13 -0
- package/dist/panes.d.ts.map +1 -0
- package/dist/panes.js +58 -0
- package/dist/panes.js.map +1 -0
- package/dist/picker.d.ts +6 -0
- package/dist/picker.d.ts.map +1 -0
- package/dist/picker.js +112 -0
- package/dist/picker.js.map +1 -0
- package/dist/registry.d.ts +25 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +39 -0
- package/dist/registry.js.map +1 -0
- package/dist/renderer.d.ts +3 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +570 -0
- package/dist/renderer.js.map +1 -0
- package/dist/resize.d.ts +7 -0
- package/dist/resize.d.ts.map +1 -0
- package/dist/resize.js +56 -0
- package/dist/resize.js.map +1 -0
- package/dist/shortcuts.d.ts +6 -0
- package/dist/shortcuts.d.ts.map +1 -0
- package/dist/shortcuts.js +64 -0
- package/dist/shortcuts.js.map +1 -0
- package/dist/styles.css +842 -0
- package/dist/tab-bar.d.ts +6 -0
- package/dist/tab-bar.d.ts.map +1 -0
- package/dist/tab-bar.js +336 -0
- package/dist/tab-bar.js.map +1 -0
- package/dist/tab-tooltip.d.ts +4 -0
- package/dist/tab-tooltip.d.ts.map +1 -0
- package/dist/tab-tooltip.js +95 -0
- package/dist/tab-tooltip.js.map +1 -0
- package/dist/tabs.d.ts +15 -0
- package/dist/tabs.d.ts.map +1 -0
- package/dist/tabs.js +126 -0
- package/dist/tabs.js.map +1 -0
- package/dist/theme.d.ts +206 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +166 -0
- package/dist/theme.js.map +1 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/windows.d.ts +23 -0
- package/dist/windows.d.ts.map +1 -0
- package/dist/windows.js +247 -0
- package/dist/windows.js.map +1 -0
- package/package.json +50 -0
- package/src/action-icons.ts +40 -0
- package/src/chrome-icons.ts +30 -0
- package/src/corners.ts +259 -0
- package/src/index.ts +27 -0
- package/src/lifetime.ts +46 -0
- package/src/menu.ts +432 -0
- package/src/panes.ts +76 -0
- package/src/picker.ts +114 -0
- package/src/registry.ts +46 -0
- package/src/renderer.ts +654 -0
- package/src/resize.ts +65 -0
- package/src/shortcuts.ts +90 -0
- package/src/styles.css +842 -0
- package/src/tab-bar.ts +382 -0
- package/src/tab-tooltip.ts +96 -0
- package/src/tabs.ts +129 -0
- package/src/theme.ts +211 -0
- package/src/types.ts +67 -0
- 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
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -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
|
+
}
|