@zeyos/zx 4.1.1 → 4.3.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 (102) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/DESIGN-SYSTEM.md +33 -3
  3. package/README.md +22 -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 +517 -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/dialog/dialog.d.ts +6 -1
  11. package/dist/types/components/dropdown/dropdown.d.ts +5 -0
  12. package/dist/types/components/kanban-view/kanban-view.d.ts +572 -0
  13. package/dist/types/components/launcher/launcher.d.ts +5 -0
  14. package/dist/types/components/message/message.d.ts +9 -1
  15. package/dist/types/components/modal/modal.d.ts +44 -7
  16. package/dist/types/components/sheet/sheet.d.ts +22 -12
  17. package/dist/types/components/table/table.d.ts +120 -31
  18. package/dist/types/components/table-view/table-view.d.ts +192 -0
  19. package/dist/types/components/view/record-view.d.ts +464 -0
  20. package/dist/types/core/overlay-host.d.ts +12 -0
  21. package/dist/types/index.d.ts +7 -0
  22. package/dist/types/zeyos/calendar.d.ts +91 -0
  23. package/dist/types/zeyos/icons.d.ts +18 -2
  24. package/dist/types/zeyos/index.d.ts +4 -0
  25. package/dist/types/zeyos/legacy-saved-views.d.ts +66 -0
  26. package/dist/types/zeyos/saved-views.d.ts +331 -0
  27. package/dist/types/zeyos/view.d.ts +305 -0
  28. package/dist/zx-zeyos.esm.js +4742 -1470
  29. package/dist/zx-zeyos.esm.min.js +1 -1
  30. package/dist/zx.css +2536 -471
  31. package/dist/zx.esm.js +5193 -223
  32. package/dist/zx.esm.min.js +3 -3
  33. package/dist/zx.global.js +5193 -223
  34. package/dist/zx.global.min.js +3 -3
  35. package/dist/zx.min.css +1 -1
  36. package/docs/api.json +1729 -182
  37. package/docs/llms.md +236 -32
  38. package/docs/llms.txt +16 -6
  39. package/package.json +1 -1
  40. package/src/components/account-menu/account-menu.css +1 -1
  41. package/src/components/app-icon/app-icon.css +77 -51
  42. package/src/components/app-icon/app-icon.js +28 -4
  43. package/src/components/app-rail/app-rail.css +12 -3
  44. package/src/components/app-sidebar/app-sidebar.css +11 -2
  45. package/src/components/aurora/aurora.css +143 -0
  46. package/src/components/aurora/aurora.js +146 -0
  47. package/src/components/avatar/avatar.css +1 -1
  48. package/src/components/button/button.css +2 -2
  49. package/src/components/calendar/calendar-model.js +402 -0
  50. package/src/components/calendar/calendar.css +609 -0
  51. package/src/components/calendar/calendar.js +1619 -0
  52. package/src/components/card/card.css +2 -2
  53. package/src/components/card-view/card-view.css +389 -0
  54. package/src/components/card-view/card-view.js +527 -0
  55. package/src/components/card-view/record-card.js +392 -0
  56. package/src/components/context-menu/context-menu.css +4 -4
  57. package/src/components/context-menu/context-menu.js +5 -1
  58. package/src/components/date-picker/date-picker.css +31 -12
  59. package/src/components/datebox/date-range-box.css +17 -2
  60. package/src/components/datebox/datebox.css +18 -3
  61. package/src/components/dialog/dialog.css +4 -3
  62. package/src/components/dialog/dialog.js +2 -1
  63. package/src/components/dropdown/dropdown.css +17 -3
  64. package/src/components/dropdown/dropdown.js +7 -2
  65. package/src/components/kanban-view/kanban-view.css +216 -0
  66. package/src/components/kanban-view/kanban-view.js +1228 -0
  67. package/src/components/launcher/launcher.css +24 -9
  68. package/src/components/launcher/launcher.js +9 -2
  69. package/src/components/master-panel/master-panel.css +14 -3
  70. package/src/components/menu-button/menu-button.css +4 -4
  71. package/src/components/message/message.css +13 -21
  72. package/src/components/message/message.js +17 -11
  73. package/src/components/modal/modal.css +19 -5
  74. package/src/components/modal/modal.js +99 -25
  75. package/src/components/navigation-bar/navigation-bar.css +17 -5
  76. package/src/components/panel/panel.css +10 -2
  77. package/src/components/select/select.css +26 -8
  78. package/src/components/sheet/sheet.css +28 -7
  79. package/src/components/sheet/sheet.js +43 -23
  80. package/src/components/table/table.css +83 -5
  81. package/src/components/table/table.js +322 -37
  82. package/src/components/table-view/table-view.css +18 -0
  83. package/src/components/table-view/table-view.js +347 -0
  84. package/src/components/tag-picker/tag-picker.css +18 -4
  85. package/src/components/tooltip/tooltip.css +17 -3
  86. package/src/components/tooltip/tooltip.js +4 -1
  87. package/src/components/view/record-view.css +114 -0
  88. package/src/components/view/record-view.js +770 -0
  89. package/src/core/overlay-host.js +23 -0
  90. package/src/index.js +15 -0
  91. package/src/zeyos/calendar.js +115 -0
  92. package/src/zeyos/icons.js +11 -4
  93. package/src/zeyos/index.js +13 -0
  94. package/src/zeyos/launcher.js +2 -2
  95. package/src/zeyos/legacy-saved-views.js +155 -0
  96. package/src/zeyos/saved-views.js +815 -0
  97. package/src/zeyos/table.js +3 -1
  98. package/src/zeyos/view.js +543 -0
  99. package/styles/icons.css +3 -10
  100. package/styles/tokens/dark.css +4 -0
  101. package/styles/tokens/semantic.css +142 -3
  102. package/styles/zx.css +6 -0
@@ -0,0 +1,527 @@
1
+ // @ts-check
2
+ import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
3
+ import { RecordView, readViewField } from '../view/record-view.js';
4
+ import {
5
+ createRecordCard, resolveRecordActions
6
+ } from './record-card.js';
7
+
8
+ /** @typedef {Record<string, any>} ViewRecord */
9
+ /** @typedef {import('../view/record-view.js').ViewField} ViewField */
10
+ /** @typedef {import('../view/record-view.js').RecordViewOptions} RecordViewOptions */
11
+ /** @typedef {import('./record-card.js').RecordCardValueSource} RecordCardValueSource */
12
+ /** @typedef {import('./record-card.js').RecordCardPreview} RecordCardPreview */
13
+ /** @typedef {import('./record-card.js').RecordCardLink} RecordCardLink */
14
+ /** @typedef {import('./record-card.js').RecordCardAction} RecordCardAction */
15
+
16
+ /**
17
+ * @typedef {Object} CardViewOptions
18
+ * @property {ViewField[]} [fields=[]] Shared ordered field descriptors.
19
+ * @property {ViewRecord[]} [data=[]] Initial records.
20
+ * @property {string|((record:ViewRecord)=>unknown)} [recordId='ID'] Stable record id accessor.
21
+ * @property {import('../view/record-view.js').ViewSort|null} [sort=null] Initial shared sort.
22
+ * @property {'local'|'server'} [sortMode='local'] Local sorting or event-only server sorting.
23
+ * @property {false|'single'|'multi'} [selectable=false] Record selection behavior.
24
+ * @property {unknown[]} [selection=[]] Initially selected record ids.
25
+ * @property {string[]} [fieldOrder=[]] Preferred shared field order.
26
+ * @property {string[]} [hiddenFields=[]] Initially hidden metadata fields.
27
+ * @property {boolean} [fieldControls=true] Whether to expose shared field controls.
28
+ * @property {string|Node|(()=>string|Node)|null} [emptyText=null] Empty-result content.
29
+ * @property {RecordCardValueSource} [titleField=null] Title field id or explicit resolver.
30
+ * @property {RecordCardValueSource} [subtitleField=null] Subtitle field id or explicit resolver.
31
+ * @property {string|((record:ViewRecord,index:number)=>RecordCardPreview)} [preview]
32
+ * Preview field id or explicit resolver.
33
+ * @property {string|((record:ViewRecord,index:number)=>unknown)|null} [previewAlt=null]
34
+ * Preview alt field, literal, or resolver.
35
+ * @property {RecordCardLink|((record:ViewRecord,index:number)=>RecordCardLink)} [link]
36
+ * Safe native title link or resolver.
37
+ * @property {RecordCardAction[]|((record:ViewRecord,index:number)=>RecordCardAction[])} [actions=[]]
38
+ * Listener-free action descriptors or resolver.
39
+ * @property {string|((record:ViewRecord,index:number)=>unknown)|null} [groupBy=null]
40
+ * Field id or local group resolver.
41
+ * @property {unknown[]} [groupOrder=[]] Preferred group ids; missing ids render as empty groups.
42
+ * @property {number|string} [minCardWidth='18rem'] Responsive minimum card width.
43
+ * @property {number|null} [maxColumns=null] Maximum cards per row; null is unconstrained.
44
+ * @property {'outlined'|'raised'|'filled'} [variant='outlined'] Card surface treatment.
45
+ * @property {number} [loadingCount=6] Skeleton cards shown while loading.
46
+ * @property {1|2|3|4|5|6} [headingLevel=3] Per-card title heading level.
47
+ * @property {string} [label='Card view'] Accessible collection label.
48
+ * @property {(event:CustomEvent<Record<string,unknown>>)=>void} [onrecordaction]
49
+ */
50
+
51
+ /**
52
+ * @typedef {Object} CardViewState
53
+ * @property {1} version State schema version.
54
+ * @property {string[]} fieldOrder Shared field order.
55
+ * @property {string[]} hiddenFields Shared hidden fields.
56
+ * @property {import('../view/record-view.js').ViewSort|null} sort Shared sort.
57
+ * @property {string[]} groupOrder Preferred group ids.
58
+ * @property {string} minCardWidth Responsive minimum card width.
59
+ * @property {number|null} maxColumns Maximum cards per row.
60
+ * @property {'outlined'|'raised'|'filled'} variant Surface treatment.
61
+ */
62
+
63
+ /**
64
+ * @typedef {Object} CardRecordGroup
65
+ * @property {string|null} id Normalized group id, null for an ungrouped collection.
66
+ * @property {string} label Display label.
67
+ * @property {ViewRecord[]} records Records in current shared display order.
68
+ */
69
+
70
+ /**
71
+ * Responsive, schema-friendly record cards with shared fields, sorting, selection, state, groups,
72
+ * safe preview media, native title links, and delegated actions.
73
+ * @fires CardView#recordaction
74
+ * @extends {RecordView}
75
+ */
76
+ export class CardView extends RecordView {
77
+ static cssName = 'card-view';
78
+
79
+ /** @type {Readonly<CardViewOptions & RecordViewOptions>} */
80
+ static defaults = {
81
+ ...RecordView.defaults,
82
+ titleField: null,
83
+ subtitleField: null,
84
+ preview: undefined,
85
+ previewAlt: null,
86
+ link: undefined,
87
+ actions: [],
88
+ groupBy: null,
89
+ groupOrder: [],
90
+ minCardWidth: '18rem',
91
+ maxColumns: null,
92
+ variant: 'outlined',
93
+ loadingCount: 6,
94
+ headingLevel: 3,
95
+ label: 'Card view'
96
+ };
97
+
98
+ /**
99
+ * Creates or enhances a card view.
100
+ * @param {Element|string|null} [target=null] Existing root, selector, or null.
101
+ * @param {CardViewOptions & RecordViewOptions} [options={}] Card and shared view options.
102
+ */
103
+ constructor(target = null, options = {}) {
104
+ super(target, options);
105
+ }
106
+
107
+ /** @returns {HTMLElement} Card-view root. */
108
+ render() {
109
+ const created = !this.el;
110
+ const root = /** @type {HTMLElement} */ (this.el ?? h('section'));
111
+ this.el = root;
112
+ this._cardCreatedRoot = created;
113
+ this._cardOriginal = created ? null : snapshotTarget(root);
114
+ this._cardCleaned = false;
115
+ this._cardNodesById = new Map();
116
+ const options = this._cardOptions();
117
+ this._cardGroupOrder = normalizeGroupOrder(options.groupOrder);
118
+ this._cardMinWidth = normalizeCardWidth(options.minCardWidth);
119
+ this._cardMaxColumns = normalizeMaxColumns(options.maxColumns);
120
+ this._cardVariant = normalizeCardVariant(options.variant);
121
+ this._cardLoadingCount = normalizeLoadingCount(options.loadingCount);
122
+ this._cardHeadingLevel = normalizeHeadingLevel(options.headingLevel);
123
+ try {
124
+ this._initRecordView(root);
125
+ root.setAttribute('aria-label', String(options.label || 'Card view'));
126
+ const sort = h('label', { ref: 'sortWrap', class: 'zx-card-view__sort' },
127
+ h('span', { class: 'zx-card-view__sort-label' }, 'Sort'),
128
+ h('select', { ref: 'sort', ariaLabel: 'Sort cards' }));
129
+ const toolbar = h('div', { class: 'zx-record-view__toolbar zx-card-view__toolbar' }, sort);
130
+ if (this.options.fieldControls) toolbar.append(this._createViewFieldControls('Fields'));
131
+ const content = h('div', { ref: 'content', class: 'zx-card-view__content' });
132
+ root.replaceChildren(toolbar, content);
133
+ this.listen(this.refs.sort, 'change', () => this._changeCardSort());
134
+ this.listen(content, 'click', (event) => this._cardClick(/** @type {MouseEvent} */ (event)));
135
+ this.listen(content, 'dblclick', (event) => this._cardDoubleClick(/** @type {MouseEvent} */ (event)));
136
+ this.listen(content, 'keydown', (event) => this._cardKeydown(/** @type {KeyboardEvent} */ (event)));
137
+ this.listen(content, 'error', (event) => this._cardPreviewError(event), true);
138
+ this._applyCardLayout();
139
+ this._renderCardSort();
140
+ this._renderCards();
141
+ return root;
142
+ } catch (error) {
143
+ if (!created) restoreTarget(root, this._cardOriginal);
144
+ throw error;
145
+ }
146
+ }
147
+
148
+ /** Returns shared plus CardView configuration, never records or rendered values. @returns {CardViewState} */
149
+ getViewState() {
150
+ return {
151
+ ...super.getViewState(),
152
+ groupOrder: [...this._cardGroupOrder],
153
+ minCardWidth: this._cardMinWidth,
154
+ maxColumns: this._cardMaxColumns,
155
+ variant: this._cardVariant
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Restores shared and CardView configuration. Unknown shared fields reconcile in RecordView.
161
+ * @param {Partial<CardViewState>|null} state Saved JSON-safe state.
162
+ * @param {{silent?:boolean}} [options={}] Event behavior.
163
+ * @returns {this}
164
+ */
165
+ setViewState(state, options = {}) {
166
+ const source = state && typeof state === 'object' ? state : {};
167
+ if (Object.prototype.hasOwnProperty.call(source, 'groupOrder')) {
168
+ this._cardGroupOrder = normalizeGroupOrder(source.groupOrder);
169
+ }
170
+ if (Object.prototype.hasOwnProperty.call(source, 'minCardWidth')) {
171
+ this._cardMinWidth = normalizeCardWidth(source.minCardWidth);
172
+ }
173
+ if (Object.prototype.hasOwnProperty.call(source, 'maxColumns')) {
174
+ this._cardMaxColumns = normalizeMaxColumns(source.maxColumns);
175
+ }
176
+ if (Object.prototype.hasOwnProperty.call(source, 'variant')) {
177
+ this._cardVariant = normalizeCardVariant(source.variant);
178
+ }
179
+ this._applyCardLayout();
180
+ return super.setViewState(source, options);
181
+ }
182
+
183
+ /** Restores an enhanced target exactly, or removes an owned root. @returns {void} */
184
+ destroy() {
185
+ if (this._cardCleaned) return;
186
+ this._cardCleaned = true;
187
+ super.destroy();
188
+ if (!this._cardCreatedRoot) restoreTarget(this.el, this._cardOriginal);
189
+ }
190
+
191
+ /** @protected @param {string} reason Refresh reason. @returns {void} */
192
+ _refreshView(reason) {
193
+ if (!this.refs?.content) return;
194
+ if (reason === 'selection') this._syncCardSelection();
195
+ else this._renderCards();
196
+ this._renderCardSort();
197
+ }
198
+
199
+ /** @private @returns {void} */
200
+ _applyCardLayout() {
201
+ if (!this.el) return;
202
+ const root = /** @type {HTMLElement} */ (this.el);
203
+ root.style.setProperty('--zx-card-view-min-card-width', this._cardMinWidth);
204
+ const basis = this._cardMaxColumns ? `${100 / this._cardMaxColumns}%` : '0%';
205
+ root.style.setProperty('--zx-card-view-column-basis', basis);
206
+ if (this._cardMaxColumns) root.dataset.maxColumns = String(this._cardMaxColumns);
207
+ else delete root.dataset.maxColumns;
208
+ }
209
+
210
+ /** @private @returns {void} */
211
+ _renderCardSort() {
212
+ if (!this.refs?.sort || !this.refs?.sortWrap) return;
213
+ const select = /** @type {HTMLSelectElement} */ (this.refs.sort);
214
+ const sort = this.getSort();
215
+ const options = [h('option', { value: '', selected: sort === null }, 'Unsorted')];
216
+ for (const field of this.getFields().filter((candidate) => candidate.sortable)) {
217
+ options.push(h('option', {
218
+ selected: sort?.id === field.id && sort.dir === 'asc',
219
+ dataset: { sortId: field.id, sortDir: 'asc' }
220
+ }, `${field.label} (ascending)`));
221
+ options.push(h('option', {
222
+ selected: sort?.id === field.id && sort.dir === 'desc',
223
+ dataset: { sortId: field.id, sortDir: 'desc' }
224
+ }, `${field.label} (descending)`));
225
+ }
226
+ select.replaceChildren(...options);
227
+ /** @type {HTMLElement} */ (this.refs.sortWrap).hidden = options.length === 1;
228
+ }
229
+
230
+ /** @private @returns {void} */
231
+ _changeCardSort() {
232
+ const select = /** @type {HTMLSelectElement} */ (this.refs.sort);
233
+ const option = select.selectedOptions[0];
234
+ const id = option?.dataset.sortId;
235
+ if (!id) this.setSort(null);
236
+ else this.setSort(id, option.dataset.sortDir === 'desc' ? 'desc' : 'asc');
237
+ }
238
+
239
+ /** @private @returns {void} */
240
+ _renderCards() {
241
+ const content = /** @type {HTMLElement} */ (this.refs.content);
242
+ const active = /** @type {Element|null} */ (content.querySelector('.zx-record-card:focus'));
243
+ const activeIndex = active ? Number(/** @type {HTMLElement} */ (active).dataset.recordIndex) : -1;
244
+ const activeRecord = activeIndex >= 0 ? this._viewData[activeIndex] : null;
245
+ const activeId = activeRecord ? this._viewRecordId(activeRecord) : null;
246
+ this._cardNodesById = new Map();
247
+ if (/** @type {HTMLElement} */ (this.el).dataset.loading === 'true') {
248
+ content.replaceChildren(this._createCardSkeletons());
249
+ return;
250
+ }
251
+ const groups = groupCardRecords(this._viewData, this._cardOptions().groupBy,
252
+ this._cardGroupOrder, this._viewFields);
253
+ if (this._viewData.length === 0 && groups.length === 0) {
254
+ content.replaceChildren(this._createCardEmpty());
255
+ return;
256
+ }
257
+ const fields = this.getFields();
258
+ const visibleFields = this.getVisibleFields();
259
+ const sections = groups.map((group, groupIndex) =>
260
+ this._createCardGroup(group, fields, visibleFields, groupIndex));
261
+ content.replaceChildren(...sections);
262
+ if (activeId !== null) {
263
+ const next = this._cardNodesById.get(activeId);
264
+ if (next) queueMicrotask(() => next.isConnected && next.focus());
265
+ }
266
+ }
267
+
268
+ /** @private @param {CardRecordGroup} group @param {ViewField[]} fields @param {ViewField[]} visibleFields @param {number} groupIndex @returns {HTMLElement} */
269
+ _createCardGroup(group, fields, visibleFields, groupIndex) {
270
+ const options = this._cardOptions();
271
+ const headingId = group.id === null ? null : `zx-card-view-group-${safeDomPart(group.id)}-${groupIndex}`;
272
+ const list = h('ul', {
273
+ class: 'zx-card-view__list',
274
+ ariaLabelledby: headingId ?? undefined
275
+ });
276
+ for (const record of group.records) {
277
+ const index = this._viewData.indexOf(record);
278
+ const id = this._viewRecordId(record);
279
+ const card = createRecordCard(record, index, {
280
+ fields,
281
+ visibleFields,
282
+ titleField: options.titleField,
283
+ subtitleField: options.subtitleField,
284
+ preview: options.preview,
285
+ previewAlt: options.previewAlt,
286
+ link: options.link,
287
+ actions: options.actions,
288
+ selectable: this.options.selectable,
289
+ selected: this._isViewSelected(id),
290
+ variant: this._cardVariant,
291
+ headingLevel: this._cardHeadingLevel
292
+ });
293
+ this._cardNodesById.set(id, card);
294
+ list.append(card);
295
+ }
296
+ if (group.records.length === 0) {
297
+ list.append(h('li', { class: 'zx-card-view__group-empty' }, 'No records in this group.'));
298
+ }
299
+ if (group.id === null) return list;
300
+ return h('section', { class: 'zx-card-view__group', ariaLabelledby: headingId },
301
+ h('h2', { class: 'zx-card-view__group-heading', id: headingId }, group.label), list);
302
+ }
303
+
304
+ /** @private @returns {HTMLElement} */
305
+ _createCardSkeletons() {
306
+ const list = h('ul', {
307
+ class: 'zx-card-view__list zx-card-view__skeletons',
308
+ ariaHidden: 'true'
309
+ });
310
+ for (let index = 0; index < this._cardLoadingCount; index += 1) {
311
+ list.append(h('li', { class: 'zx-record-card zx-card-view__skeleton', tabindex: -1 },
312
+ h('span', { class: 'zx-card-view__skeleton-preview zx-skeleton' }),
313
+ h('span', { class: 'zx-card-view__skeleton-line zx-skeleton' }),
314
+ h('span', { class: 'zx-card-view__skeleton-line zx-skeleton' }),
315
+ h('span', { class: 'zx-card-view__skeleton-line zx-skeleton' })));
316
+ }
317
+ return list;
318
+ }
319
+
320
+ /** @private @returns {HTMLElement} */
321
+ _createCardEmpty() {
322
+ const empty = this.options.emptyText;
323
+ const value = typeof empty === 'function' ? empty() : empty ?? this.msg('table.empty');
324
+ return h('div', { class: 'zx-card-view__empty', role: 'status' }, isNode(value) ? value : String(value));
325
+ }
326
+
327
+ /** @private @returns {void} */
328
+ _syncCardSelection() {
329
+ for (const [id, card] of this._cardNodesById) {
330
+ const selected = this._isViewSelected(id);
331
+ card.dataset.selected = String(selected);
332
+ if (this.options.selectable) card.setAttribute('aria-selected', String(selected));
333
+ const checkbox = /** @type {HTMLInputElement|null} */ (card.querySelector('[data-record-selection]'));
334
+ if (checkbox) checkbox.checked = selected;
335
+ }
336
+ }
337
+
338
+ /** @private @param {MouseEvent} event @returns {void} */
339
+ _cardClick(event) {
340
+ const target = /** @type {Element|null} */ (event.target);
341
+ const card = /** @type {HTMLElement|null} */ (target?.closest?.('.zx-record-card'));
342
+ if (!card || !this.refs.content.contains(card)) return;
343
+ const index = Number(card.dataset.recordIndex);
344
+ const record = this._viewData[index];
345
+ if (!record) return;
346
+ const id = this._viewRecordId(record);
347
+ const selection = /** @type {HTMLInputElement|null} */ (target?.closest?.('[data-record-selection]'));
348
+ if (selection) {
349
+ this.toggleSelection(id, { selected: selection.checked, range: event.shiftKey });
350
+ return;
351
+ }
352
+ const actionElement = /** @type {HTMLElement|null} */ (target?.closest?.('[data-record-action]'));
353
+ if (actionElement) {
354
+ this._activateCardAction(record, id, index, actionElement.dataset.recordAction, event);
355
+ return;
356
+ }
357
+ if (isInteractiveWithin(target, card) || hasCardTextSelection(card)) return;
358
+ this._activateCard(record, id, index, event);
359
+ if (this.options.selectable === 'single') this.toggleSelection(id, { selected: true });
360
+ }
361
+
362
+ /** @private @param {MouseEvent} event @returns {void} */
363
+ _cardDoubleClick(event) {
364
+ const target = /** @type {Element|null} */ (event.target);
365
+ const card = /** @type {HTMLElement|null} */ (target?.closest?.('.zx-record-card'));
366
+ if (!card || isInteractiveWithin(target, card) || hasCardTextSelection(card)) return;
367
+ const index = Number(card.dataset.recordIndex);
368
+ const record = this._viewData[index];
369
+ if (!record) return;
370
+ this.emit('recorddblclick', { record, id: this._viewRecordId(record), index, event });
371
+ }
372
+
373
+ /** @private @param {KeyboardEvent} event @returns {void} */
374
+ _cardKeydown(event) {
375
+ const card = /** @type {HTMLElement|null} */ (event.target instanceof Element
376
+ ? event.target.closest('.zx-record-card') : null);
377
+ if (!card || event.target !== card) return;
378
+ const index = Number(card.dataset.recordIndex);
379
+ const record = this._viewData[index];
380
+ if (!record) return;
381
+ const id = this._viewRecordId(record);
382
+ if (event.key === 'Enter') {
383
+ event.preventDefault();
384
+ this._activateCard(record, id, index, event);
385
+ } else if (event.key === ' ' && this.options.selectable) {
386
+ event.preventDefault();
387
+ this.toggleSelection(id);
388
+ }
389
+ }
390
+
391
+ /** @private @param {ViewRecord} record @param {unknown} id @param {number} index @param {Event} event @returns {void} */
392
+ _activateCard(record, id, index, event) {
393
+ this.emit('recordclick', { record, id, index, event });
394
+ }
395
+
396
+ /** @private @param {ViewRecord} record @param {unknown} id @param {number} index @param {string|undefined} actionId @param {MouseEvent} event @returns {void} */
397
+ _activateCardAction(record, id, index, actionId, event) {
398
+ const action = resolveRecordActions(record, index, this._cardOptions().actions)
399
+ .find((candidate) => candidate.id === actionId);
400
+ if (!action || action.disabled) {
401
+ event.preventDefault();
402
+ return;
403
+ }
404
+ if (typeof action.onclick === 'function') action.onclick(record, index, event);
405
+ const emitted = this.emit('recordaction', { record, id, index, action: { ...action }, event });
406
+ if (emitted.defaultPrevented) event.preventDefault();
407
+ }
408
+
409
+ /** @private @param {Event} event @returns {void} */
410
+ _cardPreviewError(event) {
411
+ const image = /** @type {HTMLImageElement|null} */ (event.target);
412
+ if (!image?.matches?.('[data-record-preview]')) return;
413
+ const preview = /** @type {HTMLElement|null} */ (image.closest('.zx-record-card__preview'));
414
+ if (preview) preview.dataset.failed = 'true';
415
+ image.hidden = true;
416
+ }
417
+
418
+ /** @private @returns {Readonly<CardViewOptions & RecordViewOptions>} */
419
+ _cardOptions() {
420
+ return /** @type {Readonly<CardViewOptions & RecordViewOptions>} */ (this.options);
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Groups a cloned record list without mutating records or caller arrays. Explicit order entries
426
+ * are always materialized, including empty groups; remaining values keep first-seen order.
427
+ * @param {ViewRecord[]} records Records in display order.
428
+ * @param {CardViewOptions['groupBy']} groupBy Group field or resolver.
429
+ * @param {unknown[]} [groupOrder=[]] Preferred group ids.
430
+ * @param {ViewField[]} [fields=[]] Complete fields for accessor-aware grouping.
431
+ * @returns {CardRecordGroup[]} Deterministic groups.
432
+ */
433
+ export function groupCardRecords(records, groupBy, groupOrder = [], fields = []) {
434
+ if (!Array.isArray(records)) throw new TypeError('Card view records must be an array');
435
+ if (groupBy == null) return records.length ? [{ id: null, label: '', records: [...records] }] : [];
436
+ const preferred = normalizeGroupOrder(groupOrder);
437
+ const groups = new Map(preferred.map((id) => [id, []]));
438
+ for (const [index, record] of records.entries()) {
439
+ const raw = typeof groupBy === 'function'
440
+ ? groupBy(record, index)
441
+ : readGroupField(record, index, groupBy, fields);
442
+ const id = normalizeGroupId(raw);
443
+ if (!groups.has(id)) groups.set(id, []);
444
+ groups.get(id).push(record);
445
+ }
446
+ return [...groups].map(([id, grouped]) => ({
447
+ id,
448
+ label: id || 'Ungrouped',
449
+ records: grouped
450
+ }));
451
+ }
452
+
453
+ /** @param {ViewRecord} record @param {number} index @param {string} id @param {ViewField[]} fields @returns {unknown} */
454
+ function readGroupField(record, index, id, fields) {
455
+ const field = fields.find((candidate) => candidate.id === id);
456
+ return field ? readViewField(field, record, index) : record?.[id];
457
+ }
458
+
459
+ /** @param {unknown} value @returns {string[]} */
460
+ function normalizeGroupOrder(value) {
461
+ if (!Array.isArray(value)) return [];
462
+ return [...new Set(value.map(normalizeGroupId))];
463
+ }
464
+
465
+ /** @param {unknown} value @returns {string} */
466
+ function normalizeGroupId(value) {
467
+ return value == null || value === '' ? '' : String(value);
468
+ }
469
+
470
+ /** @param {unknown} value @returns {string} */
471
+ export function normalizeCardWidth(value) {
472
+ if (typeof value === 'number') return Number.isFinite(value) && value > 0 ? `${value}px` : '18rem';
473
+ const text = String(value ?? '').trim();
474
+ if (!text || text.length > 128 || /[;{}\u0000-\u001f\u007f]/.test(text)) return '18rem';
475
+ if (/(?:url|image|image-set|cross-fade|element|expression)\s*\(/i.test(text)) return '18rem';
476
+ if (typeof globalThis.CSS?.supports === 'function' && !globalThis.CSS.supports('width', text)) return '18rem';
477
+ return text;
478
+ }
479
+
480
+ /** @param {unknown} value @returns {number|null} */
481
+ function normalizeMaxColumns(value) {
482
+ if (value == null || value === '') return null;
483
+ const number = Number(value);
484
+ return Number.isFinite(number) ? Math.max(1, Math.min(12, Math.floor(number))) : null;
485
+ }
486
+
487
+ /** @param {unknown} value @returns {'outlined'|'raised'|'filled'} */
488
+ function normalizeCardVariant(value) {
489
+ return value === 'raised' || value === 'filled' ? value : 'outlined';
490
+ }
491
+
492
+ /** @param {unknown} value @returns {number} */
493
+ function normalizeLoadingCount(value) {
494
+ const number = Number(value ?? 6);
495
+ return Number.isFinite(number) ? Math.max(1, Math.min(50, Math.floor(number))) : 6;
496
+ }
497
+
498
+ /** @param {unknown} value @returns {1|2|3|4|5|6} */
499
+ function normalizeHeadingLevel(value) {
500
+ const number = Number(value ?? 3);
501
+ return /** @type {1|2|3|4|5|6} */ (Number.isInteger(number) && number >= 1 && number <= 6 ? number : 3);
502
+ }
503
+
504
+ /** @param {Element|null} target @param {HTMLElement} card @returns {boolean} */
505
+ function isInteractiveWithin(target, card) {
506
+ const interactive = target?.closest?.('a,button,input,select,textarea,summary,[contenteditable="true"],[role="button"]');
507
+ return Boolean(interactive && interactive !== card && card.contains(interactive));
508
+ }
509
+
510
+ /** @param {HTMLElement} card @returns {boolean} */
511
+ function hasCardTextSelection(card) {
512
+ const selection = globalThis.getSelection?.();
513
+ return Boolean(selection && !selection.isCollapsed
514
+ && (selection.anchorNode && card.contains(selection.anchorNode)
515
+ || selection.focusNode && card.contains(selection.focusNode)));
516
+ }
517
+
518
+ /** @param {unknown} value @returns {value is Node} */
519
+ function isNode(value) {
520
+ return Boolean(value && typeof value === 'object'
521
+ && typeof /** @type {{nodeType?:unknown}} */ (value).nodeType === 'number');
522
+ }
523
+
524
+ /** @param {string} value @returns {string} */
525
+ function safeDomPart(value) {
526
+ return value.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'empty';
527
+ }