@zeyos/zx 4.4.0 → 5.2.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 +447 -0
- package/README.md +44 -11
- package/dist/types/components/account-menu/account-menu.d.ts +8 -0
- package/dist/types/components/activity-list/activity-list.d.ts +31 -1
- package/dist/types/components/app-icon/app-icon.d.ts +14 -2
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +293 -17
- package/dist/types/components/avatar/avatar.d.ts +46 -7
- package/dist/types/components/badge/badge.d.ts +21 -1
- package/dist/types/components/button/button.d.ts +15 -0
- package/dist/types/components/calendar/calendar.d.ts +67 -5
- package/dist/types/components/card-view/card-view.d.ts +7 -4
- package/dist/types/components/card-view/record-card.d.ts +11 -0
- package/dist/types/components/chart/chart.d.ts +10 -0
- package/dist/types/components/checklist/checklist.d.ts +9 -0
- package/dist/types/components/code-editor/code-editor.d.ts +6 -17
- package/dist/types/components/context-menu/context-menu.d.ts +6 -2
- package/dist/types/components/data-filter/data-filter.d.ts +4 -4
- package/dist/types/components/datebox/date-range-box.d.ts +5 -1
- package/dist/types/components/datebox/datebox.d.ts +7 -2
- package/dist/types/components/dialog/dialog.d.ts +46 -6
- package/dist/types/components/dropdown/dropdown.d.ts +24 -9
- package/dist/types/components/entity-ref/entity-ref.d.ts +2 -2
- package/dist/types/components/field/field.d.ts +54 -2
- package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
- package/dist/types/components/fieldset/fieldset.d.ts +26 -1
- package/dist/types/components/file-item/file-item.d.ts +34 -6
- package/dist/types/components/file-list/file-list.d.ts +35 -3
- package/dist/types/components/filter/filter.d.ts +127 -3
- package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
- package/dist/types/components/form/form.d.ts +12 -1
- package/dist/types/components/kanban-view/kanban-card.d.ts +8 -0
- package/dist/types/components/launcher/launcher.d.ts +268 -15
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +16 -7
- package/dist/types/components/menu-button/menu-button.d.ts +39 -5
- package/dist/types/components/message/message.d.ts +132 -17
- package/dist/types/components/modal/modal.d.ts +43 -2
- package/dist/types/components/module-bar/module-bar.d.ts +395 -0
- package/dist/types/components/pagination/pagination.d.ts +17 -3
- package/dist/types/components/select/color.d.ts +12 -0
- package/dist/types/components/select/glyphs.d.ts +124 -0
- package/dist/types/components/select/select.d.ts +17 -3
- package/dist/types/components/sheet/sheet.d.ts +15 -0
- package/dist/types/components/stat-tile/stat-tile.d.ts +16 -5
- package/dist/types/components/tabbox/tabbox.d.ts +9 -0
- package/dist/types/components/table/table.d.ts +311 -18
- package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
- package/dist/types/components/tree/tree.d.ts +13 -0
- package/dist/types/components/view/record-view.d.ts +59 -2
- package/dist/types/components/view-settings/view-settings.d.ts +879 -0
- package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
- package/dist/types/core/breakpoint.d.ts +1 -1
- package/dist/types/core/component.d.ts +12 -2
- package/dist/types/core/i18n.d.ts +27 -3
- package/dist/types/core/icons.d.ts +23 -25
- package/dist/types/core/keyboard.d.ts +98 -1
- package/dist/types/core/messages.d.ts +382 -0
- package/dist/types/core/motion.d.ts +59 -0
- package/dist/types/core/overlay-host.d.ts +42 -1
- package/dist/types/core/overlay-stack.d.ts +260 -0
- package/dist/types/index.d.ts +10 -5
- package/dist/types/internal/app-rail.d.ts +38 -0
- package/dist/types/internal/menu-items.d.ts +71 -3
- package/dist/types/zeyos/connect.d.ts +6 -0
- package/dist/types/zeyos/form.d.ts +50 -9
- package/dist/types/zeyos/index.d.ts +2 -1
- package/dist/types/zeyos/launcher.d.ts +9 -0
- package/dist/types/zeyos/module-bar.d.ts +176 -0
- package/dist/types/zeyos/query.d.ts +110 -5
- package/dist/types/zeyos/schema.d.ts +24 -1
- package/dist/types/zeyos/table.d.ts +345 -22
- package/dist/types/zeyos/view.d.ts +26 -2
- package/dist/zx-zeyos.esm.js +6178 -1008
- package/dist/zx-zeyos.esm.min.js +53 -5
- package/dist/zx.css +2861 -827
- package/dist/zx.esm.js +8909 -1480
- package/dist/zx.esm.min.js +55 -7
- package/dist/zx.global.js +8911 -1482
- package/dist/zx.global.min.js +55 -7
- package/dist/zx.min.css +1 -1
- package/docs/api.json +1079 -117
- package/docs/llms.md +956 -154
- package/docs/llms.txt +9 -5
- package/package.json +1 -1
- package/src/components/account-menu/account-menu.css +35 -10
- package/src/components/account-menu/account-menu.js +12 -0
- package/src/components/activity-item/activity-item.css +1 -1
- package/src/components/activity-item/activity-item.js +3 -0
- package/src/components/activity-list/activity-list.css +38 -3
- package/src/components/activity-list/activity-list.js +61 -1
- package/src/components/app-icon/app-icon.css +14 -3
- package/src/components/app-icon/app-icon.js +11 -1
- package/src/components/app-rail/app-rail.css +59 -79
- package/src/components/app-sidebar/app-sidebar.css +304 -168
- package/src/components/app-sidebar/app-sidebar.js +667 -46
- package/src/components/aurora/aurora.css +5 -3
- package/src/components/avatar/avatar.css +32 -7
- package/src/components/avatar/avatar.js +72 -8
- package/src/components/badge/badge.css +54 -2
- package/src/components/badge/badge.js +29 -2
- package/src/components/breadcrumb/breadcrumb.css +1 -1
- package/src/components/button/button.css +67 -9
- package/src/components/button/button.js +38 -0
- package/src/components/calendar/calendar.css +47 -47
- package/src/components/calendar/calendar.js +26 -52
- package/src/components/card/card.css +4 -4
- package/src/components/card-view/card-view.css +14 -12
- package/src/components/card-view/card-view.js +13 -9
- package/src/components/card-view/record-card.js +14 -3
- package/src/components/chart/chart.css +4 -1
- package/src/components/chart/chart.js +12 -0
- package/src/components/check-button/check-button.css +10 -2
- package/src/components/checklist/checklist.css +9 -2
- package/src/components/checklist/checklist.js +11 -5
- package/src/components/checklist/field-adapter.js +3 -1
- package/src/components/code-editor/code-editor.css +7 -6
- package/src/components/code-editor/code-editor.js +19 -1
- package/src/components/code-editor/field-adapter.js +9 -2
- package/src/components/context-menu/context-menu.css +9 -4
- package/src/components/context-menu/context-menu.js +8 -2
- package/src/components/copy/copy-button.css +6 -0
- package/src/components/copy/copy-input.css +9 -4
- package/src/components/data-filter/data-filter.css +5 -4
- package/src/components/data-filter/data-filter.js +2 -2
- package/src/components/date-picker/date-picker.css +6 -6
- package/src/components/date-picker/date-range-picker.css +3 -3
- package/src/components/date-picker/time-picker.css +3 -3
- package/src/components/datebox/date-range-box.css +79 -2
- package/src/components/datebox/date-range-box.js +13 -2
- package/src/components/datebox/datebox.css +79 -2
- package/src/components/datebox/datebox.js +13 -2
- package/src/components/dialog/dialog.css +26 -0
- package/src/components/dialog/dialog.js +99 -17
- package/src/components/dock/dock.css +5 -4
- package/src/components/dropdown/dropdown.css +1 -1
- package/src/components/dropdown/dropdown.js +159 -72
- package/src/components/empty-state/empty-state.css +2 -2
- package/src/components/entity-ref/entity-ref.css +1 -1
- package/src/components/entity-ref/entity-ref.js +1 -1
- package/src/components/field/field.css +28 -9
- package/src/components/field/field.js +101 -29
- package/src/components/field-upload/field-upload.css +1 -1
- package/src/components/fieldset/external-adapter.js +363 -0
- package/src/components/fieldset/fieldset.css +5 -3
- package/src/components/fieldset/fieldset.js +38 -64
- package/src/components/file-item/file-item.js +144 -10
- package/src/components/file-list/file-list.js +144 -21
- package/src/components/filter/filter.css +86 -2
- package/src/components/filter/filter.js +307 -11
- package/src/components/filter-panel/filter-panel.css +1 -1
- package/src/components/filter-panel/filter-panel.js +149 -32
- package/src/components/finder/finder.css +16 -14
- package/src/components/form/form.css +5 -4
- package/src/components/form/form.js +11 -2
- package/src/components/grid/grid.css +1 -1
- package/src/components/groupbox/groupbox.css +1 -1
- package/src/components/kanban-view/kanban-card.js +11 -3
- package/src/components/kanban-view/kanban-view.css +42 -18
- package/src/components/kanban-view/kanban-view.js +9 -6
- package/src/components/launcher/launcher.css +108 -26
- package/src/components/launcher/launcher.js +530 -42
- package/src/components/list-toolbar/list-toolbar.css +13 -8
- package/src/components/list-toolbar/list-toolbar.js +40 -14
- package/src/components/master-panel/master-panel.css +2 -0
- package/src/components/master-panel/master-panel.js +0 -1
- package/src/components/menu-button/menu-button.css +72 -4
- package/src/components/menu-button/menu-button.js +160 -18
- package/src/components/message/message.css +33 -3
- package/src/components/message/message.js +264 -22
- package/src/components/modal/modal.css +1 -1
- package/src/components/modal/modal.js +83 -15
- package/src/components/module-bar/module-bar.css +486 -0
- package/src/components/module-bar/module-bar.js +1074 -0
- package/src/components/multi-value-editor/multi-value-editor.css +1 -1
- package/src/components/navigation-bar/navigation-bar.css +18 -9
- package/src/components/number-field/field-adapter.js +3 -1
- package/src/components/number-field/number-field.css +70 -0
- package/src/components/number-field/number-field.js +2 -0
- package/src/components/pagination/pagination.css +13 -11
- package/src/components/pagination/pagination.js +20 -4
- package/src/components/panel/panel.css +4 -3
- package/src/components/questionnaire/questionnaire.css +4 -4
- package/src/components/rating/field-adapter.js +2 -1
- package/src/components/rating/rating.css +8 -1
- package/src/components/search/search.css +4 -1
- package/src/components/select/color.js +22 -0
- package/src/components/select/field-adapter.js +2 -0
- package/src/components/select/glyphs.js +165 -0
- package/src/components/select/select.css +138 -45
- package/src/components/select/select.js +92 -47
- package/src/components/sheet/sheet.css +12 -9
- package/src/components/sheet/sheet.js +74 -32
- package/src/components/slider/field-adapter.js +6 -1
- package/src/components/slider/slider.css +18 -4
- package/src/components/slider/slider.js +1 -1
- package/src/components/sort-control/sort-control.css +4 -1
- package/src/components/split-view/split-view.css +1 -1
- package/src/components/stat-tile/stat-tile.css +42 -47
- package/src/components/stat-tile/stat-tile.js +17 -6
- package/src/components/stepper/stepper.css +1 -1
- package/src/components/tabbox/tabbox.css +41 -32
- package/src/components/tabbox/tabbox.js +5 -1
- package/src/components/table/table.css +187 -37
- package/src/components/table/table.js +718 -73
- package/src/components/tag-picker/field-adapter.js +2 -1
- package/src/components/tag-picker/tag-picker.css +83 -5
- package/src/components/tag-picker/tag-picker.js +25 -8
- package/src/components/timebox/timebox.css +22 -2
- package/src/components/toggle/toggle.css +12 -1
- package/src/components/toolbar/toolbar.js +2 -2
- package/src/components/tooltip/tooltip.css +4 -0
- package/src/components/tooltip/tooltip.js +44 -19
- package/src/components/tree/tree.css +26 -17
- package/src/components/tree/tree.js +31 -0
- package/src/components/value-list/value-list.css +3 -3
- package/src/components/view/record-view.css +1 -1
- package/src/components/view/record-view.js +178 -18
- package/src/components/view-settings/view-settings.css +285 -0
- package/src/components/view-settings/view-settings.js +1830 -0
- package/src/components/workspace-switcher/workspace-switcher.css +188 -0
- package/src/components/workspace-switcher/workspace-switcher.js +463 -0
- package/src/core/breakpoint.js +1 -1
- package/src/core/component.js +88 -17
- package/src/core/fontawesome.js +5 -1
- package/src/core/i18n.js +47 -4
- package/src/core/icons.js +204 -61
- package/src/core/keyboard.js +206 -11
- package/src/core/messages.js +427 -0
- package/src/core/motion.js +147 -0
- package/src/core/overlay-host.js +77 -3
- package/src/core/overlay-stack.js +487 -0
- package/src/core/position.js +22 -3
- package/src/index.js +10 -5
- package/src/internal/app-rail.js +135 -55
- package/src/internal/menu-items.js +61 -3
- package/src/zeyos/connect.js +6 -2
- package/src/zeyos/form.js +69 -27
- package/src/zeyos/index.js +4 -1
- package/src/zeyos/launcher.js +8 -3
- package/src/zeyos/module-bar.js +181 -0
- package/src/zeyos/query.js +197 -13
- package/src/zeyos/schema.js +144 -27
- package/src/zeyos/table.js +287 -84
- package/src/zeyos/view.js +49 -93
- package/styles/base.css +72 -1
- package/styles/icons.css +11 -1
- package/styles/print.css +1 -1
- package/styles/tokens/dark.css +117 -37
- package/styles/tokens/global.css +121 -27
- package/styles/tokens/semantic.css +140 -43
- package/styles/tokens/themes.css +7 -6
- package/styles/zx.css +3 -1
- package/styles/tokens/density.css +0 -9
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
inline-size: 100%;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/* A caption for the group, in sentence case and the same quiet voice as the field labels inside
|
|
19
|
+
it — the fields are the content. */
|
|
18
20
|
.zx-fieldset__legend {
|
|
19
21
|
padding-inline: var(--zx-space-2);
|
|
20
|
-
color: var(--zx-color-text);
|
|
21
|
-
font-size: var(--zx-text-
|
|
22
|
-
font-weight:
|
|
22
|
+
color: var(--zx-color-text-muted);
|
|
23
|
+
font-size: var(--zx-text-sm);
|
|
24
|
+
font-weight: var(--zx-weight-medium);
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
.zx-fieldset__grid {
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { Component } from '../../core/component.js';
|
|
2
2
|
import { h } from '../../core/dom.js';
|
|
3
|
-
import { isElement } from '../../core/util.js';
|
|
3
|
+
import { isElement, uid } from '../../core/util.js';
|
|
4
4
|
import { Field } from '../field/field.js';
|
|
5
|
+
import { externalAdapter, readExternalDisabled, readExternalReadonly, readExternalValue } from './external-adapter.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* @typedef {Object} FieldsetOptions
|
|
8
9
|
* @property {string} [title=''] Legend text.
|
|
9
10
|
* @property {1|2|3} [columns=1] Grid column count.
|
|
10
11
|
* @property {Record<string, FieldOptions|Field|Element|Component>} [fields={}] Initial fields.
|
|
12
|
+
* @property {string|null} [idPrefix=null] Prefix of the DOM ids generated for the fields, as
|
|
13
|
+
* `<idPrefix>-<key>`. Null generates one per instance, so the same fieldset mounted twice
|
|
14
|
+
* never repeats an id and a field key never collides with an element id elsewhere on the page.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
/** @typedef {import('../field/field.js').FieldOptions} FieldOptions */
|
|
@@ -20,7 +24,7 @@ export class Fieldset extends Component {
|
|
|
20
24
|
static cssName = 'fieldset';
|
|
21
25
|
|
|
22
26
|
/** @type {FieldsetOptions} */
|
|
23
|
-
static defaults = { title: '', columns: 1, fields: {} };
|
|
27
|
+
static defaults = { title: '', columns: 1, fields: {}, idPrefix: null };
|
|
24
28
|
|
|
25
29
|
/** @returns {HTMLElement} */
|
|
26
30
|
render() {
|
|
@@ -30,6 +34,7 @@ export class Fieldset extends Component {
|
|
|
30
34
|
this._origins = new Map();
|
|
31
35
|
this._fieldIdAttributes = new Map();
|
|
32
36
|
this._ownedNodes = [];
|
|
37
|
+
this._idPrefix = this.options.idPrefix ? String(this.options.idPrefix) : uid('zx-fieldset');
|
|
33
38
|
this._columnAttribute = root.getAttribute('data-columns');
|
|
34
39
|
const columns = [1, 2, 3].includes(Number(this.options.columns)) ? Number(this.options.columns) : 1;
|
|
35
40
|
root.dataset.columns = String(columns);
|
|
@@ -49,7 +54,16 @@ export class Fieldset extends Component {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/**
|
|
52
|
-
* Adds a field from options, an existing field, an element, or a component.
|
|
57
|
+
* Adds a field from options, an existing field, an element, or a component. The key becomes the
|
|
58
|
+
* field's `name` and its `data-field-id`; the control's DOM id is `<idPrefix>-<key>`, unless the
|
|
59
|
+
* field options give an `id` of their own that differs from the key.
|
|
60
|
+
*
|
|
61
|
+
* An element or a component that is not a registered field type is wrapped in a `custom` field
|
|
62
|
+
* that reads and writes it through its own API — `getValue`/`setValue`, `get`/`set`,
|
|
63
|
+
* `getValues`/`setValues`, `isChecked`/`setChecked`, or a `value` property — disables it
|
|
64
|
+
* through `setDisabled(bool)` or `enable()`/`disable()`, and makes it read-only through
|
|
65
|
+
* `setReadonly(bool)`, falling back to the element's native properties only when the component
|
|
66
|
+
* has none of these. The component's own `change` becomes the field's `change`, once per edit.
|
|
53
67
|
* @param {string} id Logical field identifier.
|
|
54
68
|
* @param {FieldOptions|Field|Element|Component} field Field source.
|
|
55
69
|
* @returns {Field}
|
|
@@ -64,17 +78,26 @@ export class Fieldset extends Component {
|
|
|
64
78
|
} else if (isElement(field) || field instanceof Component) {
|
|
65
79
|
const source = field;
|
|
66
80
|
instance = new Field(null, {
|
|
67
|
-
id: key,
|
|
81
|
+
id: this._controlId(key),
|
|
82
|
+
name: key,
|
|
68
83
|
type: 'custom',
|
|
69
84
|
label: key,
|
|
70
85
|
value: readExternalValue(source),
|
|
71
86
|
disabled: readExternalDisabled(source),
|
|
72
|
-
|
|
87
|
+
readonly: readExternalReadonly(source),
|
|
88
|
+
adapter: (owner) => externalAdapter(source, owner)
|
|
73
89
|
});
|
|
74
90
|
owned = true;
|
|
75
91
|
} else {
|
|
76
92
|
const options = field && typeof field === 'object' ? field : {};
|
|
77
|
-
|
|
93
|
+
// An `id` equal to the key is the key restated (schema descriptors carry both), not a
|
|
94
|
+
// request for that DOM id.
|
|
95
|
+
const explicit = options.id != null && String(options.id) !== '' && String(options.id) !== key;
|
|
96
|
+
instance = new Field(null, {
|
|
97
|
+
...options,
|
|
98
|
+
id: explicit ? String(options.id) : this._controlId(key),
|
|
99
|
+
name: options.name ?? key
|
|
100
|
+
});
|
|
78
101
|
owned = true;
|
|
79
102
|
}
|
|
80
103
|
|
|
@@ -218,6 +241,15 @@ export class Fieldset extends Component {
|
|
|
218
241
|
return this;
|
|
219
242
|
}
|
|
220
243
|
|
|
244
|
+
/**
|
|
245
|
+
* The DOM id generated for a field key: unique per instance unless `idPrefix` was given.
|
|
246
|
+
* @param {string} key Field key.
|
|
247
|
+
* @returns {string}
|
|
248
|
+
*/
|
|
249
|
+
_controlId(key) {
|
|
250
|
+
return `${this._idPrefix}-${key.trim().replace(/\s+/g, '-')}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
221
253
|
/** @param {Field} field @returns {void} */
|
|
222
254
|
_releaseField(field) {
|
|
223
255
|
const fieldId = this._fieldIdAttributes.get(field);
|
|
@@ -247,61 +279,3 @@ export class Fieldset extends Component {
|
|
|
247
279
|
super.destroy();
|
|
248
280
|
}
|
|
249
281
|
}
|
|
250
|
-
|
|
251
|
-
/** @param {Element|Component} source @returns {import('../field/field.js').FieldAdapter} */
|
|
252
|
-
function externalAdapter(source) {
|
|
253
|
-
const el = isElement(source) ? source : source.toElement();
|
|
254
|
-
const parent = el.parentNode;
|
|
255
|
-
const next = el.nextSibling;
|
|
256
|
-
const attributes = new Map(['id', 'aria-labelledby', 'aria-describedby', 'aria-disabled', 'aria-invalid']
|
|
257
|
-
.map((name) => [name, el.getAttribute(name)]));
|
|
258
|
-
const originalDisabled = 'disabled' in el ? el.disabled : undefined;
|
|
259
|
-
const originalValue = 'value' in el ? el.value : undefined;
|
|
260
|
-
return {
|
|
261
|
-
el,
|
|
262
|
-
get: () => {
|
|
263
|
-
if (typeof source.getValue === 'function') return source.getValue();
|
|
264
|
-
if (typeof source.getValues === 'function') return source.getValues();
|
|
265
|
-
if ('value' in el) return el.value;
|
|
266
|
-
return null;
|
|
267
|
-
},
|
|
268
|
-
set: (value, options) => {
|
|
269
|
-
if (typeof source.setValue === 'function') source.setValue(value, options);
|
|
270
|
-
else if (typeof source.setValues === 'function' && value && typeof value === 'object') source.setValues(value, options);
|
|
271
|
-
else if ('value' in el) el.value = value == null ? '' : value;
|
|
272
|
-
},
|
|
273
|
-
focus: () => {
|
|
274
|
-
if (typeof source.focus === 'function') source.focus();
|
|
275
|
-
else if (typeof el.focus === 'function') el.focus();
|
|
276
|
-
},
|
|
277
|
-
setDisabled: (disabled) => {
|
|
278
|
-
if (typeof source.setDisabled === 'function') source.setDisabled(disabled);
|
|
279
|
-
else if ('disabled' in el) el.disabled = disabled;
|
|
280
|
-
else el.setAttribute('aria-disabled', String(disabled));
|
|
281
|
-
},
|
|
282
|
-
destroy: () => {
|
|
283
|
-
for (const [name, value] of attributes) {
|
|
284
|
-
if (value === null) el.removeAttribute(name);
|
|
285
|
-
else el.setAttribute(name, value);
|
|
286
|
-
}
|
|
287
|
-
if (originalDisabled !== undefined) el.disabled = originalDisabled;
|
|
288
|
-
if (originalValue !== undefined) el.value = originalValue;
|
|
289
|
-
if (parent) parent.insertBefore(el, next?.parentNode === parent ? next : null);
|
|
290
|
-
else el.remove();
|
|
291
|
-
}
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** @param {Element|Component} source @returns {unknown} */
|
|
296
|
-
function readExternalValue(source) {
|
|
297
|
-
const el = isElement(source) ? source : source.toElement();
|
|
298
|
-
if (typeof source.getValue === 'function') return source.getValue();
|
|
299
|
-
if (typeof source.getValues === 'function') return source.getValues();
|
|
300
|
-
return 'value' in el ? el.value : null;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** @param {Element|Component} source @returns {boolean} */
|
|
304
|
-
function readExternalDisabled(source) {
|
|
305
|
-
const el = isElement(source) ? source : source.toElement();
|
|
306
|
-
return 'disabled' in el ? Boolean(el.disabled) : el.getAttribute('aria-disabled') === 'true';
|
|
307
|
-
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Component } from '../../core/component.js';
|
|
3
3
|
import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
|
|
4
4
|
import { formatFileSize } from '../../core/format.js';
|
|
5
|
+
import { getIconConfig, iconNames } from '../../core/icons.js';
|
|
5
6
|
import { isElement } from '../../core/util.js';
|
|
6
7
|
import { badge } from '../badge/badge.js';
|
|
7
8
|
import {
|
|
@@ -20,9 +21,13 @@ import {
|
|
|
20
21
|
* @property {unknown} [id=null] Application-owned file identifier included in events.
|
|
21
22
|
* @property {string} [name=''] File name.
|
|
22
23
|
* @property {number|string|null} [size=null] Byte count; invalid and negative values are omitted.
|
|
23
|
-
* @property {string} [mime=''] MIME type.
|
|
24
|
+
* @property {string} [mime=''] MIME type. Shown as a readable type ("PDF", "Image"), never as
|
|
25
|
+
* the raw string, and used to pick the icon.
|
|
26
|
+
* @property {string|null} [typeLabel=null] Type shown beside the size; null derives it from the
|
|
27
|
+
* MIME type (or the name's extension when the MIME type is unfamiliar), an empty string hides it.
|
|
24
28
|
* @property {EntityRefContent} [subtitle=null] Optional supporting text before generated metadata.
|
|
25
|
-
* @property {EntityRefIcon} [icon='file'] File icon name, node, component, or lazy renderer.
|
|
29
|
+
* @property {EntityRefIcon} [icon='file'] File icon name, node, component, or lazy renderer. The
|
|
30
|
+
* generic `'file'` glyph gives way to one for the MIME type when the active icon set has it.
|
|
26
31
|
* @property {EntityRefMetadata[]} [metadata=[]] Additional labelled metadata.
|
|
27
32
|
* @property {EntityRefLinkValue} [link=null] Optional safe native file/download link.
|
|
28
33
|
* @property {EntityRefActionEntry[]} [actions=[]] File actions; application policy stays external.
|
|
@@ -30,7 +35,7 @@ import {
|
|
|
30
35
|
* @property {string|null} [statusLabel=null] Overrides the generated state label; an empty string hides it.
|
|
31
36
|
* @property {number|null} [progress=null] Completed percentage, clamped to 0–100.
|
|
32
37
|
* @property {boolean} [indeterminate=false] Whether active progress has no known amount.
|
|
33
|
-
* @property {'sm'|'md'|'lg'} [
|
|
38
|
+
* @property {'sm'|'md'|'lg'} [identitySize='md'] Size of the file's icon and name.
|
|
34
39
|
* @property {boolean} [wrap=false] Whether long identity text may wrap.
|
|
35
40
|
* @property {string} [actionsLabel='File actions'] Accessible name for secondary actions.
|
|
36
41
|
* @property {string} [locale] Locale passed to `formatFileSize`.
|
|
@@ -46,6 +51,7 @@ import {
|
|
|
46
51
|
* @property {string} name File name.
|
|
47
52
|
* @property {number|null} size Byte count.
|
|
48
53
|
* @property {string} mime MIME type.
|
|
54
|
+
* @property {string|null} typeLabel Explicit type label, or null for the one derived from `mime`.
|
|
49
55
|
* @property {EntityRefContent} subtitle Supporting content.
|
|
50
56
|
* @property {EntityRefIcon} icon File visual.
|
|
51
57
|
* @property {EntityRefMetadata[]} metadata Additional metadata.
|
|
@@ -55,7 +61,7 @@ import {
|
|
|
55
61
|
* @property {string|null} statusLabel Explicit state label, or null for the generated label.
|
|
56
62
|
* @property {number|null} progress Completed percentage.
|
|
57
63
|
* @property {boolean} indeterminate Unknown-progress state.
|
|
58
|
-
* @property {'sm'|'md'|'lg'}
|
|
64
|
+
* @property {'sm'|'md'|'lg'} identitySize Size of the file's icon and name.
|
|
59
65
|
* @property {boolean} wrap Wrapping state.
|
|
60
66
|
* @property {string} actionsLabel Action-group label.
|
|
61
67
|
* @property {string|undefined} locale File-size locale.
|
|
@@ -91,6 +97,7 @@ export class FileItem extends Component {
|
|
|
91
97
|
name: '',
|
|
92
98
|
size: null,
|
|
93
99
|
mime: '',
|
|
100
|
+
typeLabel: null,
|
|
94
101
|
subtitle: null,
|
|
95
102
|
icon: 'file',
|
|
96
103
|
metadata: [],
|
|
@@ -100,7 +107,7 @@ export class FileItem extends Component {
|
|
|
100
107
|
statusLabel: null,
|
|
101
108
|
progress: null,
|
|
102
109
|
indeterminate: false,
|
|
103
|
-
|
|
110
|
+
identitySize: 'md',
|
|
104
111
|
wrap: false,
|
|
105
112
|
actionsLabel: 'File actions'
|
|
106
113
|
};
|
|
@@ -207,7 +214,7 @@ export class FileItem extends Component {
|
|
|
207
214
|
const track = /** @type {HTMLElement} */ (this.refs.progressTrack);
|
|
208
215
|
const fill = /** @type {HTMLElement} */ (this.refs.progressFill);
|
|
209
216
|
root.dataset.status = state.status;
|
|
210
|
-
root.dataset.
|
|
217
|
+
root.dataset.identitySize = state.identitySize;
|
|
211
218
|
this._entity.set(entityOptions(state));
|
|
212
219
|
|
|
213
220
|
const label = fileStatusLabel(state);
|
|
@@ -282,6 +289,7 @@ export function normalizeFileItem(values = {}) {
|
|
|
282
289
|
name: String(source.name ?? ''),
|
|
283
290
|
size: rawSize !== null && Number.isFinite(rawSize) && rawSize >= 0 ? rawSize : null,
|
|
284
291
|
mime: String(source.mime ?? ''),
|
|
292
|
+
typeLabel: source.typeLabel == null ? null : String(source.typeLabel),
|
|
285
293
|
subtitle: normalizeFileContent(source.subtitle),
|
|
286
294
|
icon: source.icon === undefined ? 'file' : source.icon,
|
|
287
295
|
metadata: normalizeEntityRefMetadata(source.metadata),
|
|
@@ -292,7 +300,7 @@ export function normalizeFileItem(values = {}) {
|
|
|
292
300
|
progress: rawProgress === null || !Number.isFinite(rawProgress)
|
|
293
301
|
? null : Math.min(100, Math.max(0, rawProgress)),
|
|
294
302
|
indeterminate: Boolean(source.indeterminate),
|
|
295
|
-
|
|
303
|
+
identitySize: source.identitySize === 'sm' || source.identitySize === 'lg' ? source.identitySize : 'md',
|
|
296
304
|
wrap: Boolean(source.wrap),
|
|
297
305
|
actionsLabel: String(source.actionsLabel || 'File actions'),
|
|
298
306
|
locale: normalizeLocale(source.locale)
|
|
@@ -302,7 +310,8 @@ export function normalizeFileItem(values = {}) {
|
|
|
302
310
|
/** @param {FileItemState} state @returns {import('../entity-ref/entity-ref.js').EntityRefOptions} */
|
|
303
311
|
function entityOptions(state) {
|
|
304
312
|
const metadata = [];
|
|
305
|
-
|
|
313
|
+
const type = state.typeLabel ?? fileTypeLabel(state.mime, state.name);
|
|
314
|
+
if (type) metadata.push({ label: 'Type', value: type, showLabel: false });
|
|
306
315
|
if (state.size !== null) {
|
|
307
316
|
metadata.push({
|
|
308
317
|
label: 'Size',
|
|
@@ -315,16 +324,141 @@ function entityOptions(state) {
|
|
|
315
324
|
id: state.id,
|
|
316
325
|
title: state.name,
|
|
317
326
|
subtitle: state.subtitle,
|
|
318
|
-
icon: state.icon,
|
|
327
|
+
icon: state.icon === 'file' ? fileTypeIcon(state.mime) : state.icon,
|
|
319
328
|
metadata,
|
|
320
329
|
link: state.link,
|
|
321
330
|
actions: state.actions,
|
|
322
|
-
size: state.
|
|
331
|
+
size: state.identitySize,
|
|
323
332
|
wrap: state.wrap,
|
|
324
333
|
actionsLabel: state.actionsLabel
|
|
325
334
|
};
|
|
326
335
|
}
|
|
327
336
|
|
|
337
|
+
/**
|
|
338
|
+
* What a MIME type is called and drawn as. Labels are what a reader recognises — a format's own
|
|
339
|
+
* acronym where it has a well-known one, the family otherwise — and icons are Font Awesome names
|
|
340
|
+
* in order of preference, used only when the active icon set can draw them.
|
|
341
|
+
* @type {Readonly<Record<string, {label: string, icons: string[]}>>}
|
|
342
|
+
*/
|
|
343
|
+
const FILE_KINDS = Object.freeze({
|
|
344
|
+
pdf: { label: 'PDF', icons: ['file-pdf'] },
|
|
345
|
+
csv: { label: 'CSV', icons: ['file-csv', 'file-excel'] },
|
|
346
|
+
tsv: { label: 'TSV', icons: ['file-csv', 'file-excel'] },
|
|
347
|
+
spreadsheet: { label: 'Spreadsheet', icons: ['file-excel'] },
|
|
348
|
+
document: { label: 'Document', icons: ['file-word', 'file-lines'] },
|
|
349
|
+
presentation: { label: 'Presentation', icons: ['file-powerpoint'] },
|
|
350
|
+
text: { label: 'Text', icons: ['file-lines'] },
|
|
351
|
+
markdown: { label: 'Markdown', icons: ['file-lines'] },
|
|
352
|
+
html: { label: 'HTML', icons: ['file-code', 'code'] },
|
|
353
|
+
css: { label: 'CSS', icons: ['file-code', 'code'] },
|
|
354
|
+
javascript: { label: 'JavaScript', icons: ['file-code', 'code'] },
|
|
355
|
+
json: { label: 'JSON', icons: ['file-code', 'code'] },
|
|
356
|
+
xml: { label: 'XML', icons: ['file-code', 'code'] },
|
|
357
|
+
zip: { label: 'ZIP', icons: ['file-zipper'] },
|
|
358
|
+
gzip: { label: 'GZIP', icons: ['file-zipper'] },
|
|
359
|
+
tar: { label: 'TAR', icons: ['file-zipper'] },
|
|
360
|
+
rar: { label: 'RAR', icons: ['file-zipper'] },
|
|
361
|
+
sevenZip: { label: '7Z', icons: ['file-zipper'] },
|
|
362
|
+
email: { label: 'Email', icons: ['envelope'] },
|
|
363
|
+
calendar: { label: 'Calendar', icons: ['calendar'] },
|
|
364
|
+
contact: { label: 'Contact', icons: ['address-card'] },
|
|
365
|
+
image: { label: 'Image', icons: ['file-image', 'image'] },
|
|
366
|
+
video: { label: 'Video', icons: ['file-video', 'video'] },
|
|
367
|
+
audio: { label: 'Audio', icons: ['file-audio'] },
|
|
368
|
+
font: { label: 'Font', icons: ['font'] }
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
/** Exact MIME types, lower case and without parameters, mapped to their kind. */
|
|
372
|
+
const MIME_KINDS = new Map(Object.entries({
|
|
373
|
+
'application/pdf': 'pdf',
|
|
374
|
+
'text/csv': 'csv',
|
|
375
|
+
'application/csv': 'csv',
|
|
376
|
+
'text/comma-separated-values': 'csv',
|
|
377
|
+
'text/tab-separated-values': 'tsv',
|
|
378
|
+
'application/vnd.ms-excel': 'spreadsheet',
|
|
379
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'spreadsheet',
|
|
380
|
+
'application/vnd.oasis.opendocument.spreadsheet': 'spreadsheet',
|
|
381
|
+
'application/msword': 'document',
|
|
382
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document',
|
|
383
|
+
'application/vnd.oasis.opendocument.text': 'document',
|
|
384
|
+
'application/rtf': 'document',
|
|
385
|
+
'text/rtf': 'document',
|
|
386
|
+
'application/vnd.ms-powerpoint': 'presentation',
|
|
387
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'presentation',
|
|
388
|
+
'application/vnd.oasis.opendocument.presentation': 'presentation',
|
|
389
|
+
'text/plain': 'text',
|
|
390
|
+
'text/markdown': 'markdown',
|
|
391
|
+
'text/x-markdown': 'markdown',
|
|
392
|
+
'text/html': 'html',
|
|
393
|
+
'application/xhtml+xml': 'html',
|
|
394
|
+
'text/css': 'css',
|
|
395
|
+
'text/javascript': 'javascript',
|
|
396
|
+
'application/javascript': 'javascript',
|
|
397
|
+
'application/json': 'json',
|
|
398
|
+
'application/ld+json': 'json',
|
|
399
|
+
'application/xml': 'xml',
|
|
400
|
+
'text/xml': 'xml',
|
|
401
|
+
'application/zip': 'zip',
|
|
402
|
+
'application/x-zip-compressed': 'zip',
|
|
403
|
+
'application/gzip': 'gzip',
|
|
404
|
+
'application/x-gzip': 'gzip',
|
|
405
|
+
'application/x-tar': 'tar',
|
|
406
|
+
'application/vnd.rar': 'rar',
|
|
407
|
+
'application/x-rar-compressed': 'rar',
|
|
408
|
+
'application/x-7z-compressed': 'sevenZip',
|
|
409
|
+
'message/rfc822': 'email',
|
|
410
|
+
'text/calendar': 'calendar',
|
|
411
|
+
'text/vcard': 'contact',
|
|
412
|
+
'text/x-vcard': 'contact'
|
|
413
|
+
}));
|
|
414
|
+
|
|
415
|
+
/** Top-level MIME types that name a whole family. */
|
|
416
|
+
const MIME_FAMILIES = new Map([
|
|
417
|
+
['image', 'image'], ['video', 'video'], ['audio', 'audio'], ['font', 'font'], ['text', 'text']
|
|
418
|
+
]);
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* @param {string} mime MIME type, possibly with parameters.
|
|
422
|
+
* @returns {{label: string, icons: string[]}|null}
|
|
423
|
+
*/
|
|
424
|
+
function fileKind(mime) {
|
|
425
|
+
const essence = String(mime ?? '').split(';')[0].trim().toLowerCase();
|
|
426
|
+
if (!essence) return null;
|
|
427
|
+
const kind = MIME_KINDS.get(essence) ?? MIME_FAMILIES.get(essence.split('/')[0]);
|
|
428
|
+
return kind ? FILE_KINDS[kind] : null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* A readable name for a file's type: "PDF", "CSV", "Image", "Spreadsheet". An unfamiliar MIME type
|
|
433
|
+
* falls back to the name's extension in capitals ("EXE"); without a MIME type, or with neither,
|
|
434
|
+
* the result is empty — a raw MIME string is never shown.
|
|
435
|
+
* @param {string} mime MIME type, possibly with parameters (`text/csv; charset=utf-8`).
|
|
436
|
+
* @param {string} [name=''] File name, consulted only when the MIME type is unfamiliar.
|
|
437
|
+
* @returns {string}
|
|
438
|
+
*/
|
|
439
|
+
export function fileTypeLabel(mime, name = '') {
|
|
440
|
+
if (!String(mime ?? '').trim()) return '';
|
|
441
|
+
const kind = fileKind(mime);
|
|
442
|
+
if (kind) return kind.label;
|
|
443
|
+
const extension = /\.([a-z0-9]{1,5})$/i.exec(String(name ?? ''))?.[1];
|
|
444
|
+
return extension ? extension.toUpperCase() : '';
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The icon name for a MIME type: a type-specific glyph when the active icon set can draw one,
|
|
449
|
+
* `'file'` otherwise. Font Awesome resolves every name it is given; the bundled inline set only
|
|
450
|
+
* those it carries or that `registerIcons()` added, so a missing glyph never shows a placeholder.
|
|
451
|
+
* @param {string} mime MIME type, possibly with parameters.
|
|
452
|
+
* @returns {string}
|
|
453
|
+
*/
|
|
454
|
+
export function fileTypeIcon(mime) {
|
|
455
|
+
const candidates = fileKind(mime)?.icons ?? [];
|
|
456
|
+
if (!candidates.length) return 'file';
|
|
457
|
+
if (getIconConfig().provider === 'fontawesome') return candidates[0];
|
|
458
|
+
const available = new Set(iconNames());
|
|
459
|
+
return candidates.find((name) => available.has(name)) ?? 'file';
|
|
460
|
+
}
|
|
461
|
+
|
|
328
462
|
/** @param {FileItemState} state @returns {string} */
|
|
329
463
|
function fileStatusLabel(state) {
|
|
330
464
|
return state.statusLabel === null ? STATUS_LABELS[state.status] : state.statusLabel;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { Component } from '../../core/component.js';
|
|
3
3
|
import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
|
|
4
|
+
import { captureItemFocus, restoreItemFocus } from '../../core/keyboard.js';
|
|
4
5
|
import { skeleton, skeletonText } from '../skeleton/skeleton.js';
|
|
5
6
|
import { FileItem, normalizeFileItem } from '../file-item/file-item.js';
|
|
6
7
|
|
|
@@ -26,6 +27,12 @@ import { FileItem, normalizeFileItem } from '../file-item/file-item.js';
|
|
|
26
27
|
/**
|
|
27
28
|
* Accessible collection for durable and transient files. Ordering and mutation are local
|
|
28
29
|
* presentation concerns only; transport, authorization, and persistence remain host-owned.
|
|
30
|
+
*
|
|
31
|
+
* Mutations are reconciled by file `id`: a file whose descriptor did not change keeps its row
|
|
32
|
+
* untouched, a changed one is updated in place through `FileItem.set()`, and only added or removed
|
|
33
|
+
* files create or drop rows. Upload progress can therefore tick through `updateItem()` while the
|
|
34
|
+
* reader's focus — on that file's Cancel button, say — and the list's scroll position stay put.
|
|
35
|
+
* Files without an `id` cannot be matched and are rebuilt on every mutation.
|
|
29
36
|
* @fires FileList#activate
|
|
30
37
|
* @fires FileList#action
|
|
31
38
|
* @fires FileList#datachange
|
|
@@ -63,6 +70,12 @@ export class FileList extends Component {
|
|
|
63
70
|
this._items = normalizeFileListItems(this.options.items);
|
|
64
71
|
this._loading = Boolean(this.options.loading);
|
|
65
72
|
this._itemComponents = [];
|
|
73
|
+
/** @type {Map<FileItem, {row: HTMLElement, state: FileItemState}>} */
|
|
74
|
+
this._itemRecords = new Map();
|
|
75
|
+
this._itemList = /** @type {HTMLElement|null} */ (null);
|
|
76
|
+
this._emptyRegion = /** @type {HTMLElement|null} */ (null);
|
|
77
|
+
this._loadingRegion = /** @type {HTMLElement|null} */ (null);
|
|
78
|
+
this._loadingPlaceholders = false;
|
|
66
79
|
|
|
67
80
|
root.setAttribute('aria-label', String(this.options.label || 'Files'));
|
|
68
81
|
this.listen(root, 'zx-activate', (event) => this._forwardActivate(/** @type {CustomEvent} */ (event)));
|
|
@@ -166,37 +179,104 @@ export class FileList extends Component {
|
|
|
166
179
|
if (!this._createdRoot && root) restoreTarget(root, this._snapshot);
|
|
167
180
|
}
|
|
168
181
|
|
|
169
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* Brings the rendered rows in line with `_items`, keyed by file id, and keeps focus on the
|
|
184
|
+
* control that held it when that control's row was updated or removed.
|
|
185
|
+
* @returns {void}
|
|
186
|
+
*/
|
|
170
187
|
_renderList() {
|
|
171
|
-
this._destroyItems();
|
|
172
188
|
const root = /** @type {HTMLElement} */ (this.el);
|
|
189
|
+
const focus = captureItemFocus(root, (element) => {
|
|
190
|
+
const index = this._itemComponents.findIndex((item) => item.el?.contains(element));
|
|
191
|
+
return index < 0 ? null : { key: { component: this._itemComponents[index], index }, element: this._itemComponents[index].el };
|
|
192
|
+
});
|
|
173
193
|
root.setAttribute('aria-busy', String(this._loading));
|
|
174
|
-
/** @type {HTMLElement[]} */
|
|
175
|
-
const content = [];
|
|
176
194
|
try {
|
|
177
|
-
|
|
178
|
-
const list = h('ol', { class: 'zx-file-list__items' });
|
|
179
|
-
for (const descriptor of this._items) {
|
|
180
|
-
const component = new FileItem(null, descriptor);
|
|
181
|
-
this._itemComponents.push(component);
|
|
182
|
-
list.append(h('li', { class: 'zx-file-list__item' }, component));
|
|
183
|
-
}
|
|
184
|
-
content.push(list);
|
|
185
|
-
} else if (!this._loading) {
|
|
186
|
-
content.push(h('div', {
|
|
187
|
-
class: 'zx-file-list__empty', role: 'status'
|
|
188
|
-
}, listContent(this.options.emptyText, 'FileList emptyText')));
|
|
189
|
-
}
|
|
190
|
-
if (this._loading) content.push(this._createLoading(this._items.length === 0));
|
|
191
|
-
root.replaceChildren(...content);
|
|
195
|
+
this._reconcileItems();
|
|
192
196
|
} catch (error) {
|
|
193
197
|
this._destroyItems();
|
|
194
198
|
throw error;
|
|
195
199
|
}
|
|
200
|
+
/** @type {HTMLElement[]} */
|
|
201
|
+
const content = [];
|
|
202
|
+
if (this._items.length) content.push(/** @type {HTMLElement} */ (this._itemList));
|
|
203
|
+
else if (!this._loading) {
|
|
204
|
+
this._emptyRegion ??= h('div', {
|
|
205
|
+
class: 'zx-file-list__empty', role: 'status'
|
|
206
|
+
}, listContent(this.options.emptyText, 'FileList emptyText'));
|
|
207
|
+
content.push(this._emptyRegion);
|
|
208
|
+
}
|
|
209
|
+
if (this._loading) content.push(this._createLoading(this._items.length === 0));
|
|
210
|
+
syncChildren(root, content);
|
|
211
|
+
restoreItemFocus(root, focus, (key) => {
|
|
212
|
+
const memo = /** @type {{component: FileItem, index: number}} */ (key);
|
|
213
|
+
if (this._itemComponents.includes(memo.component)) return memo.component.el;
|
|
214
|
+
// The focused file went away: its successor — or the new last file — takes over.
|
|
215
|
+
const fallback = this._itemComponents[Math.min(memo.index, this._itemComponents.length - 1)];
|
|
216
|
+
return fallback?.el ?? null;
|
|
217
|
+
});
|
|
196
218
|
}
|
|
197
219
|
|
|
198
|
-
/**
|
|
220
|
+
/**
|
|
221
|
+
* Keyed reconciliation of `FileItem` children. Rows are only moved when out of place, because
|
|
222
|
+
* detaching a row — even to re-insert it a moment later — takes focus out of it.
|
|
223
|
+
* @returns {void}
|
|
224
|
+
*/
|
|
225
|
+
_reconcileItems() {
|
|
226
|
+
const previous = this._itemComponents;
|
|
227
|
+
/** @type {Map<unknown, FileItem>} */
|
|
228
|
+
const byId = new Map();
|
|
229
|
+
for (const component of previous) {
|
|
230
|
+
const id = this._itemRecords.get(component)?.state.id ?? null;
|
|
231
|
+
if (id !== null) byId.set(id, component);
|
|
232
|
+
}
|
|
233
|
+
/** @type {FileItem[]} */
|
|
234
|
+
const next = [];
|
|
235
|
+
for (const descriptor of this._items) {
|
|
236
|
+
const existing = descriptor.id === null ? undefined : byId.get(descriptor.id);
|
|
237
|
+
if (existing) {
|
|
238
|
+
byId.delete(descriptor.id);
|
|
239
|
+
const record = /** @type {{row: HTMLElement, state: FileItemState}} */ (this._itemRecords.get(existing));
|
|
240
|
+
if (!sameDescriptor(record.state, descriptor)) existing.set(descriptor);
|
|
241
|
+
record.state = descriptor;
|
|
242
|
+
next.push(existing);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const component = new FileItem(null, descriptor);
|
|
246
|
+
this._itemRecords.set(component, {
|
|
247
|
+
row: h('li', { class: 'zx-file-list__item' }, component),
|
|
248
|
+
state: descriptor
|
|
249
|
+
});
|
|
250
|
+
next.push(component);
|
|
251
|
+
}
|
|
252
|
+
const kept = new Set(next);
|
|
253
|
+
for (const component of previous) {
|
|
254
|
+
if (kept.has(component)) continue;
|
|
255
|
+
this._itemRecords.get(component)?.row.remove();
|
|
256
|
+
this._itemRecords.delete(component);
|
|
257
|
+
component.destroy();
|
|
258
|
+
}
|
|
259
|
+
this._itemComponents = next;
|
|
260
|
+
if (next.length === 0) return;
|
|
261
|
+
const list = this._itemList ??= h('ol', { class: 'zx-file-list__items' });
|
|
262
|
+
syncChildren(list, next.map((component) => /** @type {{row: HTMLElement}} */ (this._itemRecords.get(component)).row));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The loading region, kept across renders while its shape stays the same so that its live
|
|
267
|
+
* status is not re-announced by every mutation that happens during a load.
|
|
268
|
+
* @param {boolean} placeholders Whether skeleton rows stand in for an empty list.
|
|
269
|
+
* @returns {HTMLElement}
|
|
270
|
+
*/
|
|
199
271
|
_createLoading(placeholders) {
|
|
272
|
+
if (this._loadingRegion && this._loadingPlaceholders === placeholders) return this._loadingRegion;
|
|
273
|
+
this._loadingPlaceholders = placeholders;
|
|
274
|
+
this._loadingRegion = this._buildLoading(placeholders);
|
|
275
|
+
return this._loadingRegion;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** @param {boolean} placeholders @returns {HTMLElement} */
|
|
279
|
+
_buildLoading(placeholders) {
|
|
200
280
|
const region = h('div', {
|
|
201
281
|
class: 'zx-file-list__loading', role: 'status', ariaLive: 'polite'
|
|
202
282
|
}, h('span', { class: 'zx-file-list__loading-label' }, String(this.options.loadingText)));
|
|
@@ -214,7 +294,9 @@ export class FileList extends Component {
|
|
|
214
294
|
/** @returns {void} */
|
|
215
295
|
_destroyItems() {
|
|
216
296
|
for (const item of this._itemComponents) item.destroy();
|
|
217
|
-
this._itemComponents
|
|
297
|
+
this._itemComponents = [];
|
|
298
|
+
this._itemRecords?.clear();
|
|
299
|
+
this._itemList?.replaceChildren();
|
|
218
300
|
}
|
|
219
301
|
|
|
220
302
|
/** @param {CustomEvent} event @returns {void} */
|
|
@@ -297,6 +379,47 @@ function cloneFileDescriptor(item) {
|
|
|
297
379
|
};
|
|
298
380
|
}
|
|
299
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Makes `parent`'s children exactly `children`, in order, moving only the nodes that are out of
|
|
384
|
+
* place so that an untouched node — and the focus inside it — never leaves the document.
|
|
385
|
+
* @param {Element} parent Container.
|
|
386
|
+
* @param {Node[]} children Desired children.
|
|
387
|
+
* @returns {void}
|
|
388
|
+
*/
|
|
389
|
+
function syncChildren(parent, children) {
|
|
390
|
+
const wanted = new Set(children);
|
|
391
|
+
for (const node of [...parent.childNodes]) if (!wanted.has(node)) node.remove();
|
|
392
|
+
children.forEach((node, index) => {
|
|
393
|
+
const current = parent.childNodes[index] ?? null;
|
|
394
|
+
if (current !== node) parent.insertBefore(node, current);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Structural equality for normalized file descriptors: primitives by value, arrays and plain
|
|
400
|
+
* objects member by member, and Nodes, Components, or functions by identity.
|
|
401
|
+
* @param {unknown} left Previous value.
|
|
402
|
+
* @param {unknown} right Next value.
|
|
403
|
+
* @returns {boolean}
|
|
404
|
+
*/
|
|
405
|
+
function sameDescriptor(left, right) {
|
|
406
|
+
if (Object.is(left, right)) return true;
|
|
407
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
408
|
+
return left.length === right.length && left.every((value, index) => sameDescriptor(value, right[index]));
|
|
409
|
+
}
|
|
410
|
+
if (!isPlainRecord(left) || !isPlainRecord(right)) return false;
|
|
411
|
+
const keys = Object.keys(left);
|
|
412
|
+
return keys.length === Object.keys(right).length
|
|
413
|
+
&& keys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && sameDescriptor(left[key], right[key]));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
417
|
+
function isPlainRecord(value) {
|
|
418
|
+
if (!value || typeof value !== 'object') return false;
|
|
419
|
+
const prototype = Object.getPrototypeOf(value);
|
|
420
|
+
return prototype === Object.prototype || prototype === null;
|
|
421
|
+
}
|
|
422
|
+
|
|
300
423
|
/** @param {unknown} value @param {string} label @returns {Node|string} */
|
|
301
424
|
function listContent(value, label) {
|
|
302
425
|
if (value == null) return '';
|