@zeyos/zx 2.3.0 → 3.0.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/CHANGELOG.md +87 -0
- package/DESIGN-SYSTEM.md +136 -0
- package/MIGRATION.md +155 -4
- package/README.md +24 -13
- package/dist/types/compat/map/data.d.ts +3 -2
- package/dist/types/components/account-menu/account-menu.d.ts +144 -0
- package/dist/types/components/app-rail/app-rail.d.ts +250 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +169 -0
- package/dist/types/components/avatar/avatar.d.ts +100 -0
- package/dist/types/components/chart/chart.d.ts +321 -0
- package/dist/types/components/date-picker/month-picker.d.ts +6 -0
- package/dist/types/components/dialog/dialog.d.ts +9 -0
- package/dist/types/components/dock/dock.d.ts +495 -0
- package/dist/types/components/dropdown/dropdown.d.ts +2 -2
- package/dist/types/components/field/field.d.ts +14 -0
- package/dist/types/components/field-upload/field-upload.d.ts +37 -4
- package/dist/types/components/form/form.d.ts +25 -3
- package/dist/types/components/grid/grid.d.ts +172 -0
- package/dist/types/components/launcher/launcher.d.ts +300 -0
- package/dist/types/components/layout/layout.d.ts +4 -4
- package/dist/types/components/menu-button/menu-button.d.ts +10 -2
- package/dist/types/components/modal/modal.d.ts +25 -0
- package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +7 -1
- package/dist/types/components/questionnaire/items.d.ts +327 -0
- package/dist/types/components/questionnaire/questionnaire.d.ts +449 -0
- package/dist/types/components/sheet/sheet-stack.d.ts +167 -0
- package/dist/types/components/sheet/sheet.d.ts +294 -0
- package/dist/types/components/split-view/split-view.d.ts +2 -21
- package/dist/types/components/table/table.d.ts +219 -12
- package/dist/types/components/tooltip/tooltip.d.ts +2 -2
- package/dist/types/components/value-list/value-list.d.ts +24 -3
- package/dist/types/core/dom.d.ts +114 -0
- package/dist/types/core/drag-axis.d.ts +111 -0
- package/dist/types/core/position.d.ts +4 -2
- package/dist/types/index.d.ts +12 -2
- package/dist/zx-compat.esm.js +641 -277
- package/dist/zx-compat.esm.min.js +1 -1
- package/dist/zx-compat.global.js +7609 -2236
- package/dist/zx-compat.global.min.js +3 -3
- package/dist/zx-zeyos.esm.js +546 -87
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +1452 -101
- package/dist/zx.esm.js +7952 -2592
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +7952 -2592
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +2276 -337
- package/docs/llms.md +426 -30
- package/docs/llms.txt +21 -9
- package/package.json +4 -2
- package/src/compat/globals.js +3 -1
- package/src/compat/map/data.js +31 -9
- package/src/components/account-menu/account-menu.css +70 -0
- package/src/components/account-menu/account-menu.js +208 -0
- package/src/components/app-rail/app-rail.css +107 -0
- package/src/components/app-rail/app-rail.js +668 -0
- package/src/components/app-sidebar/app-sidebar.css +68 -0
- package/src/components/app-sidebar/app-sidebar.js +388 -0
- package/src/components/avatar/avatar.css +61 -0
- package/src/components/avatar/avatar.js +222 -0
- package/src/components/button/button.css +0 -1
- package/src/components/chart/chart.css +61 -0
- package/src/components/chart/chart.js +518 -0
- package/src/components/date-picker/month-picker.js +9 -0
- package/src/components/dialog/dialog.js +11 -4
- package/src/components/dock/dock.css +228 -0
- package/src/components/dock/dock.js +977 -0
- package/src/components/dropdown/dropdown.js +1 -1
- package/src/components/field/field.js +2 -0
- package/src/components/field-upload/field-upload.js +9 -4
- package/src/components/form/form.js +6 -3
- package/src/components/grid/grid.css +6 -0
- package/src/components/grid/grid.js +213 -0
- package/src/components/launcher/launcher.css +100 -0
- package/src/components/launcher/launcher.js +557 -0
- package/src/components/layout/layout.js +2 -2
- package/src/components/menu-button/menu-button.js +27 -2
- package/src/components/modal/modal.css +5 -1
- package/src/components/modal/modal.js +37 -2
- package/src/components/multi-value-editor/multi-value-editor.js +2 -1
- package/src/components/questionnaire/items.js +370 -0
- package/src/components/questionnaire/questionnaire.css +387 -0
- package/src/components/questionnaire/questionnaire.js +1033 -0
- package/src/components/select/select.css +0 -1
- package/src/components/sheet/sheet-stack.js +304 -0
- package/src/components/sheet/sheet.css +351 -0
- package/src/components/sheet/sheet.js +551 -0
- package/src/components/split-view/split-view.js +76 -161
- package/src/components/table/table.css +43 -0
- package/src/components/table/table.js +523 -45
- package/src/components/tooltip/tooltip.js +5 -2
- package/src/components/value-list/value-list.js +6 -3
- package/src/core/dom.js +302 -0
- package/src/core/drag-axis.js +203 -0
- package/src/core/position.js +68 -22
- package/src/index.js +19 -2
- package/styles/tokens/dark.css +2 -3
- package/styles/tokens/global.css +5 -5
- package/styles/tokens/semantic.css +5 -3
- package/styles/tokens/themes.css +1 -1
- package/styles/zx.css +10 -1
- package/dist/types/components/permission/permission.d.ts +0 -96
- package/src/components/permission/permission.css +0 -47
- package/src/components/permission/permission.js +0 -201
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
import { Component } from '../../core/component.js';
|
|
2
|
+
import { STEP, STEP_LARGE, dragAxis } from '../../core/drag-axis.js';
|
|
3
|
+
import { matchBreakpoint, onBreakpoint } from '../../core/breakpoint.js';
|
|
4
|
+
import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
|
|
5
|
+
import { icon } from '../../core/icons.js';
|
|
6
|
+
import { rovingTabindex } from '../../core/keyboard.js';
|
|
7
|
+
import { storage } from '../../core/storage.js';
|
|
8
|
+
import { clamp, uid } from '../../core/util.js';
|
|
9
|
+
|
|
10
|
+
/** Storage namespace shared by every dock that opted into persistence. */
|
|
11
|
+
const STORAGE_NAMESPACE = 'dock';
|
|
12
|
+
|
|
13
|
+
/** @typedef {string|Node|Component|null} DockContentValue */
|
|
14
|
+
/** @typedef {DockContentValue|(() => DockContentValue)} DockContent */
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} DockTab
|
|
18
|
+
* @property {string} name Stable tab name.
|
|
19
|
+
* @property {string} title Visible tab label.
|
|
20
|
+
* @property {DockContent} [content] Panel content, or a factory called on first reveal.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} DockPane
|
|
25
|
+
* @property {string} name Stable pane name.
|
|
26
|
+
* @property {string} [title=''] Header title. For a tab group it names the collapse control only.
|
|
27
|
+
* @property {DockContent} [content] Body content, or a factory called on first reveal. Ignored
|
|
28
|
+
* when `tabs` is given.
|
|
29
|
+
* @property {DockTab[]} [tabs] Tabs whose strip replaces the pane's title in the header.
|
|
30
|
+
* @property {string} [active] Initially active tab name; defaults to the first tab.
|
|
31
|
+
* @property {number|string} [size] Initial size along the dock's axis. A number is pixels.
|
|
32
|
+
* @property {number} [min=0] Smallest the pane may be dragged to, in pixels. Never smaller than
|
|
33
|
+
* its own header.
|
|
34
|
+
* @property {boolean} [grow=false] Whether this pane absorbs leftover space. At most one pane
|
|
35
|
+
* does; with `content` present the content absorbs it instead.
|
|
36
|
+
* @property {boolean} [collapsed=false] Whether the pane starts collapsed to its header.
|
|
37
|
+
* @property {boolean} [collapsible=true] Whether the pane may be collapsed at all.
|
|
38
|
+
* @property {'start'|'end'} [side='start'] Which side of `content` the pane sits on. Ignored when
|
|
39
|
+
* the dock has no `content`.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} DockState
|
|
44
|
+
* @property {Record<string, number>} sizes Pane sizes in pixels, by pane name.
|
|
45
|
+
* @property {string[]} collapsed Names of collapsed panes.
|
|
46
|
+
* @property {Record<string, string>} active Active tab name, by pane name.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {Object} DockOptions
|
|
51
|
+
* @property {'vertical'|'horizontal'} [orientation='vertical'] Axis the panes stack along.
|
|
52
|
+
* `vertical` stacks them top to bottom behind horizontal dividers.
|
|
53
|
+
* @property {DockContent} [content=null] Optional flexible middle. With it the dock is a region
|
|
54
|
+
* and panes sit on either `side` of it; without it the panes fill the dock.
|
|
55
|
+
* @property {DockPane[]} [panes=[]] Panes, in order.
|
|
56
|
+
* @property {boolean} [resizable=true] Whether dividers may be dragged.
|
|
57
|
+
* @property {boolean} [collapsible=true] Default for `pane.collapsible`.
|
|
58
|
+
* @property {boolean} [lazy=true] Whether content factories run on first reveal rather than up
|
|
59
|
+
* front.
|
|
60
|
+
* @property {string|null} [storageKey=null] Key under which sizes, collapsed panes, and active
|
|
61
|
+
* tabs are remembered. Storage failures degrade to memory rather than throwing.
|
|
62
|
+
* @property {string} [label='Resize panes'] Accessible name given to every divider.
|
|
63
|
+
* @property {(event: CustomEvent<{name: string}>) => void} [oncollapse] Collapse listener.
|
|
64
|
+
* @property {(event: CustomEvent<{name: string}>) => void} [onexpand] Expand listener.
|
|
65
|
+
* @property {(event: CustomEvent<{name: string, size: number}>) => void} [onresize] Resize listener.
|
|
66
|
+
* @property {(event: CustomEvent<{pane: string, tab: string, previous: string|null}>) => void} [ontabchange] Tab listener.
|
|
67
|
+
* @property {(event: CustomEvent<{name: string}>) => void} [onreveal] Reveal listener.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A stack of collapsible, resizable panes — the docked inspector column of a design tool, and the
|
|
72
|
+
* detail side of an ERP master–detail screen.
|
|
73
|
+
*
|
|
74
|
+
* The sizing model is one custom property per pane and one flex rule: a sized pane is
|
|
75
|
+
* `flex: 0 0 var(--zx-pane-size)`, a collapsed one falls to `flex: 0 0 auto` with its body hidden,
|
|
76
|
+
* and exactly one pane (or the `content`, when there is one) grows to absorb the slack. That is
|
|
77
|
+
* what keeps a drag local — it rewrites two properties and nothing else moves — and what makes
|
|
78
|
+
* collapsing feed the grower rather than redistributing across every pane.
|
|
79
|
+
*
|
|
80
|
+
* A pane is either titled or a tab group. A tab group puts its strip where the title would go,
|
|
81
|
+
* which is the pattern every panel dock uses and the reason `tabs` is a key on a pane rather than
|
|
82
|
+
* a separate component nested inside one.
|
|
83
|
+
* @fires Dock#collapse
|
|
84
|
+
* @fires Dock#expand
|
|
85
|
+
* @fires Dock#resize
|
|
86
|
+
* @fires Dock#tabchange
|
|
87
|
+
* @fires Dock#reveal
|
|
88
|
+
* @extends {Component<DockOptions>}
|
|
89
|
+
*/
|
|
90
|
+
export class Dock extends Component {
|
|
91
|
+
static cssName = 'dock';
|
|
92
|
+
|
|
93
|
+
/** @type {Readonly<DockOptions>} */
|
|
94
|
+
static defaults = {
|
|
95
|
+
orientation: 'vertical',
|
|
96
|
+
content: null,
|
|
97
|
+
panes: [],
|
|
98
|
+
resizable: true,
|
|
99
|
+
collapsible: true,
|
|
100
|
+
lazy: true,
|
|
101
|
+
storageKey: null,
|
|
102
|
+
label: 'Resize panes'
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Creates a dock, or turns an existing element into one.
|
|
107
|
+
* @param {Element|string|null} [target=null] Existing container, selector, or null.
|
|
108
|
+
* @param {DockOptions} [options={}] Dock options.
|
|
109
|
+
*/
|
|
110
|
+
constructor(target = null, options = {}) {
|
|
111
|
+
super(target, options);
|
|
112
|
+
|
|
113
|
+
const key = this.options.storageKey;
|
|
114
|
+
if (key !== null && key !== undefined && key !== '') {
|
|
115
|
+
this._store = storage(STORAGE_NAMESPACE);
|
|
116
|
+
const stored = this._store.get(String(key));
|
|
117
|
+
if (stored && typeof stored === 'object') this.setState(stored);
|
|
118
|
+
}
|
|
119
|
+
this._syncGrow();
|
|
120
|
+
this._syncDividers();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Builds the panes, dividers, and optional content region. Runs inside the base constructor,
|
|
125
|
+
* before any class field would exist, so all state is assigned here as plain properties.
|
|
126
|
+
* @returns {HTMLElement}
|
|
127
|
+
*/
|
|
128
|
+
render() {
|
|
129
|
+
const options = /** @type {DockOptions} */ (this.options);
|
|
130
|
+
this._orientation = options.orientation === 'horizontal' ? 'horizontal' : 'vertical';
|
|
131
|
+
this._resizable = options.resizable !== false;
|
|
132
|
+
this._lazy = options.lazy !== false;
|
|
133
|
+
this._destroyed = false;
|
|
134
|
+
/** @type {Map<string, Record<string, any>>} */
|
|
135
|
+
this._panes = new Map();
|
|
136
|
+
/** @type {ReturnType<typeof storage>|null} */
|
|
137
|
+
this._store = null;
|
|
138
|
+
/** @type {Record<string, any>|null} */
|
|
139
|
+
this._drag = null;
|
|
140
|
+
/**
|
|
141
|
+
* One controller per divider. Dividers are rebuilt on every relayout, so the old controllers
|
|
142
|
+
* have to come down with them or each relayout would leave a set behind.
|
|
143
|
+
* @type {ReturnType<typeof dragAxis>[]}
|
|
144
|
+
*/
|
|
145
|
+
this._dividerDrags = [];
|
|
146
|
+
|
|
147
|
+
this._createdRoot = !this.el;
|
|
148
|
+
const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
|
|
149
|
+
this.el = root;
|
|
150
|
+
this._snapshot = this._createdRoot ? null : snapshotTarget(root);
|
|
151
|
+
root.dataset.orientation = this._orientation;
|
|
152
|
+
root.replaceChildren();
|
|
153
|
+
|
|
154
|
+
this._content = options.content == null ? null : h('div', {
|
|
155
|
+
class: 'zx-dock__content', ref: 'content'
|
|
156
|
+
});
|
|
157
|
+
if (this._content) appendContent(this._content, options.content, false);
|
|
158
|
+
|
|
159
|
+
for (const definition of Array.isArray(options.panes) ? options.panes : []) {
|
|
160
|
+
this._panes.set(String(definition.name), this._buildPane(definition));
|
|
161
|
+
}
|
|
162
|
+
this._layout();
|
|
163
|
+
return root;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Returns a pane's record.
|
|
168
|
+
* @param {string} name Pane name.
|
|
169
|
+
* @returns {Record<string, any>|null}
|
|
170
|
+
*/
|
|
171
|
+
pane(name) {
|
|
172
|
+
return this._panes.get(String(name)) ?? null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Lists pane names in visual order.
|
|
177
|
+
* @returns {string[]}
|
|
178
|
+
*/
|
|
179
|
+
names() {
|
|
180
|
+
return [...this._panes.keys()];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Adds a pane.
|
|
185
|
+
* @param {DockPane} definition Pane definition.
|
|
186
|
+
* @param {{index?: number}} [options={}] Insertion index; omitted appends.
|
|
187
|
+
* @returns {Record<string, any>}
|
|
188
|
+
*/
|
|
189
|
+
add(definition, options = {}) {
|
|
190
|
+
const name = String(definition?.name ?? '');
|
|
191
|
+
if (!name) throw new TypeError('Dock pane requires a name');
|
|
192
|
+
if (this._panes.has(name)) this.remove(name);
|
|
193
|
+
const record = this._buildPane(definition);
|
|
194
|
+
const entries = [...this._panes.entries()];
|
|
195
|
+
const index = Number.isInteger(options.index)
|
|
196
|
+
? clamp(Number(options.index), 0, entries.length)
|
|
197
|
+
: entries.length;
|
|
198
|
+
entries.splice(index, 0, [name, record]);
|
|
199
|
+
this._panes = new Map(entries);
|
|
200
|
+
this._layout();
|
|
201
|
+
this._syncGrow();
|
|
202
|
+
this._syncDividers();
|
|
203
|
+
return record;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Removes a pane and its element.
|
|
208
|
+
* @param {string} name Pane name.
|
|
209
|
+
* @returns {this}
|
|
210
|
+
*/
|
|
211
|
+
remove(name) {
|
|
212
|
+
const record = this._panes.get(String(name));
|
|
213
|
+
if (!record) return this;
|
|
214
|
+
if (record.kind === 'sheet') return this.release(record.sheet);
|
|
215
|
+
record.roving?.destroy();
|
|
216
|
+
record.element.remove();
|
|
217
|
+
this._panes.delete(String(name));
|
|
218
|
+
this._layout();
|
|
219
|
+
this._syncGrow();
|
|
220
|
+
this._syncDividers();
|
|
221
|
+
return this;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Collapses a pane to its header.
|
|
226
|
+
* @param {string} name Pane name.
|
|
227
|
+
* @returns {this}
|
|
228
|
+
* @fires Dock#collapse
|
|
229
|
+
*/
|
|
230
|
+
collapse(name) {
|
|
231
|
+
const record = this._panes.get(String(name));
|
|
232
|
+
if (!record || !record.collapsible || record.collapsed) return this;
|
|
233
|
+
record.collapsed = true;
|
|
234
|
+
this._syncPaneState(record);
|
|
235
|
+
this._syncGrow();
|
|
236
|
+
this._syncDividers();
|
|
237
|
+
this._persist();
|
|
238
|
+
this.emit('collapse', { name: record.name });
|
|
239
|
+
return this;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Expands a pane.
|
|
244
|
+
* @param {string} name Pane name.
|
|
245
|
+
* @returns {this}
|
|
246
|
+
* @fires Dock#expand
|
|
247
|
+
*/
|
|
248
|
+
expand(name) {
|
|
249
|
+
const record = this._panes.get(String(name));
|
|
250
|
+
if (!record || !record.collapsed) return this;
|
|
251
|
+
record.collapsed = false;
|
|
252
|
+
this._syncPaneState(record);
|
|
253
|
+
this._materialize(record);
|
|
254
|
+
this._syncGrow();
|
|
255
|
+
this._syncDividers();
|
|
256
|
+
this._persist();
|
|
257
|
+
this.emit('expand', { name: record.name });
|
|
258
|
+
return this;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Toggles a pane's collapsed state.
|
|
263
|
+
* @param {string} name Pane name.
|
|
264
|
+
* @returns {this}
|
|
265
|
+
*/
|
|
266
|
+
toggle(name) {
|
|
267
|
+
const record = this._panes.get(String(name));
|
|
268
|
+
if (!record) return this;
|
|
269
|
+
return record.collapsed ? this.expand(name) : this.collapse(name);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Reports whether a pane is collapsed.
|
|
274
|
+
* @param {string} name Pane name.
|
|
275
|
+
* @returns {boolean}
|
|
276
|
+
*/
|
|
277
|
+
isCollapsed(name) {
|
|
278
|
+
return Boolean(this._panes.get(String(name))?.collapsed);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Brings something into view by name — a pane, or a tab in one of them. Expands the pane,
|
|
283
|
+
* activates the tab where the name is a tab's, and scrolls the pane into view.
|
|
284
|
+
* @param {string} name Pane or tab name.
|
|
285
|
+
* @returns {this}
|
|
286
|
+
* @fires Dock#reveal
|
|
287
|
+
*/
|
|
288
|
+
reveal(name) {
|
|
289
|
+
const key = String(name);
|
|
290
|
+
let record = this._panes.get(key);
|
|
291
|
+
if (record) this.expand(key);
|
|
292
|
+
else {
|
|
293
|
+
for (const candidate of this._panes.values()) {
|
|
294
|
+
if (!candidate.tabs?.has(key)) continue;
|
|
295
|
+
record = candidate;
|
|
296
|
+
this.expand(candidate.name);
|
|
297
|
+
this.activate(candidate.name, key);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (!record) return this;
|
|
302
|
+
record.element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
303
|
+
this.emit('reveal', { name: record.name });
|
|
304
|
+
return this;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Sets a pane's size along the dock's axis.
|
|
309
|
+
* @param {string} name Pane name.
|
|
310
|
+
* @param {number|string} size Pixels, or any CSS length.
|
|
311
|
+
* @returns {this}
|
|
312
|
+
*/
|
|
313
|
+
setSize(name, size) {
|
|
314
|
+
const record = this._panes.get(String(name));
|
|
315
|
+
if (!record) return this;
|
|
316
|
+
const value = normalizeSize(size);
|
|
317
|
+
if (value === null) record.element.style.removeProperty('--zx-pane-size');
|
|
318
|
+
else record.element.style.setProperty('--zx-pane-size', value);
|
|
319
|
+
this._syncDividers();
|
|
320
|
+
this._persist();
|
|
321
|
+
return this;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Returns a pane's current size in pixels.
|
|
326
|
+
* @param {string} name Pane name.
|
|
327
|
+
* @returns {number}
|
|
328
|
+
*/
|
|
329
|
+
getSize(name) {
|
|
330
|
+
const record = this._panes.get(String(name));
|
|
331
|
+
return record ? this._axisSize(record.element) : 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Activates a tab inside a tab-group pane.
|
|
336
|
+
* @param {string} paneName Pane name.
|
|
337
|
+
* @param {string} tabName Tab name.
|
|
338
|
+
* @returns {this}
|
|
339
|
+
* @fires Dock#tabchange
|
|
340
|
+
*/
|
|
341
|
+
activate(paneName, tabName) {
|
|
342
|
+
const record = this._panes.get(String(paneName));
|
|
343
|
+
const tab = record?.tabs?.get(String(tabName));
|
|
344
|
+
if (!tab || record.active === tab.name) return this;
|
|
345
|
+
const previous = record.active;
|
|
346
|
+
record.active = tab.name;
|
|
347
|
+
for (const candidate of record.tabs.values()) {
|
|
348
|
+
const selected = candidate === tab;
|
|
349
|
+
candidate.tab.setAttribute('aria-selected', String(selected));
|
|
350
|
+
candidate.tab.tabIndex = selected ? 0 : -1;
|
|
351
|
+
candidate.panel.hidden = !selected;
|
|
352
|
+
}
|
|
353
|
+
this._materialize(record);
|
|
354
|
+
this._persist();
|
|
355
|
+
this.emit('tabchange', { pane: record.name, tab: tab.name, previous });
|
|
356
|
+
return this;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Returns the active tab name of a tab-group pane.
|
|
361
|
+
* @param {string} paneName Pane name.
|
|
362
|
+
* @returns {string|null}
|
|
363
|
+
*/
|
|
364
|
+
getActive(paneName) {
|
|
365
|
+
return this._panes.get(String(paneName))?.active ?? null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Captures the layout as a serialisable object.
|
|
370
|
+
* @returns {DockState}
|
|
371
|
+
*/
|
|
372
|
+
state() {
|
|
373
|
+
/** @type {DockState} */
|
|
374
|
+
const state = { sizes: {}, collapsed: [], active: {} };
|
|
375
|
+
for (const record of this._panes.values()) {
|
|
376
|
+
if (record.kind === 'sheet') continue;
|
|
377
|
+
if (record.collapsed) state.collapsed.push(record.name);
|
|
378
|
+
else if (!record.grow) {
|
|
379
|
+
const size = this._axisSize(record.element);
|
|
380
|
+
if (size > 0) state.sizes[record.name] = size;
|
|
381
|
+
}
|
|
382
|
+
if (record.active) state.active[record.name] = record.active;
|
|
383
|
+
}
|
|
384
|
+
return state;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Restores a layout captured by `state()`. Unknown pane names are ignored, so a stored layout
|
|
389
|
+
* survives a release that added or removed panes.
|
|
390
|
+
* @param {DockState} state Layout state.
|
|
391
|
+
* @returns {this}
|
|
392
|
+
*/
|
|
393
|
+
setState(state) {
|
|
394
|
+
if (!state || typeof state !== 'object') return this;
|
|
395
|
+
const collapsed = new Set(Array.isArray(state.collapsed) ? state.collapsed.map(String) : []);
|
|
396
|
+
for (const record of this._panes.values()) {
|
|
397
|
+
if (record.kind === 'sheet') continue;
|
|
398
|
+
const size = state.sizes?.[record.name];
|
|
399
|
+
if (typeof size === 'number' && Number.isFinite(size) && size > 0) {
|
|
400
|
+
record.element.style.setProperty('--zx-pane-size', `${size}px`);
|
|
401
|
+
}
|
|
402
|
+
const active = state.active?.[record.name];
|
|
403
|
+
if (active && record.tabs?.has(String(active))) this.activate(record.name, String(active));
|
|
404
|
+
const shouldCollapse = collapsed.has(record.name);
|
|
405
|
+
if (shouldCollapse !== record.collapsed && record.collapsible) {
|
|
406
|
+
record.collapsed = shouldCollapse;
|
|
407
|
+
this._syncPaneState(record);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
this._syncGrow();
|
|
411
|
+
this._syncDividers();
|
|
412
|
+
return this;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Removes created nodes and restores an enhanced target.
|
|
417
|
+
* @returns {void}
|
|
418
|
+
*/
|
|
419
|
+
destroy() {
|
|
420
|
+
if (this._destroyed) return;
|
|
421
|
+
this._destroyed = true;
|
|
422
|
+
for (const controller of this._dividerDrags) controller.destroy();
|
|
423
|
+
this._dividerDrags = [];
|
|
424
|
+
for (const record of this._panes.values()) {
|
|
425
|
+
record.roving?.destroy();
|
|
426
|
+
record.breakpoint?.destroy();
|
|
427
|
+
// The dock never owned the sheet, so it hands it back rather than taking it down with it.
|
|
428
|
+
if (record.kind === 'sheet') record.sheet._setDock(null);
|
|
429
|
+
}
|
|
430
|
+
super.destroy();
|
|
431
|
+
if (!this._createdRoot && this._snapshot) restoreTarget(this.el, this._snapshot);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Builds one pane, titled or tabbed.
|
|
436
|
+
* @param {DockPane} definition Pane definition.
|
|
437
|
+
* @returns {Record<string, any>}
|
|
438
|
+
*/
|
|
439
|
+
_buildPane(definition) {
|
|
440
|
+
const name = String(definition.name);
|
|
441
|
+
const title = String(definition.title ?? '');
|
|
442
|
+
const collapsible = definition.collapsible ?? this.options.collapsible !== false;
|
|
443
|
+
const bodyId = uid('zx-dock-body');
|
|
444
|
+
const hasTabs = Array.isArray(definition.tabs) && definition.tabs.length > 0;
|
|
445
|
+
|
|
446
|
+
const chevron = h('span', { class: 'zx-dock__chevron', ariaHidden: 'true' },
|
|
447
|
+
icon('chevron-right', { size: 14 }));
|
|
448
|
+
const toggle = h('button', {
|
|
449
|
+
class: 'zx-dock__toggle',
|
|
450
|
+
type: 'button',
|
|
451
|
+
ariaControls: bodyId,
|
|
452
|
+
ariaExpanded: 'true',
|
|
453
|
+
// A tabbed pane's visible text belongs to its tabs, so the control names itself.
|
|
454
|
+
ariaLabel: hasTabs ? (title || name) : null
|
|
455
|
+
}, chevron, hasTabs ? null : h('span', { class: 'zx-dock__title' }, title));
|
|
456
|
+
if (!collapsible) toggle.disabled = true;
|
|
457
|
+
|
|
458
|
+
const header = h('div', { class: 'zx-dock__header' }, toggle);
|
|
459
|
+
const body = h('div', { class: 'zx-dock__body', id: bodyId });
|
|
460
|
+
const element = h('section', {
|
|
461
|
+
class: 'zx-dock__pane',
|
|
462
|
+
dataset: { name, state: 'open' }
|
|
463
|
+
}, header, body);
|
|
464
|
+
|
|
465
|
+
const record = {
|
|
466
|
+
name,
|
|
467
|
+
title,
|
|
468
|
+
element,
|
|
469
|
+
header,
|
|
470
|
+
body,
|
|
471
|
+
toggle,
|
|
472
|
+
collapsible,
|
|
473
|
+
collapsed: false,
|
|
474
|
+
grow: Boolean(definition.grow),
|
|
475
|
+
side: definition.side === 'end' ? 'end' : 'start',
|
|
476
|
+
min: Number.isFinite(definition.min) ? Math.max(0, Number(definition.min)) : 0,
|
|
477
|
+
/** @type {Map<string, Record<string, any>>|null} */
|
|
478
|
+
tabs: null,
|
|
479
|
+
/** @type {string|null} */
|
|
480
|
+
active: null,
|
|
481
|
+
content: null,
|
|
482
|
+
built: false,
|
|
483
|
+
roving: null
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
if (hasTabs) {
|
|
487
|
+
record.tabs = new Map();
|
|
488
|
+
const list = h('div', {
|
|
489
|
+
class: 'zx-dock__tabs',
|
|
490
|
+
role: 'tablist',
|
|
491
|
+
ariaOrientation: 'horizontal',
|
|
492
|
+
ariaLabel: title || name
|
|
493
|
+
});
|
|
494
|
+
for (const tabDefinition of definition.tabs) {
|
|
495
|
+
const tabName = String(tabDefinition.name);
|
|
496
|
+
const tabId = uid('zx-dock-tab');
|
|
497
|
+
const panelId = uid('zx-dock-panel');
|
|
498
|
+
const tab = h('button', {
|
|
499
|
+
class: 'zx-dock__tab',
|
|
500
|
+
type: 'button',
|
|
501
|
+
role: 'tab',
|
|
502
|
+
id: tabId,
|
|
503
|
+
ariaControls: panelId,
|
|
504
|
+
ariaSelected: 'false',
|
|
505
|
+
tabindex: -1
|
|
506
|
+
}, String(tabDefinition.title ?? tabName));
|
|
507
|
+
const panel = h('div', {
|
|
508
|
+
class: 'zx-dock__panel',
|
|
509
|
+
role: 'tabpanel',
|
|
510
|
+
id: panelId,
|
|
511
|
+
ariaLabelledby: tabId,
|
|
512
|
+
tabindex: 0,
|
|
513
|
+
hidden: true
|
|
514
|
+
});
|
|
515
|
+
list.append(tab);
|
|
516
|
+
body.append(panel);
|
|
517
|
+
record.tabs.set(tabName, { name: tabName, tab, panel, content: tabDefinition.content, built: false });
|
|
518
|
+
}
|
|
519
|
+
header.append(list);
|
|
520
|
+
record.roving = rovingTabindex(list, '.zx-dock__tab', { orientation: 'horizontal' });
|
|
521
|
+
this.listen(list, 'click', (event) => {
|
|
522
|
+
const button = /** @type {Element} */ (event.target).closest?.('.zx-dock__tab');
|
|
523
|
+
if (!button || !list.contains(button)) return;
|
|
524
|
+
const found = [...record.tabs.values()].find((candidate) => candidate.tab === button);
|
|
525
|
+
if (found) this.activate(record.name, found.name);
|
|
526
|
+
});
|
|
527
|
+
const first = String(definition.active ?? '');
|
|
528
|
+
const initial = record.tabs.has(first) ? first : [...record.tabs.keys()][0];
|
|
529
|
+
// Assigned directly rather than through activate(), which would emit before construction ends.
|
|
530
|
+
record.active = initial;
|
|
531
|
+
for (const candidate of record.tabs.values()) {
|
|
532
|
+
const selected = candidate.name === initial;
|
|
533
|
+
candidate.tab.setAttribute('aria-selected', String(selected));
|
|
534
|
+
candidate.tab.tabIndex = selected ? 0 : -1;
|
|
535
|
+
candidate.panel.hidden = !selected;
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
record.content = h('div', { class: 'zx-dock__pane-content' });
|
|
539
|
+
body.append(record.content);
|
|
540
|
+
record.factory = definition.content;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const size = normalizeSize(definition.size);
|
|
544
|
+
if (size !== null) element.style.setProperty('--zx-pane-size', size);
|
|
545
|
+
if (collapsible) this.listen(toggle, 'click', () => this.toggle(record.name));
|
|
546
|
+
if (definition.collapsed && collapsible) {
|
|
547
|
+
record.collapsed = true;
|
|
548
|
+
this._syncPaneState(record);
|
|
549
|
+
}
|
|
550
|
+
if (!this._lazy || !record.collapsed) this._materialize(record);
|
|
551
|
+
return record;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Runs any not-yet-run content factory for whatever the pane currently shows.
|
|
556
|
+
* @param {Record<string, any>} record Pane record.
|
|
557
|
+
* @returns {void}
|
|
558
|
+
*/
|
|
559
|
+
_materialize(record) {
|
|
560
|
+
if (record.collapsed) return;
|
|
561
|
+
if (record.tabs) {
|
|
562
|
+
const active = record.tabs.get(record.active);
|
|
563
|
+
if (!active || active.built) return;
|
|
564
|
+
active.built = true;
|
|
565
|
+
appendContent(active.panel, active.content, true);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (record.built) return;
|
|
569
|
+
record.built = true;
|
|
570
|
+
appendContent(record.content, record.factory, true);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Re-inserts panes, dividers, and the content region in visual order.
|
|
575
|
+
* @returns {void}
|
|
576
|
+
*/
|
|
577
|
+
_layout() {
|
|
578
|
+
const records = [...this._panes.values()]
|
|
579
|
+
// A sheet the breakpoint floated back to an overlay is still registered, but it is the
|
|
580
|
+
// document's child now and must not claim a track.
|
|
581
|
+
.filter((record) => record.kind !== 'sheet' || record.sheet.isDocked());
|
|
582
|
+
const order = this._content
|
|
583
|
+
? [...records.filter((record) => record.side === 'start'), this._content,
|
|
584
|
+
...records.filter((record) => record.side === 'end')]
|
|
585
|
+
: records;
|
|
586
|
+
for (const controller of this._dividerDrags) controller.destroy();
|
|
587
|
+
this._dividerDrags = [];
|
|
588
|
+
/** @type {Node[]} */
|
|
589
|
+
const children = [];
|
|
590
|
+
order.forEach((entry, index) => {
|
|
591
|
+
if (index > 0) children.push(this._divider());
|
|
592
|
+
children.push(entry instanceof Element ? entry : entry.element);
|
|
593
|
+
});
|
|
594
|
+
/** @type {HTMLElement} */ (this.el).replaceChildren(...children);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Creates one divider with its separator semantics and drag wiring.
|
|
599
|
+
* @returns {HTMLElement}
|
|
600
|
+
*/
|
|
601
|
+
_divider() {
|
|
602
|
+
const divider = h('div', {
|
|
603
|
+
class: 'zx-dock__divider',
|
|
604
|
+
role: 'separator',
|
|
605
|
+
tabindex: this._resizable ? 0 : -1,
|
|
606
|
+
// A separator's aria-orientation describes the separator, not the stack: panes stacked
|
|
607
|
+
// vertically are parted by horizontal lines.
|
|
608
|
+
ariaOrientation: this._orientation === 'vertical' ? 'horizontal' : 'vertical',
|
|
609
|
+
ariaLabel: String(this.options.label ?? 'Resize panes')
|
|
610
|
+
});
|
|
611
|
+
this._dividerDrags.push(dragAxis(divider, {
|
|
612
|
+
orientation: this._orientation,
|
|
613
|
+
disabled: () => !this._resizable,
|
|
614
|
+
onStart: () => {
|
|
615
|
+
const { before, after } = this._neighbours(divider);
|
|
616
|
+
const subject = this._resizeSubject(before, after);
|
|
617
|
+
if (!subject) return false;
|
|
618
|
+
this._drag = {
|
|
619
|
+
subject,
|
|
620
|
+
startSize: this._axisSize(subject.element),
|
|
621
|
+
oppositeStart: subject.opposite ? this._axisSize(subject.opposite.element) : 0
|
|
622
|
+
};
|
|
623
|
+
/** @type {HTMLElement} */ (this.el).dataset.dragging = 'true';
|
|
624
|
+
return true;
|
|
625
|
+
},
|
|
626
|
+
onMove: (delta) => this._trackDelta(delta),
|
|
627
|
+
onEnd: (delta, moved) => {
|
|
628
|
+
if (moved) this._trackDelta(delta);
|
|
629
|
+
this._drag = null;
|
|
630
|
+
delete /** @type {HTMLElement} */ (this.el).dataset.dragging;
|
|
631
|
+
if (moved) this._persist();
|
|
632
|
+
},
|
|
633
|
+
onStep: (direction, large) => this._stepDivider(divider, direction * (large ? STEP_LARGE : STEP)),
|
|
634
|
+
onActivate: () => {
|
|
635
|
+
const { before } = this._neighbours(divider);
|
|
636
|
+
if (before?.collapsible) this.toggle(before.name);
|
|
637
|
+
}
|
|
638
|
+
}));
|
|
639
|
+
return divider;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Whether a record currently occupies no usable space: a collapsed pane, or a closed sheet.
|
|
644
|
+
* A closed sheet differs from a collapsed pane in that it leaves nothing at all behind, not
|
|
645
|
+
* even a header — which is why the two cannot share one flag.
|
|
646
|
+
* @param {Record<string, any>} record Pane or sheet record.
|
|
647
|
+
* @returns {boolean}
|
|
648
|
+
*/
|
|
649
|
+
_isHidden(record) {
|
|
650
|
+
return record.kind === 'sheet' ? !record.sheet.isOpen() : Boolean(record.collapsed);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Hands a Sheet's positioning to this dock. The sheet becomes a track in the dock's flow: the
|
|
655
|
+
* dock owns its side, size, and resizing, while the sheet keeps its own content and lifecycle.
|
|
656
|
+
*
|
|
657
|
+
* Nothing about the sheet is rebuilt — adoption moves the element and reopens it in place, so
|
|
658
|
+
* listeners and DOM state survive the handoff.
|
|
659
|
+
* @param {Object} sheet Sheet to adopt.
|
|
660
|
+
* @param {{name?: string, side?: 'start'|'end', index?: number, size?: number|string,
|
|
661
|
+
* min?: number, dockAt?: 'sm'|'md'|'lg'|'xl'|null}} [options={}] Placement options.
|
|
662
|
+
* `dockAt` floats the sheet back to a free overlay below that breakpoint, measured on the
|
|
663
|
+
* dock's own width.
|
|
664
|
+
* @returns {Record<string, any>}
|
|
665
|
+
*/
|
|
666
|
+
adopt(sheet, options = {}) {
|
|
667
|
+
if (!sheet || typeof sheet._setDock !== 'function' || !sheet.el) {
|
|
668
|
+
throw new TypeError('Dock.adopt expects a Sheet');
|
|
669
|
+
}
|
|
670
|
+
const name = String(options.name ?? sheet.el.id ?? '') || uid('zx-dock-sheet');
|
|
671
|
+
const record = {
|
|
672
|
+
kind: 'sheet',
|
|
673
|
+
name,
|
|
674
|
+
sheet,
|
|
675
|
+
element: sheet.el,
|
|
676
|
+
side: options.side === 'end' ? 'end' : 'start',
|
|
677
|
+
min: Number.isFinite(options.min) ? Math.max(0, Number(options.min)) : 0,
|
|
678
|
+
grow: false,
|
|
679
|
+
collapsed: false,
|
|
680
|
+
collapsible: false,
|
|
681
|
+
breakpoint: null,
|
|
682
|
+
resync: null
|
|
683
|
+
};
|
|
684
|
+
const entries = [...this._panes.entries()];
|
|
685
|
+
const index = Number.isInteger(options.index)
|
|
686
|
+
? clamp(Number(options.index), 0, entries.length)
|
|
687
|
+
: entries.length;
|
|
688
|
+
entries.splice(index, 0, [name, record]);
|
|
689
|
+
this._panes = new Map(entries);
|
|
690
|
+
|
|
691
|
+
const size = normalizeSize(options.size);
|
|
692
|
+
if (size !== null) sheet.el.style.setProperty('--zx-pane-size', size);
|
|
693
|
+
|
|
694
|
+
// A docked sheet opening or closing changes what the stack has to absorb.
|
|
695
|
+
record.resync = () => {
|
|
696
|
+
this._syncGrow();
|
|
697
|
+
this._syncDividers();
|
|
698
|
+
};
|
|
699
|
+
sheet.on('open', record.resync);
|
|
700
|
+
sheet.on('close', record.resync);
|
|
701
|
+
|
|
702
|
+
const dockAt = options.dockAt ?? null;
|
|
703
|
+
if (dockAt) {
|
|
704
|
+
/*
|
|
705
|
+
* Measured on the dock itself rather than the viewport: a dock inside a split pane knows
|
|
706
|
+
* nothing about the window, and docking does not change the dock's own width, so there is
|
|
707
|
+
* no feedback loop between the observer and what it triggers.
|
|
708
|
+
*/
|
|
709
|
+
record.breakpoint = onBreakpoint((_name, width) => {
|
|
710
|
+
this._applyDockAt(record, matchBreakpoint(dockAt, width));
|
|
711
|
+
}, { target: this.el });
|
|
712
|
+
} else {
|
|
713
|
+
this._applyDockAt(record, true);
|
|
714
|
+
}
|
|
715
|
+
return record;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Hands a sheet back. It becomes a free overlay again, still open if it was open.
|
|
720
|
+
* @param {Object} sheet Sheet to release.
|
|
721
|
+
* @returns {this}
|
|
722
|
+
*/
|
|
723
|
+
release(sheet) {
|
|
724
|
+
const record = [...this._panes.values()].find((candidate) => candidate.sheet === sheet);
|
|
725
|
+
if (!record) return this;
|
|
726
|
+
record.breakpoint?.destroy();
|
|
727
|
+
record.breakpoint = null;
|
|
728
|
+
this._panes.delete(record.name);
|
|
729
|
+
sheet.off('open', record.resync);
|
|
730
|
+
sheet.off('close', record.resync);
|
|
731
|
+
sheet.el.style.removeProperty('--zx-pane-size');
|
|
732
|
+
sheet._setDock(null);
|
|
733
|
+
this._layout();
|
|
734
|
+
this._syncGrow();
|
|
735
|
+
this._syncDividers();
|
|
736
|
+
return this;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Drops a sheet that destroyed itself while adopted, without re-hosting a dead element.
|
|
741
|
+
* @param {Object} sheet Sheet being destroyed.
|
|
742
|
+
* @returns {void}
|
|
743
|
+
*/
|
|
744
|
+
_forget(sheet) {
|
|
745
|
+
const record = [...this._panes.values()].find((candidate) => candidate.sheet === sheet);
|
|
746
|
+
if (!record) return;
|
|
747
|
+
record.breakpoint?.destroy();
|
|
748
|
+
this._panes.delete(record.name);
|
|
749
|
+
if (this._destroyed) return;
|
|
750
|
+
this._layout();
|
|
751
|
+
this._syncGrow();
|
|
752
|
+
this._syncDividers();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Docks or floats one adopted sheet, per its breakpoint.
|
|
757
|
+
* @param {Record<string, any>} record Sheet record.
|
|
758
|
+
* @param {boolean} docked Whether the dock is wide enough to hold it.
|
|
759
|
+
* @returns {void}
|
|
760
|
+
*/
|
|
761
|
+
_applyDockAt(record, docked) {
|
|
762
|
+
if (record.sheet.isDocked() === docked) return;
|
|
763
|
+
record.sheet._setDock(docked ? this : null);
|
|
764
|
+
this._layout();
|
|
765
|
+
this._syncGrow();
|
|
766
|
+
this._syncDividers();
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Picks which element absorbs leftover space. The content always wins where there is one;
|
|
771
|
+
* otherwise the declared `grow` pane, or the last expanded pane so the stack still fills.
|
|
772
|
+
* @returns {void}
|
|
773
|
+
*/
|
|
774
|
+
_syncGrow() {
|
|
775
|
+
const records = [...this._panes.values()];
|
|
776
|
+
for (const record of records) record.element.removeAttribute('data-grow');
|
|
777
|
+
if (this._content) return;
|
|
778
|
+
const expanded = records.filter((record) => !this._isHidden(record));
|
|
779
|
+
if (expanded.length === 0) return;
|
|
780
|
+
const declared = expanded.find((record) => record.grow);
|
|
781
|
+
(declared ?? expanded[expanded.length - 1]).element.dataset.grow = 'true';
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Refreshes every divider's live separator values and disables the ones with nothing to resize.
|
|
786
|
+
* @returns {void}
|
|
787
|
+
*/
|
|
788
|
+
_syncDividers() {
|
|
789
|
+
for (const divider of this._dividers()) {
|
|
790
|
+
const { before, after } = this._neighbours(divider);
|
|
791
|
+
const subject = this._resizeSubject(before, after);
|
|
792
|
+
const enabled = this._resizable && subject !== null;
|
|
793
|
+
divider.toggleAttribute('data-disabled', !enabled);
|
|
794
|
+
divider.tabIndex = enabled ? 0 : -1;
|
|
795
|
+
if (!enabled) {
|
|
796
|
+
divider.removeAttribute('aria-valuenow');
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
const total = this._axisSize(/** @type {HTMLElement} */ (this.el));
|
|
800
|
+
divider.setAttribute('aria-valuenow', String(Math.round(this._axisSize(subject.element))));
|
|
801
|
+
divider.setAttribute('aria-valuemin', String(Math.round(this._minSize(subject))));
|
|
802
|
+
divider.setAttribute('aria-valuemax', String(Math.round(total)));
|
|
803
|
+
divider.setAttribute('aria-controls', subject.element.id || setId(subject.element));
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Applies the collapsed flag to the DOM.
|
|
809
|
+
* @param {Record<string, any>} record Pane record.
|
|
810
|
+
* @returns {void}
|
|
811
|
+
*/
|
|
812
|
+
_syncPaneState(record) {
|
|
813
|
+
record.element.dataset.state = record.collapsed ? 'collapsed' : 'open';
|
|
814
|
+
record.toggle.setAttribute('aria-expanded', String(!record.collapsed));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** @returns {HTMLElement[]} */
|
|
818
|
+
_dividers() {
|
|
819
|
+
return [.../** @type {HTMLElement} */ (this.el).querySelectorAll(':scope > .zx-dock__divider')];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* @param {HTMLElement} divider
|
|
824
|
+
* @returns {{before: Record<string, any>|null, after: Record<string, any>|null}}
|
|
825
|
+
*/
|
|
826
|
+
_neighbours(divider) {
|
|
827
|
+
const find = (/** @type {Element|null} */ element) => {
|
|
828
|
+
if (!element) return null;
|
|
829
|
+
for (const record of this._panes.values()) if (record.element === element) return record;
|
|
830
|
+
return null;
|
|
831
|
+
};
|
|
832
|
+
return { before: find(divider.previousElementSibling), after: find(divider.nextElementSibling) };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Decides which pane a divider actually resizes. A pane that grows or is collapsed is not a
|
|
837
|
+
* candidate, and neither is the content region — dragging beside those resizes the other side.
|
|
838
|
+
* @param {Record<string, any>|null} before
|
|
839
|
+
* @param {Record<string, any>|null} after
|
|
840
|
+
* @returns {{element: HTMLElement, record: Record<string, any>, sign: number, opposite: Record<string, any>|null}|null}
|
|
841
|
+
*/
|
|
842
|
+
_resizeSubject(before, after) {
|
|
843
|
+
const sizable = (/** @type {Record<string, any>|null} */ record) =>
|
|
844
|
+
Boolean(record) && !this._isHidden(record) && record.element.dataset.grow !== 'true';
|
|
845
|
+
if (sizable(before)) {
|
|
846
|
+
return { element: before.element, record: before, sign: 1, opposite: sizable(after) ? after : null };
|
|
847
|
+
}
|
|
848
|
+
if (sizable(after)) return { element: after.element, record: after, sign: -1, opposite: null };
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** @param {Record<string, any>} subject @returns {number} */
|
|
853
|
+
_minSize(subject) {
|
|
854
|
+
const element = subject.record.header
|
|
855
|
+
?? subject.record.element.querySelector('.zx-dialog__header')
|
|
856
|
+
?? subject.record.element;
|
|
857
|
+
const header = element.getBoundingClientRect();
|
|
858
|
+
const headerSize = this._orientation === 'vertical' ? header.height : header.width;
|
|
859
|
+
return Math.max(subject.record.min, headerSize);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** @param {Element} element @returns {number} */
|
|
863
|
+
_axisSize(element) {
|
|
864
|
+
const rect = element.getBoundingClientRect();
|
|
865
|
+
return this._orientation === 'vertical' ? rect.height : rect.width;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Applies a pointer delta to the pane the divider resizes, and to its neighbour where the pair
|
|
870
|
+
* shares a fixed budget.
|
|
871
|
+
* @param {number} delta Pixels travelled along the axis since the drag began.
|
|
872
|
+
* @returns {void}
|
|
873
|
+
*/
|
|
874
|
+
_trackDelta(delta) {
|
|
875
|
+
if (!this._drag || this._destroyed) return;
|
|
876
|
+
const { subject, startSize, oppositeStart } = this._drag;
|
|
877
|
+
const min = this._minSize(subject);
|
|
878
|
+
let next = Math.max(min, startSize + delta * subject.sign);
|
|
879
|
+
/*
|
|
880
|
+
* With a sizable pane on both sides the pair shares a fixed budget, so the neighbour's own
|
|
881
|
+
* minimum caps how far this one may grow — otherwise the stack would push past its own end.
|
|
882
|
+
*/
|
|
883
|
+
if (subject.opposite) {
|
|
884
|
+
const oppositeMin = this._minSize({ record: subject.opposite, element: subject.opposite.element });
|
|
885
|
+
next = Math.max(min, Math.min(next, startSize + oppositeStart - oppositeMin));
|
|
886
|
+
subject.opposite.element.style.setProperty('--zx-pane-size', `${startSize + oppositeStart - next}px`);
|
|
887
|
+
}
|
|
888
|
+
subject.element.style.setProperty('--zx-pane-size', `${next}px`);
|
|
889
|
+
this._syncDividers();
|
|
890
|
+
this.emit('resize', { name: subject.record.name, size: next });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Moves a divider by a keyboard step.
|
|
895
|
+
* @param {HTMLElement} divider Divider being driven.
|
|
896
|
+
* @param {number} amount Signed pixels.
|
|
897
|
+
* @returns {void}
|
|
898
|
+
*/
|
|
899
|
+
_stepDivider(divider, amount) {
|
|
900
|
+
const { before, after } = this._neighbours(divider);
|
|
901
|
+
const subject = this._resizeSubject(before, after);
|
|
902
|
+
if (!subject) return;
|
|
903
|
+
const next = Math.max(this._minSize(subject), this._axisSize(subject.element) + amount * subject.sign);
|
|
904
|
+
subject.element.style.setProperty('--zx-pane-size', `${next}px`);
|
|
905
|
+
this._syncDividers();
|
|
906
|
+
this._persist();
|
|
907
|
+
this.emit('resize', { name: subject.record.name, size: next });
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** @returns {void} */
|
|
911
|
+
_persist() {
|
|
912
|
+
const key = this.options.storageKey;
|
|
913
|
+
if (!this._store || key === null || key === undefined || key === '') return;
|
|
914
|
+
this._store.set(String(key), this.state());
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Emitted after a pane collapses.
|
|
920
|
+
* @event Dock#collapse
|
|
921
|
+
* @type {CustomEvent<{name: string}>}
|
|
922
|
+
*/
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Emitted after a pane expands.
|
|
926
|
+
* @event Dock#expand
|
|
927
|
+
* @type {CustomEvent<{name: string}>}
|
|
928
|
+
*/
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Emitted while a divider changes a pane's size.
|
|
932
|
+
* @event Dock#resize
|
|
933
|
+
* @type {CustomEvent<{name: string, size: number}>}
|
|
934
|
+
*/
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Emitted after a tab group switches tabs.
|
|
938
|
+
* @event Dock#tabchange
|
|
939
|
+
* @type {CustomEvent<{pane: string, tab: string, previous: string|null}>}
|
|
940
|
+
*/
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Emitted after `reveal()` brings a pane into view.
|
|
944
|
+
* @event Dock#reveal
|
|
945
|
+
* @type {CustomEvent<{name: string}>}
|
|
946
|
+
*/
|
|
947
|
+
|
|
948
|
+
/** @param {Element} element @returns {string} */
|
|
949
|
+
function setId(element) {
|
|
950
|
+
const id = uid('zx-dock-pane');
|
|
951
|
+
element.id = id;
|
|
952
|
+
return id;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** @param {number|string|null|undefined} size @returns {string|null} */
|
|
956
|
+
function normalizeSize(size) {
|
|
957
|
+
if (size === null || size === undefined || size === '') return null;
|
|
958
|
+
if (typeof size === 'number') return Number.isFinite(size) ? `${Math.max(0, size)}px` : null;
|
|
959
|
+
return String(size);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* @param {Element} target
|
|
964
|
+
* @param {DockContent} content
|
|
965
|
+
* @param {boolean} callFactory Whether a function is a lazy content factory rather than a value.
|
|
966
|
+
* @returns {void}
|
|
967
|
+
*/
|
|
968
|
+
function appendContent(target, content, callFactory) {
|
|
969
|
+
const value = callFactory && typeof content === 'function' ? content() : content;
|
|
970
|
+
if (value === null || value === undefined) return;
|
|
971
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
972
|
+
target.append(document.createTextNode(String(value)));
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const node = value instanceof Component ? value.toElement() : value;
|
|
976
|
+
if (node && typeof (/** @type {Node} */ (node).nodeType) === 'number') target.append(node);
|
|
977
|
+
}
|