@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.
- package/CHANGELOG.md +197 -0
- package/DESIGN-SYSTEM.md +136 -0
- package/MIGRATION.md +155 -4
- package/README.md +51 -14
- 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 +74 -9
- package/dist/types/components/questionnaire/items.d.ts +327 -0
- package/dist/types/components/questionnaire/questionnaire.d.ts +449 -0
- package/dist/types/components/select/select.d.ts +36 -1
- 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/tabbox/tabbox.d.ts +12 -3
- 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 +943 -311
- package/dist/zx-compat.esm.min.js +1 -1
- package/dist/zx-compat.global.js +7893 -2252
- package/dist/zx-compat.global.min.js +3 -3
- package/dist/zx-zeyos.esm.js +826 -114
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +1720 -243
- package/dist/zx.esm.js +8227 -2599
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +8227 -2599
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +2597 -255
- package/docs/llms.md +475 -37
- 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/date-range-picker.css +7 -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/fieldset/fieldset.css +7 -0
- package/src/components/form/form.css +7 -0
- 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.css +99 -15
- package/src/components/multi-value-editor/multi-value-editor.js +237 -19
- package/src/components/navigation-bar/navigation-bar.css +7 -0
- 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/search/search.css +10 -2
- package/src/components/select/select.css +6 -1
- package/src/components/select/select.js +99 -11
- 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/stepper/stepper.css +12 -2
- package/src/components/tabbox/tabbox.css +13 -3
- package/src/components/tabbox/tabbox.js +34 -10
- 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 +15 -13
- package/styles/tokens/global.css +17 -5
- package/styles/tokens/semantic.css +14 -8
- package/styles/tokens/themes.css +72 -0
- package/styles/zx.css +11 -1
- package/dist/types/components/permission/permission.d.ts +0 -93
- package/src/components/permission/permission.css +0 -47
- 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
|
+
*/
|