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/tab-bar.ts
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { bindTabTooltip } from './tab-tooltip.js';
|
|
2
|
+
import type { TabBarOptions, TabBarStyle } from './types.js';
|
|
3
|
+
import type { Scope } from './lifetime.js';
|
|
4
|
+
|
|
5
|
+
export function validateTabBar(options: TabBarOptions) {
|
|
6
|
+
for (const style of [options, ...Object.values(options.regions ?? {})]) {
|
|
7
|
+
for (const [key, values] of Object.entries({
|
|
8
|
+
display: ['automatic', 'compact'],
|
|
9
|
+
attachment: ['anchored', 'floating'],
|
|
10
|
+
fit: ['full', 'fit'],
|
|
11
|
+
corners: ['fitted', 'rounded', 'capsule'],
|
|
12
|
+
})) {
|
|
13
|
+
const value = style[key as keyof TabBarStyle];
|
|
14
|
+
if (value !== undefined && !values.includes(String(value)))
|
|
15
|
+
throw new Error(`Unknown tab bar ${key}`);
|
|
16
|
+
}
|
|
17
|
+
if (style.placement !== undefined && !['top', 'left'].includes(style.placement))
|
|
18
|
+
throw new Error('Unknown tab bar placement');
|
|
19
|
+
if (style.mode !== undefined && !['full', 'tapered'].includes(style.mode))
|
|
20
|
+
throw new Error('Unknown tab bar mode');
|
|
21
|
+
if (
|
|
22
|
+
style.shape !== undefined &&
|
|
23
|
+
!['angle', 'round', 'scoop', 'vertical', 'rounded'].includes(style.shape)
|
|
24
|
+
)
|
|
25
|
+
throw new Error('Unknown tab bar shape');
|
|
26
|
+
if (
|
|
27
|
+
style.taperWidth !== undefined &&
|
|
28
|
+
(!Number.isFinite(style.taperWidth) || style.taperWidth <= 0)
|
|
29
|
+
)
|
|
30
|
+
throw new Error('Tab bar taperWidth must be positive and finite');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Chrome measurement never mounts or replaces application pane views. */
|
|
35
|
+
export function bindTabBar(
|
|
36
|
+
region: HTMLElement,
|
|
37
|
+
header: HTMLElement,
|
|
38
|
+
root: HTMLElement,
|
|
39
|
+
scope: Scope,
|
|
40
|
+
getStyle: () => TabBarStyle,
|
|
41
|
+
) {
|
|
42
|
+
const doc = region.ownerDocument,
|
|
43
|
+
win = doc.defaultView!;
|
|
44
|
+
const drawChrome = (
|
|
45
|
+
natural: number,
|
|
46
|
+
occupied: number,
|
|
47
|
+
height: number,
|
|
48
|
+
capWidth: number,
|
|
49
|
+
fits: boolean,
|
|
50
|
+
left: boolean,
|
|
51
|
+
style: TabBarStyle,
|
|
52
|
+
) => {
|
|
53
|
+
if (style.attachment === 'floating') return;
|
|
54
|
+
if (fits && capWidth > 0) {
|
|
55
|
+
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
56
|
+
svg.classList.add('layouts-tab-cap');
|
|
57
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
58
|
+
svg.setAttribute('viewBox', '0 0 1 1');
|
|
59
|
+
svg.setAttribute('preserveAspectRatio', 'none');
|
|
60
|
+
svg.style.width = `${left ? height : capWidth}px`;
|
|
61
|
+
if (left) svg.style.height = `${capWidth}px`;
|
|
62
|
+
const path = doc.createElementNS(svg.namespaceURI, 'path');
|
|
63
|
+
const curve =
|
|
64
|
+
style.shape === 'round'
|
|
65
|
+
? 'A1 1 0 0 0 1 0'
|
|
66
|
+
: style.shape === 'scoop'
|
|
67
|
+
? 'A1 1 0 0 1 1 0'
|
|
68
|
+
: 'L1 0';
|
|
69
|
+
path.setAttribute('d', `M0 1 ${curve} H0 Z`);
|
|
70
|
+
if (left) path.setAttribute('transform', 'matrix(0 1 1 0 0 0)');
|
|
71
|
+
svg.append(path);
|
|
72
|
+
header.append(svg);
|
|
73
|
+
}
|
|
74
|
+
// A single closed, inset outline covers the controls and cap. Splitting
|
|
75
|
+
// this between CSS borders and SVG strokes leaves seams and clipped edges.
|
|
76
|
+
const outline = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
77
|
+
outline.classList.add('layouts-tab-outline');
|
|
78
|
+
outline.setAttribute('aria-hidden', 'true');
|
|
79
|
+
outline.setAttribute('viewBox', `0 0 ${left ? height : occupied} ${left ? occupied : height}`);
|
|
80
|
+
outline.style.width = `${left ? height : occupied}px`;
|
|
81
|
+
outline.style.height = `${left ? occupied : height}px`;
|
|
82
|
+
const edge = doc.createElementNS(outline.namespaceURI, 'path');
|
|
83
|
+
const right = Math.max(0.5, occupied - 0.5);
|
|
84
|
+
const bottom = Math.max(0.5, height - 0.5);
|
|
85
|
+
const end = fits && capWidth > 0 ? natural : right;
|
|
86
|
+
const curve =
|
|
87
|
+
fits && capWidth > 0
|
|
88
|
+
? style.shape === 'round' || style.shape === 'scoop'
|
|
89
|
+
? `A${Math.max(0.01, right - end)} ${Math.max(0.01, bottom - 0.5)} 0 0 ${style.shape === 'round' ? 1 : 0} ${end} ${bottom}`
|
|
90
|
+
: `L${end} ${bottom}`
|
|
91
|
+
: `V${bottom}`;
|
|
92
|
+
if (style.shape === 'rounded') {
|
|
93
|
+
const radius = Math.max(
|
|
94
|
+
0,
|
|
95
|
+
Math.min(
|
|
96
|
+
parseFloat(win.getComputedStyle(header).borderTopLeftRadius) || 0,
|
|
97
|
+
(right - 0.5) / 2,
|
|
98
|
+
(bottom - 0.5) / 2,
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
edge.setAttribute(
|
|
102
|
+
'd',
|
|
103
|
+
`M${0.5 + radius} 0.5 H${right - radius} Q${right} 0.5 ${right} ${0.5 + radius} V${bottom - radius} Q${right} ${bottom} ${right - radius} ${bottom} H${0.5 + radius} Q0.5 ${bottom} 0.5 ${bottom - radius} V${0.5 + radius} Q0.5 0.5 ${0.5 + radius} 0.5 Z`,
|
|
104
|
+
);
|
|
105
|
+
} else edge.setAttribute('d', `M0.5 0.5 H${right} ${curve} H0.5 Z`);
|
|
106
|
+
if (left) edge.setAttribute('transform', 'matrix(0 1 1 0 0 0)');
|
|
107
|
+
const shared = region.dataset.sharedEdges?.split(' ') ?? [];
|
|
108
|
+
const w = left ? height : occupied;
|
|
109
|
+
const h = left ? occupied : height;
|
|
110
|
+
// Clip only decorative strokes on edges owned by an ancestor split.
|
|
111
|
+
const insets = [
|
|
112
|
+
shared.includes('top') ? 1 : 0,
|
|
113
|
+
shared.includes('right') && w >= region.clientWidth - 0.5 ? 1 : 0,
|
|
114
|
+
shared.includes('bottom') && h >= region.clientHeight - 0.5 ? 1 : 0,
|
|
115
|
+
shared.includes('left') ? 1 : 0,
|
|
116
|
+
];
|
|
117
|
+
outline.style.clipPath = `inset(${insets.map((value) => `${value}px`).join(' ')})`;
|
|
118
|
+
outline.append(edge);
|
|
119
|
+
header.append(outline);
|
|
120
|
+
};
|
|
121
|
+
const refreshTooltip = bindTabTooltip(region, header, root, scope);
|
|
122
|
+
let frame = 0,
|
|
123
|
+
disposed = false;
|
|
124
|
+
const observedContent = new Set<Element>();
|
|
125
|
+
const contentResize = new ResizeObserver(() => schedule());
|
|
126
|
+
const scrollbarClearance = () => {
|
|
127
|
+
const next = new Set<Element>();
|
|
128
|
+
const bounds = region.getBoundingClientRect();
|
|
129
|
+
let right = 0,
|
|
130
|
+
bottom = 0;
|
|
131
|
+
const body = region.querySelector<HTMLElement>(':scope > .layouts-body');
|
|
132
|
+
if (body) {
|
|
133
|
+
for (const element of [body, ...body.querySelectorAll<HTMLElement>('*')]) {
|
|
134
|
+
if (!element.clientWidth || !element.clientHeight) continue;
|
|
135
|
+
const css = win.getComputedStyle(element);
|
|
136
|
+
if (!/(auto|scroll)/.test(css.overflowX + css.overflowY)) continue;
|
|
137
|
+
next.add(element);
|
|
138
|
+
for (const child of element.children) next.add(child);
|
|
139
|
+
const rect = element.getBoundingClientRect();
|
|
140
|
+
const fallback = parseFloat(css.getPropertyValue('--layouts-scrollbar-size')) || 6;
|
|
141
|
+
// Overlay scrollbars report zero gutter: use the themed size then.
|
|
142
|
+
if (
|
|
143
|
+
/(auto|scroll)/.test(css.overflowY) &&
|
|
144
|
+
element.scrollHeight > element.clientHeight &&
|
|
145
|
+
Math.abs(bounds.right - rect.right) < 1
|
|
146
|
+
) {
|
|
147
|
+
const gutter =
|
|
148
|
+
element.offsetWidth -
|
|
149
|
+
element.clientWidth -
|
|
150
|
+
(parseFloat(css.borderLeftWidth) || 0) -
|
|
151
|
+
(parseFloat(css.borderRightWidth) || 0);
|
|
152
|
+
right = Math.max(right, gutter, fallback);
|
|
153
|
+
}
|
|
154
|
+
if (
|
|
155
|
+
/(auto|scroll)/.test(css.overflowX) &&
|
|
156
|
+
element.scrollWidth > element.clientWidth &&
|
|
157
|
+
Math.abs(bounds.bottom - rect.bottom) < 1
|
|
158
|
+
) {
|
|
159
|
+
const gutter =
|
|
160
|
+
element.offsetHeight -
|
|
161
|
+
element.clientHeight -
|
|
162
|
+
(parseFloat(css.borderTopWidth) || 0) -
|
|
163
|
+
(parseFloat(css.borderBottomWidth) || 0);
|
|
164
|
+
bottom = Math.max(bottom, gutter, fallback);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const element of observedContent) if (!next.has(element)) contentResize.unobserve(element);
|
|
169
|
+
for (const element of next) if (!observedContent.has(element)) contentResize.observe(element);
|
|
170
|
+
observedContent.clear();
|
|
171
|
+
for (const element of next) observedContent.add(element);
|
|
172
|
+
return { right, bottom };
|
|
173
|
+
};
|
|
174
|
+
const measure = () => {
|
|
175
|
+
frame = 0;
|
|
176
|
+
if (disposed || !region.isConnected) return;
|
|
177
|
+
const style = getStyle();
|
|
178
|
+
const left = style.placement === 'left';
|
|
179
|
+
const compact = style.display === 'compact';
|
|
180
|
+
region.dataset.tabDisplay = compact ? 'compact' : 'automatic';
|
|
181
|
+
header.dataset.tabDisplay = compact ? 'compact' : 'automatic';
|
|
182
|
+
const floating = style.attachment === 'floating';
|
|
183
|
+
const padding = parseFloat(
|
|
184
|
+
win.getComputedStyle(region).getPropertyValue('--layouts-panel-padding'),
|
|
185
|
+
);
|
|
186
|
+
const inset = floating ? (Number.isFinite(padding) ? Math.max(0, padding) : 8) : 0;
|
|
187
|
+
const scrollbar = floating ? scrollbarClearance() : { right: 0, bottom: 0 };
|
|
188
|
+
const insetX = inset;
|
|
189
|
+
const insetY = inset;
|
|
190
|
+
header.style.setProperty('--layouts-floating-inset-x', `${insetX}px`);
|
|
191
|
+
header.style.setProperty('--layouts-floating-inset-y', `${insetY}px`);
|
|
192
|
+
if (!floating) {
|
|
193
|
+
contentResize.disconnect();
|
|
194
|
+
observedContent.clear();
|
|
195
|
+
}
|
|
196
|
+
const tapered = floating || style.mode === 'tapered';
|
|
197
|
+
region.dataset.tabAttachment = floating ? 'floating' : 'anchored';
|
|
198
|
+
header.dataset.tabCorners = floating
|
|
199
|
+
? (style.corners ?? 'rounded')
|
|
200
|
+
: style.shape === 'rounded'
|
|
201
|
+
? 'rounded'
|
|
202
|
+
: 'fitted';
|
|
203
|
+
if (!left) header.style.removeProperty('height');
|
|
204
|
+
region.dataset.tabPlacement = left ? 'left' : 'top';
|
|
205
|
+
refreshTooltip();
|
|
206
|
+
header
|
|
207
|
+
.querySelector('[role=tablist]')
|
|
208
|
+
?.setAttribute('aria-orientation', left ? 'vertical' : 'horizontal');
|
|
209
|
+
region.dataset.tabBar = tapered ? 'tapered' : 'full';
|
|
210
|
+
header.dataset.tabBarShape = style.shape ?? 'angle';
|
|
211
|
+
header.querySelector('.layouts-tab-cap')?.remove();
|
|
212
|
+
header.querySelector('.layouts-tab-outline')?.remove();
|
|
213
|
+
const items = Array.from(header.querySelectorAll<HTMLElement>('.layouts-tab-item'));
|
|
214
|
+
header.dataset.tabBarEmpty = String(tapered && !items.length);
|
|
215
|
+
const height = header.hidden ? 0 : header.getBoundingClientRect().height;
|
|
216
|
+
// clientWidth rounds fractional split widths up, allowing chrome to spill
|
|
217
|
+
// outside the region and change the stage's scrollbar/resize geometry.
|
|
218
|
+
const available = Math.max(
|
|
219
|
+
0,
|
|
220
|
+
parseFloat(win.getComputedStyle(region).width) - insetX * 2 - scrollbar.right,
|
|
221
|
+
);
|
|
222
|
+
let occupied = available;
|
|
223
|
+
if (left) {
|
|
224
|
+
header.style.removeProperty('width');
|
|
225
|
+
for (const item of items) item.style.removeProperty('--layouts-tab-preferred-width');
|
|
226
|
+
const width = header.hidden ? 0 : header.getBoundingClientRect().width;
|
|
227
|
+
const regionHeight = Math.max(
|
|
228
|
+
0,
|
|
229
|
+
parseFloat(win.getComputedStyle(region).height) - insetY * 2 - scrollbar.bottom,
|
|
230
|
+
);
|
|
231
|
+
let occupiedHeight = regionHeight;
|
|
232
|
+
if (tapered && width) {
|
|
233
|
+
const computed = win.getComputedStyle(header);
|
|
234
|
+
const list = header.querySelector<HTMLElement>('.layouts-tabs')!;
|
|
235
|
+
const controls = Array.from(
|
|
236
|
+
header.querySelectorAll<HTMLElement>(':scope > .layouts-button'),
|
|
237
|
+
);
|
|
238
|
+
const natural =
|
|
239
|
+
items.reduce((sum, item) => sum + parseFloat(win.getComputedStyle(item).flexBasis), 0) +
|
|
240
|
+
Math.max(0, items.length - 1) * (parseFloat(win.getComputedStyle(list).rowGap) || 0) +
|
|
241
|
+
controls.reduce((sum, control) => sum + control.getBoundingClientRect().height, 0) +
|
|
242
|
+
Math.max(0, controls.length - (items.length ? 0 : 1)) *
|
|
243
|
+
(parseFloat(computed.rowGap) || 0) +
|
|
244
|
+
parseFloat(computed.paddingTop) +
|
|
245
|
+
parseFloat(computed.paddingBottom);
|
|
246
|
+
const cap =
|
|
247
|
+
floating || ['vertical', 'rounded'].includes(style.shape ?? '')
|
|
248
|
+
? 0
|
|
249
|
+
: (style.taperWidth ?? width);
|
|
250
|
+
const fits =
|
|
251
|
+
!(floating && style.fit === 'full') &&
|
|
252
|
+
natural + cap + (floating ? 0 : 12) <= regionHeight;
|
|
253
|
+
occupiedHeight = fits ? natural + cap : regionHeight;
|
|
254
|
+
header.style.height = `${fits ? natural : regionHeight}px`;
|
|
255
|
+
region.dataset.tabBarFilled = String(!fits);
|
|
256
|
+
drawChrome(natural, occupiedHeight, width, cap, fits, true, style);
|
|
257
|
+
} else {
|
|
258
|
+
header.style.removeProperty('height');
|
|
259
|
+
delete region.dataset.tabBarFilled;
|
|
260
|
+
}
|
|
261
|
+
region.style.setProperty(
|
|
262
|
+
'--layouts-tab-bar-width',
|
|
263
|
+
`${occupiedHeight ? width + insetX : 0}px`,
|
|
264
|
+
);
|
|
265
|
+
region.style.setProperty(
|
|
266
|
+
'--layouts-tab-bar-height',
|
|
267
|
+
`${width ? occupiedHeight + insetY : 0}px`,
|
|
268
|
+
);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (!tapered) {
|
|
272
|
+
header.style.removeProperty('width');
|
|
273
|
+
for (const item of items) item.style.removeProperty('--layouts-tab-preferred-width');
|
|
274
|
+
delete region.dataset.tabBarFilled;
|
|
275
|
+
} else if (!height) {
|
|
276
|
+
header.style.width = '0px';
|
|
277
|
+
occupied = 0;
|
|
278
|
+
region.dataset.tabBarFilled = 'false';
|
|
279
|
+
} else {
|
|
280
|
+
// Inline-size container queries suppress intrinsic sizing. Measure a hidden,
|
|
281
|
+
// noninteractive copy with containment disabled, including real icons/fonts.
|
|
282
|
+
const probe = header.cloneNode(true) as HTMLElement;
|
|
283
|
+
probe.classList.add('layouts-tab-measure');
|
|
284
|
+
probe.setAttribute('aria-hidden', 'true');
|
|
285
|
+
probe.inert = true;
|
|
286
|
+
probe.removeAttribute('id');
|
|
287
|
+
for (const node of probe.querySelectorAll('[id]')) node.removeAttribute('id');
|
|
288
|
+
root.append(probe);
|
|
289
|
+
try {
|
|
290
|
+
const widths = Array.from(probe.querySelectorAll<HTMLElement>('.layouts-tab-item')).map(
|
|
291
|
+
(item) => Math.ceil(item.getBoundingClientRect().width),
|
|
292
|
+
);
|
|
293
|
+
const computed = win.getComputedStyle(probe);
|
|
294
|
+
const controls = Array.from(
|
|
295
|
+
probe.querySelectorAll<HTMLElement>(':scope > .layouts-button'),
|
|
296
|
+
);
|
|
297
|
+
const tabGap =
|
|
298
|
+
parseFloat(win.getComputedStyle(probe.querySelector('.layouts-tabs')!).columnGap) || 0;
|
|
299
|
+
const natural = Math.ceil(
|
|
300
|
+
widths.reduce((sum, width) => sum + width, 0) +
|
|
301
|
+
Math.max(0, widths.length - 1) * tabGap +
|
|
302
|
+
controls.reduce((sum, control) => sum + control.getBoundingClientRect().width, 0) +
|
|
303
|
+
parseFloat(computed.paddingLeft) +
|
|
304
|
+
parseFloat(computed.paddingRight) +
|
|
305
|
+
Math.max(0, controls.length - (items.length ? 0 : 1)) *
|
|
306
|
+
(parseFloat(computed.columnGap) || 0),
|
|
307
|
+
);
|
|
308
|
+
const capWidth =
|
|
309
|
+
floating || ['vertical', 'rounded'].includes(style.shape ?? '')
|
|
310
|
+
? 0
|
|
311
|
+
: (style.taperWidth ?? height);
|
|
312
|
+
// Leave breathing room at the pane edge instead of showing a nearly
|
|
313
|
+
// touching cap. This clearance is not part of the occupied footprint.
|
|
314
|
+
const edgeClearance = floating ? 0 : 12;
|
|
315
|
+
const fits =
|
|
316
|
+
!(floating && style.fit === 'full') && natural + capWidth + edgeClearance <= available;
|
|
317
|
+
items.forEach((item, index) =>
|
|
318
|
+
item.style.setProperty('--layouts-tab-preferred-width', `${widths[index]}px`),
|
|
319
|
+
);
|
|
320
|
+
region.dataset.tabBarFilled = String(!fits);
|
|
321
|
+
header.style.width = `${fits ? natural : available}px`;
|
|
322
|
+
occupied = fits ? natural + capWidth : available;
|
|
323
|
+
drawChrome(natural, occupied, height, capWidth, fits, false, style);
|
|
324
|
+
} finally {
|
|
325
|
+
probe.remove();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
region.style.setProperty('--layouts-tab-bar-width', `${height ? occupied + insetX : 0}px`);
|
|
329
|
+
region.style.setProperty('--layouts-tab-bar-height', `${height ? height + insetY : 0}px`);
|
|
330
|
+
};
|
|
331
|
+
const schedule = () => {
|
|
332
|
+
if (!disposed && !frame) frame = win.requestAnimationFrame(measure);
|
|
333
|
+
};
|
|
334
|
+
let observedHeaderCrossSize = -1;
|
|
335
|
+
const resize = new ResizeObserver((entries) => {
|
|
336
|
+
let changed = false;
|
|
337
|
+
for (const entry of entries) {
|
|
338
|
+
if (entry.target === region) changed = true;
|
|
339
|
+
else if (
|
|
340
|
+
(getStyle().placement === 'left' ? entry.contentRect.width : entry.contentRect.height) !==
|
|
341
|
+
observedHeaderCrossSize
|
|
342
|
+
) {
|
|
343
|
+
observedHeaderCrossSize =
|
|
344
|
+
getStyle().placement === 'left' ? entry.contentRect.width : entry.contentRect.height;
|
|
345
|
+
changed = true;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Header width is our output, not a measurement input. Observing our own
|
|
349
|
+
// width writes feeds compression changes back into another measurement.
|
|
350
|
+
if (changed) schedule();
|
|
351
|
+
});
|
|
352
|
+
resize.observe(region);
|
|
353
|
+
resize.observe(header);
|
|
354
|
+
const mutation = new MutationObserver(schedule);
|
|
355
|
+
for (let node: HTMLElement | null = root; node; node = node.parentElement)
|
|
356
|
+
mutation.observe(node, { attributes: true, attributeFilter: ['style', 'class'] });
|
|
357
|
+
mutation.observe(doc.head, { subtree: true, childList: true, characterData: true });
|
|
358
|
+
const body = region.querySelector(':scope > .layouts-body');
|
|
359
|
+
const contentMutation = new MutationObserver(() => {
|
|
360
|
+
if (getStyle().attachment === 'floating') schedule();
|
|
361
|
+
});
|
|
362
|
+
if (body)
|
|
363
|
+
contentMutation.observe(body, {
|
|
364
|
+
subtree: true,
|
|
365
|
+
childList: true,
|
|
366
|
+
characterData: true,
|
|
367
|
+
attributes: true,
|
|
368
|
+
attributeFilter: ['class', 'style', 'hidden'],
|
|
369
|
+
});
|
|
370
|
+
scope.listen(doc.fonts, 'loadingdone', schedule);
|
|
371
|
+
void doc.fonts.ready.then(schedule);
|
|
372
|
+
scope.add(() => {
|
|
373
|
+
disposed = true;
|
|
374
|
+
if (frame) win.cancelAnimationFrame(frame);
|
|
375
|
+
resize.disconnect();
|
|
376
|
+
mutation.disconnect();
|
|
377
|
+
contentMutation.disconnect();
|
|
378
|
+
contentResize.disconnect();
|
|
379
|
+
observedContent.clear();
|
|
380
|
+
});
|
|
381
|
+
return schedule;
|
|
382
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Scope } from './lifetime.js';
|
|
2
|
+
|
|
3
|
+
let nextTooltip = 0;
|
|
4
|
+
/** Icon-only tab hints and empty-pane hints follow the region lifetime. */
|
|
5
|
+
export function bindTabTooltip(
|
|
6
|
+
region: HTMLElement,
|
|
7
|
+
header: HTMLElement,
|
|
8
|
+
root: HTMLElement,
|
|
9
|
+
scope: Scope,
|
|
10
|
+
) {
|
|
11
|
+
const doc = root.ownerDocument;
|
|
12
|
+
const win = doc.defaultView!;
|
|
13
|
+
let anchor: HTMLElement | undefined;
|
|
14
|
+
let tooltip: HTMLDivElement | undefined;
|
|
15
|
+
const removeTooltip = () => {
|
|
16
|
+
anchor?.removeAttribute('aria-describedby');
|
|
17
|
+
tooltip?.remove();
|
|
18
|
+
tooltip = undefined;
|
|
19
|
+
};
|
|
20
|
+
const resize = new ResizeObserver(() => refresh());
|
|
21
|
+
const hide = () => {
|
|
22
|
+
removeTooltip();
|
|
23
|
+
resize.disconnect();
|
|
24
|
+
anchor = undefined;
|
|
25
|
+
};
|
|
26
|
+
const needsTooltip = (tab: HTMLElement) => {
|
|
27
|
+
if (tab.matches('.layouts-empty'))
|
|
28
|
+
return !tab.matches(':disabled') && !region.querySelector('.layouts-picker');
|
|
29
|
+
const label = tab.querySelector<HTMLElement>('.layouts-tab-label');
|
|
30
|
+
if (!label || !label.getClientRects().length) return true;
|
|
31
|
+
const css = win.getComputedStyle(label);
|
|
32
|
+
return (
|
|
33
|
+
!label.clientWidth ||
|
|
34
|
+
!label.clientHeight ||
|
|
35
|
+
css.visibility === 'hidden' ||
|
|
36
|
+
css.opacity === '0'
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
const refresh = () => {
|
|
40
|
+
if (!anchor) return;
|
|
41
|
+
if (!anchor.isConnected || !anchor.getClientRects().length) {
|
|
42
|
+
hide();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!needsTooltip(anchor)) {
|
|
46
|
+
removeTooltip();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!tooltip) {
|
|
50
|
+
tooltip = doc.createElement('div');
|
|
51
|
+
tooltip.className = 'layouts-tab-tooltip';
|
|
52
|
+
tooltip.id = `layouts-tooltip-${++nextTooltip}`;
|
|
53
|
+
tooltip.setAttribute('role', 'tooltip');
|
|
54
|
+
anchor.setAttribute('aria-describedby', tooltip.id);
|
|
55
|
+
root.append(tooltip);
|
|
56
|
+
}
|
|
57
|
+
const empty = anchor.matches('.layouts-empty');
|
|
58
|
+
tooltip.textContent = empty ? 'Click to add a pane' : (anchor.getAttribute('aria-label') ?? '');
|
|
59
|
+
const rect = (empty ? region : anchor).getBoundingClientRect();
|
|
60
|
+
const size = tooltip.getBoundingClientRect();
|
|
61
|
+
const horizontal = !empty && region.dataset.tabPlacement !== 'left';
|
|
62
|
+
const x = empty || horizontal ? rect.left + (rect.width - size.width) / 2 : rect.right + 8;
|
|
63
|
+
const y = horizontal ? rect.bottom + 8 : rect.top + (rect.height - size.height) / 2;
|
|
64
|
+
tooltip.style.left = `${Math.max(8, Math.min(x, win.innerWidth - size.width - 8))}px`;
|
|
65
|
+
tooltip.style.top = `${Math.max(8, Math.min(y, win.innerHeight - size.height - 8))}px`;
|
|
66
|
+
};
|
|
67
|
+
const show = (event: Event) => {
|
|
68
|
+
const target = event.target as Element | null;
|
|
69
|
+
const tab = target?.closest<HTMLElement>('.layouts-tab, .layouts-empty');
|
|
70
|
+
if (!tab || !region.contains(tab)) return;
|
|
71
|
+
if (!tab.matches('.layouts-empty') && !header.contains(tab)) return;
|
|
72
|
+
if (anchor !== tab) {
|
|
73
|
+
hide();
|
|
74
|
+
anchor = tab;
|
|
75
|
+
resize.observe(tab);
|
|
76
|
+
const label = tab.querySelector('.layouts-tab-label');
|
|
77
|
+
if (label) resize.observe(label);
|
|
78
|
+
}
|
|
79
|
+
refresh();
|
|
80
|
+
};
|
|
81
|
+
scope.listen(region, 'pointerover', show);
|
|
82
|
+
scope.listen(region, 'focusin', show);
|
|
83
|
+
scope.listen(region, 'pointerout', (event) => {
|
|
84
|
+
if (!anchor?.contains((event as PointerEvent).relatedTarget as Node | null)) hide();
|
|
85
|
+
});
|
|
86
|
+
scope.listen(region, 'focusout', hide);
|
|
87
|
+
scope.listen(region, 'pointerdown', hide);
|
|
88
|
+
scope.listen(region, 'dragstart', hide);
|
|
89
|
+
scope.listen(root, 'scroll', hide, { capture: true });
|
|
90
|
+
scope.listen(win, 'resize', refresh);
|
|
91
|
+
scope.listen(doc, 'keydown', (event) => {
|
|
92
|
+
if ((event as KeyboardEvent).key === 'Escape') hide();
|
|
93
|
+
});
|
|
94
|
+
scope.add(hide);
|
|
95
|
+
return refresh;
|
|
96
|
+
}
|
package/src/tabs.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { chromeIcon } from './chrome-icons.js';
|
|
2
|
+
import type { Group, Pane } from 'quilt-core';
|
|
3
|
+
import type { LayoutOptions } from './types.js';
|
|
4
|
+
import { el } from './lifetime.js';
|
|
5
|
+
import { shortcutEnabled } from './shortcuts.js';
|
|
6
|
+
interface TabDependencies {
|
|
7
|
+
options: LayoutOptions;
|
|
8
|
+
prefix: string;
|
|
9
|
+
act: (fn: () => void) => void;
|
|
10
|
+
getDrag: () => string | undefined;
|
|
11
|
+
setDrag: (id: string | undefined) => void;
|
|
12
|
+
clearDrop: () => void;
|
|
13
|
+
focusActive: () => void;
|
|
14
|
+
}
|
|
15
|
+
/** Header nodes are disposable; pane views are owned separately by the renderer. */
|
|
16
|
+
export function fillTabs(tabs: HTMLElement, group: Group, panes: Pane[], deps: TabDependencies) {
|
|
17
|
+
const { options, prefix, act } = deps;
|
|
18
|
+
const doc = tabs.ownerDocument;
|
|
19
|
+
panes.forEach((pane, i) => {
|
|
20
|
+
const active = pane.id === group.active;
|
|
21
|
+
const item = el(doc, 'div', 'layouts-tab-item');
|
|
22
|
+
item.dataset.active = String(active);
|
|
23
|
+
const tab = el(doc, 'button', 'layouts-tab');
|
|
24
|
+
tab.type = 'button';
|
|
25
|
+
tab.setAttribute('aria-label', pane.title);
|
|
26
|
+
tab.setAttribute('role', 'tab');
|
|
27
|
+
tab.setAttribute('aria-selected', String(active));
|
|
28
|
+
tab.tabIndex = active ? 0 : -1;
|
|
29
|
+
tab.dataset.focusId = `tab-${pane.id}`;
|
|
30
|
+
tab.id = `${prefix}-tab-${pane.id}`;
|
|
31
|
+
tab.setAttribute('aria-controls', `${prefix}-panel-${pane.id}`);
|
|
32
|
+
const icon = el(doc, 'span', 'layouts-tab-icon');
|
|
33
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
34
|
+
let resolved: Element | undefined;
|
|
35
|
+
act(() => {
|
|
36
|
+
resolved = pane.icon ? options.renderIcon?.(pane.icon, doc) : undefined;
|
|
37
|
+
});
|
|
38
|
+
if (resolved) icon.append(resolved);
|
|
39
|
+
else icon.textContent = Array.from(pane.title.trim())[0] || '•';
|
|
40
|
+
tab.append(icon, el(doc, 'span', 'layouts-tab-label', pane.title));
|
|
41
|
+
tab.onclick = () => act(() => options.store.activate(group.id, pane.id));
|
|
42
|
+
tab.draggable = options.store.can(pane.id, 'move');
|
|
43
|
+
tab.ondragstart = (e) => {
|
|
44
|
+
deps.setDrag(pane.id);
|
|
45
|
+
e.dataTransfer?.setData('text/plain', pane.id);
|
|
46
|
+
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
|
47
|
+
};
|
|
48
|
+
tab.ondragend = () => {
|
|
49
|
+
deps.setDrag(undefined);
|
|
50
|
+
deps.clearDrop();
|
|
51
|
+
};
|
|
52
|
+
const vertical = () => tabs.getAttribute('aria-orientation') === 'vertical';
|
|
53
|
+
const after = (e: DragEvent) => {
|
|
54
|
+
const rect = item.getBoundingClientRect();
|
|
55
|
+
return vertical()
|
|
56
|
+
? e.clientY > rect.top + rect.height / 2
|
|
57
|
+
: e.clientX > rect.left + rect.width / 2;
|
|
58
|
+
};
|
|
59
|
+
item.ondragover = (e) => {
|
|
60
|
+
const id = deps.getDrag();
|
|
61
|
+
if (
|
|
62
|
+
!id ||
|
|
63
|
+
!options.store.can(id, 'move') ||
|
|
64
|
+
!group.panes.every((p) => options.store.can(p, 'move'))
|
|
65
|
+
)
|
|
66
|
+
return;
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
e.stopPropagation();
|
|
69
|
+
deps.clearDrop();
|
|
70
|
+
item.dataset.tabDrop = after(e) ? 'after' : 'before';
|
|
71
|
+
};
|
|
72
|
+
item.ondragleave = () => {
|
|
73
|
+
delete item.dataset.tabDrop;
|
|
74
|
+
};
|
|
75
|
+
item.ondrop = (e) => {
|
|
76
|
+
const id = deps.getDrag();
|
|
77
|
+
if (!id) return;
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
e.stopPropagation();
|
|
80
|
+
let index = i + (after(e) ? 1 : 0);
|
|
81
|
+
const source = group.panes.indexOf(id);
|
|
82
|
+
// The controller accepts an insertion index after removing the source tab.
|
|
83
|
+
if (source >= 0 && source < index) index--;
|
|
84
|
+
deps.setDrag(undefined);
|
|
85
|
+
deps.clearDrop();
|
|
86
|
+
act(() => options.store.move(id, group.id, 'tab', index, { source: 'user' }));
|
|
87
|
+
};
|
|
88
|
+
tab.onkeydown = (e) => {
|
|
89
|
+
let index = i;
|
|
90
|
+
if (e.key === (vertical() ? 'ArrowDown' : 'ArrowRight')) index = (i + 1) % panes.length;
|
|
91
|
+
else if (e.key === (vertical() ? 'ArrowUp' : 'ArrowLeft'))
|
|
92
|
+
index = (i + panes.length - 1) % panes.length;
|
|
93
|
+
else if (e.key === 'Home') index = 0;
|
|
94
|
+
else if (e.key === 'End') index = panes.length - 1;
|
|
95
|
+
else return;
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
act(() => options.store.activate(group.id, panes[index]!.id));
|
|
98
|
+
deps.focusActive();
|
|
99
|
+
};
|
|
100
|
+
const close = () =>
|
|
101
|
+
act(() => {
|
|
102
|
+
options.store.close(pane.id, { source: 'user' });
|
|
103
|
+
deps.focusActive();
|
|
104
|
+
});
|
|
105
|
+
item.append(tab);
|
|
106
|
+
if (options.store.can(pane.id, 'close')) {
|
|
107
|
+
const x = el(doc, 'button', 'layouts-button layouts-tab-close');
|
|
108
|
+
x.append(chromeIcon(doc, 'close'));
|
|
109
|
+
x.type = 'button';
|
|
110
|
+
x.title = `Close ${pane.title}`;
|
|
111
|
+
x.setAttribute('aria-label', x.title);
|
|
112
|
+
x.dataset.focusId = `close-${pane.id}`;
|
|
113
|
+
x.onclick = close;
|
|
114
|
+
item.append(x);
|
|
115
|
+
if (shortcutEnabled(options, 'middleClickClose')) {
|
|
116
|
+
item.onmousedown = (e) => {
|
|
117
|
+
if (e.button === 1) e.preventDefault();
|
|
118
|
+
};
|
|
119
|
+
item.onauxclick = (e) => {
|
|
120
|
+
if (e.button === 1) {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
close();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
tabs.append(item);
|
|
128
|
+
});
|
|
129
|
+
}
|