@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
|
@@ -22,10 +22,18 @@
|
|
|
22
22
|
padding-inline: var(--zx-control-pad-x) var(--zx-control-height);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
/* The field draws one ring for the whole control (:focus-within above). Inner controls must not
|
|
26
|
+
add a second, smaller ring — an outset ring on the input cut the submit button out of the field.
|
|
27
|
+
The input rides the field ring; the buttons mark themselves with an inset one, as .zx-icon-btn
|
|
28
|
+
does, so the field's outline stays whole. The transparent outline from base.css is left in place
|
|
29
|
+
for forced-colors mode. */
|
|
30
|
+
.zx-search__input:focus-visible {
|
|
31
|
+
box-shadow: none;
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
.zx-search__clear:focus-visible,
|
|
27
35
|
.zx-search__submit:focus-visible {
|
|
28
|
-
box-shadow: var(--zx-focus-ring);
|
|
36
|
+
box-shadow: inset var(--zx-focus-ring);
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
.zx-search__input::-webkit-search-cancel-button {
|
|
@@ -118,7 +118,6 @@
|
|
|
118
118
|
background: var(--zx-color-bg-selected);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
.zx-select__option > svg,
|
|
122
121
|
.zx-select__option > .zx-icon {
|
|
123
122
|
flex: none;
|
|
124
123
|
color: var(--zx-color-accent);
|
|
@@ -137,6 +136,12 @@
|
|
|
137
136
|
text-transform: uppercase;
|
|
138
137
|
}
|
|
139
138
|
|
|
139
|
+
.zx-select__separator {
|
|
140
|
+
block-size: 1px;
|
|
141
|
+
margin: var(--zx-space-1) var(--zx-control-pad-x);
|
|
142
|
+
background: var(--zx-color-border);
|
|
143
|
+
}
|
|
144
|
+
|
|
140
145
|
.zx-select__empty {
|
|
141
146
|
color: var(--zx-color-text-muted);
|
|
142
147
|
padding: var(--zx-space-3) var(--zx-control-pad-x);
|
|
@@ -13,6 +13,7 @@ import { matchItems } from './filter.js';
|
|
|
13
13
|
/**
|
|
14
14
|
* @typedef {Object} SelectOptions
|
|
15
15
|
* @property {SelectItem[]} [items=[]] Available values.
|
|
16
|
+
* @property {SelectItem[]} [fixedItems=[]] Choices pinned above the list, kept through filtering and async loads.
|
|
16
17
|
* @property {SelectValueReader} [valueKey='ID'] Item ID property or reader.
|
|
17
18
|
* @property {SelectLabelReader} [labelKey='name'] Item label property or reader.
|
|
18
19
|
* @property {((item: SelectItem) => Node|string)|null} [renderItem=null] Option renderer.
|
|
@@ -65,6 +66,7 @@ export class Select extends Component {
|
|
|
65
66
|
/** @type {SelectOptions} */
|
|
66
67
|
static defaults = {
|
|
67
68
|
items: [],
|
|
69
|
+
fixedItems: [],
|
|
68
70
|
valueKey: 'ID',
|
|
69
71
|
labelKey: 'name',
|
|
70
72
|
renderItem: null,
|
|
@@ -86,7 +88,7 @@ export class Select extends Component {
|
|
|
86
88
|
render() {
|
|
87
89
|
// Instance state initialized here because render() runs inside the base
|
|
88
90
|
// constructor, before class-field initializers would run (and clobber it).
|
|
89
|
-
this._items = []; this._visibleItems = []; this._selected = null; this._optionEntries = []; this._activeIndex = -1; this._open = false; this._disabled = false; this._loading = false; this._requestSequence = 0; this._queryTimer = null; this._position = null; this._createdRoot = false; this._root = null; this._originalChildren = []; this._originalAttributes = new Map();
|
|
91
|
+
this._items = []; this._visibleItems = []; this._fixedItems = []; this._visibleFixed = []; this._selected = null; this._optionEntries = []; this._activeIndex = -1; this._open = false; this._disabled = false; this._loading = false; this._requestSequence = 0; this._queryTimer = null; this._position = null; this._createdRoot = false; this._root = null; this._originalChildren = []; this._originalAttributes = new Map();
|
|
90
92
|
this._createdRoot = this.el === null;
|
|
91
93
|
const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
|
|
92
94
|
this.el = root;
|
|
@@ -151,6 +153,8 @@ export class Select extends Component {
|
|
|
151
153
|
|
|
152
154
|
this._items = Array.isArray(this.options.items) ? this.options.items.slice() : [];
|
|
153
155
|
this._visibleItems = this._items.slice();
|
|
156
|
+
this._fixedItems = Array.isArray(this.options.fixedItems) ? this.options.fixedItems.slice() : [];
|
|
157
|
+
this._visibleFixed = this._fixedItems.slice();
|
|
154
158
|
this._disabled = Boolean(this.options.disabled);
|
|
155
159
|
this._handleTypeahead = typeahead(
|
|
156
160
|
() => this._optionEntries.filter((entry) => !entry.clear).map((entry) => entry.element),
|
|
@@ -228,8 +232,23 @@ export class Select extends Component {
|
|
|
228
232
|
const selectedValue = this.value;
|
|
229
233
|
this._items = Array.isArray(items) ? items.slice() : [];
|
|
230
234
|
this._visibleItems = this._items.slice();
|
|
231
|
-
this._selected = selectedValue == null ? null :
|
|
232
|
-
|
|
235
|
+
this._selected = selectedValue == null ? null : this._findItem(selectedValue);
|
|
236
|
+
this._syncInputValue();
|
|
237
|
+
this._renderOptions();
|
|
238
|
+
this._position?.update();
|
|
239
|
+
return this;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Replaces the pinned choices and preserves selection by ID when possible.
|
|
244
|
+
* @param {SelectItem[]} items New fixed items.
|
|
245
|
+
* @returns {this}
|
|
246
|
+
*/
|
|
247
|
+
setFixedItems(items) {
|
|
248
|
+
const selectedValue = this.value;
|
|
249
|
+
this._fixedItems = Array.isArray(items) ? items.slice() : [];
|
|
250
|
+
this._visibleFixed = this._fixedItems.slice();
|
|
251
|
+
this._selected = selectedValue == null ? null : this._findItem(selectedValue);
|
|
233
252
|
this._syncInputValue();
|
|
234
253
|
this._renderOptions();
|
|
235
254
|
this._position?.update();
|
|
@@ -283,6 +302,7 @@ export class Select extends Component {
|
|
|
283
302
|
this.refs.input.removeAttribute('aria-activedescendant');
|
|
284
303
|
this._activeIndex = -1;
|
|
285
304
|
this._visibleItems = this._items.slice();
|
|
305
|
+
this._visibleFixed = this._fixedItems.slice();
|
|
286
306
|
this._syncInputValue();
|
|
287
307
|
this.emit('close', {});
|
|
288
308
|
return this;
|
|
@@ -341,6 +361,40 @@ export class Select extends Component {
|
|
|
341
361
|
return select;
|
|
342
362
|
}
|
|
343
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Creates the record-permission picker: the fixed Private and Public choices above the groups a
|
|
366
|
+
* record can be shared with. The value is the tri-state ZeyOS stores — `false` for private,
|
|
367
|
+
* `true` for public, or a group ID — so the control binds straight to the record field.
|
|
368
|
+
*
|
|
369
|
+
* @param {Element|string|null} target Component target.
|
|
370
|
+
* @param {SelectOptions & {groups?: SelectItem[]|((query: string) => Promise<SelectItem[]>|SelectItem[])}} [options={}]
|
|
371
|
+
* Select overrides other than the fixed choices; `groups` holds the shareable groups, either as
|
|
372
|
+
* an array (filtered locally) or as a loader function (queried remotely).
|
|
373
|
+
* @returns {Select}
|
|
374
|
+
*/
|
|
375
|
+
static permission(target, options = {}) {
|
|
376
|
+
const { groups = [], value = true, valueKey = 'ID', labelKey = 'name', ...rest } = options;
|
|
377
|
+
const loader = typeof groups === 'function' ? groups : null;
|
|
378
|
+
const shareable = loader ? [] : groups;
|
|
379
|
+
const choices = [{ ID: false, name: 'Private' }, { ID: true, name: 'Public' }];
|
|
380
|
+
const select = new Select(target, {
|
|
381
|
+
filter: loader ?? 'local',
|
|
382
|
+
...rest,
|
|
383
|
+
value: null,
|
|
384
|
+
items: shareable,
|
|
385
|
+
fixedItems: [],
|
|
386
|
+
// The two choices carry their own ID and label, so a caller's group readers stay untouched.
|
|
387
|
+
valueKey: (item) => choices.includes(item) ? item.ID : readItem(item, valueKey),
|
|
388
|
+
labelKey: (item) => choices.includes(item) ?
|
|
389
|
+
item.name : String(readItem(item, labelKey) ?? '')
|
|
390
|
+
});
|
|
391
|
+
choices[0].name = select._message('permission.private', 'Private');
|
|
392
|
+
choices[1].name = select._message('permission.public', 'Public');
|
|
393
|
+
select.setFixedItems(choices);
|
|
394
|
+
select.set(value, { silent: true });
|
|
395
|
+
return select;
|
|
396
|
+
}
|
|
397
|
+
|
|
344
398
|
/** Cleans up the panel, pending async work, and enhanced target contents. @returns {void} */
|
|
345
399
|
destroy() {
|
|
346
400
|
this._cancelQuery();
|
|
@@ -407,8 +461,8 @@ export class Select extends Component {
|
|
|
407
461
|
const query = /** @type {HTMLInputElement} */ (this.refs.input).value;
|
|
408
462
|
if (!this._open) this.open();
|
|
409
463
|
if (this.options.filter === 'local') {
|
|
410
|
-
|
|
411
|
-
this._visibleItems = matchItems(this._items, query,
|
|
464
|
+
this._visibleFixed = this._matchFixed(query);
|
|
465
|
+
this._visibleItems = matchItems(this._items, query, this._searchKeys());
|
|
412
466
|
this._renderOptions();
|
|
413
467
|
this._setActive(this._optionEntries.length > 0 ? 0 : -1);
|
|
414
468
|
this._position?.update();
|
|
@@ -466,6 +520,11 @@ export class Select extends Component {
|
|
|
466
520
|
if (this.options.clearable) {
|
|
467
521
|
this._appendOption(null, true, this.options.placeholder || this._message('select.none', 'No selection'));
|
|
468
522
|
}
|
|
523
|
+
for (const item of this._visibleFixed) this._appendOption(item, false);
|
|
524
|
+
// A group heading already divides the two sections, and a rule right above one reads as a slip.
|
|
525
|
+
if (this._visibleFixed.length > 0 && this._visibleItems.length > 0 && !this.options.groupKey) {
|
|
526
|
+
list.append(h('div', { class: 'zx-select__separator', role: 'presentation' }));
|
|
527
|
+
}
|
|
469
528
|
for (const item of this._visibleItems) {
|
|
470
529
|
if (this.options.groupKey) {
|
|
471
530
|
const group = this._read(item, this.options.groupKey);
|
|
@@ -479,7 +538,7 @@ export class Select extends Component {
|
|
|
479
538
|
}
|
|
480
539
|
this._appendOption(item, false);
|
|
481
540
|
}
|
|
482
|
-
if (this._visibleItems.length === 0 && !this._loading) {
|
|
541
|
+
if (this._visibleItems.length === 0 && this._visibleFixed.length === 0 && !this._loading) {
|
|
483
542
|
list.append(h('div', {
|
|
484
543
|
class: 'zx-select__empty',
|
|
485
544
|
role: 'status'
|
|
@@ -521,6 +580,7 @@ export class Select extends Component {
|
|
|
521
580
|
if (this._queryTimer !== null) clearTimeout(this._queryTimer);
|
|
522
581
|
this._requestSequence += 1;
|
|
523
582
|
const sequence = this._requestSequence;
|
|
583
|
+
this._visibleFixed = this._matchFixed(query);
|
|
524
584
|
if (query.length < Math.max(0, Number(this.options.minQuery) || 0)) {
|
|
525
585
|
this._setLoading(false);
|
|
526
586
|
this._visibleItems = [];
|
|
@@ -601,11 +661,14 @@ export class Select extends Component {
|
|
|
601
661
|
|
|
602
662
|
/** @param {unknown} candidate @returns {SelectItem|null} */
|
|
603
663
|
_findItem(candidate) {
|
|
604
|
-
|
|
664
|
+
// Fixed choices are searched too: an async filter replaces the item list on every query, so a
|
|
665
|
+
// pinned value would otherwise stop resolving the moment the first result arrived.
|
|
666
|
+
const pool = this._fixedItems.length === 0 ? this._items : [...this._fixedItems, ...this._items];
|
|
667
|
+
const direct = pool.find((item) => item === candidate);
|
|
605
668
|
if (direct !== undefined) return direct;
|
|
606
669
|
const value = candidate !== null && typeof candidate === 'object' ?
|
|
607
670
|
this._itemValue(/** @type {SelectItem} */ (candidate)) : candidate;
|
|
608
|
-
return
|
|
671
|
+
return pool.find((item) => Object.is(this._itemValue(item), value)) ?? null;
|
|
609
672
|
}
|
|
610
673
|
|
|
611
674
|
/** @param {SelectItem|null} item @returns {unknown} */
|
|
@@ -621,9 +684,22 @@ export class Select extends Component {
|
|
|
621
684
|
|
|
622
685
|
/** @param {SelectItem|null} item @param {SelectValueReader|SelectLabelReader} reader @returns {unknown} */
|
|
623
686
|
_read(item, reader) {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
687
|
+
return readItem(item, reader);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** @returns {Array<SelectValueReader|SelectLabelReader>} */
|
|
691
|
+
_searchKeys() {
|
|
692
|
+
return this.options.searchKeys ?? [this.options.labelKey];
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Narrows the pinned choices. They are always matched here and never handed to a filter
|
|
697
|
+
* function: a fixed choice belongs to the control, not to whatever the remote source returns.
|
|
698
|
+
* @param {string} query Search text.
|
|
699
|
+
* @returns {SelectItem[]}
|
|
700
|
+
*/
|
|
701
|
+
_matchFixed(query) {
|
|
702
|
+
return matchItems(this._fixedItems, query, this._searchKeys());
|
|
627
703
|
}
|
|
628
704
|
|
|
629
705
|
/** @returns {number} */
|
|
@@ -657,6 +733,18 @@ export class Select extends Component {
|
|
|
657
733
|
/** Async-query start notification. @event Select#query @type {CustomEvent<SelectQueryDetail>} */
|
|
658
734
|
/** Async results notification. @event Select#loaded @type {CustomEvent<SelectLoadedDetail>} */
|
|
659
735
|
|
|
736
|
+
/**
|
|
737
|
+
* Reads a value off an item through a property name or a reader function.
|
|
738
|
+
* @param {SelectItem|null} item Item to read.
|
|
739
|
+
* @param {SelectValueReader|SelectLabelReader} reader Property name or reader.
|
|
740
|
+
* @returns {unknown}
|
|
741
|
+
*/
|
|
742
|
+
function readItem(item, reader) {
|
|
743
|
+
if (typeof reader === 'function') return reader(item);
|
|
744
|
+
if (item === null || typeof item !== 'object') return item;
|
|
745
|
+
return item[reader];
|
|
746
|
+
}
|
|
747
|
+
|
|
660
748
|
/** @param {KeyboardEvent} event @returns {boolean} */
|
|
661
749
|
function isPrintable(event) {
|
|
662
750
|
return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/** @typedef {import('./sheet.js').Sheet} Sheet */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} SheetStackOptions
|
|
5
|
+
* @property {'stack'|'cascade'} [layout='stack'] How depth is expressed. `stack` slides each
|
|
6
|
+
* covered sheet back toward its edge and scales it down, leaving only the top one usable —
|
|
7
|
+
* the drill-down feel. `cascade` shifts each covered sheet clear of the ones in front so they
|
|
8
|
+
* sit side by side and all stay usable, which is what an ERP screen usually wants: the parent
|
|
9
|
+
* record stays readable while a line item is edited.
|
|
10
|
+
* @property {number} [offset=24] Pixels a `stack` layout moves per depth step.
|
|
11
|
+
* @property {number} [scale=0.04] How much a `stack` layout shrinks per depth step, as a fraction.
|
|
12
|
+
* @property {number} [max=3] Depth at which a sheet stops being drawn. Deeper sheets stay open and
|
|
13
|
+
* in the stack; they are simply no longer visible behind the ones in front.
|
|
14
|
+
* @property {(event: CustomEvent<{sheet: Sheet, depth: number}>) => void} [onpush] Push listener.
|
|
15
|
+
* @property {(event: CustomEvent<{sheet: Sheet}>) => void} [onpop] Pop listener.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An ordered group of Sheets that read as one drill-down.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately tiny, because the browser already does the hard part: nested dialogs stack in the
|
|
22
|
+
* top layer by open order, so there is no z-index bookkeeping here, and Escape closes only the
|
|
23
|
+
* topmost, so unwinding one level at a time is native behaviour rather than something to
|
|
24
|
+
* implement. What is left is bookkeeping — which sheet is at which depth — written onto the
|
|
25
|
+
* elements as `data-depth` and `--zx-sheet-depth` for CSS to interpret.
|
|
26
|
+
*
|
|
27
|
+
* Owns no element of its own, so it is a controller rather than a `Component`: there is nothing
|
|
28
|
+
* to render, only sheets to arrange.
|
|
29
|
+
*
|
|
30
|
+
* @fires SheetStack#push
|
|
31
|
+
* @fires SheetStack#pop
|
|
32
|
+
*/
|
|
33
|
+
export class SheetStack extends EventTarget {
|
|
34
|
+
/** @type {Readonly<SheetStackOptions>} */
|
|
35
|
+
static defaults = Object.freeze({
|
|
36
|
+
layout: 'stack',
|
|
37
|
+
offset: 24,
|
|
38
|
+
scale: 0.04,
|
|
39
|
+
max: 3
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** @type {Sheet[]} Bottom first; the last entry is the top of the stack. */
|
|
43
|
+
#sheets = [];
|
|
44
|
+
/** @type {WeakMap<Sheet, () => void>} */
|
|
45
|
+
#closers = new WeakMap();
|
|
46
|
+
#destroyed = false;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Creates a sheet stack.
|
|
50
|
+
* @param {SheetStackOptions} [options={}] Stack options.
|
|
51
|
+
*/
|
|
52
|
+
constructor(options = {}) {
|
|
53
|
+
super();
|
|
54
|
+
const merged = { ...SheetStack.defaults, ...(options && typeof options === 'object' ? options : {}) };
|
|
55
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
56
|
+
if (!/^on[a-z]/.test(key) || typeof value !== 'function') continue;
|
|
57
|
+
this.on(key.slice(2), value);
|
|
58
|
+
delete merged[key];
|
|
59
|
+
}
|
|
60
|
+
this.options = Object.freeze(merged);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Adds a sheet to the top of the stack, opening it if it is closed. A sheet already in the stack
|
|
65
|
+
* is raised rather than added twice.
|
|
66
|
+
* @param {Sheet} sheet Sheet to push.
|
|
67
|
+
* @returns {this}
|
|
68
|
+
* @fires SheetStack#push
|
|
69
|
+
*/
|
|
70
|
+
push(sheet) {
|
|
71
|
+
if (!sheet || typeof sheet.open !== 'function') throw new TypeError('SheetStack.push expects a Sheet');
|
|
72
|
+
const existing = this.#sheets.indexOf(sheet);
|
|
73
|
+
if (existing !== -1) this.#sheets.splice(existing, 1);
|
|
74
|
+
this.#sheets.push(sheet);
|
|
75
|
+
|
|
76
|
+
if (!this.#closers.has(sheet)) {
|
|
77
|
+
// A sheet dismissed by Escape, its close button, or its own code leaves the stack too.
|
|
78
|
+
const onClose = () => this.#drop(sheet);
|
|
79
|
+
this.#closers.set(sheet, onClose);
|
|
80
|
+
sheet.on('close', onClose);
|
|
81
|
+
}
|
|
82
|
+
if (!sheet.isOpen()) sheet.open();
|
|
83
|
+
this.#sync();
|
|
84
|
+
this.#emit('push', { sheet, depth: 0 });
|
|
85
|
+
return this;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Closes the top sheet and removes it from the stack.
|
|
90
|
+
*
|
|
91
|
+
* The removal is synchronous while the close is not — a dialog dispatches `close` in a queued
|
|
92
|
+
* task — so the stack updates itself here rather than waiting for the event. The close listener
|
|
93
|
+
* is left to handle the other direction: a sheet dismissed by Escape or its own button.
|
|
94
|
+
* @returns {Sheet|null} The sheet that was closed, or null on an empty stack.
|
|
95
|
+
* @fires SheetStack#pop
|
|
96
|
+
*/
|
|
97
|
+
pop() {
|
|
98
|
+
const sheet = this.top();
|
|
99
|
+
if (!sheet) return null;
|
|
100
|
+
this.#remove(sheet);
|
|
101
|
+
sheet.close();
|
|
102
|
+
return sheet;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Closes everything above a sheet, leaving it on top.
|
|
107
|
+
* @param {Sheet} sheet Sheet to return to.
|
|
108
|
+
* @returns {this}
|
|
109
|
+
*/
|
|
110
|
+
popTo(sheet) {
|
|
111
|
+
const index = this.#sheets.indexOf(sheet);
|
|
112
|
+
if (index === -1) return this;
|
|
113
|
+
while (this.#sheets.length > index + 1) this.pop();
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Closes every sheet, from the top down.
|
|
119
|
+
* @returns {this}
|
|
120
|
+
*/
|
|
121
|
+
clear() {
|
|
122
|
+
while (this.#sheets.length > 0) this.pop();
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The sheet currently on top.
|
|
128
|
+
* @returns {Sheet|null}
|
|
129
|
+
*/
|
|
130
|
+
top() {
|
|
131
|
+
return this.#sheets[this.#sheets.length - 1] ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* How many sheets the stack holds.
|
|
136
|
+
* @returns {number}
|
|
137
|
+
*/
|
|
138
|
+
size() {
|
|
139
|
+
return this.#sheets.length;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The sheets, bottom first.
|
|
144
|
+
* @returns {Sheet[]}
|
|
145
|
+
*/
|
|
146
|
+
sheets() {
|
|
147
|
+
return [...this.#sheets];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether a sheet is in this stack.
|
|
152
|
+
* @param {Sheet} sheet Sheet to look for.
|
|
153
|
+
* @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
has(sheet) {
|
|
156
|
+
return this.#sheets.includes(sheet);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Subscribes to a stack event.
|
|
161
|
+
* @param {string} type Event name.
|
|
162
|
+
* @param {(event: any) => void} fn Listener.
|
|
163
|
+
* @returns {this}
|
|
164
|
+
*/
|
|
165
|
+
on(type, fn) {
|
|
166
|
+
this.addEventListener(type, fn);
|
|
167
|
+
return this;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Removes a stack event listener.
|
|
172
|
+
* @param {string} type Event name.
|
|
173
|
+
* @param {(event: any) => void} fn Listener.
|
|
174
|
+
* @returns {this}
|
|
175
|
+
*/
|
|
176
|
+
off(type, fn) {
|
|
177
|
+
this.removeEventListener(type, fn);
|
|
178
|
+
return this;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Subscribes to one occurrence of a stack event.
|
|
183
|
+
* @param {string} type Event name.
|
|
184
|
+
* @param {(event: any) => void} fn Listener.
|
|
185
|
+
* @returns {this}
|
|
186
|
+
*/
|
|
187
|
+
once(type, fn) {
|
|
188
|
+
this.addEventListener(type, fn, { once: true });
|
|
189
|
+
return this;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Empties the stack without closing anything. The sheets are not the stack's to destroy, so
|
|
194
|
+
* they are simply released with their depth styling removed.
|
|
195
|
+
* @returns {void}
|
|
196
|
+
*/
|
|
197
|
+
destroy() {
|
|
198
|
+
if (this.#destroyed) return;
|
|
199
|
+
this.#destroyed = true;
|
|
200
|
+
for (const sheet of this.#sheets) {
|
|
201
|
+
const onClose = this.#closers.get(sheet);
|
|
202
|
+
if (onClose) sheet.off('close', onClose);
|
|
203
|
+
clearDepth(sheet);
|
|
204
|
+
}
|
|
205
|
+
this.#sheets = [];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Handles a sheet that closed on its own. A sheet popped through `pop()` has already been
|
|
210
|
+
* removed by the time its close event lands, so this is a no-op for that path.
|
|
211
|
+
* @param {Sheet} sheet Sheet that closed.
|
|
212
|
+
* @returns {void}
|
|
213
|
+
*/
|
|
214
|
+
#drop(sheet) {
|
|
215
|
+
if (this.#sheets.includes(sheet)) this.#remove(sheet);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Takes a sheet out of the stack and re-numbers what is left.
|
|
220
|
+
* @param {Sheet} sheet Sheet to remove.
|
|
221
|
+
* @returns {void}
|
|
222
|
+
* @fires SheetStack#pop
|
|
223
|
+
*/
|
|
224
|
+
#remove(sheet) {
|
|
225
|
+
const index = this.#sheets.indexOf(sheet);
|
|
226
|
+
if (index === -1) return;
|
|
227
|
+
this.#sheets.splice(index, 1);
|
|
228
|
+
const onClose = this.#closers.get(sheet);
|
|
229
|
+
if (onClose) sheet.off('close', onClose);
|
|
230
|
+
this.#closers.delete(sheet);
|
|
231
|
+
clearDepth(sheet);
|
|
232
|
+
this.#sync();
|
|
233
|
+
this.#emit('pop', { sheet });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Writes each sheet's depth, and for a cascade the distance it must clear.
|
|
238
|
+
* @returns {void}
|
|
239
|
+
*/
|
|
240
|
+
#sync() {
|
|
241
|
+
const { layout, offset, scale, max } = this.options;
|
|
242
|
+
const cascade = layout === 'cascade';
|
|
243
|
+
/** Cumulative extent of the sheets in front of the one being positioned. */
|
|
244
|
+
let ahead = 0;
|
|
245
|
+
|
|
246
|
+
// Top first, so `ahead` accumulates from the front of the stack backwards.
|
|
247
|
+
for (let depth = 0; depth < this.#sheets.length; depth += 1) {
|
|
248
|
+
const sheet = this.#sheets[this.#sheets.length - 1 - depth];
|
|
249
|
+
const element = /** @type {HTMLElement} */ (sheet.el);
|
|
250
|
+
element.dataset.stack = cascade ? 'cascade' : 'stack';
|
|
251
|
+
element.dataset.depth = String(depth);
|
|
252
|
+
element.style.setProperty('--zx-sheet-depth', String(depth));
|
|
253
|
+
element.style.setProperty('--zx-sheet-stack-offset', `${Number(offset) || 0}px`);
|
|
254
|
+
element.style.setProperty('--zx-sheet-stack-scale', String(Number(scale) || 0));
|
|
255
|
+
element.toggleAttribute('data-stack-hidden', depth >= Math.max(1, Number(max) || 1));
|
|
256
|
+
/*
|
|
257
|
+
* A covered sheet in a drill-down is not just visually behind — it must leave the tab order
|
|
258
|
+
* too. The browser does that for stacked *modal* dialogs on its own, but a non-modal stack
|
|
259
|
+
* gets nothing, so the attribute is set either way.
|
|
260
|
+
*/
|
|
261
|
+
element.inert = !cascade && depth > 0;
|
|
262
|
+
if (cascade) {
|
|
263
|
+
element.style.setProperty('--zx-sheet-depth-offset', `${ahead}px`);
|
|
264
|
+
ahead += sheet.getSize();
|
|
265
|
+
} else {
|
|
266
|
+
element.style.removeProperty('--zx-sheet-depth-offset');
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* @param {string} type
|
|
273
|
+
* @param {Record<string, unknown>} detail
|
|
274
|
+
* @returns {void}
|
|
275
|
+
*/
|
|
276
|
+
#emit(type, detail) {
|
|
277
|
+
this.dispatchEvent(new CustomEvent(type, { detail }));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Fired after a sheet is pushed onto the stack.
|
|
283
|
+
* @event SheetStack#push
|
|
284
|
+
* @type {CustomEvent<{sheet: Sheet, depth: number}>}
|
|
285
|
+
*/
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Fired after a sheet leaves the stack, however it was dismissed.
|
|
289
|
+
* @event SheetStack#pop
|
|
290
|
+
* @type {CustomEvent<{sheet: Sheet}>}
|
|
291
|
+
*/
|
|
292
|
+
|
|
293
|
+
/** @param {Sheet} sheet @returns {void} */
|
|
294
|
+
function clearDepth(sheet) {
|
|
295
|
+
const element = /** @type {HTMLElement} */ (sheet.el);
|
|
296
|
+
if (!element) return;
|
|
297
|
+
delete element.dataset.stack;
|
|
298
|
+
delete element.dataset.depth;
|
|
299
|
+
element.removeAttribute('data-stack-hidden');
|
|
300
|
+
element.inert = false;
|
|
301
|
+
for (const property of [
|
|
302
|
+
'--zx-sheet-depth', '--zx-sheet-stack-offset', '--zx-sheet-stack-scale', '--zx-sheet-depth-offset'
|
|
303
|
+
]) element.style.removeProperty(property);
|
|
304
|
+
}
|