@zeyos/zx 5.0.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 +164 -0
- package/README.md +2 -1
- package/dist/types/components/account-menu/account-menu.d.ts +8 -0
- package/dist/types/components/activity-list/activity-list.d.ts +9 -1
- package/dist/types/components/app-icon/app-icon.d.ts +14 -2
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +288 -14
- 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/context-menu/context-menu.d.ts +6 -2
- package/dist/types/components/data-filter/data-filter.d.ts +4 -4
- package/dist/types/components/dropdown/dropdown.d.ts +22 -7
- package/dist/types/components/field/field.d.ts +30 -0
- package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
- package/dist/types/components/fieldset/fieldset.d.ts +7 -0
- 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/launcher/launcher.d.ts +8 -0
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
- package/dist/types/components/menu-button/menu-button.d.ts +6 -0
- package/dist/types/components/message/message.d.ts +14 -1
- package/dist/types/components/modal/modal.d.ts +30 -0
- 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/select.d.ts +6 -0
- package/dist/types/components/sheet/sheet.d.ts +15 -0
- package/dist/types/components/table/table.d.ts +265 -17
- 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/component.d.ts +12 -2
- package/dist/types/core/i18n.d.ts +27 -3
- package/dist/types/core/keyboard.d.ts +98 -1
- package/dist/types/core/messages.d.ts +382 -0
- package/dist/types/core/overlay-host.d.ts +28 -1
- package/dist/types/core/overlay-stack.d.ts +260 -0
- package/dist/types/index.d.ts +5 -2
- package/dist/types/internal/app-rail.d.ts +38 -0
- package/dist/types/internal/menu-items.d.ts +71 -3
- package/dist/types/zeyos/index.d.ts +1 -0
- 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 +31 -5
- package/dist/types/zeyos/table.d.ts +245 -8
- package/dist/types/zeyos/view.d.ts +16 -2
- package/dist/zx-zeyos.esm.js +4422 -430
- package/dist/zx-zeyos.esm.min.js +5 -5
- package/dist/zx.css +1417 -134
- package/dist/zx.esm.js +6986 -830
- package/dist/zx.esm.min.js +8 -8
- package/dist/zx.global.js +6986 -830
- package/dist/zx.global.min.js +8 -8
- package/dist/zx.min.css +1 -1
- package/docs/api.json +794 -17
- package/docs/llms.md +503 -63
- package/docs/llms.txt +6 -2
- package/package.json +1 -1
- package/src/components/account-menu/account-menu.js +12 -0
- package/src/components/activity-list/activity-list.js +27 -1
- package/src/components/app-icon/app-icon.css +13 -2
- package/src/components/app-icon/app-icon.js +11 -1
- package/src/components/app-rail/app-rail.css +19 -0
- package/src/components/app-sidebar/app-sidebar.css +204 -3
- package/src/components/app-sidebar/app-sidebar.js +646 -40
- package/src/components/breadcrumb/breadcrumb.css +1 -1
- package/src/components/calendar/calendar.css +1 -1
- package/src/components/calendar/calendar.js +26 -52
- package/src/components/card/card.css +2 -2
- package/src/components/card-view/card-view.css +5 -5
- package/src/components/card-view/card-view.js +13 -9
- package/src/components/card-view/record-card.js +14 -3
- package/src/components/code-editor/code-editor.css +1 -1
- package/src/components/code-editor/field-adapter.js +9 -2
- package/src/components/context-menu/context-menu.js +8 -2
- package/src/components/copy/copy-input.css +1 -1
- package/src/components/data-filter/data-filter.js +2 -2
- package/src/components/date-picker/time-picker.css +2 -2
- package/src/components/dock/dock.css +5 -4
- package/src/components/dropdown/dropdown.js +157 -72
- package/src/components/field/field.js +33 -3
- package/src/components/fieldset/external-adapter.js +363 -0
- package/src/components/fieldset/fieldset.js +10 -59
- package/src/components/file-list/file-list.js +144 -21
- package/src/components/filter/filter.css +86 -2
- package/src/components/filter/filter.js +300 -8
- package/src/components/filter-panel/filter-panel.js +149 -32
- package/src/components/finder/finder.css +1 -1
- package/src/components/kanban-view/kanban-view.css +3 -3
- package/src/components/kanban-view/kanban-view.js +5 -2
- package/src/components/launcher/launcher.css +1 -1
- package/src/components/launcher/launcher.js +29 -3
- package/src/components/list-toolbar/list-toolbar.js +39 -13
- package/src/components/master-panel/master-panel.js +0 -1
- package/src/components/menu-button/menu-button.css +63 -0
- package/src/components/menu-button/menu-button.js +97 -11
- package/src/components/message/message.js +60 -20
- package/src/components/modal/modal.css +1 -1
- package/src/components/modal/modal.js +66 -13
- 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/number-field/field-adapter.js +2 -1
- package/src/components/pagination/pagination.css +5 -5
- package/src/components/pagination/pagination.js +20 -4
- package/src/components/panel/panel.css +3 -2
- package/src/components/questionnaire/questionnaire.css +4 -4
- package/src/components/rating/field-adapter.js +2 -1
- package/src/components/rating/rating.css +1 -1
- package/src/components/search/search.css +4 -1
- package/src/components/select/select.js +9 -0
- package/src/components/sheet/sheet.css +1 -1
- package/src/components/sheet/sheet.js +74 -32
- package/src/components/slider/field-adapter.js +6 -1
- package/src/components/slider/slider.css +2 -2
- package/src/components/split-view/split-view.css +1 -1
- package/src/components/stat-tile/stat-tile.css +1 -1
- package/src/components/stepper/stepper.css +1 -1
- package/src/components/tabbox/tabbox.css +9 -5
- package/src/components/table/table.css +86 -11
- package/src/components/table/table.js +628 -57
- package/src/components/tag-picker/field-adapter.js +2 -1
- package/src/components/tooltip/tooltip.js +35 -17
- package/src/components/tree/tree.css +1 -1
- 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/component.js +88 -17
- package/src/core/fontawesome.js +5 -1
- package/src/core/i18n.js +47 -4
- package/src/core/icons.js +12 -2
- package/src/core/keyboard.js +206 -11
- package/src/core/messages.js +427 -0
- package/src/core/overlay-host.js +64 -9
- package/src/core/overlay-stack.js +487 -0
- package/src/core/position.js +22 -3
- package/src/index.js +5 -2
- package/src/internal/app-rail.js +134 -53
- package/src/internal/menu-items.js +61 -3
- package/src/zeyos/form.js +5 -3
- package/src/zeyos/index.js +3 -0
- package/src/zeyos/launcher.js +8 -3
- package/src/zeyos/module-bar.js +181 -0
- package/src/zeyos/query.js +44 -13
- package/src/zeyos/table.js +220 -80
- package/src/zeyos/view.js +43 -93
- package/styles/base.css +16 -1
- package/styles/print.css +1 -1
- package/styles/tokens/dark.css +4 -0
- package/styles/tokens/semantic.css +56 -28
- package/styles/zx.css +3 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { isElement } from '../../core/util.js';
|
|
2
|
+
|
|
3
|
+
/** @typedef {import('../field/field.js').Field} Field */
|
|
4
|
+
/** @typedef {import('../field/field.js').FieldAdapter} FieldAdapter */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An element or component a Fieldset adopts with `addField(id, source)`: anything that renders
|
|
8
|
+
* into an element. Components are read through the methods they have, elements through their
|
|
9
|
+
* native properties.
|
|
10
|
+
* @typedef {Element|{el?: Element, toElement?: () => Element}} ExternalSource
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The value accessor pairs the library's components use, in the order they are tried. A reader
|
|
15
|
+
* counts only when it takes no required argument (`Fieldset.getValue(id)` is not a reader), a
|
|
16
|
+
* writer of another pair only when the value is its sole required argument.
|
|
17
|
+
* - `getValue/setValue`: Field, CodeEditor, Filter, SortControl, Toggle (`getValue()` is the
|
|
18
|
+
* configured on-value or `false`, the same as the `toggle` field type reads).
|
|
19
|
+
* - `get/set`: NumberField, Select, Slider, Rating, CheckButton, Datebox, Timebox, Search,
|
|
20
|
+
* CopyInput, DatePicker and the other pickers.
|
|
21
|
+
* - `getValues/setValues`: Checklist, TagPicker, ValueList, MultiValueEditor, Fieldset, Form.
|
|
22
|
+
* - `isChecked/setChecked`: boolean controls that name their state that way.
|
|
23
|
+
* @type {ReadonlyArray<readonly [string, string]>}
|
|
24
|
+
*/
|
|
25
|
+
export const VALUE_METHODS = Object.freeze([
|
|
26
|
+
Object.freeze(['getValue', 'setValue']),
|
|
27
|
+
Object.freeze(['get', 'set']),
|
|
28
|
+
Object.freeze(['getValues', 'setValues']),
|
|
29
|
+
Object.freeze(['isChecked', 'setChecked'])
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** Value properties a component may expose instead of methods, in the order they are tried. */
|
|
33
|
+
const VALUE_PROPERTIES = Object.freeze(['value', 'values', 'checked']);
|
|
34
|
+
|
|
35
|
+
/** Form controls the fallback disables or makes read-only inside a plain wrapper element. */
|
|
36
|
+
const CONTROLS = 'input, select, textarea, button';
|
|
37
|
+
const TEXT_CONTROLS = 'input, textarea';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The element a source renders into.
|
|
41
|
+
* @param {ExternalSource} source Element or component.
|
|
42
|
+
* @returns {Element}
|
|
43
|
+
*/
|
|
44
|
+
export function externalElement(source) {
|
|
45
|
+
if (isElement(source)) return /** @type {Element} */ (source);
|
|
46
|
+
const component = /** @type {{el?: Element, toElement?: () => Element}} */ (source);
|
|
47
|
+
if (typeof component.toElement === 'function') return component.toElement();
|
|
48
|
+
if (isElement(component.el)) return /** @type {Element} */ (component.el);
|
|
49
|
+
throw new TypeError('External field source must be an Element or a Component');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reads the current value of an element or component through the first value dialect it
|
|
54
|
+
* supports (see {@link VALUE_METHODS}), then a `value`, `values`, or `checked` property, then the
|
|
55
|
+
* element itself: `checked` for a checkbox or radio, `value` for any other control. Null when
|
|
56
|
+
* the source has no value at all.
|
|
57
|
+
* @param {ExternalSource} source Element or component.
|
|
58
|
+
* @returns {unknown}
|
|
59
|
+
*/
|
|
60
|
+
export function readExternalValue(source) {
|
|
61
|
+
return valueAccess(source, externalElement(source)).get();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Reports whether an element or component is disabled: its `isDisabled()`, a `disabled`
|
|
66
|
+
* property, the element's own `disabled`, an `aria-disabled="true"` or `data-disabled` root, or
|
|
67
|
+
* a wrapper whose every form control is disabled.
|
|
68
|
+
* @param {ExternalSource} source Element or component.
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
export function readExternalDisabled(source) {
|
|
72
|
+
const el = externalElement(source);
|
|
73
|
+
const component = isElement(source) ? null : /** @type {Record<string, any>} */ (source);
|
|
74
|
+
if (component && callable(component, 'isDisabled', 0)) return Boolean(component.isDisabled());
|
|
75
|
+
if (component && typeof component.disabled === 'boolean') return component.disabled;
|
|
76
|
+
if (hasNativeProperty(el, 'disabled')) return Boolean(/** @type {any} */ (el).disabled) || el.hasAttribute('disabled');
|
|
77
|
+
if (el.getAttribute('aria-disabled') === 'true') return true;
|
|
78
|
+
const flag = el.getAttribute('data-disabled');
|
|
79
|
+
if (flag !== null && flag !== 'false') return true;
|
|
80
|
+
const controls = [...el.querySelectorAll(CONTROLS)];
|
|
81
|
+
return controls.length > 0 && controls.every((control) => /** @type {any} */ (control).disabled);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reports whether an element or component is read-only: a `readonly`/`readOnly` property, the
|
|
86
|
+
* element's own `readOnly`, or an `aria-readonly="true"` or `data-readonly` root.
|
|
87
|
+
* @param {ExternalSource} source Element or component.
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
export function readExternalReadonly(source) {
|
|
91
|
+
const el = externalElement(source);
|
|
92
|
+
const component = isElement(source) ? null : /** @type {Record<string, any>} */ (source);
|
|
93
|
+
for (const name of ['readonly', 'readOnly']) {
|
|
94
|
+
if (component && typeof component[name] === 'boolean') return component[name];
|
|
95
|
+
}
|
|
96
|
+
if (hasNativeProperty(el, 'readOnly')) return Boolean(/** @type {any} */ (el).readOnly) || el.hasAttribute('readonly');
|
|
97
|
+
if (el.getAttribute('aria-readonly') === 'true') return true;
|
|
98
|
+
const flag = el.getAttribute('data-readonly');
|
|
99
|
+
return flag !== null && flag !== 'false';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Builds the Field adapter for an element or component that is not a registered field type.
|
|
104
|
+
*
|
|
105
|
+
* Values go through the source's own accessors ({@link readExternalValue}); disabling through
|
|
106
|
+
* `setDisabled(bool)`, then `enable()`/`disable()`, then a `disabled` property, and only then the
|
|
107
|
+
* DOM — the element's `disabled`, or for a plain wrapper `aria-disabled` plus `disabled` on every
|
|
108
|
+
* form control inside it. Read-only goes through `setReadonly(bool)`/`setReadOnly(bool)`, a
|
|
109
|
+
* `readonly`/`readOnly` property, or the element's own `readOnly`.
|
|
110
|
+
*
|
|
111
|
+
* Writes pass the caller's options to the component: `Field.setValue(v)` or
|
|
112
|
+
* `Fieldset.setValues(values)` without `silent` lets the component emit its own `change` to its
|
|
113
|
+
* own listeners, while the field emits one `change` for the write; with `silent` neither does.
|
|
114
|
+
*
|
|
115
|
+
* Given the field, a component's own `change` event becomes the field's `change`, and the native
|
|
116
|
+
* `change` its inner controls bubble is dropped unless it carries a value the component did not
|
|
117
|
+
* already report — so one edit is one field `change`, whether the component reports it, its
|
|
118
|
+
* input does, or (Search, CopyInput) only its input does.
|
|
119
|
+
*
|
|
120
|
+
* `destroy()` puts the element back where it was found and undoes what the adapter changed:
|
|
121
|
+
* attributes, the native value, disabled and read-only state.
|
|
122
|
+
* @param {ExternalSource} source Element or component to adopt.
|
|
123
|
+
* @param {Field|null} [field=null] The field the adapter serves; needed to forward events. Its
|
|
124
|
+
* `listen()` owns every listener the adapter adds, so destroying the field removes them.
|
|
125
|
+
* @returns {FieldAdapter}
|
|
126
|
+
*/
|
|
127
|
+
export function externalAdapter(source, field = null) {
|
|
128
|
+
const el = externalElement(source);
|
|
129
|
+
const component = isElement(source) ? null : /** @type {Record<string, any>} */ (source);
|
|
130
|
+
const parent = el.parentNode;
|
|
131
|
+
const next = el.nextSibling;
|
|
132
|
+
const attributes = new Map(['id', 'aria-labelledby', 'aria-describedby', 'aria-disabled', 'aria-invalid']
|
|
133
|
+
.map((name) => [name, el.getAttribute(name)]));
|
|
134
|
+
const native = component === null;
|
|
135
|
+
const originalValue = native && hasNativeProperty(el, 'value') ? /** @type {any} */ (el).value : undefined;
|
|
136
|
+
const originalChecked = native && isCheckable(el) ? /** @type {any} */ (el).checked : undefined;
|
|
137
|
+
const originalDisabled = readExternalDisabled(source);
|
|
138
|
+
const originalReadonly = readExternalReadonly(source);
|
|
139
|
+
/** @type {Map<Element, {disabled?: boolean, readOnly?: boolean}>} */
|
|
140
|
+
const poked = new Map();
|
|
141
|
+
const value = valueAccess(source, el);
|
|
142
|
+
const disabled = disabledAccess(component, el, poked);
|
|
143
|
+
const readonly = readonlyAccess(component, el, poked);
|
|
144
|
+
let lastDisabled = originalDisabled;
|
|
145
|
+
let lastReadonly = originalReadonly;
|
|
146
|
+
let setting = false;
|
|
147
|
+
let reported = value.get();
|
|
148
|
+
|
|
149
|
+
if (field && component && typeof component.addEventListener === 'function') {
|
|
150
|
+
// Through the field's `listen()`, so destroying the field unsubscribes. A write through
|
|
151
|
+
// `set()` is not forwarded: the Field reports that one itself.
|
|
152
|
+
field.listen(/** @type {any} */ (component), 'change', () => {
|
|
153
|
+
if (setting) return;
|
|
154
|
+
reported = value.get();
|
|
155
|
+
field.emit('change', { value: reported });
|
|
156
|
+
});
|
|
157
|
+
// Registered before the Field's own `change` listener on the same element, so a native
|
|
158
|
+
// change the component already reported never reaches it.
|
|
159
|
+
field.listen(el, 'change', (event) => {
|
|
160
|
+
const current = value.get();
|
|
161
|
+
if (sameValue(current, reported)) event.stopImmediatePropagation();
|
|
162
|
+
else reported = current;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** @type {FieldAdapter} */
|
|
167
|
+
const adapter = {
|
|
168
|
+
el,
|
|
169
|
+
get: () => value.get(),
|
|
170
|
+
set: (next, options) => {
|
|
171
|
+
setting = true;
|
|
172
|
+
try { value.set(next, options); }
|
|
173
|
+
finally { setting = false; }
|
|
174
|
+
reported = value.get();
|
|
175
|
+
},
|
|
176
|
+
focus: () => {
|
|
177
|
+
if (component && callable(component, 'focus', 0)) component.focus();
|
|
178
|
+
else if (typeof (/** @type {any} */ (el).focus) === 'function') /** @type {any} */ (el).focus();
|
|
179
|
+
},
|
|
180
|
+
setDisabled: (state) => {
|
|
181
|
+
lastDisabled = Boolean(state);
|
|
182
|
+
disabled(lastDisabled);
|
|
183
|
+
},
|
|
184
|
+
destroy: () => {
|
|
185
|
+
if (lastReadonly !== originalReadonly && readonly) readonly(originalReadonly);
|
|
186
|
+
if (lastDisabled !== originalDisabled) disabled(originalDisabled);
|
|
187
|
+
for (const [control, state] of poked) {
|
|
188
|
+
if (state.disabled !== undefined) /** @type {any} */ (control).disabled = state.disabled;
|
|
189
|
+
if (state.readOnly !== undefined) /** @type {any} */ (control).readOnly = state.readOnly;
|
|
190
|
+
}
|
|
191
|
+
poked.clear();
|
|
192
|
+
for (const [name, saved] of attributes) {
|
|
193
|
+
if (saved === null) el.removeAttribute(name);
|
|
194
|
+
else el.setAttribute(name, saved);
|
|
195
|
+
}
|
|
196
|
+
if (originalValue !== undefined) /** @type {any} */ (el).value = originalValue;
|
|
197
|
+
if (originalChecked !== undefined) /** @type {any} */ (el).checked = originalChecked;
|
|
198
|
+
if (parent) parent.insertBefore(el, next?.parentNode === parent ? next : null);
|
|
199
|
+
else el.remove();
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
if (readonly) {
|
|
203
|
+
adapter.setReadonly = (state) => {
|
|
204
|
+
lastReadonly = Boolean(state);
|
|
205
|
+
readonly(lastReadonly);
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return adapter;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The value reader and writer for a source: the first {@link VALUE_METHODS} pair whose reader
|
|
213
|
+
* exists (its own writer first, then any other dialect's), then a value property, then the
|
|
214
|
+
* element.
|
|
215
|
+
* @param {ExternalSource} source Element or component.
|
|
216
|
+
* @param {Element} el Its element.
|
|
217
|
+
* @returns {{get: () => unknown, set: (value: unknown, options?: {silent?: boolean}) => void}}
|
|
218
|
+
*/
|
|
219
|
+
function valueAccess(source, el) {
|
|
220
|
+
const component = isElement(source) ? null : /** @type {Record<string, any>} */ (source);
|
|
221
|
+
if (component) {
|
|
222
|
+
const pair = VALUE_METHODS.find(([reader]) => callable(component, reader, 0));
|
|
223
|
+
// The pair's own writer whatever its signature; another dialect's only when the value is its
|
|
224
|
+
// sole required argument.
|
|
225
|
+
const writer = !pair ? undefined : callable(component, pair[1], 2)
|
|
226
|
+
? pair[1]
|
|
227
|
+
: VALUE_METHODS.map(([, name]) => name).find((name) => callable(component, name, 1));
|
|
228
|
+
if (pair) {
|
|
229
|
+
return {
|
|
230
|
+
get: () => component[pair[0]](),
|
|
231
|
+
// The caller's options pass through: a write that is not silent reaches the component's
|
|
232
|
+
// own `change` listeners too, and the adapter does not forward it a second time.
|
|
233
|
+
set: (next, options = {}) => {
|
|
234
|
+
if (writer) component[writer](next, { ...options, silent: Boolean(options.silent) });
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const property = VALUE_PROPERTIES.find((name) => name in component);
|
|
239
|
+
if (property) {
|
|
240
|
+
return {
|
|
241
|
+
get: () => component[property],
|
|
242
|
+
set: (next) => { component[property] = next; }
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (isCheckable(el)) {
|
|
247
|
+
return {
|
|
248
|
+
get: () => Boolean(/** @type {any} */ (el).checked),
|
|
249
|
+
set: (next) => { /** @type {any} */ (el).checked = Boolean(next); }
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (hasNativeProperty(el, 'value')) {
|
|
253
|
+
return {
|
|
254
|
+
get: () => /** @type {any} */ (el).value,
|
|
255
|
+
set: (next) => { /** @type {any} */ (el).value = next == null ? '' : next; }
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return { get: () => null, set: () => {} };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The disabling dialect for a source.
|
|
263
|
+
* @param {Record<string, any>|null} component Component, or null for a plain element.
|
|
264
|
+
* @param {Element} el Its element.
|
|
265
|
+
* @param {Map<Element, {disabled?: boolean, readOnly?: boolean}>} poked Original states of the
|
|
266
|
+
* controls the DOM fallback touched.
|
|
267
|
+
* @returns {(disabled: boolean) => void}
|
|
268
|
+
*/
|
|
269
|
+
function disabledAccess(component, el, poked) {
|
|
270
|
+
if (component && callable(component, 'setDisabled', 1)) return (state) => component.setDisabled(state);
|
|
271
|
+
if (component && callable(component, 'enable', 0) && callable(component, 'disable', 0)) {
|
|
272
|
+
return (state) => (state ? component.disable() : component.enable());
|
|
273
|
+
}
|
|
274
|
+
if (component && typeof component.disabled === 'boolean') return (state) => { component.disabled = state; };
|
|
275
|
+
if (hasNativeProperty(el, 'disabled')) return (state) => { /** @type {any} */ (el).disabled = state; };
|
|
276
|
+
return (state) => {
|
|
277
|
+
el.setAttribute('aria-disabled', String(state));
|
|
278
|
+
for (const control of el.querySelectorAll(CONTROLS)) {
|
|
279
|
+
remember(poked, control, 'disabled');
|
|
280
|
+
/** @type {any} */ (control).disabled = state || Boolean(poked.get(control)?.disabled);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The read-only dialect for a source, or null when it has none.
|
|
287
|
+
* @param {Record<string, any>|null} component Component, or null for a plain element.
|
|
288
|
+
* @param {Element} el Its element.
|
|
289
|
+
* @param {Map<Element, {disabled?: boolean, readOnly?: boolean}>} poked Original states of the
|
|
290
|
+
* controls the DOM fallback touched.
|
|
291
|
+
* @returns {((readonly: boolean) => void)|null}
|
|
292
|
+
*/
|
|
293
|
+
function readonlyAccess(component, el, poked) {
|
|
294
|
+
if (component) {
|
|
295
|
+
for (const name of ['setReadonly', 'setReadOnly']) {
|
|
296
|
+
if (callable(component, name, 1)) return (state) => component[name](state);
|
|
297
|
+
}
|
|
298
|
+
for (const name of ['readonly', 'readOnly']) {
|
|
299
|
+
if (typeof component[name] === 'boolean') return (state) => { component[name] = state; };
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
if (hasNativeProperty(el, 'readOnly')) return (state) => { /** @type {any} */ (el).readOnly = state; };
|
|
304
|
+
const controls = [...el.querySelectorAll(TEXT_CONTROLS)];
|
|
305
|
+
if (!controls.length) return null;
|
|
306
|
+
return (state) => {
|
|
307
|
+
for (const control of controls) {
|
|
308
|
+
remember(poked, control, 'readOnly');
|
|
309
|
+
/** @type {any} */ (control).readOnly = state || Boolean(poked.get(control)?.readOnly);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* @param {Map<Element, {disabled?: boolean, readOnly?: boolean}>} poked
|
|
316
|
+
* @param {Element} control
|
|
317
|
+
* @param {'disabled'|'readOnly'} name
|
|
318
|
+
* @returns {void}
|
|
319
|
+
*/
|
|
320
|
+
function remember(poked, control, name) {
|
|
321
|
+
const state = poked.get(control) ?? {};
|
|
322
|
+
if (state[name] === undefined) state[name] = Boolean(/** @type {any} */ (control)[name]);
|
|
323
|
+
poked.set(control, state);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Whether `target[name]` is a method taking at most `arity` required arguments.
|
|
328
|
+
* @param {Record<string, any>} target
|
|
329
|
+
* @param {string} name
|
|
330
|
+
* @param {number} arity
|
|
331
|
+
* @returns {boolean}
|
|
332
|
+
*/
|
|
333
|
+
function callable(target, name, arity) {
|
|
334
|
+
const fn = target[name];
|
|
335
|
+
return typeof fn === 'function' && fn.length <= arity;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** @param {Element} el @param {string} name @returns {boolean} */
|
|
339
|
+
function hasNativeProperty(el, name) {
|
|
340
|
+
return ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'FIELDSET', 'OUTPUT'].includes(el.tagName) && name in el;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** @param {Element} el @returns {boolean} */
|
|
344
|
+
function isCheckable(el) {
|
|
345
|
+
const type = String(/** @type {any} */ (el).type ?? '').toLowerCase();
|
|
346
|
+
return el.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Value equality for change de-duplication: identical primitives, Dates at the same instant,
|
|
351
|
+
* arrays and plain objects with the same JSON.
|
|
352
|
+
* @param {unknown} a
|
|
353
|
+
* @param {unknown} b
|
|
354
|
+
* @returns {boolean}
|
|
355
|
+
*/
|
|
356
|
+
function sameValue(a, b) {
|
|
357
|
+
if (Object.is(a, b)) return true;
|
|
358
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
359
|
+
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
|
360
|
+
try { return JSON.stringify(a) === JSON.stringify(b); } catch { return false; }
|
|
361
|
+
}
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
@@ -2,6 +2,7 @@ import { Component } from '../../core/component.js';
|
|
|
2
2
|
import { h } from '../../core/dom.js';
|
|
3
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
|
|
@@ -56,6 +57,13 @@ export class Fieldset extends Component {
|
|
|
56
57
|
* Adds a field from options, an existing field, an element, or a component. The key becomes the
|
|
57
58
|
* field's `name` and its `data-field-id`; the control's DOM id is `<idPrefix>-<key>`, unless the
|
|
58
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.
|
|
59
67
|
* @param {string} id Logical field identifier.
|
|
60
68
|
* @param {FieldOptions|Field|Element|Component} field Field source.
|
|
61
69
|
* @returns {Field}
|
|
@@ -76,7 +84,8 @@ export class Fieldset extends Component {
|
|
|
76
84
|
label: key,
|
|
77
85
|
value: readExternalValue(source),
|
|
78
86
|
disabled: readExternalDisabled(source),
|
|
79
|
-
|
|
87
|
+
readonly: readExternalReadonly(source),
|
|
88
|
+
adapter: (owner) => externalAdapter(source, owner)
|
|
80
89
|
});
|
|
81
90
|
owned = true;
|
|
82
91
|
} else {
|
|
@@ -270,61 +279,3 @@ export class Fieldset extends Component {
|
|
|
270
279
|
super.destroy();
|
|
271
280
|
}
|
|
272
281
|
}
|
|
273
|
-
|
|
274
|
-
/** @param {Element|Component} source @returns {import('../field/field.js').FieldAdapter} */
|
|
275
|
-
function externalAdapter(source) {
|
|
276
|
-
const el = isElement(source) ? source : source.toElement();
|
|
277
|
-
const parent = el.parentNode;
|
|
278
|
-
const next = el.nextSibling;
|
|
279
|
-
const attributes = new Map(['id', 'aria-labelledby', 'aria-describedby', 'aria-disabled', 'aria-invalid']
|
|
280
|
-
.map((name) => [name, el.getAttribute(name)]));
|
|
281
|
-
const originalDisabled = 'disabled' in el ? el.disabled : undefined;
|
|
282
|
-
const originalValue = 'value' in el ? el.value : undefined;
|
|
283
|
-
return {
|
|
284
|
-
el,
|
|
285
|
-
get: () => {
|
|
286
|
-
if (typeof source.getValue === 'function') return source.getValue();
|
|
287
|
-
if (typeof source.getValues === 'function') return source.getValues();
|
|
288
|
-
if ('value' in el) return el.value;
|
|
289
|
-
return null;
|
|
290
|
-
},
|
|
291
|
-
set: (value, options) => {
|
|
292
|
-
if (typeof source.setValue === 'function') source.setValue(value, options);
|
|
293
|
-
else if (typeof source.setValues === 'function' && value && typeof value === 'object') source.setValues(value, options);
|
|
294
|
-
else if ('value' in el) el.value = value == null ? '' : value;
|
|
295
|
-
},
|
|
296
|
-
focus: () => {
|
|
297
|
-
if (typeof source.focus === 'function') source.focus();
|
|
298
|
-
else if (typeof el.focus === 'function') el.focus();
|
|
299
|
-
},
|
|
300
|
-
setDisabled: (disabled) => {
|
|
301
|
-
if (typeof source.setDisabled === 'function') source.setDisabled(disabled);
|
|
302
|
-
else if ('disabled' in el) el.disabled = disabled;
|
|
303
|
-
else el.setAttribute('aria-disabled', String(disabled));
|
|
304
|
-
},
|
|
305
|
-
destroy: () => {
|
|
306
|
-
for (const [name, value] of attributes) {
|
|
307
|
-
if (value === null) el.removeAttribute(name);
|
|
308
|
-
else el.setAttribute(name, value);
|
|
309
|
-
}
|
|
310
|
-
if (originalDisabled !== undefined) el.disabled = originalDisabled;
|
|
311
|
-
if (originalValue !== undefined) el.value = originalValue;
|
|
312
|
-
if (parent) parent.insertBefore(el, next?.parentNode === parent ? next : null);
|
|
313
|
-
else el.remove();
|
|
314
|
-
}
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/** @param {Element|Component} source @returns {unknown} */
|
|
319
|
-
function readExternalValue(source) {
|
|
320
|
-
const el = isElement(source) ? source : source.toElement();
|
|
321
|
-
if (typeof source.getValue === 'function') return source.getValue();
|
|
322
|
-
if (typeof source.getValues === 'function') return source.getValues();
|
|
323
|
-
return 'value' in el ? el.value : null;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/** @param {Element|Component} source @returns {boolean} */
|
|
327
|
-
function readExternalDisabled(source) {
|
|
328
|
-
const el = isElement(source) ? source : source.toElement();
|
|
329
|
-
return 'disabled' in el ? Boolean(el.disabled) : el.getAttribute('aria-disabled') === 'true';
|
|
330
|
-
}
|
|
@@ -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 '';
|