@zeyos/zx 4.1.0 → 4.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 (83) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/DESIGN-SYSTEM.md +33 -3
  3. package/README.md +17 -5
  4. package/dist/types/components/app-icon/app-icon.d.ts +8 -1
  5. package/dist/types/components/aurora/aurora.d.ts +72 -0
  6. package/dist/types/components/calendar/calendar-model.d.ts +202 -0
  7. package/dist/types/components/calendar/calendar.d.ts +500 -0
  8. package/dist/types/components/card-view/card-view.d.ts +303 -0
  9. package/dist/types/components/card-view/record-card.d.ts +242 -0
  10. package/dist/types/components/kanban-view/kanban-view.d.ts +572 -0
  11. package/dist/types/components/table/table.d.ts +120 -31
  12. package/dist/types/components/table-view/table-view.d.ts +192 -0
  13. package/dist/types/components/view/record-view.d.ts +464 -0
  14. package/dist/types/index.d.ts +7 -0
  15. package/dist/types/zeyos/calendar.d.ts +91 -0
  16. package/dist/types/zeyos/icons.d.ts +19 -3
  17. package/dist/types/zeyos/index.d.ts +4 -0
  18. package/dist/types/zeyos/legacy-saved-views.d.ts +66 -0
  19. package/dist/types/zeyos/saved-views.d.ts +331 -0
  20. package/dist/types/zeyos/view.d.ts +305 -0
  21. package/dist/zx-zeyos.esm.js +4627 -1460
  22. package/dist/zx-zeyos.esm.min.js +1 -1
  23. package/dist/zx.css +2492 -410
  24. package/dist/zx.esm.js +4891 -115
  25. package/dist/zx.esm.min.js +3 -3
  26. package/dist/zx.global.js +4891 -115
  27. package/dist/zx.global.min.js +3 -3
  28. package/dist/zx.min.css +1 -1
  29. package/docs/api.json +1702 -191
  30. package/docs/llms.md +207 -20
  31. package/docs/llms.txt +16 -6
  32. package/package.json +1 -1
  33. package/src/components/app-icon/app-icon.css +140 -30
  34. package/src/components/app-icon/app-icon.js +32 -5
  35. package/src/components/app-rail/app-rail.css +11 -2
  36. package/src/components/app-sidebar/app-sidebar.css +11 -2
  37. package/src/components/aurora/aurora.css +143 -0
  38. package/src/components/aurora/aurora.js +146 -0
  39. package/src/components/avatar/avatar.css +1 -1
  40. package/src/components/button/button.css +34 -2
  41. package/src/components/calendar/calendar-model.js +402 -0
  42. package/src/components/calendar/calendar.css +567 -0
  43. package/src/components/calendar/calendar.js +1524 -0
  44. package/src/components/card/card.css +25 -3
  45. package/src/components/card-view/card-view.css +389 -0
  46. package/src/components/card-view/card-view.js +527 -0
  47. package/src/components/card-view/record-card.js +392 -0
  48. package/src/components/context-menu/context-menu.css +2 -2
  49. package/src/components/date-picker/date-picker.css +15 -3
  50. package/src/components/datebox/date-range-box.css +15 -2
  51. package/src/components/datebox/datebox.css +16 -3
  52. package/src/components/dropdown/dropdown.css +15 -3
  53. package/src/components/kanban-view/kanban-view.css +216 -0
  54. package/src/components/kanban-view/kanban-view.js +1228 -0
  55. package/src/components/launcher/launcher.css +18 -5
  56. package/src/components/master-panel/master-panel.css +14 -3
  57. package/src/components/menu-button/menu-button.css +2 -2
  58. package/src/components/message/message.css +15 -10
  59. package/src/components/modal/modal.css +15 -3
  60. package/src/components/navigation-bar/navigation-bar.css +17 -5
  61. package/src/components/panel/panel.css +10 -2
  62. package/src/components/select/select.css +20 -4
  63. package/src/components/sheet/sheet.css +4 -1
  64. package/src/components/table/table.css +80 -4
  65. package/src/components/table/table.js +322 -37
  66. package/src/components/table-view/table-view.css +18 -0
  67. package/src/components/table-view/table-view.js +347 -0
  68. package/src/components/tag-picker/tag-picker.css +15 -3
  69. package/src/components/tooltip/tooltip.css +15 -3
  70. package/src/components/view/record-view.css +114 -0
  71. package/src/components/view/record-view.js +770 -0
  72. package/src/index.js +15 -0
  73. package/src/zeyos/calendar.js +115 -0
  74. package/src/zeyos/icons.js +15 -8
  75. package/src/zeyos/index.js +13 -0
  76. package/src/zeyos/launcher.js +2 -2
  77. package/src/zeyos/legacy-saved-views.js +155 -0
  78. package/src/zeyos/saved-views.js +815 -0
  79. package/src/zeyos/table.js +3 -1
  80. package/src/zeyos/view.js +543 -0
  81. package/styles/icons.css +3 -10
  82. package/styles/tokens/semantic.css +74 -2
  83. package/styles/zx.css +6 -0
@@ -0,0 +1,770 @@
1
+ // @ts-check
2
+ import { Component } from '../../core/component.js';
3
+ import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
4
+ import { icon } from '../../core/icons.js';
5
+ import { sortRows } from '../table/sort.js';
6
+
7
+ /** @typedef {Record<string, any>} ViewRecord */
8
+ /** @typedef {'asc'|'desc'} ViewSortDirection */
9
+ /** @typedef {false|'single'|'multi'} ViewSelectionMode */
10
+
11
+ /**
12
+ * @typedef {Object} ViewField
13
+ * @property {string} id Stable field identifier and default record property.
14
+ * @property {string} label Human-readable field label.
15
+ * @property {(record: ViewRecord, index: number) => unknown} [get] Value accessor.
16
+ * @property {(record: ViewRecord, index: number, value: unknown) => Node|string|number|null|undefined} [render]
17
+ * Text-safe renderer. A returned Node is adopted; every other value is inserted as text.
18
+ * @property {(record: ViewRecord, index: number) => unknown} [sortValue] Local sort accessor.
19
+ * @property {boolean} [sortable=false] Whether the field may become the active sort.
20
+ * @property {boolean} [visible=true] Initial visibility when `hiddenFields` does not name the field.
21
+ * @property {string} [type='text'] Semantic display type available to concrete views.
22
+ * @property {boolean} [duplicate=false] Whether title/subtitle fields also appear in metadata.
23
+ * @property {string} [width] Table-oriented width hint.
24
+ * @property {'start'|'center'|'end'|'left'|'right'} [align] Alignment hint.
25
+ * @property {Record<string, unknown>} [view] View-specific descriptor values.
26
+ */
27
+
28
+ /**
29
+ * @typedef {Object} ViewSort
30
+ * @property {string} id Field id.
31
+ * @property {ViewSortDirection} dir Sort direction.
32
+ */
33
+
34
+ /**
35
+ * @typedef {Object} RecordViewState
36
+ * @property {1} version State schema version.
37
+ * @property {string[]} fieldOrder Complete reconciled field order.
38
+ * @property {string[]} hiddenFields Hidden field ids.
39
+ * @property {ViewSort|null} sort Active sort.
40
+ */
41
+
42
+ /**
43
+ * @typedef {Object} RecordViewOptions
44
+ * @property {ViewField[]} [fields=[]] Shared ordered field descriptors.
45
+ * @property {ViewRecord[]} [data=[]] Initial records.
46
+ * @property {string|((record: ViewRecord) => unknown)} [recordId='ID'] Record id accessor.
47
+ * @property {ViewSort|null} [sort=null] Initial sort.
48
+ * @property {'local'|'server'} [sortMode='local'] Whether Zx reorders local records.
49
+ * @property {ViewSelectionMode} [selectable=false] Record selection behavior.
50
+ * @property {unknown[]} [selection=[]] Initially selected record ids.
51
+ * @property {string[]} [fieldOrder=[]] Field ids in preferred order.
52
+ * @property {string[]} [hiddenFields=[]] Initially hidden field ids.
53
+ * @property {boolean} [fieldControls=true] Whether concrete views show the shared field chooser.
54
+ * @property {string|Node|(() => string|Node)|null} [emptyText=null] Empty-result content.
55
+ * @property {(event: CustomEvent<Record<string, unknown>>) => void} [onrecordclick]
56
+ * @property {(event: CustomEvent<Record<string, unknown>>) => void} [onrecorddblclick]
57
+ * @property {(event: CustomEvent<{records:ViewRecord[]}>) => void} [ondatachange]
58
+ * @property {(event: CustomEvent<{records:ViewRecord[],ids:unknown[]}>) => void} [onselectionchange]
59
+ * @property {(event: CustomEvent<{id:string|null,dir:ViewSortDirection|null}>) => void} [onsortchange]
60
+ * @property {(event: CustomEvent<{visible:string[],hidden:string[]}>) => void} [onfieldvisibilitychange]
61
+ * @property {(event: CustomEvent<{order:string[]}>) => void} [onfieldorderchange]
62
+ * @property {(event: CustomEvent<{state:RecordViewState,reason:string}>) => void} [onstatechange]
63
+ */
64
+
65
+ /**
66
+ * Presentation-neutral state and controls shared by record collection views. Concrete view classes
67
+ * initialize this base from their `render()` method and implement `_refreshView()`.
68
+ * @fires RecordView#datachange
69
+ * @fires RecordView#selectionchange
70
+ * @fires RecordView#sortchange
71
+ * @fires RecordView#fieldvisibilitychange
72
+ * @fires RecordView#fieldorderchange
73
+ * @fires RecordView#statechange
74
+ * @extends {Component<RecordViewOptions>}
75
+ */
76
+ export class RecordView extends Component {
77
+ static cssName = 'record-view';
78
+
79
+ /** @type {Readonly<RecordViewOptions>} */
80
+ static defaults = {
81
+ fields: [],
82
+ data: [],
83
+ recordId: 'ID',
84
+ sort: null,
85
+ sortMode: 'local',
86
+ selectable: false,
87
+ selection: [],
88
+ fieldOrder: [],
89
+ hiddenFields: [],
90
+ fieldControls: true,
91
+ emptyText: null
92
+ };
93
+
94
+ /**
95
+ * Creates the abstract base as a minimal record view. Concrete views normally call this through
96
+ * their own explicitly typed constructors.
97
+ * @param {Element|string|null} [target=null] Existing root, selector, or null.
98
+ * @param {RecordViewOptions} [options={}] Shared view options.
99
+ */
100
+ constructor(target = null, options = {}) {
101
+ super(target, options);
102
+ }
103
+
104
+ /** @returns {HTMLElement} Minimal base root. */
105
+ render() {
106
+ const created = !this.el;
107
+ const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
108
+ this._recordViewOriginal = created ? null : snapshotTarget(root);
109
+ this._recordViewDestroyed = false;
110
+ this._initRecordView(root);
111
+ return root;
112
+ }
113
+
114
+ /** Cleans up a directly instantiated base view and restores an enhanced target. @returns {void} */
115
+ destroy() {
116
+ if (this._recordViewDestroyed) return;
117
+ this._recordViewDestroyed = true;
118
+ const root = this.el;
119
+ const snapshot = this._recordViewOriginal;
120
+ super.destroy();
121
+ if (snapshot && root) restoreTarget(root, snapshot);
122
+ }
123
+
124
+ /**
125
+ * Initializes shared state during a concrete view's `render()` call.
126
+ * @protected
127
+ * @param {HTMLElement} root Concrete view root.
128
+ * @returns {void}
129
+ */
130
+ _initRecordView(root) {
131
+ this.el = root;
132
+ root.classList.add('zx-record-view');
133
+ this._viewFields = normalizeViewFields(this.options.fields);
134
+ this._viewFieldOrder = reconcileFieldOrder(this._viewFields, this.options.fieldOrder);
135
+ this._viewHidden = normalizeHiddenFields(this._viewFields, this.options.hiddenFields, true);
136
+ this._viewData = normalizeViewRecords(this.options.data);
137
+ this._viewSort = normalizeViewSort(this.options.sort, this._viewFields);
138
+ this._viewSelected = new Set();
139
+ this._viewSelectionAnchor = null;
140
+ this._viewFieldControls = null;
141
+ this._viewFieldList = null;
142
+ this._viewInitialized = true;
143
+ if (this._viewSort && this.options.sortMode === 'local') this._sortViewData();
144
+ this._setInitialSelection(this.options.selection);
145
+ }
146
+
147
+ /**
148
+ * Replaces every record and clears selections whose ids disappeared.
149
+ * @param {ViewRecord[]} records New records.
150
+ * @returns {this}
151
+ */
152
+ setData(records) {
153
+ this._viewData = normalizeViewRecords(records);
154
+ if (this._viewSort && this.options.sortMode === 'local') this._sortViewData();
155
+ const selectionChanged = this._pruneViewSelection();
156
+ this.setLoading(false);
157
+ this._refreshView('data');
158
+ if (selectionChanged) this._emitViewSelection();
159
+ this.emit('datachange', { records: this.getData() });
160
+ return this;
161
+ }
162
+
163
+ /**
164
+ * Appends records without mutating the supplied array.
165
+ * @param {ViewRecord[]} records Records to append.
166
+ * @returns {this}
167
+ */
168
+ addData(records) {
169
+ this._viewData.push(...normalizeViewRecords(records));
170
+ if (this._viewSort && this.options.sortMode === 'local') this._sortViewData();
171
+ this._refreshView('data');
172
+ this.emit('datachange', { records: this.getData() });
173
+ return this;
174
+ }
175
+
176
+ /**
177
+ * Replaces the record currently identified by `id`.
178
+ * @param {unknown} id Existing record id.
179
+ * @param {ViewRecord} record Replacement record.
180
+ * @returns {this}
181
+ */
182
+ updateRecord(id, record) {
183
+ assertViewRecord(record);
184
+ const index = this._viewData.findIndex((candidate) => Object.is(this._viewRecordId(candidate), id));
185
+ if (index < 0) return this;
186
+ const selected = this._viewSelected.delete(id);
187
+ this._viewData[index] = record;
188
+ const nextId = this._viewRecordId(record);
189
+ if (selected) this._viewSelected.add(nextId);
190
+ if (this._viewSort && this.options.sortMode === 'local') this._sortViewData();
191
+ this._refreshView('data');
192
+ if (selected && !Object.is(id, nextId)) this._emitViewSelection();
193
+ this.emit('datachange', { records: this.getData() });
194
+ return this;
195
+ }
196
+
197
+ /**
198
+ * Removes one record by id.
199
+ * @param {unknown} id Record id.
200
+ * @returns {this}
201
+ */
202
+ removeRecord(id) {
203
+ const index = this._viewData.findIndex((record) => Object.is(this._viewRecordId(record), id));
204
+ if (index < 0) return this;
205
+ this._viewData.splice(index, 1);
206
+ const selectionChanged = this._viewSelected.delete(id);
207
+ if (Object.is(this._viewSelectionAnchor, id)) this._viewSelectionAnchor = null;
208
+ this._refreshView('data');
209
+ if (selectionChanged) this._emitViewSelection();
210
+ this.emit('datachange', { records: this.getData() });
211
+ return this;
212
+ }
213
+
214
+ /** Returns a record by id. @param {unknown} id Record id. @returns {ViewRecord|null} */
215
+ getRecord(id) {
216
+ return this._viewData.find((record) => Object.is(this._viewRecordId(record), id)) ?? null;
217
+ }
218
+
219
+ /** Returns a shallow copy in current display order. @returns {ViewRecord[]} */
220
+ getData() {
221
+ return [...this._viewData];
222
+ }
223
+
224
+ /**
225
+ * Replaces field descriptors while reconciling order, visibility, and sort by stable id.
226
+ * @param {ViewField[]} fields Next fields.
227
+ * @returns {this}
228
+ */
229
+ setFields(fields) {
230
+ const previousOrder = this.getFieldOrder();
231
+ const previousHidden = this.getHiddenFields();
232
+ this._viewFields = normalizeViewFields(fields);
233
+ this._viewFieldOrder = reconcileFieldOrder(this._viewFields, previousOrder);
234
+ this._viewHidden = normalizeHiddenFields(this._viewFields, previousHidden);
235
+ this._viewSort = normalizeViewSort(this._viewSort, this._viewFields);
236
+ if (this._viewSort && this.options.sortMode === 'local') this._sortViewData();
237
+ this._refreshView('fields');
238
+ this._renderViewFieldControls();
239
+ this._emitViewState('fields');
240
+ return this;
241
+ }
242
+
243
+ /** Returns cloned field descriptors in configured order. @returns {ViewField[]} */
244
+ getFields() {
245
+ const fields = new Map(this._viewFields.map((field) => [field.id, field]));
246
+ return this._viewFieldOrder.map((id) => ({ ...fields.get(id) }));
247
+ }
248
+
249
+ /** Returns visible cloned descriptors in configured order. @returns {ViewField[]} */
250
+ getVisibleFields() {
251
+ return this.getFields().filter((field) => !this._viewHidden.has(field.id));
252
+ }
253
+
254
+ /** Returns hidden field ids in configured order. @returns {string[]} */
255
+ getHiddenFields() {
256
+ return this._viewFieldOrder.filter((id) => this._viewHidden.has(id));
257
+ }
258
+
259
+ /** Returns the complete field id order. @returns {string[]} */
260
+ getFieldOrder() {
261
+ return [...this._viewFieldOrder];
262
+ }
263
+
264
+ /**
265
+ * Shows or hides one field. The final visible field cannot be hidden.
266
+ * @param {string} id Field id.
267
+ * @param {boolean} [visible=true] Desired visibility.
268
+ * @param {{silent?:boolean}} [options={}] Event behavior.
269
+ * @returns {this}
270
+ */
271
+ setFieldVisible(id, visible = true, options = {}) {
272
+ const fieldId = String(id);
273
+ if (!this._viewFields.some((field) => field.id === fieldId)) {
274
+ throw new RangeError(`Unknown view field: ${fieldId}`);
275
+ }
276
+ if (!visible && !this._viewHidden.has(fieldId) && this.getVisibleFields().length <= 1) {
277
+ this._renderViewFieldControls(fieldId);
278
+ return this;
279
+ }
280
+ const changed = visible ? this._viewHidden.delete(fieldId) : !this._viewHidden.has(fieldId);
281
+ if (!visible && changed) this._viewHidden.add(fieldId);
282
+ if (!changed) return this;
283
+ this._refreshView('fields');
284
+ this._renderViewFieldControls(fieldId);
285
+ if (!options.silent) {
286
+ this.emit('fieldvisibilitychange', {
287
+ visible: this.getVisibleFields().map((field) => field.id),
288
+ hidden: this.getHiddenFields()
289
+ });
290
+ this._emitViewState('fieldvisibility');
291
+ }
292
+ return this;
293
+ }
294
+
295
+ /** Toggles one field. @param {string} id Field id. @param {{silent?:boolean}} [options={}] @returns {this} */
296
+ toggleField(id, options = {}) {
297
+ return this.setFieldVisible(id, this._viewHidden.has(String(id)), options);
298
+ }
299
+
300
+ /**
301
+ * Moves one field relative to another.
302
+ * @param {string} id Moving field id.
303
+ * @param {string} targetId Target field id.
304
+ * @param {'before'|'after'} [position='before'] Target edge.
305
+ * @param {{silent?:boolean}} [options={}] Event behavior.
306
+ * @returns {this}
307
+ */
308
+ moveField(id, targetId, position = 'before', options = {}) {
309
+ const next = moveViewField(this._viewFieldOrder, String(id), String(targetId), position);
310
+ if (arraysEqual(next, this._viewFieldOrder)) return this;
311
+ return this.setFieldOrder(next, options);
312
+ }
313
+
314
+ /**
315
+ * Reconciles a preferred field order with every current field.
316
+ * @param {string[]} order Preferred ids.
317
+ * @param {{silent?:boolean}} [options={}] Event behavior.
318
+ * @returns {this}
319
+ */
320
+ setFieldOrder(order, options = {}) {
321
+ const next = reconcileFieldOrder(this._viewFields, order);
322
+ if (arraysEqual(next, this._viewFieldOrder)) return this;
323
+ this._viewFieldOrder = next;
324
+ this._refreshView('fields');
325
+ this._renderViewFieldControls();
326
+ if (!options.silent) {
327
+ this.emit('fieldorderchange', { order: this.getFieldOrder() });
328
+ this._emitViewState('fieldorder');
329
+ }
330
+ return this;
331
+ }
332
+
333
+ /**
334
+ * Changes or clears the active sort.
335
+ * @param {string|null} id Sort field, or null to clear.
336
+ * @param {ViewSortDirection} [dir='asc'] Direction.
337
+ * @param {{silent?:boolean}} [options={}] Event behavior.
338
+ * @returns {this}
339
+ */
340
+ setSort(id, dir = 'asc', options = {}) {
341
+ const next = id == null ? null : normalizeViewSort({ id: String(id), dir }, this._viewFields);
342
+ if (id != null && !next) throw new RangeError(`Unknown or unsortable view field: ${id}`);
343
+ if (sameSort(next, this._viewSort)) return this;
344
+ this._viewSort = next;
345
+ if (next && this.options.sortMode === 'local') this._sortViewData();
346
+ this._refreshView('sort');
347
+ if (!options.silent) {
348
+ this.emit('sortchange', next ? { ...next } : { id: null, dir: null });
349
+ this._emitViewState('sort');
350
+ }
351
+ return this;
352
+ }
353
+
354
+ /** Returns a copy of the active sort. @returns {ViewSort|null} */
355
+ getSort() {
356
+ return this._viewSort ? { ...this._viewSort } : null;
357
+ }
358
+
359
+ /** Returns selected records in display order. @returns {ViewRecord[]} */
360
+ getSelection() {
361
+ return this._viewData.filter((record) => this._viewSelected.has(this._viewRecordId(record)));
362
+ }
363
+
364
+ /** Returns selected record ids in display order. @returns {unknown[]} */
365
+ getSelectionIds() {
366
+ return this.getSelection().map((record) => this._viewRecordId(record));
367
+ }
368
+
369
+ /**
370
+ * Replaces selection from record ids.
371
+ * @param {unknown[]} ids Record ids.
372
+ * @param {{silent?:boolean}} [options={}] Event behavior.
373
+ * @returns {this}
374
+ */
375
+ setSelection(ids, options = {}) {
376
+ const requested = new Set(Array.isArray(ids) ? ids : []);
377
+ const available = this._viewData.map((record) => this._viewRecordId(record));
378
+ let next = available.filter((id) => requested.has(id));
379
+ if (this.options.selectable === false) next = [];
380
+ if (this.options.selectable === 'single') next = next.slice(0, 1);
381
+ if (setEqual(this._viewSelected, new Set(next))) return this;
382
+ this._viewSelected = new Set(next);
383
+ this._viewSelectionAnchor = next.at(-1) ?? null;
384
+ this._refreshView('selection');
385
+ if (!options.silent) this._emitViewSelection();
386
+ return this;
387
+ }
388
+
389
+ /** Clears selection. @param {{silent?:boolean}} [options={}] @returns {this} */
390
+ clearSelection(options = {}) {
391
+ return this.setSelection([], options);
392
+ }
393
+
394
+ /**
395
+ * Toggles a record selection, with optional multi-select range behavior.
396
+ * @param {unknown} id Record id.
397
+ * @param {{selected?:boolean,range?:boolean}} [options={}] Toggle behavior.
398
+ * @returns {this}
399
+ */
400
+ toggleSelection(id, options = {}) {
401
+ if (this.options.selectable === false || !this.getRecord(id)) return this;
402
+ const selected = options.selected ?? !this._viewSelected.has(id);
403
+ if (this.options.selectable === 'single') {
404
+ this._viewSelected = selected ? new Set([id]) : new Set();
405
+ } else if (options.range && this._viewSelectionAnchor != null) {
406
+ const ids = this._viewData.map((record) => this._viewRecordId(record));
407
+ const start = ids.findIndex((candidate) => Object.is(candidate, this._viewSelectionAnchor));
408
+ const end = ids.findIndex((candidate) => Object.is(candidate, id));
409
+ if (start >= 0 && end >= 0) {
410
+ for (const candidate of ids.slice(Math.min(start, end), Math.max(start, end) + 1)) {
411
+ if (selected) this._viewSelected.add(candidate);
412
+ else this._viewSelected.delete(candidate);
413
+ }
414
+ }
415
+ } else if (selected) {
416
+ this._viewSelected.add(id);
417
+ } else {
418
+ this._viewSelected.delete(id);
419
+ }
420
+ this._viewSelectionAnchor = id;
421
+ this._refreshView('selection');
422
+ this._emitViewSelection();
423
+ return this;
424
+ }
425
+
426
+ /**
427
+ * Marks the whole view busy without imposing a loading presentation.
428
+ * @param {boolean} [loading=true] Busy state.
429
+ * @returns {this}
430
+ */
431
+ setLoading(loading = true) {
432
+ if (loading) this.el.setAttribute('data-loading', 'true');
433
+ else this.el.removeAttribute('data-loading');
434
+ this.el.setAttribute('aria-busy', String(Boolean(loading)));
435
+ this._refreshView('loading');
436
+ return this;
437
+ }
438
+
439
+ /** Returns JSON-safe, persistence-neutral view configuration. @returns {RecordViewState} */
440
+ getViewState() {
441
+ return {
442
+ version: 1,
443
+ fieldOrder: this.getFieldOrder(),
444
+ hiddenFields: this.getHiddenFields(),
445
+ sort: this.getSort()
446
+ };
447
+ }
448
+
449
+ /**
450
+ * Restores common configuration. Unknown fields are ignored and newly introduced fields append.
451
+ * @param {Partial<RecordViewState>|null} state Saved state.
452
+ * @param {{silent?:boolean}} [options={}] Event behavior.
453
+ * @returns {this}
454
+ */
455
+ setViewState(state, options = {}) {
456
+ const normalized = normalizeViewState(state, this._viewFields);
457
+ this._viewFieldOrder = normalized.fieldOrder;
458
+ this._viewHidden = new Set(normalized.hiddenFields);
459
+ this._viewSort = normalized.sort;
460
+ if (this._viewSort && this.options.sortMode === 'local') this._sortViewData();
461
+ this._refreshView('state');
462
+ this._renderViewFieldControls();
463
+ if (!options.silent) this._emitViewState('restore');
464
+ return this;
465
+ }
466
+
467
+ /**
468
+ * Builds the shared show/hide and ordering disclosure for a concrete view toolbar.
469
+ * @protected
470
+ * @param {string} [label='Fields'] Trigger label.
471
+ * @returns {HTMLElement} Disclosure element.
472
+ */
473
+ _createViewFieldControls(label = 'Fields') {
474
+ const list = h('div', { class: 'zx-record-view__field-list' });
475
+ const details = h('details', { class: 'zx-record-view__field-controls' },
476
+ h('summary', { class: 'zx-button zx-button--sm' }, icon('fields', { size: 13 }), label),
477
+ list);
478
+ this._viewFieldControls = details;
479
+ this._viewFieldList = list;
480
+ this._renderViewFieldControls();
481
+ this.listen(list, 'change', (event) => {
482
+ const input = /** @type {HTMLInputElement|null} */ (event.target);
483
+ if (input?.classList.contains('zx-record-view__field-toggle')) {
484
+ this.setFieldVisible(input.value, input.checked);
485
+ }
486
+ });
487
+ this.listen(list, 'click', (event) => {
488
+ const button = /** @type {Element|null} */ (event.target)?.closest?.('[data-view-field-action]');
489
+ if (!button) return;
490
+ const id = /** @type {HTMLElement} */ (button).dataset.fieldId;
491
+ const action = /** @type {HTMLElement} */ (button).dataset.viewFieldAction;
492
+ const index = this._viewFieldOrder.indexOf(String(id));
493
+ if (action === 'up' && index > 0) this.moveField(String(id), this._viewFieldOrder[index - 1], 'before');
494
+ if (action === 'down' && index >= 0 && index < this._viewFieldOrder.length - 1) {
495
+ this.moveField(String(id), this._viewFieldOrder[index + 1], 'after');
496
+ }
497
+ });
498
+ return details;
499
+ }
500
+
501
+ /** Concrete renderers override this hook. @protected @param {string} _reason Refresh reason. @returns {void} */
502
+ _refreshView(_reason) {}
503
+
504
+ /** Reads one record's stable id. @protected @param {ViewRecord} record Record. @returns {unknown} */
505
+ _viewRecordId(record) {
506
+ return viewRecordId(record, this.options.recordId);
507
+ }
508
+
509
+ /** Returns whether a record is selected. @protected @param {unknown} id Record id. @returns {boolean} */
510
+ _isViewSelected(id) {
511
+ return this._viewSelected.has(id);
512
+ }
513
+
514
+ /** Sorts the internal record copy. @private @returns {void} */
515
+ _sortViewData() {
516
+ if (!this._viewSort) return;
517
+ this._viewData = sortViewRecords(this._viewData, this._viewFields, this._viewSort);
518
+ }
519
+
520
+ /** @private @param {unknown[]} selection @returns {void} */
521
+ _setInitialSelection(selection) {
522
+ const requested = new Set(Array.isArray(selection) ? selection : []);
523
+ const available = this._viewData.map((record) => this._viewRecordId(record));
524
+ let selected = available.filter((id) => requested.has(id));
525
+ if (this.options.selectable === false) selected = [];
526
+ if (this.options.selectable === 'single') selected = selected.slice(0, 1);
527
+ this._viewSelected = new Set(selected);
528
+ this._viewSelectionAnchor = selected.at(-1) ?? null;
529
+ }
530
+
531
+ /** @private @returns {boolean} */
532
+ _pruneViewSelection() {
533
+ const available = new Set(this._viewData.map((record) => this._viewRecordId(record)));
534
+ const next = new Set([...this._viewSelected].filter((id) => available.has(id)));
535
+ const changed = !setEqual(next, this._viewSelected);
536
+ this._viewSelected = next;
537
+ if (this._viewSelectionAnchor != null && !available.has(this._viewSelectionAnchor)) {
538
+ this._viewSelectionAnchor = null;
539
+ }
540
+ return changed;
541
+ }
542
+
543
+ /** @private @returns {void} */
544
+ _emitViewSelection() {
545
+ this.emit('selectionchange', { records: this.getSelection(), ids: this.getSelectionIds() });
546
+ }
547
+
548
+ /** @private @param {string} reason @returns {void} */
549
+ _emitViewState(reason) {
550
+ this.emit('statechange', { state: this.getViewState(), reason });
551
+ }
552
+
553
+ /** @private @param {string|null} [focusId=null] @returns {void} */
554
+ _renderViewFieldControls(focusId = null) {
555
+ if (!this._viewFieldList) return;
556
+ const fields = this.getFields();
557
+ const visible = fields.filter((field) => !this._viewHidden.has(field.id));
558
+ this._viewFieldControls.hidden = fields.length === 0;
559
+ const controls = fields.map((field, index) => {
560
+ const toggle = h('input', {
561
+ class: 'zx-record-view__field-toggle',
562
+ type: 'checkbox',
563
+ value: field.id,
564
+ checked: !this._viewHidden.has(field.id),
565
+ disabled: !this._viewHidden.has(field.id) && visible.length === 1
566
+ });
567
+ const up = h('button', {
568
+ class: 'zx-record-view__field-move',
569
+ type: 'button',
570
+ ariaLabel: `Move ${field.label} up`,
571
+ disabled: index === 0,
572
+ dataset: { viewFieldAction: 'up', fieldId: field.id }
573
+ }, icon('chevron-up', { size: 11 }));
574
+ const down = h('button', {
575
+ class: 'zx-record-view__field-move',
576
+ type: 'button',
577
+ ariaLabel: `Move ${field.label} down`,
578
+ disabled: index === fields.length - 1,
579
+ dataset: { viewFieldAction: 'down', fieldId: field.id }
580
+ }, icon('chevron-down', { size: 11 }));
581
+ return h('div', { class: 'zx-record-view__field-option' },
582
+ h('label', {}, toggle, h('span', {}, field.label)),
583
+ h('span', { class: 'zx-record-view__field-order' }, up, down));
584
+ });
585
+ this._viewFieldList.replaceChildren(...controls);
586
+ if (focusId != null) {
587
+ const match = [...this._viewFieldList.querySelectorAll('.zx-record-view__field-toggle')]
588
+ .find((control) => /** @type {HTMLInputElement} */ (control).value === focusId);
589
+ queueMicrotask(() => {
590
+ if (match?.isConnected) /** @type {HTMLElement} */ (match).focus();
591
+ });
592
+ }
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Validates and clones field descriptors.
598
+ * @param {ViewField[]} fields Fields.
599
+ * @returns {ViewField[]} Normalized fields.
600
+ */
601
+ export function normalizeViewFields(fields) {
602
+ if (!Array.isArray(fields)) throw new TypeError('Record view fields must be an array');
603
+ const seen = new Set();
604
+ return fields.map((field, index) => {
605
+ if (!field || typeof field !== 'object' || Array.isArray(field)) {
606
+ throw new TypeError(`Record view field at index ${index} must be an object`);
607
+ }
608
+ const id = String(field.id ?? '').trim();
609
+ if (!id) throw new TypeError(`Record view field at index ${index} requires an id`);
610
+ if (seen.has(id)) throw new TypeError(`Duplicate record view field: ${id}`);
611
+ seen.add(id);
612
+ return {
613
+ ...field,
614
+ id,
615
+ label: String(field.label ?? humanizeViewField(id)),
616
+ sortable: Boolean(field.sortable),
617
+ type: String(field.type ?? 'text')
618
+ };
619
+ });
620
+ }
621
+
622
+ /**
623
+ * Reconciles a partial or stale order with all current fields.
624
+ * @param {ViewField[]} fields Current fields.
625
+ * @param {unknown} order Preferred ids.
626
+ * @returns {string[]} Complete order.
627
+ */
628
+ export function reconcileFieldOrder(fields, order) {
629
+ const available = fields.map((field) => field.id);
630
+ const allowed = new Set(available);
631
+ const preferred = Array.isArray(order) ? order.map(String) : [];
632
+ return [...new Set(preferred.filter((id) => allowed.has(id))),
633
+ ...available.filter((id) => !preferred.includes(id))];
634
+ }
635
+
636
+ /**
637
+ * Moves one id in an immutable order.
638
+ * @param {string[]} order Current order.
639
+ * @param {string} id Moving id.
640
+ * @param {string} targetId Target id.
641
+ * @param {'before'|'after'} [position='before'] Target edge.
642
+ * @returns {string[]} Next order, or an unchanged copy for invalid moves.
643
+ */
644
+ export function moveViewField(order, id, targetId, position = 'before') {
645
+ const next = [...order];
646
+ const from = next.indexOf(id);
647
+ const target = next.indexOf(targetId);
648
+ if (from < 0 || target < 0 || from === target) return next;
649
+ next.splice(from, 1);
650
+ let insertion = next.indexOf(targetId) + (position === 'after' ? 1 : 0);
651
+ insertion = Math.max(0, Math.min(next.length, insertion));
652
+ next.splice(insertion, 0, id);
653
+ return next;
654
+ }
655
+
656
+ /**
657
+ * Reads one field through its accessor or record property.
658
+ * @param {ViewField} field Field.
659
+ * @param {ViewRecord} record Record.
660
+ * @param {number} [index=0] Display index.
661
+ * @returns {unknown} Field value.
662
+ */
663
+ export function readViewField(field, record, index = 0) {
664
+ return typeof field.get === 'function' ? field.get(record, index) : record?.[field.id];
665
+ }
666
+
667
+ /**
668
+ * Resolves display content without parsing HTML.
669
+ * @param {ViewField} field Field.
670
+ * @param {ViewRecord} record Record.
671
+ * @param {number} [index=0] Display index.
672
+ * @returns {Node|string|number|null|undefined} Rendered content.
673
+ */
674
+ export function renderViewField(field, record, index = 0) {
675
+ const value = readViewField(field, record, index);
676
+ return typeof field.render === 'function' ? field.render(record, index, value) : value == null ? '' : String(value);
677
+ }
678
+
679
+ /**
680
+ * Stably sorts a cloned record array by one shared field descriptor.
681
+ * @param {ViewRecord[]} records Records.
682
+ * @param {ViewField[]} fields Fields.
683
+ * @param {ViewSort|null} sort Sort.
684
+ * @returns {ViewRecord[]} Sorted copy.
685
+ */
686
+ export function sortViewRecords(records, fields, sort) {
687
+ if (!sort) return [...records];
688
+ const field = fields.find((candidate) => candidate.id === sort.id);
689
+ if (!field) return [...records];
690
+ const indexes = new WeakMap(records.map((record, index) => [record, index]));
691
+ return sortRows(records, (record) => typeof field.sortValue === 'function'
692
+ ? field.sortValue(record, indexes.get(record) ?? 0)
693
+ : readViewField(field, record, indexes.get(record) ?? 0), sort.dir);
694
+ }
695
+
696
+ /**
697
+ * Normalizes saved common state against current fields.
698
+ * @param {Partial<RecordViewState>|null|undefined} state Saved state.
699
+ * @param {ViewField[]} fields Current normalized fields.
700
+ * @returns {RecordViewState} Reconciled state.
701
+ */
702
+ export function normalizeViewState(state, fields) {
703
+ const source = state && typeof state === 'object' ? state : {};
704
+ return {
705
+ version: 1,
706
+ fieldOrder: reconcileFieldOrder(fields, source.fieldOrder),
707
+ hiddenFields: [...normalizeHiddenFields(fields, source.hiddenFields)],
708
+ sort: normalizeViewSort(source.sort, fields)
709
+ };
710
+ }
711
+
712
+ /** Reads a stable record id. @param {ViewRecord} record @param {string|((record:ViewRecord)=>unknown)} accessor @returns {unknown} */
713
+ export function viewRecordId(record, accessor) {
714
+ return typeof accessor === 'function' ? accessor(record) : record?.[String(accessor ?? 'ID')];
715
+ }
716
+
717
+ /** @param {ViewSort|null|undefined} sort @param {ViewField[]} fields @returns {ViewSort|null} */
718
+ function normalizeViewSort(sort, fields) {
719
+ if (!sort || typeof sort !== 'object') return null;
720
+ const id = String(sort.id ?? '');
721
+ const field = fields.find((candidate) => candidate.id === id);
722
+ if (!field || !field.sortable) return null;
723
+ return { id, dir: sort.dir === 'desc' ? 'desc' : 'asc' };
724
+ }
725
+
726
+ /** @param {ViewField[]} fields @param {unknown} hidden @param {boolean} [includeDefaults=false] @returns {Set<string>} */
727
+ function normalizeHiddenFields(fields, hidden, includeDefaults = false) {
728
+ const fieldIds = new Set(fields.map((field) => field.id));
729
+ const result = new Set(Array.isArray(hidden) ? hidden.map(String).filter((id) => fieldIds.has(id)) : []);
730
+ if (includeDefaults) for (const field of fields) if (field.visible === false) result.add(field.id);
731
+ if (result.size >= fields.length && fields[0]) result.delete(fields[0].id);
732
+ return result;
733
+ }
734
+
735
+ /** @param {unknown} records @returns {ViewRecord[]} */
736
+ function normalizeViewRecords(records) {
737
+ if (!Array.isArray(records)) throw new TypeError('Record view data must be an array');
738
+ records.forEach(assertViewRecord);
739
+ return [...records];
740
+ }
741
+
742
+ /** @param {unknown} record @returns {asserts record is ViewRecord} */
743
+ function assertViewRecord(record) {
744
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
745
+ throw new TypeError('Record view data entries must be objects');
746
+ }
747
+ }
748
+
749
+ /** @param {unknown} value @returns {string} */
750
+ function humanizeViewField(value) {
751
+ const source = String(value).split('.').at(-1) ?? '';
752
+ if (source === 'ID') return 'ID';
753
+ const text = source.replace(/([a-z\d])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim();
754
+ return text ? text.charAt(0).toUpperCase() + text.slice(1) : source;
755
+ }
756
+
757
+ /** @param {ViewSort|null} left @param {ViewSort|null} right @returns {boolean} */
758
+ function sameSort(left, right) {
759
+ return left === null && right === null || Boolean(left && right && left.id === right.id && left.dir === right.dir);
760
+ }
761
+
762
+ /** @param {unknown[]} left @param {unknown[]} right @returns {boolean} */
763
+ function arraysEqual(left, right) {
764
+ return left.length === right.length && left.every((value, index) => Object.is(value, right[index]));
765
+ }
766
+
767
+ /** @param {Set<unknown>} left @param {Set<unknown>} right @returns {boolean} */
768
+ function setEqual(left, right) {
769
+ return left.size === right.size && [...left].every((value) => right.has(value));
770
+ }