@zeyos/zx 2.3.0 → 4.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.
Files changed (150) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DESIGN-SYSTEM.md +96 -0
  3. package/README.md +29 -39
  4. package/dist/types/components/account-menu/account-menu.d.ts +144 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +243 -0
  6. package/dist/types/components/avatar/avatar.d.ts +100 -0
  7. package/dist/types/components/card/card.d.ts +128 -0
  8. package/dist/types/components/chart/chart.d.ts +322 -0
  9. package/dist/types/components/date-picker/month-picker.d.ts +6 -0
  10. package/dist/types/components/dialog/dialog.d.ts +9 -0
  11. package/dist/types/components/dock/dock.d.ts +495 -0
  12. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  13. package/dist/types/components/field/field.d.ts +14 -0
  14. package/dist/types/components/field-upload/field-upload.d.ts +37 -4
  15. package/dist/types/components/form/form.d.ts +25 -3
  16. package/dist/types/components/grid/grid.d.ts +172 -0
  17. package/dist/types/components/launcher/launcher.d.ts +359 -0
  18. package/dist/types/components/layout/layout.d.ts +4 -4
  19. package/dist/types/components/menu-button/menu-button.d.ts +10 -2
  20. package/dist/types/components/modal/modal.d.ts +25 -0
  21. package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +7 -1
  22. package/dist/types/components/questionnaire/items.d.ts +327 -0
  23. package/dist/types/components/questionnaire/questionnaire.d.ts +449 -0
  24. package/dist/types/components/select/select.d.ts +51 -0
  25. package/dist/types/components/sheet/sheet-stack.d.ts +167 -0
  26. package/dist/types/components/sheet/sheet.d.ts +294 -0
  27. package/dist/types/components/split-view/split-view.d.ts +2 -21
  28. package/dist/types/components/table/table.d.ts +223 -15
  29. package/dist/types/components/tooltip/tooltip.d.ts +2 -2
  30. package/dist/types/components/value-list/value-list.d.ts +24 -3
  31. package/dist/types/core/dom.d.ts +122 -0
  32. package/dist/types/core/drag-axis.d.ts +111 -0
  33. package/dist/types/core/position.d.ts +4 -2
  34. package/dist/types/index.d.ts +13 -2
  35. package/dist/types/internal/app-rail.d.ts +265 -0
  36. package/dist/types/zeyos/icons.d.ts +5 -0
  37. package/dist/types/zeyos/index.d.ts +2 -1
  38. package/dist/types/zeyos/launcher.d.ts +147 -0
  39. package/dist/types/zeyos/select.d.ts +39 -0
  40. package/dist/zx-zeyos.esm.js +1870 -133
  41. package/dist/zx-zeyos.esm.min.js +1 -1
  42. package/dist/zx.css +2006 -267
  43. package/dist/zx.esm.js +8987 -2757
  44. package/dist/zx.esm.min.js +3 -3
  45. package/dist/zx.global.js +8987 -2757
  46. package/dist/zx.global.min.js +3 -3
  47. package/dist/zx.min.css +1 -1
  48. package/docs/api.json +4396 -2344
  49. package/docs/llms.md +463 -86
  50. package/docs/llms.txt +32 -19
  51. package/package.json +10 -17
  52. package/src/components/account-menu/account-menu.css +70 -0
  53. package/src/components/account-menu/account-menu.js +208 -0
  54. package/src/components/app-rail/app-rail.css +107 -0
  55. package/src/components/app-sidebar/app-sidebar.css +80 -0
  56. package/src/components/app-sidebar/app-sidebar.js +588 -0
  57. package/src/components/avatar/avatar.css +61 -0
  58. package/src/components/avatar/avatar.js +222 -0
  59. package/src/components/button/button.css +0 -1
  60. package/src/components/card/card.css +149 -0
  61. package/src/components/card/card.js +285 -0
  62. package/src/components/chart/chart.css +61 -0
  63. package/src/components/chart/chart.js +523 -0
  64. package/src/components/date-picker/month-picker.js +9 -0
  65. package/src/components/dialog/dialog.js +11 -4
  66. package/src/components/dock/dock.css +228 -0
  67. package/src/components/dock/dock.js +977 -0
  68. package/src/components/dropdown/dropdown.js +1 -1
  69. package/src/components/field/field.js +2 -0
  70. package/src/components/field-upload/field-upload.js +9 -4
  71. package/src/components/form/form.js +6 -3
  72. package/src/components/grid/grid.css +6 -0
  73. package/src/components/grid/grid.js +220 -0
  74. package/src/components/launcher/launcher.css +210 -0
  75. package/src/components/launcher/launcher.js +765 -0
  76. package/src/components/layout/layout.js +2 -2
  77. package/src/components/menu-button/menu-button.js +27 -2
  78. package/src/components/modal/modal.css +5 -1
  79. package/src/components/modal/modal.js +37 -2
  80. package/src/components/multi-value-editor/multi-value-editor.js +2 -1
  81. package/src/components/questionnaire/items.js +370 -0
  82. package/src/components/questionnaire/questionnaire.css +387 -0
  83. package/src/components/questionnaire/questionnaire.js +1033 -0
  84. package/src/components/select/field-adapter.js +9 -4
  85. package/src/components/select/select.css +144 -11
  86. package/src/components/select/select.js +259 -27
  87. package/src/components/sheet/sheet-stack.js +304 -0
  88. package/src/components/sheet/sheet.css +351 -0
  89. package/src/components/sheet/sheet.js +551 -0
  90. package/src/components/split-view/split-view.js +76 -161
  91. package/src/components/table/table.css +43 -0
  92. package/src/components/table/table.js +531 -52
  93. package/src/components/tooltip/tooltip.js +5 -2
  94. package/src/components/value-list/value-list.js +6 -3
  95. package/src/core/dom.js +322 -0
  96. package/src/core/drag-axis.js +203 -0
  97. package/src/core/position.js +68 -22
  98. package/src/index.js +20 -2
  99. package/src/internal/app-rail.js +718 -0
  100. package/src/zeyos/icons.js +8 -3
  101. package/src/zeyos/index.js +7 -1
  102. package/src/zeyos/launcher.js +338 -0
  103. package/src/zeyos/modules.js +1 -0
  104. package/src/zeyos/select.js +115 -1
  105. package/styles/tokens/dark.css +2 -3
  106. package/styles/tokens/global.css +6 -6
  107. package/styles/tokens/modules.css +1 -0
  108. package/styles/tokens/semantic.css +5 -3
  109. package/styles/tokens/themes.css +1 -1
  110. package/styles/zx.css +11 -1
  111. package/MIGRATION.md +0 -598
  112. package/dist/types/compat/base.d.ts +0 -156
  113. package/dist/types/compat/globals.d.ts +0 -20
  114. package/dist/types/compat/index.d.ts +0 -128
  115. package/dist/types/compat/map/basic.d.ts +0 -175
  116. package/dist/types/compat/map/data.d.ts +0 -102
  117. package/dist/types/compat/map/date-time.d.ts +0 -80
  118. package/dist/types/compat/map/factory.d.ts +0 -9
  119. package/dist/types/compat/map/form.d.ts +0 -98
  120. package/dist/types/compat/map/helpers.d.ts +0 -12
  121. package/dist/types/compat/map/http.d.ts +0 -36
  122. package/dist/types/compat/map/layout.d.ts +0 -106
  123. package/dist/types/compat/map/options.d.ts +0 -80
  124. package/dist/types/compat/map/select.d.ts +0 -95
  125. package/dist/types/compat/map/stubs.d.ts +0 -32
  126. package/dist/types/compat/map/utilities.d.ts +0 -37
  127. package/dist/types/compat-entry.d.ts +0 -5
  128. package/dist/types/components/permission/permission.d.ts +0 -96
  129. package/dist/zx-compat.esm.js +0 -17484
  130. package/dist/zx-compat.esm.min.js +0 -1
  131. package/dist/zx-compat.global.js +0 -23635
  132. package/dist/zx-compat.global.min.js +0 -3
  133. package/src/compat/base.js +0 -291
  134. package/src/compat/globals.js +0 -165
  135. package/src/compat/index.js +0 -84
  136. package/src/compat/map/basic.js +0 -464
  137. package/src/compat/map/data.js +0 -232
  138. package/src/compat/map/date-time.js +0 -177
  139. package/src/compat/map/factory.js +0 -45
  140. package/src/compat/map/form.js +0 -330
  141. package/src/compat/map/helpers.js +0 -46
  142. package/src/compat/map/http.js +0 -117
  143. package/src/compat/map/layout.js +0 -256
  144. package/src/compat/map/options.js +0 -267
  145. package/src/compat/map/select.js +0 -198
  146. package/src/compat/map/stubs.js +0 -26
  147. package/src/compat/map/utilities.js +0 -113
  148. package/src/compat-entry.js +0 -11
  149. package/src/components/permission/permission.css +0 -47
  150. 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
+ }