@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.
Files changed (154) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +2 -1
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +9 -1
  5. package/dist/types/components/app-icon/app-icon.d.ts +14 -2
  6. package/dist/types/components/app-sidebar/app-sidebar.d.ts +288 -14
  7. package/dist/types/components/calendar/calendar.d.ts +67 -5
  8. package/dist/types/components/card-view/card-view.d.ts +7 -4
  9. package/dist/types/components/card-view/record-card.d.ts +11 -0
  10. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  11. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  12. package/dist/types/components/dropdown/dropdown.d.ts +22 -7
  13. package/dist/types/components/field/field.d.ts +30 -0
  14. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  15. package/dist/types/components/fieldset/fieldset.d.ts +7 -0
  16. package/dist/types/components/file-list/file-list.d.ts +35 -3
  17. package/dist/types/components/filter/filter.d.ts +127 -3
  18. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  19. package/dist/types/components/launcher/launcher.d.ts +8 -0
  20. package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
  21. package/dist/types/components/menu-button/menu-button.d.ts +6 -0
  22. package/dist/types/components/message/message.d.ts +14 -1
  23. package/dist/types/components/modal/modal.d.ts +30 -0
  24. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  25. package/dist/types/components/pagination/pagination.d.ts +17 -3
  26. package/dist/types/components/select/select.d.ts +6 -0
  27. package/dist/types/components/sheet/sheet.d.ts +15 -0
  28. package/dist/types/components/table/table.d.ts +265 -17
  29. package/dist/types/components/view/record-view.d.ts +59 -2
  30. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  31. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  32. package/dist/types/core/component.d.ts +12 -2
  33. package/dist/types/core/i18n.d.ts +27 -3
  34. package/dist/types/core/keyboard.d.ts +98 -1
  35. package/dist/types/core/messages.d.ts +382 -0
  36. package/dist/types/core/overlay-host.d.ts +28 -1
  37. package/dist/types/core/overlay-stack.d.ts +260 -0
  38. package/dist/types/index.d.ts +5 -2
  39. package/dist/types/internal/app-rail.d.ts +38 -0
  40. package/dist/types/internal/menu-items.d.ts +71 -3
  41. package/dist/types/zeyos/index.d.ts +1 -0
  42. package/dist/types/zeyos/launcher.d.ts +9 -0
  43. package/dist/types/zeyos/module-bar.d.ts +176 -0
  44. package/dist/types/zeyos/query.d.ts +31 -5
  45. package/dist/types/zeyos/table.d.ts +245 -8
  46. package/dist/types/zeyos/view.d.ts +16 -2
  47. package/dist/zx-zeyos.esm.js +4422 -430
  48. package/dist/zx-zeyos.esm.min.js +5 -5
  49. package/dist/zx.css +1417 -134
  50. package/dist/zx.esm.js +6986 -830
  51. package/dist/zx.esm.min.js +8 -8
  52. package/dist/zx.global.js +6986 -830
  53. package/dist/zx.global.min.js +8 -8
  54. package/dist/zx.min.css +1 -1
  55. package/docs/api.json +794 -17
  56. package/docs/llms.md +503 -63
  57. package/docs/llms.txt +6 -2
  58. package/package.json +1 -1
  59. package/src/components/account-menu/account-menu.js +12 -0
  60. package/src/components/activity-list/activity-list.js +27 -1
  61. package/src/components/app-icon/app-icon.css +13 -2
  62. package/src/components/app-icon/app-icon.js +11 -1
  63. package/src/components/app-rail/app-rail.css +19 -0
  64. package/src/components/app-sidebar/app-sidebar.css +204 -3
  65. package/src/components/app-sidebar/app-sidebar.js +646 -40
  66. package/src/components/breadcrumb/breadcrumb.css +1 -1
  67. package/src/components/calendar/calendar.css +1 -1
  68. package/src/components/calendar/calendar.js +26 -52
  69. package/src/components/card/card.css +2 -2
  70. package/src/components/card-view/card-view.css +5 -5
  71. package/src/components/card-view/card-view.js +13 -9
  72. package/src/components/card-view/record-card.js +14 -3
  73. package/src/components/code-editor/code-editor.css +1 -1
  74. package/src/components/code-editor/field-adapter.js +9 -2
  75. package/src/components/context-menu/context-menu.js +8 -2
  76. package/src/components/copy/copy-input.css +1 -1
  77. package/src/components/data-filter/data-filter.js +2 -2
  78. package/src/components/date-picker/time-picker.css +2 -2
  79. package/src/components/dock/dock.css +5 -4
  80. package/src/components/dropdown/dropdown.js +157 -72
  81. package/src/components/field/field.js +33 -3
  82. package/src/components/fieldset/external-adapter.js +363 -0
  83. package/src/components/fieldset/fieldset.js +10 -59
  84. package/src/components/file-list/file-list.js +144 -21
  85. package/src/components/filter/filter.css +86 -2
  86. package/src/components/filter/filter.js +300 -8
  87. package/src/components/filter-panel/filter-panel.js +149 -32
  88. package/src/components/finder/finder.css +1 -1
  89. package/src/components/kanban-view/kanban-view.css +3 -3
  90. package/src/components/kanban-view/kanban-view.js +5 -2
  91. package/src/components/launcher/launcher.css +1 -1
  92. package/src/components/launcher/launcher.js +29 -3
  93. package/src/components/list-toolbar/list-toolbar.js +39 -13
  94. package/src/components/master-panel/master-panel.js +0 -1
  95. package/src/components/menu-button/menu-button.css +63 -0
  96. package/src/components/menu-button/menu-button.js +97 -11
  97. package/src/components/message/message.js +60 -20
  98. package/src/components/modal/modal.css +1 -1
  99. package/src/components/modal/modal.js +66 -13
  100. package/src/components/module-bar/module-bar.css +486 -0
  101. package/src/components/module-bar/module-bar.js +1074 -0
  102. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  103. package/src/components/number-field/field-adapter.js +2 -1
  104. package/src/components/pagination/pagination.css +5 -5
  105. package/src/components/pagination/pagination.js +20 -4
  106. package/src/components/panel/panel.css +3 -2
  107. package/src/components/questionnaire/questionnaire.css +4 -4
  108. package/src/components/rating/field-adapter.js +2 -1
  109. package/src/components/rating/rating.css +1 -1
  110. package/src/components/search/search.css +4 -1
  111. package/src/components/select/select.js +9 -0
  112. package/src/components/sheet/sheet.css +1 -1
  113. package/src/components/sheet/sheet.js +74 -32
  114. package/src/components/slider/field-adapter.js +6 -1
  115. package/src/components/slider/slider.css +2 -2
  116. package/src/components/split-view/split-view.css +1 -1
  117. package/src/components/stat-tile/stat-tile.css +1 -1
  118. package/src/components/stepper/stepper.css +1 -1
  119. package/src/components/tabbox/tabbox.css +9 -5
  120. package/src/components/table/table.css +86 -11
  121. package/src/components/table/table.js +628 -57
  122. package/src/components/tag-picker/field-adapter.js +2 -1
  123. package/src/components/tooltip/tooltip.js +35 -17
  124. package/src/components/tree/tree.css +1 -1
  125. package/src/components/view/record-view.css +1 -1
  126. package/src/components/view/record-view.js +178 -18
  127. package/src/components/view-settings/view-settings.css +285 -0
  128. package/src/components/view-settings/view-settings.js +1830 -0
  129. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  130. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  131. package/src/core/component.js +88 -17
  132. package/src/core/fontawesome.js +5 -1
  133. package/src/core/i18n.js +47 -4
  134. package/src/core/icons.js +12 -2
  135. package/src/core/keyboard.js +206 -11
  136. package/src/core/messages.js +427 -0
  137. package/src/core/overlay-host.js +64 -9
  138. package/src/core/overlay-stack.js +487 -0
  139. package/src/core/position.js +22 -3
  140. package/src/index.js +5 -2
  141. package/src/internal/app-rail.js +134 -53
  142. package/src/internal/menu-items.js +61 -3
  143. package/src/zeyos/form.js +5 -3
  144. package/src/zeyos/index.js +3 -0
  145. package/src/zeyos/launcher.js +8 -3
  146. package/src/zeyos/module-bar.js +181 -0
  147. package/src/zeyos/query.js +44 -13
  148. package/src/zeyos/table.js +220 -80
  149. package/src/zeyos/view.js +43 -93
  150. package/styles/base.css +16 -1
  151. package/styles/print.css +1 -1
  152. package/styles/tokens/dark.css +4 -0
  153. package/styles/tokens/semantic.css +56 -28
  154. 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
- adapter: () => externalAdapter(source)
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
- /** @returns {void} */
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
- if (this._items.length) {
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
- /** @param {boolean} placeholders @returns {HTMLElement} */
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.length = 0;
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 '';