@zeyos/zx 5.0.0 → 5.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 (154) hide show
  1. package/CHANGELOG.md +198 -0
  2. package/README.md +2 -1
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +9 -1
  5. package/dist/types/components/app-icon/app-icon.d.ts +14 -2
  6. package/dist/types/components/app-sidebar/app-sidebar.d.ts +288 -14
  7. package/dist/types/components/calendar/calendar.d.ts +67 -5
  8. package/dist/types/components/card-view/card-view.d.ts +243 -4
  9. package/dist/types/components/card-view/record-card.d.ts +11 -0
  10. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  11. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  12. package/dist/types/components/dropdown/dropdown.d.ts +22 -7
  13. package/dist/types/components/field/field.d.ts +30 -0
  14. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  15. package/dist/types/components/fieldset/fieldset.d.ts +7 -0
  16. package/dist/types/components/file-list/file-list.d.ts +35 -3
  17. package/dist/types/components/filter/filter.d.ts +127 -3
  18. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  19. package/dist/types/components/launcher/launcher.d.ts +8 -0
  20. package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
  21. package/dist/types/components/menu-button/menu-button.d.ts +6 -0
  22. package/dist/types/components/message/message.d.ts +14 -1
  23. package/dist/types/components/modal/modal.d.ts +30 -0
  24. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  25. package/dist/types/components/pagination/pagination.d.ts +17 -3
  26. package/dist/types/components/select/select.d.ts +6 -0
  27. package/dist/types/components/sheet/sheet.d.ts +15 -0
  28. package/dist/types/components/table/table.d.ts +265 -17
  29. package/dist/types/components/view/record-view.d.ts +59 -2
  30. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  31. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  32. package/dist/types/core/component.d.ts +12 -2
  33. package/dist/types/core/i18n.d.ts +27 -3
  34. package/dist/types/core/keyboard.d.ts +98 -1
  35. package/dist/types/core/messages.d.ts +382 -0
  36. package/dist/types/core/overlay-host.d.ts +28 -1
  37. package/dist/types/core/overlay-stack.d.ts +260 -0
  38. package/dist/types/index.d.ts +5 -2
  39. package/dist/types/internal/app-rail.d.ts +38 -0
  40. package/dist/types/internal/menu-items.d.ts +71 -3
  41. package/dist/types/zeyos/index.d.ts +1 -0
  42. package/dist/types/zeyos/launcher.d.ts +9 -0
  43. package/dist/types/zeyos/module-bar.d.ts +176 -0
  44. package/dist/types/zeyos/query.d.ts +31 -5
  45. package/dist/types/zeyos/table.d.ts +245 -8
  46. package/dist/types/zeyos/view.d.ts +16 -2
  47. package/dist/zx-zeyos.esm.js +4422 -430
  48. package/dist/zx-zeyos.esm.min.js +5 -5
  49. package/dist/zx.css +1472 -133
  50. package/dist/zx.esm.js +7433 -854
  51. package/dist/zx.esm.min.js +8 -8
  52. package/dist/zx.global.js +7433 -854
  53. package/dist/zx.global.min.js +8 -8
  54. package/dist/zx.min.css +1 -1
  55. package/docs/api.json +937 -119
  56. package/docs/llms.md +531 -63
  57. package/docs/llms.txt +6 -2
  58. package/package.json +1 -1
  59. package/src/components/account-menu/account-menu.js +12 -0
  60. package/src/components/activity-list/activity-list.js +27 -1
  61. package/src/components/app-icon/app-icon.css +13 -2
  62. package/src/components/app-icon/app-icon.js +11 -1
  63. package/src/components/app-rail/app-rail.css +19 -0
  64. package/src/components/app-sidebar/app-sidebar.css +204 -3
  65. package/src/components/app-sidebar/app-sidebar.js +646 -40
  66. package/src/components/breadcrumb/breadcrumb.css +1 -1
  67. package/src/components/calendar/calendar.css +1 -1
  68. package/src/components/calendar/calendar.js +26 -52
  69. package/src/components/card/card.css +2 -2
  70. package/src/components/card-view/card-view.css +76 -5
  71. package/src/components/card-view/card-view.js +514 -20
  72. package/src/components/card-view/record-card.js +14 -3
  73. package/src/components/code-editor/code-editor.css +1 -1
  74. package/src/components/code-editor/field-adapter.js +9 -2
  75. package/src/components/context-menu/context-menu.js +8 -2
  76. package/src/components/copy/copy-input.css +1 -1
  77. package/src/components/data-filter/data-filter.js +2 -2
  78. package/src/components/date-picker/time-picker.css +2 -2
  79. package/src/components/dock/dock.css +5 -4
  80. package/src/components/dropdown/dropdown.js +157 -72
  81. package/src/components/field/field.js +33 -3
  82. package/src/components/fieldset/external-adapter.js +363 -0
  83. package/src/components/fieldset/fieldset.js +10 -59
  84. package/src/components/file-list/file-list.js +144 -21
  85. package/src/components/filter/filter.css +86 -2
  86. package/src/components/filter/filter.js +300 -8
  87. package/src/components/filter-panel/filter-panel.js +149 -32
  88. package/src/components/finder/finder.css +1 -1
  89. package/src/components/kanban-view/kanban-view.css +3 -3
  90. package/src/components/kanban-view/kanban-view.js +5 -2
  91. package/src/components/launcher/launcher.css +1 -1
  92. package/src/components/launcher/launcher.js +29 -3
  93. package/src/components/list-toolbar/list-toolbar.js +39 -13
  94. package/src/components/master-panel/master-panel.js +0 -1
  95. package/src/components/menu-button/menu-button.css +63 -0
  96. package/src/components/menu-button/menu-button.js +97 -11
  97. package/src/components/message/message.js +60 -20
  98. package/src/components/modal/modal.css +1 -1
  99. package/src/components/modal/modal.js +66 -13
  100. package/src/components/module-bar/module-bar.css +486 -0
  101. package/src/components/module-bar/module-bar.js +1074 -0
  102. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  103. package/src/components/number-field/field-adapter.js +2 -1
  104. package/src/components/pagination/pagination.css +5 -5
  105. package/src/components/pagination/pagination.js +20 -4
  106. package/src/components/panel/panel.css +3 -2
  107. package/src/components/questionnaire/questionnaire.css +4 -4
  108. package/src/components/rating/field-adapter.js +2 -1
  109. package/src/components/rating/rating.css +1 -1
  110. package/src/components/search/search.css +6 -1
  111. package/src/components/select/select.js +9 -0
  112. package/src/components/sheet/sheet.css +1 -1
  113. package/src/components/sheet/sheet.js +74 -32
  114. package/src/components/slider/field-adapter.js +6 -1
  115. package/src/components/slider/slider.css +2 -2
  116. package/src/components/split-view/split-view.css +1 -1
  117. package/src/components/stat-tile/stat-tile.css +1 -1
  118. package/src/components/stepper/stepper.css +1 -1
  119. package/src/components/tabbox/tabbox.css +9 -5
  120. package/src/components/table/table.css +86 -11
  121. package/src/components/table/table.js +628 -57
  122. package/src/components/tag-picker/field-adapter.js +2 -1
  123. package/src/components/tooltip/tooltip.js +35 -17
  124. package/src/components/tree/tree.css +1 -1
  125. package/src/components/view/record-view.css +1 -1
  126. package/src/components/view/record-view.js +178 -18
  127. package/src/components/view-settings/view-settings.css +285 -0
  128. package/src/components/view-settings/view-settings.js +1830 -0
  129. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  130. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  131. package/src/core/component.js +88 -17
  132. package/src/core/fontawesome.js +5 -1
  133. package/src/core/i18n.js +47 -4
  134. package/src/core/icons.js +12 -2
  135. package/src/core/keyboard.js +206 -11
  136. package/src/core/messages.js +427 -0
  137. package/src/core/overlay-host.js +64 -9
  138. package/src/core/overlay-stack.js +487 -0
  139. package/src/core/position.js +22 -3
  140. package/src/index.js +5 -2
  141. package/src/internal/app-rail.js +134 -53
  142. package/src/internal/menu-items.js +61 -3
  143. package/src/zeyos/form.js +5 -3
  144. package/src/zeyos/index.js +3 -0
  145. package/src/zeyos/launcher.js +8 -3
  146. package/src/zeyos/module-bar.js +181 -0
  147. package/src/zeyos/query.js +44 -13
  148. package/src/zeyos/table.js +220 -80
  149. package/src/zeyos/view.js +43 -93
  150. package/styles/base.css +16 -1
  151. package/styles/print.css +1 -1
  152. package/styles/tokens/dark.css +4 -0
  153. package/styles/tokens/semantic.css +56 -28
  154. package/styles/zx.css +3 -0
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
  import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
3
+ import { icon } from '../../core/icons.js';
4
+ import { uid } from '../../core/util.js';
3
5
  import { normalizeViewFieldControls, RecordView, readViewField } from '../view/record-view.js';
4
6
  import {
5
7
  createRecordCard, resolveRecordActions
@@ -47,8 +49,32 @@ import {
47
49
  * @property {'outlined'|'raised'|'filled'} [variant='outlined'] Card surface treatment.
48
50
  * @property {number} [loadingCount=6] Skeleton cards shown while loading.
49
51
  * @property {1|2|3|4|5|6} [headingLevel=3] Per-card title heading level.
50
- * @property {string} [label='Card view'] Accessible collection label.
52
+ * @property {string|null} [label=null] Accessible collection label; null resolves message
53
+ * `cardView.label`, "Card view".
54
+ * @property {'tabs'|'roving'} [keyboard='tabs'] Keyboard model. `'tabs'` makes every card, title
55
+ * link, checkbox, and action its own Tab stop. `'roving'` — recommended for lists — follows the
56
+ * APG Grid pattern with row focus: the collection is one Tab stop, the arrow keys, Home/End, and
57
+ * PageUp/PageDown move between cards, Enter activates, Space selects, and the controls inside a
58
+ * card stay pointer targets with `tabindex="-1"`.
59
+ * @property {boolean} [collapsibleGroups=false] Whether group headings carry a toggle that hides
60
+ * and shows the group's cards.
61
+ * @property {unknown[]} [collapsedGroups=[]] Group ids that start collapsed.
62
+ * @property {((context:CardGroupHeadingContext)=>Node|string|null|undefined)|null} [renderGroupHeading=null]
63
+ * Content of a group heading in place of its label. With `collapsibleGroups` it is placed inside
64
+ * the toggle button, so it must not contain interactive elements.
51
65
  * @property {(event:CustomEvent<Record<string,unknown>>)=>void} [onrecordaction]
66
+ * @property {(event:CustomEvent<{id:string,collapsed:boolean,collapsedGroups:string[]}>)=>void} [ongroupcollapse]
67
+ */
68
+
69
+ /**
70
+ * What `renderGroupHeading` is given for one group.
71
+ * @typedef {Object} CardGroupHeadingContext
72
+ * @property {string} id Normalized group id (`''` for records without a value).
73
+ * @property {string} label Default label.
74
+ * @property {number} count Records in the group.
75
+ * @property {ViewRecord[]} records The group's records in display order.
76
+ * @property {boolean} collapsed Whether the group is collapsed.
77
+ * @property {() => void} toggle Collapses or expands the group.
52
78
  */
53
79
 
54
80
  /**
@@ -61,6 +87,7 @@ import {
61
87
  * @property {string} minCardWidth Responsive minimum card width.
62
88
  * @property {number|null} maxColumns Maximum cards per row.
63
89
  * @property {'outlined'|'raised'|'filled'} variant Surface treatment.
90
+ * @property {string[]} collapsedGroups Collapsed group ids.
64
91
  */
65
92
 
66
93
  /**
@@ -73,7 +100,20 @@ import {
73
100
  /**
74
101
  * Responsive, schema-friendly record cards with shared fields, sorting, selection, state, groups,
75
102
  * safe preview media, native title links, and delegated actions.
103
+ *
104
+ * With `keyboard: 'roving'` the collection follows the WAI-ARIA APG Grid pattern with row focus,
105
+ * the model `Table` uses: the content is a `grid`, each card a focusable `row` (`aria-selected`
106
+ * when selectable, `aria-multiselectable` on the grid for `multi`), and the collection is a single
107
+ * Tab stop. ArrowUp/ArrowDown move to the card above or below — columns are measured from where the
108
+ * cards actually are, so a multi-column layout moves by visual row — ArrowLeft/ArrowRight within a
109
+ * row, Home/End to the first or last card, PageUp/PageDown by a visible page. Enter activates
110
+ * (`recordclick`), Space toggles selection in `selectable` modes (and activates otherwise),
111
+ * Shift+movement extends a `multi` selection, and Ctrl/Cmd+A selects every rendered card. A
112
+ * collapsible group heading is part of the same order: Enter or Space toggles it, ArrowLeft
113
+ * collapses, ArrowRight expands. The current card is kept by record id across `setData`, sorting,
114
+ * grouping, and collapsing, and focus is restored without scrolling.
76
115
  * @fires CardView#recordaction
116
+ * @fires CardView#groupcollapse
77
117
  * @extends {RecordView}
78
118
  */
79
119
  export class CardView extends RecordView {
@@ -96,7 +136,11 @@ export class CardView extends RecordView {
96
136
  variant: 'outlined',
97
137
  loadingCount: 6,
98
138
  headingLevel: 3,
99
- label: 'Card view'
139
+ label: null,
140
+ keyboard: 'tabs',
141
+ collapsibleGroups: false,
142
+ collapsedGroups: [],
143
+ renderGroupHeading: null
100
144
  };
101
145
 
102
146
  /**
@@ -124,9 +168,30 @@ export class CardView extends RecordView {
124
168
  this._cardVariant = normalizeCardVariant(options.variant);
125
169
  this._cardLoadingCount = normalizeLoadingCount(options.loadingCount);
126
170
  this._cardHeadingLevel = normalizeHeadingLevel(options.headingLevel);
171
+ this._cardRoving = options.keyboard === 'roving';
172
+ // Per instance, so two grouped views on one page never share heading or list ids.
173
+ this._cardIdPrefix = uid('zx-card-view');
174
+ this._cardCollapsed = new Set(normalizeGroupOrder(options.collapsedGroups));
175
+ /** @type {{kind:'card'|'group', id:unknown}|null} */
176
+ this._cardCurrent = null;
177
+ this._cardCurrentIndex = 0;
178
+ /** @type {HTMLElement|null} */
179
+ this._cardTabStop = null;
180
+ /** @type {number|null} */
181
+ this._cardStickyX = null;
182
+ this._cardMoving = false;
183
+ /** @type {Map<string, HTMLElement>} */
184
+ this._cardHeadersById = new Map();
185
+ /** @type {Map<unknown, string>} */
186
+ this._cardGroupOfId = new Map();
187
+ /** @type {Map<Element, string>} */
188
+ this._cardSectionIds = new Map();
189
+ /** @type {WeakMap<Element, unknown>} */
190
+ this._cardIdOfNode = new WeakMap();
191
+ if (this._cardRoving) root.dataset.keyboard = 'roving';
127
192
  try {
128
193
  this._initRecordView(root);
129
- root.setAttribute('aria-label', String(options.label || 'Card view'));
194
+ root.setAttribute('aria-label', String(options.label || this.msg('cardView.label')));
130
195
  const sort = h('label', { ref: 'sortWrap', class: 'zx-card-view__sort' },
131
196
  h('span', { class: 'zx-card-view__sort-label' }, this._message('cardView.sort', 'Sort')),
132
197
  h('select', { ref: 'sort', ariaLabel: this._message('cardView.sortCards', 'Sort cards') }));
@@ -139,7 +204,13 @@ export class CardView extends RecordView {
139
204
  this.listen(this.refs.sort, 'change', () => this._changeCardSort());
140
205
  this.listen(content, 'click', (event) => this._cardClick(/** @type {MouseEvent} */ (event)));
141
206
  this.listen(content, 'dblclick', (event) => this._cardDoubleClick(/** @type {MouseEvent} */ (event)));
142
- this.listen(content, 'keydown', (event) => this._cardKeydown(/** @type {KeyboardEvent} */ (event)));
207
+ this.listen(content, 'keydown', (event) => {
208
+ if (this._cardRoving) this._cardRovingKeydown(/** @type {KeyboardEvent} */ (event));
209
+ else this._cardKeydown(/** @type {KeyboardEvent} */ (event));
210
+ });
211
+ if (this._cardRoving) {
212
+ this.listen(content, 'focusin', (event) => this._cardFocusIn(/** @type {FocusEvent} */ (event)));
213
+ }
143
214
  this.listen(content, 'error', (event) => this._cardPreviewError(event), true);
144
215
  this._applyCardLayout();
145
216
  this._renderCardSort();
@@ -159,7 +230,8 @@ export class CardView extends RecordView {
159
230
  groupOrder: [...this._cardGroupOrder],
160
231
  minCardWidth: this._cardMinWidth,
161
232
  maxColumns: this._cardMaxColumns,
162
- variant: this._cardVariant
233
+ variant: this._cardVariant,
234
+ collapsedGroups: [...this._cardCollapsed]
163
235
  };
164
236
  }
165
237
 
@@ -183,10 +255,39 @@ export class CardView extends RecordView {
183
255
  if (Object.prototype.hasOwnProperty.call(source, 'variant')) {
184
256
  this._cardVariant = normalizeCardVariant(source.variant);
185
257
  }
258
+ if (Object.prototype.hasOwnProperty.call(source, 'collapsedGroups')) {
259
+ this._cardCollapsed = new Set(normalizeGroupOrder(source.collapsedGroups));
260
+ }
186
261
  this._applyCardLayout();
187
262
  return super.setViewState(source, options);
188
263
  }
189
264
 
265
+ /**
266
+ * Collapses or expands one group. A collapsed group keeps its heading and hides its cards.
267
+ * @param {unknown} id Group id, as `groupBy` produces it.
268
+ * @param {boolean} [collapsed=true] Next state.
269
+ * @param {{silent?:boolean}} [options={}] Set `silent` to suppress `groupcollapse` and `statechange`.
270
+ * @returns {this}
271
+ * @fires CardView#groupcollapse
272
+ */
273
+ setGroupCollapsed(id, collapsed = true, options = {}) {
274
+ const groupId = normalizeGroupId(id);
275
+ if (this._cardCollapsed.has(groupId) === Boolean(collapsed)) return this;
276
+ if (collapsed) this._cardCollapsed.add(groupId);
277
+ else this._cardCollapsed.delete(groupId);
278
+ this._renderCards();
279
+ if (!options.silent) {
280
+ this.emit('groupcollapse', { id: groupId, collapsed: Boolean(collapsed), collapsedGroups: [...this._cardCollapsed] });
281
+ this.emit('statechange', { state: this.getViewState(), reason: 'groupcollapse' });
282
+ }
283
+ return this;
284
+ }
285
+
286
+ /** Returns the collapsed group ids. @returns {string[]} */
287
+ getCollapsedGroups() {
288
+ return [...this._cardCollapsed];
289
+ }
290
+
190
291
  /** Restores an enhanced target exactly, or removes an owned root. @returns {void} */
191
292
  destroy() {
192
293
  if (this._cardCleaned) return;
@@ -248,19 +349,33 @@ export class CardView extends RecordView {
248
349
  /** @private @returns {void} */
249
350
  _renderCards() {
250
351
  const content = /** @type {HTMLElement} */ (this.refs.content);
352
+ const documentRef = content.ownerDocument;
353
+ const activeElement = documentRef?.activeElement ?? null;
354
+ const hadFocus = Boolean(activeElement && activeElement !== documentRef.body && content.contains(activeElement));
355
+ // Read by node, not by `data-record-index`: `setData()` has already replaced the records the
356
+ // index pointed into.
251
357
  const active = /** @type {Element|null} */ (content.querySelector('.zx-record-card:focus'));
252
- const activeIndex = active ? Number(/** @type {HTMLElement} */ (active).dataset.recordIndex) : -1;
253
- const activeRecord = activeIndex >= 0 ? this._viewData[activeIndex] : null;
254
- const activeId = activeRecord ? this._viewRecordId(activeRecord) : null;
358
+ const activeId = active && this._cardIdOfNode.has(active) ? this._cardIdOfNode.get(active) : null;
255
359
  this._cardNodesById = new Map();
360
+ this._cardHeadersById = new Map();
361
+ this._cardGroupOfId = new Map();
362
+ this._cardSectionIds = new Map();
363
+ this._cardTabStop = null;
364
+ if (this._cardRoving) {
365
+ content.removeAttribute('role');
366
+ content.removeAttribute('aria-label');
367
+ content.removeAttribute('aria-multiselectable');
368
+ }
256
369
  if (/** @type {HTMLElement} */ (this.el).dataset.loading === 'true') {
257
370
  content.replaceChildren(this._createCardSkeletons());
371
+ this._syncCardEmptyFocus(hadFocus);
258
372
  return;
259
373
  }
260
374
  const groups = groupCardRecords(this._viewData, this._cardOptions().groupBy,
261
- this._cardGroupOrder, this._viewFields);
375
+ this._cardGroupOrder, this._viewFields, this.msg('cardView.ungrouped'));
262
376
  if (this._viewData.length === 0 && groups.length === 0) {
263
377
  content.replaceChildren(this._createCardEmpty());
378
+ this._syncCardEmptyFocus(hadFocus);
264
379
  return;
265
380
  }
266
381
  const fields = this.getFields();
@@ -268,6 +383,14 @@ export class CardView extends RecordView {
268
383
  const sections = groups.map((group, groupIndex) =>
269
384
  this._createCardGroup(group, fields, visibleFields, groupIndex));
270
385
  content.replaceChildren(...sections);
386
+ if (this._cardRoving) {
387
+ content.setAttribute('role', 'grid');
388
+ content.setAttribute('aria-label', /** @type {HTMLElement} */ (this.el).getAttribute('aria-label') ?? '');
389
+ if (this.options.selectable === 'multi') content.setAttribute('aria-multiselectable', 'true');
390
+ this._syncCardEmptyFocus(false);
391
+ this._syncCardRoving(hadFocus);
392
+ return;
393
+ }
271
394
  if (activeId !== null) {
272
395
  const next = this._cardNodesById.get(activeId);
273
396
  if (next) queueMicrotask(() => next.isConnected && next.focus());
@@ -277,14 +400,22 @@ export class CardView extends RecordView {
277
400
  /** @private @param {CardRecordGroup} group @param {ViewField[]} fields @param {ViewField[]} visibleFields @param {number} groupIndex @returns {HTMLElement} */
278
401
  _createCardGroup(group, fields, visibleFields, groupIndex) {
279
402
  const options = this._cardOptions();
280
- const headingId = group.id === null ? null : `zx-card-view-group-${safeDomPart(group.id)}-${groupIndex}`;
403
+ const roving = this._cardRoving;
404
+ const headingId = group.id === null ? null : `${this._cardIdPrefix}-group-${safeDomPart(group.id)}-${groupIndex}`;
405
+ const collapsible = group.id !== null && Boolean(options.collapsibleGroups);
406
+ const collapsed = collapsible && this._cardCollapsed.has(/** @type {string} */ (group.id));
281
407
  const list = h('ul', {
282
408
  class: 'zx-card-view__list',
283
- ariaLabelledby: headingId ?? undefined
409
+ id: headingId ? `${headingId}-list` : undefined,
410
+ role: roving ? (group.id === null ? 'rowgroup' : 'none') : undefined,
411
+ ariaLabelledby: headingId ?? undefined,
412
+ hidden: collapsed
284
413
  });
285
414
  for (const record of group.records) {
286
- const index = this._viewData.indexOf(record);
287
415
  const id = this._viewRecordId(record);
416
+ if (group.id !== null) this._cardGroupOfId.set(id, group.id);
417
+ if (collapsed) continue;
418
+ const index = this._viewData.indexOf(record);
288
419
  const card = createRecordCard(record, index, {
289
420
  fields,
290
421
  visibleFields,
@@ -298,17 +429,267 @@ export class CardView extends RecordView {
298
429
  selectionTrigger: options.selectionTrigger,
299
430
  selected: this._isViewSelected(id),
300
431
  variant: this._cardVariant,
301
- headingLevel: this._cardHeadingLevel
432
+ headingLevel: this._cardHeadingLevel,
433
+ message: (key, ...args) => this.msg(key, ...args)
302
434
  });
435
+ if (roving) this._makeCardRow(card, id);
303
436
  this._cardNodesById.set(id, card);
437
+ this._cardIdOfNode.set(card, id);
304
438
  list.append(card);
305
439
  }
306
- if (group.records.length === 0) {
307
- list.append(h('li', { class: 'zx-card-view__group-empty' }, 'No records in this group.'));
440
+ if (group.records.length === 0 && !collapsed) {
441
+ const text = this.msg('cardView.groupEmpty');
442
+ list.append(roving
443
+ ? h('li', { class: 'zx-card-view__group-empty', role: 'row' }, h('span', { role: 'gridcell' }, text))
444
+ : h('li', { class: 'zx-card-view__group-empty' }, text));
308
445
  }
309
446
  if (group.id === null) return list;
310
- return h('section', { class: 'zx-card-view__group', ariaLabelledby: headingId },
311
- h('h2', { class: 'zx-card-view__group-heading', id: headingId }, group.label), list);
447
+ const header = this._createGroupHeader(group, /** @type {string} */ (headingId), collapsible, collapsed);
448
+ const section = h('section', {
449
+ class: 'zx-card-view__group',
450
+ role: roving ? 'rowgroup' : undefined,
451
+ ariaLabelledby: headingId,
452
+ dataset: { collapsed: collapsible ? String(collapsed) : null }
453
+ }, header, list);
454
+ this._cardSectionIds.set(section, group.id);
455
+ return section;
456
+ }
457
+
458
+ /**
459
+ * Builds a group heading: the label (or `renderGroupHeading` content), inside an APG
460
+ * disclosure button when the group can collapse.
461
+ * @private
462
+ * @param {CardRecordGroup} group Group.
463
+ * @param {string} headingId Heading id.
464
+ * @param {boolean} collapsible Whether the group can collapse.
465
+ * @param {boolean} collapsed Whether it is collapsed.
466
+ * @returns {HTMLElement}
467
+ */
468
+ _createGroupHeader(group, headingId, collapsible, collapsed) {
469
+ const id = /** @type {string} */ (group.id);
470
+ const render = this._cardOptions().renderGroupHeading;
471
+ const custom = typeof render === 'function' ? render({
472
+ id,
473
+ label: group.label,
474
+ count: group.records.length,
475
+ records: [...group.records],
476
+ collapsed,
477
+ toggle: () => this.setGroupCollapsed(id, !this._cardCollapsed.has(id))
478
+ }) : null;
479
+ const label = custom == null ? group.label : isNode(custom) ? custom : String(custom);
480
+ const labelNode = h('span', { class: 'zx-card-view__group-label' }, label);
481
+ const roving = this._cardRoving;
482
+ const content = collapsible ? h('button', {
483
+ class: 'zx-card-view__group-toggle',
484
+ type: 'button',
485
+ tabindex: roving ? -1 : undefined,
486
+ ariaExpanded: String(!collapsed),
487
+ ariaControls: `${headingId}-list`,
488
+ dataset: { groupToggle: '' }
489
+ }, icon('chevron-down', { size: 14 }), labelNode) : labelNode;
490
+ const heading = h('h2', { class: 'zx-card-view__group-heading', id: headingId }, content);
491
+ const header = h('div', {
492
+ class: 'zx-card-view__group-header',
493
+ role: roving ? 'row' : undefined,
494
+ ariaExpanded: roving && collapsible ? String(!collapsed) : undefined,
495
+ ariaLabelledby: roving ? headingId : undefined,
496
+ tabindex: roving && collapsible ? -1 : undefined,
497
+ dataset: { cardItem: roving && collapsible ? 'group' : null }
498
+ }, h('div', { class: 'zx-card-view__group-cell', role: roving ? 'rowheader' : undefined }, heading));
499
+ if (roving && collapsible) this._cardHeadersById.set(id, header);
500
+ return header;
501
+ }
502
+
503
+ /**
504
+ * Turns a rendered card into a grid row for the roving model: row focus, `aria-selected` when
505
+ * selectable, and the controls inside it out of the Tab order.
506
+ * @private
507
+ * @param {HTMLElement} card Card element.
508
+ * @param {unknown} id Record id.
509
+ * @returns {void}
510
+ */
511
+ _makeCardRow(card, id) {
512
+ card.setAttribute('role', 'row');
513
+ card.tabIndex = -1;
514
+ card.dataset.cardItem = 'card';
515
+ card.removeAttribute('aria-description');
516
+ card.removeAttribute('aria-keyshortcuts');
517
+ if (this.options.selectable) card.setAttribute('aria-selected', String(this._isViewSelected(id)));
518
+ for (const child of card.children) child.setAttribute('role', 'gridcell');
519
+ for (const control of card.querySelectorAll(FOCUSABLE)) {
520
+ if (control !== card) /** @type {HTMLElement} */ (control).tabIndex = -1;
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Moves the single Tab stop to the current item after a render — the same record or group by
526
+ * id; else the heading of the collapsed group that now hides the card; else the item now at its
527
+ * old position — and focuses it again, without scrolling, when the collection had focus.
528
+ * @private
529
+ * @param {boolean} hadFocus Whether focus was inside the collection before the render.
530
+ * @returns {void}
531
+ */
532
+ _syncCardRoving(hadFocus) {
533
+ const items = this._cardItems();
534
+ if (items.length === 0) {
535
+ this._syncCardEmptyFocus(hadFocus);
536
+ return;
537
+ }
538
+ let item = this._cardItemFor(this._cardCurrent);
539
+ if (!item && this._cardCurrent?.kind === 'card') {
540
+ const groupId = this._cardGroupOfId.get(this._cardCurrent.id);
541
+ item = groupId === undefined ? null : this._cardHeadersById.get(groupId) ?? null;
542
+ }
543
+ if (!item) item = items[Math.min(Math.max(this._cardCurrentIndex, 0), items.length - 1)];
544
+ this._setCardTabStop(item, items);
545
+ if (hadFocus) item.focus({ preventScroll: true });
546
+ }
547
+
548
+ /**
549
+ * @private
550
+ * @param {HTMLElement} item New Tab stop.
551
+ * @param {HTMLElement[]} [items] Every item, when already collected.
552
+ * @returns {void}
553
+ */
554
+ _setCardTabStop(item, items = this._cardItems()) {
555
+ for (const candidate of items) if (candidate !== item) candidate.tabIndex = -1;
556
+ item.tabIndex = 0;
557
+ this._cardTabStop = item;
558
+ const key = this._cardItemKey(item);
559
+ if (key) this._cardCurrent = key;
560
+ this._cardCurrentIndex = Math.max(0, items.indexOf(item));
561
+ }
562
+
563
+ /**
564
+ * While loading or empty there is no card to hold focus: the content takes it rather than
565
+ * letting it fall to the document, and gives the Tab order its old shape back afterwards.
566
+ * @private
567
+ * @param {boolean} hold Whether focus was inside and there is nothing to put it on.
568
+ * @returns {void}
569
+ */
570
+ _syncCardEmptyFocus(hold) {
571
+ if (!this._cardRoving) return;
572
+ const content = /** @type {HTMLElement} */ (this.refs.content);
573
+ if (hold) {
574
+ if (!content.hasAttribute('tabindex')) {
575
+ content.tabIndex = -1;
576
+ content.setAttribute('data-empty-focus', '');
577
+ }
578
+ content.focus({ preventScroll: true });
579
+ } else if (content.hasAttribute('data-empty-focus')) {
580
+ content.removeAttribute('tabindex');
581
+ content.removeAttribute('data-empty-focus');
582
+ }
583
+ }
584
+
585
+ /** @private @returns {HTMLElement[]} Roving items in display order. */
586
+ _cardItems() {
587
+ return /** @type {HTMLElement[]} */ ([...this.refs.content.querySelectorAll('[data-card-item]')]);
588
+ }
589
+
590
+ /** @private @param {HTMLElement} item @returns {{kind:'card'|'group', id:unknown}|null} */
591
+ _cardItemKey(item) {
592
+ if (item.dataset.cardItem === 'group') {
593
+ for (const [id, header] of this._cardHeadersById) if (header === item) return { kind: 'group', id };
594
+ return null;
595
+ }
596
+ return this._cardIdOfNode.has(item) ? { kind: 'card', id: this._cardIdOfNode.get(item) } : null;
597
+ }
598
+
599
+ /** @private @param {{kind:'card'|'group', id:unknown}|null} key @returns {HTMLElement|null} */
600
+ _cardItemFor(key) {
601
+ if (!key) return null;
602
+ if (key.kind === 'group') return this._cardHeadersById.get(/** @type {string} */ (key.id)) ?? null;
603
+ return /** @type {HTMLElement|undefined} */ (this._cardNodesById.get(key.id)) ?? null;
604
+ }
605
+
606
+ /** @private @param {FocusEvent} event @returns {void} */
607
+ _cardFocusIn(event) {
608
+ const target = /** @type {Element|null} */ (event.target);
609
+ const item = /** @type {HTMLElement|null} */ (target?.closest?.('[data-card-item]') ?? null);
610
+ if (!item || !this.refs.content.contains(item)) return;
611
+ if (this._cardTabStop !== item) this._setCardTabStop(item);
612
+ if (!this._cardMoving) this._cardStickyX = item.getBoundingClientRect().left;
613
+ }
614
+
615
+ /**
616
+ * The roving keyboard model; see the class description.
617
+ * @private
618
+ * @param {KeyboardEvent} event Key event.
619
+ * @returns {void}
620
+ */
621
+ _cardRovingKeydown(event) {
622
+ const target = /** @type {Element|null} */ (event.target);
623
+ const item = /** @type {HTMLElement|null} */ (target?.closest?.('[data-card-item]') ?? null);
624
+ if (!item || !this.refs.content.contains(item)) return;
625
+ const onItem = target === item;
626
+ if (!onItem && target?.matches?.(TEXT_ENTRY)) return;
627
+ const items = this._cardItems();
628
+ const index = items.indexOf(item);
629
+ const kind = item.dataset.cardItem;
630
+ const key = event.key;
631
+ const multi = this.options.selectable === 'multi';
632
+ if ((event.ctrlKey || event.metaKey) && (key === 'a' || key === 'A')) {
633
+ if (!multi) return;
634
+ event.preventDefault();
635
+ this.setSelection(this._cardIdsOf(items));
636
+ return;
637
+ }
638
+ if (onItem && (key === 'Enter' || key === ' ')) {
639
+ event.preventDefault();
640
+ if (kind === 'group') {
641
+ const groupId = /** @type {string} */ (this._cardItemKey(item)?.id);
642
+ this.setGroupCollapsed(groupId, !this._cardCollapsed.has(groupId));
643
+ return;
644
+ }
645
+ const record = this._viewData[Number(item.dataset.recordIndex)];
646
+ if (!record) return;
647
+ const id = this._viewRecordId(record);
648
+ if (key === ' ' && this.options.selectable) {
649
+ this.toggleSelection(id);
650
+ return;
651
+ }
652
+ this._activateCard(record, id, Number(item.dataset.recordIndex), event);
653
+ if (this.options.selectable === 'single') this.toggleSelection(id, { selected: true });
654
+ return;
655
+ }
656
+ if (kind === 'group' && onItem && (key === 'ArrowLeft' || key === 'ArrowRight')) {
657
+ event.preventDefault();
658
+ const groupId = /** @type {string} */ (this._cardItemKey(item)?.id);
659
+ this.setGroupCollapsed(groupId, key === 'ArrowLeft');
660
+ return;
661
+ }
662
+ if (!NAVIGATION_KEYS.has(key)) return;
663
+ const rects = items.map((candidate) => candidate.getBoundingClientRect());
664
+ const vertical = key === 'ArrowUp' || key === 'ArrowDown' || key === 'PageUp' || key === 'PageDown';
665
+ const x = vertical ? this._cardStickyX ?? rects[index].left : rects[index].left;
666
+ const page = cardPageSize(viewportHeight(this.refs.content), rects[index].height);
667
+ const next = cardGridTarget(rects, index, key, { page, x });
668
+ if (next === null) return;
669
+ event.preventDefault();
670
+ if (next === index) return;
671
+ const destination = items[next];
672
+ if (!vertical) this._cardStickyX = rects[next].left;
673
+ this._cardMoving = true;
674
+ try {
675
+ this._setCardTabStop(destination, items);
676
+ destination.focus({ preventScroll: true });
677
+ destination.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
678
+ } finally {
679
+ this._cardMoving = false;
680
+ }
681
+ if (event.shiftKey && multi) {
682
+ const span = items.slice(Math.min(index, next), Math.max(index, next) + 1);
683
+ this.setSelection([...this.getSelectionIds(), ...this._cardIdsOf(span)]);
684
+ }
685
+ }
686
+
687
+ /** @private @param {HTMLElement[]} items @returns {unknown[]} Record ids of the card items. */
688
+ _cardIdsOf(items) {
689
+ return items.filter((item) => item.dataset.cardItem === 'card')
690
+ .map((item) => this._viewData[Number(item.dataset.recordIndex)])
691
+ .filter(Boolean)
692
+ .map((record) => this._viewRecordId(record));
312
693
  }
313
694
 
314
695
  /** @private @returns {HTMLElement} */
@@ -339,7 +720,12 @@ export class CardView extends RecordView {
339
720
  for (const [id, card] of this._cardNodesById) {
340
721
  const selected = this._isViewSelected(id);
341
722
  card.dataset.selected = String(selected);
342
- if (this.options.selectable) card.setAttribute('aria-description', selected ? 'Selected' : 'Not selected');
723
+ if (this._cardRoving) {
724
+ if (this.options.selectable) card.setAttribute('aria-selected', String(selected));
725
+ } else if (this.options.selectable) {
726
+ card.setAttribute('aria-description',
727
+ this.msg(selected ? 'recordCard.selected' : 'recordCard.notSelected'));
728
+ }
343
729
  const checkbox = /** @type {HTMLInputElement|null} */ (card.querySelector('[data-record-selection]'));
344
730
  if (checkbox) checkbox.checked = selected;
345
731
  }
@@ -348,6 +734,13 @@ export class CardView extends RecordView {
348
734
  /** @private @param {MouseEvent} event @returns {void} */
349
735
  _cardClick(event) {
350
736
  const target = /** @type {Element|null} */ (event.target);
737
+ const toggle = /** @type {HTMLElement|null} */ (target?.closest?.('[data-group-toggle]'));
738
+ if (toggle && this.refs.content.contains(toggle)) {
739
+ const section = toggle.closest('.zx-card-view__group');
740
+ const id = section ? this._cardSectionIds.get(section) : undefined;
741
+ if (id !== undefined) this.setGroupCollapsed(id, !this._cardCollapsed.has(id));
742
+ return;
743
+ }
351
744
  const card = /** @type {HTMLElement|null} */ (target?.closest?.('.zx-record-card'));
352
745
  if (!card || !this.refs.content.contains(card)) return;
353
746
  const index = Number(card.dataset.recordIndex);
@@ -437,6 +830,106 @@ export class CardView extends RecordView {
437
830
  }
438
831
  }
439
832
 
833
+ /** Controls inside a card that would otherwise be Tab stops. */
834
+ const FOCUSABLE = 'a[href],button,input,select,textarea,summary,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]';
835
+
836
+ /** Targets inside a card where the arrow keys belong to the control. */
837
+ const TEXT_ENTRY = 'input:not([type="checkbox"]):not([type="radio"]),select,textarea,[contenteditable="true"]';
838
+
839
+ /** Keys the roving model moves on. */
840
+ const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown']);
841
+
842
+ /**
843
+ * Sorts item rectangles into visual rows: items whose tops are within `tolerance` pixels share a
844
+ * row, rows run top to bottom, and items within a row left to right.
845
+ * @param {Array<{top:number,left:number}>} rects Item rectangles in display order.
846
+ * @param {number} [tolerance=4] Pixels two tops may differ by and still share a row.
847
+ * @returns {number[][]} Item indexes per row.
848
+ */
849
+ export function cardGridRows(rects, tolerance = 4) {
850
+ const order = rects.map((rect, index) => ({ index, top: Number(rect.top) || 0, left: Number(rect.left) || 0 }))
851
+ .sort((a, b) => a.top - b.top || a.left - b.left);
852
+ /** @type {{top:number, items:{index:number,left:number}[]}[]} */
853
+ const rows = [];
854
+ for (const entry of order) {
855
+ const row = rows.at(-1);
856
+ if (row && Math.abs(entry.top - row.top) <= tolerance) row.items.push(entry);
857
+ else rows.push({ top: entry.top, items: [entry] });
858
+ }
859
+ return rows.map((row) => row.items.sort((a, b) => a.left - b.left).map((entry) => entry.index));
860
+ }
861
+
862
+ /**
863
+ * How many columns the cards are laid out in: the length of the longest visual row.
864
+ * @param {Array<{top:number,left:number}>} rects Item rectangles.
865
+ * @returns {number} At least 1 for a non-empty list, 0 for an empty one.
866
+ */
867
+ export function cardColumnCount(rects) {
868
+ return cardGridRows(rects).reduce((max, row) => Math.max(max, row.length), 0);
869
+ }
870
+
871
+ /**
872
+ * Where a navigation key moves in a laid-out card grid. ArrowUp/ArrowDown go to the previous or
873
+ * next visual row and PageUp/PageDown `page` rows, landing on the item nearest to `x` (the column
874
+ * the reader is travelling in); ArrowLeft/ArrowRight move within the row and stop at its ends;
875
+ * Home/End go to the first or last item.
876
+ * @param {Array<{top:number,left:number}>} rects Item rectangles in display order.
877
+ * @param {number} index Current item.
878
+ * @param {string} key `KeyboardEvent.key`.
879
+ * @param {{page?:number, x?:number}} [options={}] Rows per page (default 10) and preferred x.
880
+ * @returns {number|null} Next item index, or null when the key is not a move.
881
+ */
882
+ export function cardGridTarget(rects, index, key, options = {}) {
883
+ if (rects.length === 0) return null;
884
+ const current = Math.min(Math.max(Math.trunc(Number(index) || 0), 0), rects.length - 1);
885
+ if (key === 'Home') return 0;
886
+ if (key === 'End') return rects.length - 1;
887
+ const rows = cardGridRows(rects);
888
+ const rowIndex = rows.findIndex((row) => row.includes(current));
889
+ const row = rows[rowIndex];
890
+ if (key === 'ArrowLeft' || key === 'ArrowRight') {
891
+ const position = row.indexOf(current) + (key === 'ArrowLeft' ? -1 : 1);
892
+ return position < 0 || position >= row.length ? current : row[position];
893
+ }
894
+ const page = Math.max(1, Math.trunc(Number(options.page) || 10));
895
+ const step = { ArrowUp: -1, ArrowDown: 1, PageUp: -page, PageDown: page }[key];
896
+ if (step === undefined) return null;
897
+ const targetRow = rows[Math.min(Math.max(rowIndex + step, 0), rows.length - 1)];
898
+ if (targetRow === row) return current;
899
+ const x = Number.isFinite(Number(options.x)) ? Number(options.x) : Number(rects[current].left) || 0;
900
+ let best = targetRow[0];
901
+ for (const candidate of targetRow) {
902
+ if (Math.abs((Number(rects[candidate].left) || 0) - x) < Math.abs((Number(rects[best].left) || 0) - x)) best = candidate;
903
+ }
904
+ return best;
905
+ }
906
+
907
+ /**
908
+ * How many rows a PageUp or PageDown moves: the rows that fit in the visible height, less one so
909
+ * the row focus leaves stays in view. Falls back when either height is unknown.
910
+ * @param {number} viewport Visible height of the scroll region.
911
+ * @param {number} rowHeight Height of one row of cards.
912
+ * @param {number} [fallback=10] Rows to move when the heights are unusable.
913
+ * @returns {number} At least 1.
914
+ */
915
+ export function cardPageSize(viewport, rowHeight, fallback = 10) {
916
+ const visible = Number(viewport);
917
+ const height = Number(rowHeight);
918
+ if (!(visible > 0) || !(height > 0)) return Math.max(1, Math.trunc(fallback) || 10);
919
+ return Math.max(1, Math.floor(visible / height) - 1);
920
+ }
921
+
922
+ /** @param {Element} element @returns {number} Visible height of its nearest vertical scroll container. */
923
+ function viewportHeight(element) {
924
+ let node = element.parentElement;
925
+ while (node && node !== element.ownerDocument.body) {
926
+ const overflow = getComputedStyle(node).overflowY;
927
+ if (/(auto|scroll|overlay)/.test(overflow) && node.scrollHeight > node.clientHeight) return node.clientHeight;
928
+ node = node.parentElement;
929
+ }
930
+ return element.ownerDocument.defaultView?.innerHeight ?? 0;
931
+ }
932
+
440
933
  /**
441
934
  * Groups a cloned record list without mutating records or caller arrays. Explicit order entries
442
935
  * are always materialized, including empty groups; remaining values keep first-seen order.
@@ -444,9 +937,10 @@ export class CardView extends RecordView {
444
937
  * @param {CardViewOptions['groupBy']} groupBy Group field or resolver.
445
938
  * @param {unknown[]} [groupOrder=[]] Preferred group ids.
446
939
  * @param {ViewField[]} [fields=[]] Complete fields for accessor-aware grouping.
940
+ * @param {string} [ungroupedLabel='Ungrouped'] Label of the group collecting records without a value.
447
941
  * @returns {CardRecordGroup[]} Deterministic groups.
448
942
  */
449
- export function groupCardRecords(records, groupBy, groupOrder = [], fields = []) {
943
+ export function groupCardRecords(records, groupBy, groupOrder = [], fields = [], ungroupedLabel = 'Ungrouped') {
450
944
  if (!Array.isArray(records)) throw new TypeError('Card view records must be an array');
451
945
  if (groupBy == null) return records.length ? [{ id: null, label: '', records: [...records] }] : [];
452
946
  const preferred = normalizeGroupOrder(groupOrder);
@@ -461,7 +955,7 @@ export function groupCardRecords(records, groupBy, groupOrder = [], fields = [])
461
955
  }
462
956
  return [...groups].map(([id, grouped]) => ({
463
957
  id,
464
- label: id || 'Ungrouped',
958
+ label: id || ungroupedLabel,
465
959
  records: grouped
466
960
  }));
467
961
  }