@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.
Files changed (252) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/README.md +44 -11
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +31 -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 +293 -17
  7. package/dist/types/components/avatar/avatar.d.ts +46 -7
  8. package/dist/types/components/badge/badge.d.ts +21 -1
  9. package/dist/types/components/button/button.d.ts +15 -0
  10. package/dist/types/components/calendar/calendar.d.ts +67 -5
  11. package/dist/types/components/card-view/card-view.d.ts +7 -4
  12. package/dist/types/components/card-view/record-card.d.ts +11 -0
  13. package/dist/types/components/chart/chart.d.ts +10 -0
  14. package/dist/types/components/checklist/checklist.d.ts +9 -0
  15. package/dist/types/components/code-editor/code-editor.d.ts +6 -17
  16. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  17. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  18. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  19. package/dist/types/components/datebox/datebox.d.ts +7 -2
  20. package/dist/types/components/dialog/dialog.d.ts +46 -6
  21. package/dist/types/components/dropdown/dropdown.d.ts +24 -9
  22. package/dist/types/components/entity-ref/entity-ref.d.ts +2 -2
  23. package/dist/types/components/field/field.d.ts +54 -2
  24. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  25. package/dist/types/components/fieldset/fieldset.d.ts +26 -1
  26. package/dist/types/components/file-item/file-item.d.ts +34 -6
  27. package/dist/types/components/file-list/file-list.d.ts +35 -3
  28. package/dist/types/components/filter/filter.d.ts +127 -3
  29. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  30. package/dist/types/components/form/form.d.ts +12 -1
  31. package/dist/types/components/kanban-view/kanban-card.d.ts +8 -0
  32. package/dist/types/components/launcher/launcher.d.ts +268 -15
  33. package/dist/types/components/list-toolbar/list-toolbar.d.ts +16 -7
  34. package/dist/types/components/menu-button/menu-button.d.ts +39 -5
  35. package/dist/types/components/message/message.d.ts +132 -17
  36. package/dist/types/components/modal/modal.d.ts +43 -2
  37. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  38. package/dist/types/components/pagination/pagination.d.ts +17 -3
  39. package/dist/types/components/select/color.d.ts +12 -0
  40. package/dist/types/components/select/glyphs.d.ts +124 -0
  41. package/dist/types/components/select/select.d.ts +17 -3
  42. package/dist/types/components/sheet/sheet.d.ts +15 -0
  43. package/dist/types/components/stat-tile/stat-tile.d.ts +16 -5
  44. package/dist/types/components/tabbox/tabbox.d.ts +9 -0
  45. package/dist/types/components/table/table.d.ts +311 -18
  46. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  47. package/dist/types/components/tree/tree.d.ts +13 -0
  48. package/dist/types/components/view/record-view.d.ts +59 -2
  49. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  50. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  51. package/dist/types/core/breakpoint.d.ts +1 -1
  52. package/dist/types/core/component.d.ts +12 -2
  53. package/dist/types/core/i18n.d.ts +27 -3
  54. package/dist/types/core/icons.d.ts +23 -25
  55. package/dist/types/core/keyboard.d.ts +98 -1
  56. package/dist/types/core/messages.d.ts +382 -0
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +42 -1
  59. package/dist/types/core/overlay-stack.d.ts +260 -0
  60. package/dist/types/index.d.ts +10 -5
  61. package/dist/types/internal/app-rail.d.ts +38 -0
  62. package/dist/types/internal/menu-items.d.ts +71 -3
  63. package/dist/types/zeyos/connect.d.ts +6 -0
  64. package/dist/types/zeyos/form.d.ts +50 -9
  65. package/dist/types/zeyos/index.d.ts +2 -1
  66. package/dist/types/zeyos/launcher.d.ts +9 -0
  67. package/dist/types/zeyos/module-bar.d.ts +176 -0
  68. package/dist/types/zeyos/query.d.ts +110 -5
  69. package/dist/types/zeyos/schema.d.ts +24 -1
  70. package/dist/types/zeyos/table.d.ts +345 -22
  71. package/dist/types/zeyos/view.d.ts +26 -2
  72. package/dist/zx-zeyos.esm.js +6178 -1008
  73. package/dist/zx-zeyos.esm.min.js +53 -5
  74. package/dist/zx.css +2861 -827
  75. package/dist/zx.esm.js +8909 -1480
  76. package/dist/zx.esm.min.js +55 -7
  77. package/dist/zx.global.js +8911 -1482
  78. package/dist/zx.global.min.js +55 -7
  79. package/dist/zx.min.css +1 -1
  80. package/docs/api.json +1079 -117
  81. package/docs/llms.md +956 -154
  82. package/docs/llms.txt +9 -5
  83. package/package.json +1 -1
  84. package/src/components/account-menu/account-menu.css +35 -10
  85. package/src/components/account-menu/account-menu.js +12 -0
  86. package/src/components/activity-item/activity-item.css +1 -1
  87. package/src/components/activity-item/activity-item.js +3 -0
  88. package/src/components/activity-list/activity-list.css +38 -3
  89. package/src/components/activity-list/activity-list.js +61 -1
  90. package/src/components/app-icon/app-icon.css +14 -3
  91. package/src/components/app-icon/app-icon.js +11 -1
  92. package/src/components/app-rail/app-rail.css +59 -79
  93. package/src/components/app-sidebar/app-sidebar.css +304 -168
  94. package/src/components/app-sidebar/app-sidebar.js +667 -46
  95. package/src/components/aurora/aurora.css +5 -3
  96. package/src/components/avatar/avatar.css +32 -7
  97. package/src/components/avatar/avatar.js +72 -8
  98. package/src/components/badge/badge.css +54 -2
  99. package/src/components/badge/badge.js +29 -2
  100. package/src/components/breadcrumb/breadcrumb.css +1 -1
  101. package/src/components/button/button.css +67 -9
  102. package/src/components/button/button.js +38 -0
  103. package/src/components/calendar/calendar.css +47 -47
  104. package/src/components/calendar/calendar.js +26 -52
  105. package/src/components/card/card.css +4 -4
  106. package/src/components/card-view/card-view.css +14 -12
  107. package/src/components/card-view/card-view.js +13 -9
  108. package/src/components/card-view/record-card.js +14 -3
  109. package/src/components/chart/chart.css +4 -1
  110. package/src/components/chart/chart.js +12 -0
  111. package/src/components/check-button/check-button.css +10 -2
  112. package/src/components/checklist/checklist.css +9 -2
  113. package/src/components/checklist/checklist.js +11 -5
  114. package/src/components/checklist/field-adapter.js +3 -1
  115. package/src/components/code-editor/code-editor.css +7 -6
  116. package/src/components/code-editor/code-editor.js +19 -1
  117. package/src/components/code-editor/field-adapter.js +9 -2
  118. package/src/components/context-menu/context-menu.css +9 -4
  119. package/src/components/context-menu/context-menu.js +8 -2
  120. package/src/components/copy/copy-button.css +6 -0
  121. package/src/components/copy/copy-input.css +9 -4
  122. package/src/components/data-filter/data-filter.css +5 -4
  123. package/src/components/data-filter/data-filter.js +2 -2
  124. package/src/components/date-picker/date-picker.css +6 -6
  125. package/src/components/date-picker/date-range-picker.css +3 -3
  126. package/src/components/date-picker/time-picker.css +3 -3
  127. package/src/components/datebox/date-range-box.css +79 -2
  128. package/src/components/datebox/date-range-box.js +13 -2
  129. package/src/components/datebox/datebox.css +79 -2
  130. package/src/components/datebox/datebox.js +13 -2
  131. package/src/components/dialog/dialog.css +26 -0
  132. package/src/components/dialog/dialog.js +99 -17
  133. package/src/components/dock/dock.css +5 -4
  134. package/src/components/dropdown/dropdown.css +1 -1
  135. package/src/components/dropdown/dropdown.js +159 -72
  136. package/src/components/empty-state/empty-state.css +2 -2
  137. package/src/components/entity-ref/entity-ref.css +1 -1
  138. package/src/components/entity-ref/entity-ref.js +1 -1
  139. package/src/components/field/field.css +28 -9
  140. package/src/components/field/field.js +101 -29
  141. package/src/components/field-upload/field-upload.css +1 -1
  142. package/src/components/fieldset/external-adapter.js +363 -0
  143. package/src/components/fieldset/fieldset.css +5 -3
  144. package/src/components/fieldset/fieldset.js +38 -64
  145. package/src/components/file-item/file-item.js +144 -10
  146. package/src/components/file-list/file-list.js +144 -21
  147. package/src/components/filter/filter.css +86 -2
  148. package/src/components/filter/filter.js +307 -11
  149. package/src/components/filter-panel/filter-panel.css +1 -1
  150. package/src/components/filter-panel/filter-panel.js +149 -32
  151. package/src/components/finder/finder.css +16 -14
  152. package/src/components/form/form.css +5 -4
  153. package/src/components/form/form.js +11 -2
  154. package/src/components/grid/grid.css +1 -1
  155. package/src/components/groupbox/groupbox.css +1 -1
  156. package/src/components/kanban-view/kanban-card.js +11 -3
  157. package/src/components/kanban-view/kanban-view.css +42 -18
  158. package/src/components/kanban-view/kanban-view.js +9 -6
  159. package/src/components/launcher/launcher.css +108 -26
  160. package/src/components/launcher/launcher.js +530 -42
  161. package/src/components/list-toolbar/list-toolbar.css +13 -8
  162. package/src/components/list-toolbar/list-toolbar.js +40 -14
  163. package/src/components/master-panel/master-panel.css +2 -0
  164. package/src/components/master-panel/master-panel.js +0 -1
  165. package/src/components/menu-button/menu-button.css +72 -4
  166. package/src/components/menu-button/menu-button.js +160 -18
  167. package/src/components/message/message.css +33 -3
  168. package/src/components/message/message.js +264 -22
  169. package/src/components/modal/modal.css +1 -1
  170. package/src/components/modal/modal.js +83 -15
  171. package/src/components/module-bar/module-bar.css +486 -0
  172. package/src/components/module-bar/module-bar.js +1074 -0
  173. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  174. package/src/components/navigation-bar/navigation-bar.css +18 -9
  175. package/src/components/number-field/field-adapter.js +3 -1
  176. package/src/components/number-field/number-field.css +70 -0
  177. package/src/components/number-field/number-field.js +2 -0
  178. package/src/components/pagination/pagination.css +13 -11
  179. package/src/components/pagination/pagination.js +20 -4
  180. package/src/components/panel/panel.css +4 -3
  181. package/src/components/questionnaire/questionnaire.css +4 -4
  182. package/src/components/rating/field-adapter.js +2 -1
  183. package/src/components/rating/rating.css +8 -1
  184. package/src/components/search/search.css +4 -1
  185. package/src/components/select/color.js +22 -0
  186. package/src/components/select/field-adapter.js +2 -0
  187. package/src/components/select/glyphs.js +165 -0
  188. package/src/components/select/select.css +138 -45
  189. package/src/components/select/select.js +92 -47
  190. package/src/components/sheet/sheet.css +12 -9
  191. package/src/components/sheet/sheet.js +74 -32
  192. package/src/components/slider/field-adapter.js +6 -1
  193. package/src/components/slider/slider.css +18 -4
  194. package/src/components/slider/slider.js +1 -1
  195. package/src/components/sort-control/sort-control.css +4 -1
  196. package/src/components/split-view/split-view.css +1 -1
  197. package/src/components/stat-tile/stat-tile.css +42 -47
  198. package/src/components/stat-tile/stat-tile.js +17 -6
  199. package/src/components/stepper/stepper.css +1 -1
  200. package/src/components/tabbox/tabbox.css +41 -32
  201. package/src/components/tabbox/tabbox.js +5 -1
  202. package/src/components/table/table.css +187 -37
  203. package/src/components/table/table.js +718 -73
  204. package/src/components/tag-picker/field-adapter.js +2 -1
  205. package/src/components/tag-picker/tag-picker.css +83 -5
  206. package/src/components/tag-picker/tag-picker.js +25 -8
  207. package/src/components/timebox/timebox.css +22 -2
  208. package/src/components/toggle/toggle.css +12 -1
  209. package/src/components/toolbar/toolbar.js +2 -2
  210. package/src/components/tooltip/tooltip.css +4 -0
  211. package/src/components/tooltip/tooltip.js +44 -19
  212. package/src/components/tree/tree.css +26 -17
  213. package/src/components/tree/tree.js +31 -0
  214. package/src/components/value-list/value-list.css +3 -3
  215. package/src/components/view/record-view.css +1 -1
  216. package/src/components/view/record-view.js +178 -18
  217. package/src/components/view-settings/view-settings.css +285 -0
  218. package/src/components/view-settings/view-settings.js +1830 -0
  219. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  220. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  221. package/src/core/breakpoint.js +1 -1
  222. package/src/core/component.js +88 -17
  223. package/src/core/fontawesome.js +5 -1
  224. package/src/core/i18n.js +47 -4
  225. package/src/core/icons.js +204 -61
  226. package/src/core/keyboard.js +206 -11
  227. package/src/core/messages.js +427 -0
  228. package/src/core/motion.js +147 -0
  229. package/src/core/overlay-host.js +77 -3
  230. package/src/core/overlay-stack.js +487 -0
  231. package/src/core/position.js +22 -3
  232. package/src/index.js +10 -5
  233. package/src/internal/app-rail.js +135 -55
  234. package/src/internal/menu-items.js +61 -3
  235. package/src/zeyos/connect.js +6 -2
  236. package/src/zeyos/form.js +69 -27
  237. package/src/zeyos/index.js +4 -1
  238. package/src/zeyos/launcher.js +8 -3
  239. package/src/zeyos/module-bar.js +181 -0
  240. package/src/zeyos/query.js +197 -13
  241. package/src/zeyos/schema.js +144 -27
  242. package/src/zeyos/table.js +287 -84
  243. package/src/zeyos/view.js +49 -93
  244. package/styles/base.css +72 -1
  245. package/styles/icons.css +11 -1
  246. package/styles/print.css +1 -1
  247. package/styles/tokens/dark.css +117 -37
  248. package/styles/tokens/global.css +121 -27
  249. package/styles/tokens/semantic.css +140 -43
  250. package/styles/tokens/themes.css +7 -6
  251. package/styles/zx.css +3 -1
  252. 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-lg);
22
- font-weight: 600;
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
- adapter: () => externalAdapter(source)
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
- instance = new Field(null, { ...options, id: options.id ?? key });
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'} [density='md'] File-identity presentation size.
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'} density Identity size.
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
- density: 'md',
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.density = state.density;
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
- density: source.density === 'sm' || source.density === 'lg' ? source.density : 'md',
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
- if (state.mime) metadata.push({ label: 'Type', value: state.mime, showLabel: false });
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.density,
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
- /** @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 '';