@zeyos/zx 2.2.1 → 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.
Files changed (117) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/DESIGN-SYSTEM.md +136 -0
  3. package/MIGRATION.md +155 -4
  4. package/README.md +51 -14
  5. package/dist/types/compat/map/data.d.ts +3 -2
  6. package/dist/types/components/account-menu/account-menu.d.ts +144 -0
  7. package/dist/types/components/app-rail/app-rail.d.ts +250 -0
  8. package/dist/types/components/app-sidebar/app-sidebar.d.ts +169 -0
  9. package/dist/types/components/avatar/avatar.d.ts +100 -0
  10. package/dist/types/components/chart/chart.d.ts +321 -0
  11. package/dist/types/components/date-picker/month-picker.d.ts +6 -0
  12. package/dist/types/components/dialog/dialog.d.ts +9 -0
  13. package/dist/types/components/dock/dock.d.ts +495 -0
  14. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  15. package/dist/types/components/field/field.d.ts +14 -0
  16. package/dist/types/components/field-upload/field-upload.d.ts +37 -4
  17. package/dist/types/components/form/form.d.ts +25 -3
  18. package/dist/types/components/grid/grid.d.ts +172 -0
  19. package/dist/types/components/launcher/launcher.d.ts +300 -0
  20. package/dist/types/components/layout/layout.d.ts +4 -4
  21. package/dist/types/components/menu-button/menu-button.d.ts +10 -2
  22. package/dist/types/components/modal/modal.d.ts +25 -0
  23. package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +74 -9
  24. package/dist/types/components/questionnaire/items.d.ts +327 -0
  25. package/dist/types/components/questionnaire/questionnaire.d.ts +449 -0
  26. package/dist/types/components/select/select.d.ts +36 -1
  27. package/dist/types/components/sheet/sheet-stack.d.ts +167 -0
  28. package/dist/types/components/sheet/sheet.d.ts +294 -0
  29. package/dist/types/components/split-view/split-view.d.ts +2 -21
  30. package/dist/types/components/tabbox/tabbox.d.ts +12 -3
  31. package/dist/types/components/table/table.d.ts +219 -12
  32. package/dist/types/components/tooltip/tooltip.d.ts +2 -2
  33. package/dist/types/components/value-list/value-list.d.ts +24 -3
  34. package/dist/types/core/dom.d.ts +114 -0
  35. package/dist/types/core/drag-axis.d.ts +111 -0
  36. package/dist/types/core/position.d.ts +4 -2
  37. package/dist/types/index.d.ts +12 -2
  38. package/dist/zx-compat.esm.js +943 -311
  39. package/dist/zx-compat.esm.min.js +1 -1
  40. package/dist/zx-compat.global.js +7893 -2252
  41. package/dist/zx-compat.global.min.js +3 -3
  42. package/dist/zx-zeyos.esm.js +826 -114
  43. package/dist/zx-zeyos.esm.min.js +1 -1
  44. package/dist/zx.css +1720 -243
  45. package/dist/zx.esm.js +8227 -2599
  46. package/dist/zx.esm.min.js +3 -3
  47. package/dist/zx.global.js +8227 -2599
  48. package/dist/zx.global.min.js +3 -3
  49. package/dist/zx.min.css +1 -1
  50. package/docs/api.json +2597 -255
  51. package/docs/llms.md +475 -37
  52. package/docs/llms.txt +21 -9
  53. package/package.json +4 -2
  54. package/src/compat/globals.js +3 -1
  55. package/src/compat/map/data.js +31 -9
  56. package/src/components/account-menu/account-menu.css +70 -0
  57. package/src/components/account-menu/account-menu.js +208 -0
  58. package/src/components/app-rail/app-rail.css +107 -0
  59. package/src/components/app-rail/app-rail.js +668 -0
  60. package/src/components/app-sidebar/app-sidebar.css +68 -0
  61. package/src/components/app-sidebar/app-sidebar.js +388 -0
  62. package/src/components/avatar/avatar.css +61 -0
  63. package/src/components/avatar/avatar.js +222 -0
  64. package/src/components/button/button.css +0 -1
  65. package/src/components/chart/chart.css +61 -0
  66. package/src/components/chart/chart.js +518 -0
  67. package/src/components/date-picker/date-range-picker.css +7 -0
  68. package/src/components/date-picker/month-picker.js +9 -0
  69. package/src/components/dialog/dialog.js +11 -4
  70. package/src/components/dock/dock.css +228 -0
  71. package/src/components/dock/dock.js +977 -0
  72. package/src/components/dropdown/dropdown.js +1 -1
  73. package/src/components/field/field.js +2 -0
  74. package/src/components/field-upload/field-upload.js +9 -4
  75. package/src/components/fieldset/fieldset.css +7 -0
  76. package/src/components/form/form.css +7 -0
  77. package/src/components/form/form.js +6 -3
  78. package/src/components/grid/grid.css +6 -0
  79. package/src/components/grid/grid.js +213 -0
  80. package/src/components/launcher/launcher.css +100 -0
  81. package/src/components/launcher/launcher.js +557 -0
  82. package/src/components/layout/layout.js +2 -2
  83. package/src/components/menu-button/menu-button.js +27 -2
  84. package/src/components/modal/modal.css +5 -1
  85. package/src/components/modal/modal.js +37 -2
  86. package/src/components/multi-value-editor/multi-value-editor.css +99 -15
  87. package/src/components/multi-value-editor/multi-value-editor.js +237 -19
  88. package/src/components/navigation-bar/navigation-bar.css +7 -0
  89. package/src/components/questionnaire/items.js +370 -0
  90. package/src/components/questionnaire/questionnaire.css +387 -0
  91. package/src/components/questionnaire/questionnaire.js +1033 -0
  92. package/src/components/search/search.css +10 -2
  93. package/src/components/select/select.css +6 -1
  94. package/src/components/select/select.js +99 -11
  95. package/src/components/sheet/sheet-stack.js +304 -0
  96. package/src/components/sheet/sheet.css +351 -0
  97. package/src/components/sheet/sheet.js +551 -0
  98. package/src/components/split-view/split-view.js +76 -161
  99. package/src/components/stepper/stepper.css +12 -2
  100. package/src/components/tabbox/tabbox.css +13 -3
  101. package/src/components/tabbox/tabbox.js +34 -10
  102. package/src/components/table/table.css +43 -0
  103. package/src/components/table/table.js +523 -45
  104. package/src/components/tooltip/tooltip.js +5 -2
  105. package/src/components/value-list/value-list.js +6 -3
  106. package/src/core/dom.js +302 -0
  107. package/src/core/drag-axis.js +203 -0
  108. package/src/core/position.js +68 -22
  109. package/src/index.js +19 -2
  110. package/styles/tokens/dark.css +15 -13
  111. package/styles/tokens/global.css +17 -5
  112. package/styles/tokens/semantic.css +14 -8
  113. package/styles/tokens/themes.css +72 -0
  114. package/styles/zx.css +11 -1
  115. package/dist/types/components/permission/permission.d.ts +0 -93
  116. package/src/components/permission/permission.css +0 -47
  117. package/src/components/permission/permission.js +0 -198
@@ -0,0 +1,551 @@
1
+ import { STEP, STEP_LARGE, dragAxis, resolveSize } from '../../core/drag-axis.js';
2
+ import { focusTrap } from '../../core/keyboard.js';
3
+ import { h } from '../../core/dom.js';
4
+ import { clamp } from '../../core/util.js';
5
+ import { Dialog } from '../dialog/dialog.js';
6
+
7
+ /** @typedef {'start'|'end'|'top'|'bottom'} SheetSide */
8
+ /** @typedef {true|'trap-focus'|false} SheetModality */
9
+ /** @typedef {'dim'|'blur'|'none'} SheetBackdrop */
10
+
11
+ const SIDES = new Set(['start', 'end', 'top', 'bottom']);
12
+ const BACKDROPS = new Set(['dim', 'blur', 'none']);
13
+ /**
14
+ * Which way a positive pointer delta grows the sheet. A sheet on the `end` edge grows as the
15
+ * pointer moves left, so its handle counts backwards.
16
+ */
17
+ const GROW_SIGN = Object.freeze({ start: 1, end: -1, top: 1, bottom: -1 });
18
+
19
+ /**
20
+ * @typedef {Object} SheetOptions
21
+ * @property {SheetSide} [side='end'] Edge the sheet is anchored to. Logical, so `start` and `end`
22
+ * follow the writing direction.
23
+ * @property {SheetModality} [modal=true] `true` traps focus, locks page scroll, and blocks pointer
24
+ * interaction behind a backdrop; `'trap-focus'` traps focus only; `false` leaves the page fully
25
+ * interactive. Only `true` renders a backdrop — that is what makes the other two non-modal.
26
+ * @property {SheetBackdrop} [backdrop='dim'] Backdrop treatment, applied only when `modal` is
27
+ * `true`. `'blur'` reads `--zx-overlay-blur`.
28
+ * @property {string|number|null} [size=null] Size along the sheet's own axis — width for
29
+ * `start`/`end`, height for `top`/`bottom`. A number is pixels, a string is any CSS length. Null
30
+ * keeps the per-side default.
31
+ * @property {boolean} [closeButton=true] Whether the header shows its close control. Independent of
32
+ * `closable`, so a sheet can offer Escape without an ✕, or the reverse.
33
+ * @property {boolean} [resizable=false] Whether the inner edge may be dragged to resize the sheet.
34
+ * @property {number} [min=160] Smallest the sheet may be dragged to, in pixels.
35
+ * @property {number|null} [max=null] Largest it may be dragged to; null means the viewport.
36
+ * @property {boolean|'auto'} [handle='auto'] Whether to show a grab handle on the inner edge.
37
+ * `'auto'` shows one for `top` and `bottom` sheets, which is what makes them read as drawers.
38
+ * @property {Array<number|string>|null} [snap=null] Detents the sheet settles on. A number at or
39
+ * below 1 is a fraction of the viewport along the sheet's axis, anything larger is pixels, and a
40
+ * string is any CSS length `resolveSize` understands (`'320px'`, `'90%'`).
41
+ * @property {string} [title=''] Header title.
42
+ * @property {Node|string|number|{toElement: () => Node|null}|null} [content=null] Body content.
43
+ * @property {import('../dialog/dialog.js').DialogButton[]} [buttons=[]] Footer buttons.
44
+ * @property {boolean} [closable=true] Whether Escape may close the sheet.
45
+ * @property {boolean} [lightDismiss=true] Whether a click outside closes the sheet — the backdrop
46
+ * when modal, a document-level pointer press when not.
47
+ * @property {boolean} [destroyOnClose=false] Whether to destroy the sheet after it closes.
48
+ * @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open event listener.
49
+ * @property {(event: CustomEvent<{result: unknown}>) => void} [onclose] Close event listener.
50
+ * @property {(event: CustomEvent<Record<string, never>>) => void} [oncancel] Cancel event listener.
51
+ * @property {(event: CustomEvent<{size: number, ratio: number}>) => void} [onresize] Resize listener.
52
+ * @property {(event: CustomEvent<{docked: boolean, dock: import('../dock/dock.js').Dock|null}>) => void} [ondockchange] Dock-state listener.
53
+ */
54
+
55
+ /**
56
+ * Edge-anchored surface: a Dialog that attaches to one side of the viewport instead of floating in
57
+ * the middle. One primitive covers both side sheets and mobile drawers; only the edge differs.
58
+ *
59
+ * Extends `Dialog` rather than reimplementing it, so the header, footer buttons, switchable views,
60
+ * and focus restoration all come from the same structured-dialog foundation.
61
+ *
62
+ * Modality is a three-way choice rather than a boolean because the three real behaviours do not
63
+ * collapse into two: a modal sheet delegates focus containment, page inertness, and Escape to the
64
+ * browser via `showModal()`; the other two open with `show()` and re-implement only what they still
65
+ * want. That is also why `backdrop` applies only to `modal: true` — `::backdrop` does not render
66
+ * for a non-modal dialog, and painting a fake one would make a sheet look blocking while it is not.
67
+ * @fires Sheet#open
68
+ * @fires Sheet#close
69
+ * @fires Sheet#cancel
70
+ * @fires Sheet#resize
71
+ * @fires Sheet#dockchange
72
+ */
73
+ export class Sheet extends Dialog {
74
+ static cssName = 'sheet';
75
+
76
+ /** @type {Readonly<SheetOptions>} */
77
+ static defaults = {
78
+ side: 'end',
79
+ modal: true,
80
+ backdrop: 'dim',
81
+ size: null,
82
+ closeButton: true,
83
+ resizable: false,
84
+ min: 160,
85
+ max: null,
86
+ handle: 'auto',
87
+ snap: null,
88
+ // A sheet is a transient surface, so dismissal is on by default — Modal's is not.
89
+ lightDismiss: true
90
+ };
91
+
92
+ /** @type {{activate: () => void, deactivate: () => void}|null} */
93
+ #trap = null;
94
+ /** @type {ReturnType<typeof dragAxis>|null} */
95
+ #drag = null;
96
+ /** Size when the current drag began, in pixels. */
97
+ #dragStart = 0;
98
+
99
+ /**
100
+ * Creates an edge-anchored sheet.
101
+ * @param {Element|string|null} [_target=null] Ignored; Sheet always owns its root.
102
+ * @param {SheetOptions} [options={}] Sheet options.
103
+ */
104
+ constructor(_target = null, options = {}) {
105
+ super(null, options);
106
+ /*
107
+ * Assigned here rather than declared as a class field: `render()` runs inside the base
108
+ * constructor, before field initializers, and `mountTarget()` is called from there. A declared
109
+ * field would be undefined at best and a private one would throw.
110
+ */
111
+ this._dock = null;
112
+ this._handoff = false;
113
+ this.setSide(this.options.side);
114
+ this.el.dataset.backdrop = BACKDROPS.has(this.options.backdrop) ? this.options.backdrop : 'dim';
115
+ this.el.dataset.modality = modality(this.options.modal);
116
+ this.refs.close.hidden = !this.options.closeButton;
117
+
118
+ if (this.options.modal !== true) {
119
+ /*
120
+ * Escape and outside-click are free for a modal dialog and absent for a non-modal one: the
121
+ * browser only wires `cancel` for `showModal()`, and there is no `::backdrop` to click.
122
+ */
123
+ this.listen(this.el, 'keydown', (event) => {
124
+ const keyboardEvent = /** @type {KeyboardEvent} */ (event);
125
+ if (keyboardEvent.key !== 'Escape' || !this.options.closable || this._dock) return;
126
+ keyboardEvent.preventDefault();
127
+ if (!this.emit('cancel').defaultPrevented) this.close();
128
+ });
129
+ if (this.options.lightDismiss) {
130
+ this.listen(document, 'pointerdown', (event) => {
131
+ const target = /** @type {Node} */ (event.target);
132
+ if (!this.isOpen() || this._dock || this.el.contains(target)) return;
133
+ this.close();
134
+ });
135
+ }
136
+ }
137
+
138
+ /*
139
+ * Registered after Dialog's own restore listener, so focus lands on the opener either way:
140
+ * the trap captured the same element, because `open()` activates it before the sheet shows.
141
+ */
142
+ this.on('close', () => this.#trap?.deactivate());
143
+
144
+ if (this.#wantsHandle()) this.#buildHandle();
145
+ }
146
+
147
+ /** @returns {boolean} */
148
+ #wantsHandle() {
149
+ const handle = this.options.handle;
150
+ const side = this.getSide();
151
+ const shown = handle === true || (handle === 'auto' && (side === 'top' || side === 'bottom'));
152
+ return Boolean(this.options.resizable) || Array.isArray(this.options.snap) || shown;
153
+ }
154
+
155
+ /**
156
+ * Adds the grab handle on the inner edge and wires it to the shared axis-drag engine.
157
+ *
158
+ * Resizing, moving between detents, and swiping to dismiss are one gesture, not three: all of
159
+ * them are "the sheet got bigger or smaller", and which one a drag turns out to be is decided
160
+ * once, when the pointer settles.
161
+ * @returns {void}
162
+ */
163
+ #buildHandle() {
164
+ const vertical = this.getSide() === 'top' || this.getSide() === 'bottom';
165
+ const handle = h('div', {
166
+ class: 'zx-sheet__handle',
167
+ ref: 'handle',
168
+ role: 'separator',
169
+ tabindex: 0,
170
+ ariaOrientation: vertical ? 'horizontal' : 'vertical',
171
+ ariaLabel: this._message('sheet.resize', 'Resize sheet')
172
+ });
173
+ this.el.append(handle);
174
+ this.refs.handle = handle;
175
+ this.#syncHandleAria();
176
+
177
+ this.#drag = dragAxis(handle, {
178
+ orientation: vertical ? 'vertical' : 'horizontal',
179
+ // While docked the dock's own divider owns resizing, so the sheet's handle stands down.
180
+ disabled: () => this.isDocked(),
181
+ onStart: () => {
182
+ this.#dragStart = this.getSize();
183
+ this.el.dataset.dragging = 'true';
184
+ },
185
+ onMove: (delta) => this.#dragTo(delta, false),
186
+ onEnd: (delta, moved) => {
187
+ delete this.el.dataset.dragging;
188
+ if (moved) this.#dragTo(delta, true);
189
+ },
190
+ onStep: (direction, large) => {
191
+ /*
192
+ * `#dragTo` applies the grow sign itself, so the step goes in as a raw pointer delta:
193
+ * ArrowLeft on an `end` sheet is a negative delta, which that sign turns into growth.
194
+ */
195
+ this.#dragStart = this.getSize();
196
+ this.#dragTo(direction * (large ? STEP_LARGE : STEP), true);
197
+ }
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Applies a pointer delta as a new size, and on settle decides what the gesture meant.
203
+ * @param {number} delta Pixels travelled along the axis since the drag began.
204
+ * @param {boolean} settle Whether the pointer has been released.
205
+ * @returns {void}
206
+ */
207
+ #dragTo(delta, settle) {
208
+ const total = this.#axisTotal();
209
+ const { min, max: bound } = this.#bounds(total);
210
+ /*
211
+ * A sheet that is neither `resizable` nor snapping still has a draggable handle — that is what
212
+ * makes a drawer dismissible — but the gesture may only take size away, never add it. Growing
213
+ * one by dragging when nothing asked for resizing would contradict `resizable: false`.
214
+ */
215
+ const free = Boolean(this.options.resizable) || this.#detents(total).length > 0;
216
+ const max = free ? bound : Math.min(bound, this.#dragStart);
217
+ const next = clamp(this.#dragStart + delta * GROW_SIGN[this.getSide()], 0, max);
218
+ if (!settle) {
219
+ // Below the minimum the sheet still follows the pointer, so a dismissing swipe is visible
220
+ // rather than pinned in place until it suddenly disappears.
221
+ this._applySize(next);
222
+ this.#syncHandleAria();
223
+ return;
224
+ }
225
+ if (this.options.closable && next < min / 2) {
226
+ this._applySize(this.#dragStart);
227
+ this.close();
228
+ return;
229
+ }
230
+ const detents = this.#detents(total);
231
+ const settled = detents.length > 0 ? nearest(next, detents) : next;
232
+ this._applySize(clamp(settled, min, max));
233
+ this.#syncHandleAria();
234
+ this.emit('resize', { size: this.getSize(), ratio: total > 0 ? this.getSize() / total : 0 });
235
+ }
236
+
237
+ /**
238
+ * Moves to one of the configured detents.
239
+ * @param {number|string} target Detent index, or one of the `snap` values.
240
+ * @returns {this}
241
+ * @fires Sheet#resize
242
+ */
243
+ snapTo(target) {
244
+ const total = this.#axisTotal();
245
+ const detents = this.#detents(total);
246
+ if (detents.length === 0) return this;
247
+ const size = typeof target === 'number' && Number.isInteger(target) && target >= 0
248
+ && target < detents.length
249
+ ? detents[target]
250
+ : resolveDetent(target, total);
251
+ if (!Number.isFinite(size)) return this;
252
+ const { min, max } = this.#bounds(total);
253
+ this._applySize(clamp(size, min, max));
254
+ this.#syncHandleAria();
255
+ this.emit('resize', { size: this.getSize(), ratio: total > 0 ? this.getSize() / total : 0 });
256
+ return this;
257
+ }
258
+
259
+ /**
260
+ * Returns the sheet's current size along its own axis, in pixels.
261
+ * @returns {number}
262
+ */
263
+ getSize() {
264
+ const rect = this.el.getBoundingClientRect();
265
+ const vertical = this.getSide() === 'top' || this.getSide() === 'bottom';
266
+ return vertical ? rect.height : rect.width;
267
+ }
268
+
269
+ /**
270
+ * Resolves a translated string, falling back to English when no translator knows the key.
271
+ * @param {string} key Message key.
272
+ * @param {string} fallback English default.
273
+ * @returns {string}
274
+ */
275
+ _message(key, fallback) {
276
+ const message = this.msg(key);
277
+ return message === key ? fallback : message;
278
+ }
279
+
280
+ /** @returns {number} */
281
+ #axisTotal() {
282
+ const vertical = this.getSide() === 'top' || this.getSide() === 'bottom';
283
+ return vertical ? window.innerHeight : window.innerWidth;
284
+ }
285
+
286
+ /** @param {number} total @returns {{min: number, max: number}} */
287
+ #bounds(total) {
288
+ const detents = this.#detents(total);
289
+ const declared = Math.max(0, Number(this.options.min) || 0);
290
+ const max = this.options.max == null ? total : Math.min(total, Number(this.options.max) || total);
291
+ // With detents the smallest one is the real floor; `min` still applies underneath it.
292
+ const min = detents.length > 0 ? Math.max(declared, Math.min(...detents)) : declared;
293
+ return { min: Math.min(min, max), max };
294
+ }
295
+
296
+ /** @param {number} total @returns {number[]} */
297
+ #detents(total) {
298
+ const snap = this.options.snap;
299
+ if (!Array.isArray(snap)) return [];
300
+ return snap.map((value) => resolveDetent(value, total))
301
+ .filter((value) => Number.isFinite(value) && value > 0)
302
+ .sort((a, b) => a - b);
303
+ }
304
+
305
+ /** @returns {void} */
306
+ #syncHandleAria() {
307
+ const handle = this.refs.handle;
308
+ if (!handle) return;
309
+ const total = this.#axisTotal();
310
+ const { min, max } = this.#bounds(total);
311
+ handle.setAttribute('aria-valuenow', String(Math.round(this.getSize())));
312
+ handle.setAttribute('aria-valuemin', String(Math.round(min)));
313
+ handle.setAttribute('aria-valuemax', String(Math.round(max)));
314
+ }
315
+
316
+ /**
317
+ * Where the dialog lives: the owning dock while adopted, the document otherwise.
318
+ * @returns {Element}
319
+ */
320
+ mountTarget() {
321
+ return this._dock?.el ?? document.body;
322
+ }
323
+
324
+ /**
325
+ * Reports whether a dock currently owns this sheet's positioning.
326
+ * @returns {boolean}
327
+ */
328
+ isDocked() {
329
+ return Boolean(this._dock);
330
+ }
331
+
332
+ /**
333
+ * Hands positioning to a dock, or takes it back. Called by `Dock.adopt()` and `Dock.release()`;
334
+ * applications go through those rather than here.
335
+ * @param {{el: Element}|null} dock Owning dock, or null to float again.
336
+ * @returns {void}
337
+ * @fires Sheet#dockchange
338
+ */
339
+ _setDock(dock) {
340
+ if (this._dock === dock) return;
341
+ this._dock = dock ?? null;
342
+ this.el.toggleAttribute('data-docked', Boolean(this._dock));
343
+ this._rehost();
344
+ this.emit('dockchange', { docked: Boolean(this._dock), dock: this._dock });
345
+ }
346
+
347
+ /**
348
+ * Moves the dialog to its current mount and, when it was open, reopens it there.
349
+ *
350
+ * The close is unavoidable — a dialog cannot cross between the top layer and the flow while
351
+ * open — but it is invisible: `_isRealClose()` drops the stale event, and reopening bypasses
352
+ * `open()` so no lifecycle event is emitted for what is only a change of address.
353
+ * @returns {void}
354
+ */
355
+ _rehost() {
356
+ const wasOpen = this.isOpen();
357
+ if (wasOpen) {
358
+ this._handoff = true;
359
+ /** @type {HTMLDialogElement} */ (this.el).close();
360
+ }
361
+ this.mountTarget().append(this.el);
362
+ if (!wasOpen) return;
363
+ this.el.returnValue = '';
364
+ this._show();
365
+ this.el.dataset.state = 'open';
366
+ if (this._dock) delete /** @type {HTMLElement} */ (this.el).dataset.zxOverlayOrder;
367
+ else this.el.dataset.zxOverlayOrder = String(nextOverlayOrder());
368
+ }
369
+
370
+ /**
371
+ * Drops the stale `close` a re-hosting handoff leaves behind.
372
+ * @returns {boolean}
373
+ */
374
+ _isRealClose() {
375
+ if (!this._handoff) return true;
376
+ this._handoff = false;
377
+ return false;
378
+ }
379
+
380
+ /**
381
+ * Opens the sheet, trapping focus first where the modality asks for it.
382
+ * @returns {this}
383
+ * @fires Modal#open
384
+ */
385
+ open() {
386
+ if (this.isOpen()) return this;
387
+ if (this.options.modal === 'trap-focus') {
388
+ this.#trap ??= focusTrap(this.el);
389
+ this.#trap.activate();
390
+ }
391
+ super.open();
392
+ // The handle is built while the sheet is still closed, where it measures zero; only now is
393
+ // there a real size to report to a screen reader.
394
+ this.#syncHandleAria();
395
+ return this;
396
+ }
397
+
398
+ /**
399
+ * Closes the sheet with an optional result.
400
+ * @param {unknown} [result] Result included in the close event.
401
+ * @returns {this}
402
+ */
403
+ close(result) {
404
+ this.#trap?.deactivate();
405
+ return super.close(result);
406
+ }
407
+
408
+ /**
409
+ * Moves the sheet to another edge. Takes effect immediately, open or closed.
410
+ * @param {SheetSide} side Edge to anchor to.
411
+ * @returns {this}
412
+ */
413
+ setSide(side) {
414
+ this.el.dataset.side = SIDES.has(side) ? side : 'end';
415
+ return this;
416
+ }
417
+
418
+ /**
419
+ * Returns the edge the sheet is anchored to.
420
+ * @returns {SheetSide}
421
+ */
422
+ getSide() {
423
+ return /** @type {SheetSide} */ (this.el.dataset.side);
424
+ }
425
+
426
+ /**
427
+ * Resizes the sheet along its own axis.
428
+ * @param {string|number|null} size CSS length, pixel number, or null for the per-side default.
429
+ * @returns {this}
430
+ */
431
+ setSize(size) {
432
+ this._applySize(size);
433
+ return this;
434
+ }
435
+
436
+ /**
437
+ * Reports whether focus containment is active.
438
+ * @returns {boolean}
439
+ */
440
+ isModal() {
441
+ return this.options.modal === true;
442
+ }
443
+
444
+ /**
445
+ * Sizes the sheet along whichever axis its side implies, by custom property rather than a fixed
446
+ * dimension so the per-side CSS default survives a null.
447
+ *
448
+ * Runs from `Dialog`'s constructor, before this class's field initializers — DOM only.
449
+ * @param {string|number|null} size CSS length, pixel number, or null.
450
+ * @returns {void}
451
+ */
452
+ _applySize(size) {
453
+ // Modal wrote an inline width and Dialog would write a preset one; neither applies to a sheet,
454
+ // and an inline dimension would beat the side rules in the stylesheet.
455
+ this.el.style.removeProperty('inline-size');
456
+ this.el.style.removeProperty('block-size');
457
+ const value = normalizeSize(size);
458
+ if (value === null) this.el.style.removeProperty('--zx-sheet-size');
459
+ else this.el.style.setProperty('--zx-sheet-size', value);
460
+ }
461
+
462
+ /**
463
+ * Opens the sheet modally or not, as `modal` asks.
464
+ * @returns {void}
465
+ */
466
+ _show() {
467
+ if (this.options.modal === true && !this._dock) {
468
+ super._show();
469
+ return;
470
+ }
471
+ /** @type {HTMLDialogElement} */ (this.el).show();
472
+ }
473
+
474
+ /**
475
+ * Releases the focus trap and the owned dialog.
476
+ * @returns {void}
477
+ */
478
+ destroy() {
479
+ this.#trap?.deactivate();
480
+ this.#trap = null;
481
+ this.#drag?.destroy();
482
+ this.#drag = null;
483
+ this._dock?._forget(this);
484
+ this._dock = null;
485
+ super.destroy();
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Next free overlay order. Same bookkeeping `Modal` and `Dropdown` each carry a copy of; the three
491
+ * should converge on one kernel helper.
492
+ * @returns {number}
493
+ */
494
+ function nextOverlayOrder() {
495
+ const orders = Array.from(document.querySelectorAll('[data-zx-overlay-order]'), (element) =>
496
+ Number(element.getAttribute('data-zx-overlay-order')) || 0);
497
+ return Math.max(0, ...orders) + 1;
498
+ }
499
+
500
+ /**
501
+ * Resolves one `snap` entry. A bare number at or below 1 is a fraction of the axis, which is the
502
+ * convention every drawer library uses; everything else goes through `resolveSize`.
503
+ * @param {number|string} value Detent.
504
+ * @param {number} total Axis extent in pixels.
505
+ * @returns {number}
506
+ */
507
+ function resolveDetent(value, total) {
508
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0 && value <= 1) {
509
+ return value * total;
510
+ }
511
+ return resolveSize(value, total);
512
+ }
513
+
514
+ /** @param {number} value @param {number[]} targets @returns {number} */
515
+ function nearest(value, targets) {
516
+ let best = value;
517
+ let bestDistance = Number.POSITIVE_INFINITY;
518
+ for (const target of targets) {
519
+ const distance = Math.abs(target - value);
520
+ if (distance < bestDistance) {
521
+ bestDistance = distance;
522
+ best = target;
523
+ }
524
+ }
525
+ return best;
526
+ }
527
+
528
+ /** @param {SheetModality} value @returns {'modal'|'trap-focus'|'none'} */
529
+ function modality(value) {
530
+ if (value === true) return 'modal';
531
+ return value === 'trap-focus' ? 'trap-focus' : 'none';
532
+ }
533
+
534
+ /** @param {string|number|null|undefined} size @returns {string|null} */
535
+ function normalizeSize(size) {
536
+ if (size === null || size === undefined || size === '') return null;
537
+ if (typeof size === 'number') return Number.isFinite(size) ? `${Math.max(0, size)}px` : null;
538
+ return String(size);
539
+ }
540
+
541
+ /**
542
+ * Sheet resize event.
543
+ * @event Sheet#resize
544
+ * @type {CustomEvent<{size: number, ratio: number}>}
545
+ */
546
+
547
+ /**
548
+ * Sheet dock-state event.
549
+ * @event Sheet#dockchange
550
+ * @type {CustomEvent<{docked: boolean, dock: import('../dock/dock.js').Dock|null}>}
551
+ */