@zeyos/zx 5.2.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.
@@ -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
@@ -49,7 +51,30 @@ import {
49
51
  * @property {1|2|3|4|5|6} [headingLevel=3] Per-card title heading level.
50
52
  * @property {string|null} [label=null] Accessible collection label; null resolves message
51
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.
52
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.
53
78
  */
54
79
 
55
80
  /**
@@ -62,6 +87,7 @@ import {
62
87
  * @property {string} minCardWidth Responsive minimum card width.
63
88
  * @property {number|null} maxColumns Maximum cards per row.
64
89
  * @property {'outlined'|'raised'|'filled'} variant Surface treatment.
90
+ * @property {string[]} collapsedGroups Collapsed group ids.
65
91
  */
66
92
 
67
93
  /**
@@ -74,7 +100,20 @@ import {
74
100
  /**
75
101
  * Responsive, schema-friendly record cards with shared fields, sorting, selection, state, groups,
76
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.
77
115
  * @fires CardView#recordaction
116
+ * @fires CardView#groupcollapse
78
117
  * @extends {RecordView}
79
118
  */
80
119
  export class CardView extends RecordView {
@@ -97,7 +136,11 @@ export class CardView extends RecordView {
97
136
  variant: 'outlined',
98
137
  loadingCount: 6,
99
138
  headingLevel: 3,
100
- label: null
139
+ label: null,
140
+ keyboard: 'tabs',
141
+ collapsibleGroups: false,
142
+ collapsedGroups: [],
143
+ renderGroupHeading: null
101
144
  };
102
145
 
103
146
  /**
@@ -125,6 +168,27 @@ export class CardView extends RecordView {
125
168
  this._cardVariant = normalizeCardVariant(options.variant);
126
169
  this._cardLoadingCount = normalizeLoadingCount(options.loadingCount);
127
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';
128
192
  try {
129
193
  this._initRecordView(root);
130
194
  root.setAttribute('aria-label', String(options.label || this.msg('cardView.label')));
@@ -140,7 +204,13 @@ export class CardView extends RecordView {
140
204
  this.listen(this.refs.sort, 'change', () => this._changeCardSort());
141
205
  this.listen(content, 'click', (event) => this._cardClick(/** @type {MouseEvent} */ (event)));
142
206
  this.listen(content, 'dblclick', (event) => this._cardDoubleClick(/** @type {MouseEvent} */ (event)));
143
- 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
+ }
144
214
  this.listen(content, 'error', (event) => this._cardPreviewError(event), true);
145
215
  this._applyCardLayout();
146
216
  this._renderCardSort();
@@ -160,7 +230,8 @@ export class CardView extends RecordView {
160
230
  groupOrder: [...this._cardGroupOrder],
161
231
  minCardWidth: this._cardMinWidth,
162
232
  maxColumns: this._cardMaxColumns,
163
- variant: this._cardVariant
233
+ variant: this._cardVariant,
234
+ collapsedGroups: [...this._cardCollapsed]
164
235
  };
165
236
  }
166
237
 
@@ -184,10 +255,39 @@ export class CardView extends RecordView {
184
255
  if (Object.prototype.hasOwnProperty.call(source, 'variant')) {
185
256
  this._cardVariant = normalizeCardVariant(source.variant);
186
257
  }
258
+ if (Object.prototype.hasOwnProperty.call(source, 'collapsedGroups')) {
259
+ this._cardCollapsed = new Set(normalizeGroupOrder(source.collapsedGroups));
260
+ }
187
261
  this._applyCardLayout();
188
262
  return super.setViewState(source, options);
189
263
  }
190
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
+
191
291
  /** Restores an enhanced target exactly, or removes an owned root. @returns {void} */
192
292
  destroy() {
193
293
  if (this._cardCleaned) return;
@@ -249,19 +349,33 @@ export class CardView extends RecordView {
249
349
  /** @private @returns {void} */
250
350
  _renderCards() {
251
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.
252
357
  const active = /** @type {Element|null} */ (content.querySelector('.zx-record-card:focus'));
253
- const activeIndex = active ? Number(/** @type {HTMLElement} */ (active).dataset.recordIndex) : -1;
254
- const activeRecord = activeIndex >= 0 ? this._viewData[activeIndex] : null;
255
- const activeId = activeRecord ? this._viewRecordId(activeRecord) : null;
358
+ const activeId = active && this._cardIdOfNode.has(active) ? this._cardIdOfNode.get(active) : null;
256
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
+ }
257
369
  if (/** @type {HTMLElement} */ (this.el).dataset.loading === 'true') {
258
370
  content.replaceChildren(this._createCardSkeletons());
371
+ this._syncCardEmptyFocus(hadFocus);
259
372
  return;
260
373
  }
261
374
  const groups = groupCardRecords(this._viewData, this._cardOptions().groupBy,
262
375
  this._cardGroupOrder, this._viewFields, this.msg('cardView.ungrouped'));
263
376
  if (this._viewData.length === 0 && groups.length === 0) {
264
377
  content.replaceChildren(this._createCardEmpty());
378
+ this._syncCardEmptyFocus(hadFocus);
265
379
  return;
266
380
  }
267
381
  const fields = this.getFields();
@@ -269,6 +383,14 @@ export class CardView extends RecordView {
269
383
  const sections = groups.map((group, groupIndex) =>
270
384
  this._createCardGroup(group, fields, visibleFields, groupIndex));
271
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
+ }
272
394
  if (activeId !== null) {
273
395
  const next = this._cardNodesById.get(activeId);
274
396
  if (next) queueMicrotask(() => next.isConnected && next.focus());
@@ -278,14 +400,22 @@ export class CardView extends RecordView {
278
400
  /** @private @param {CardRecordGroup} group @param {ViewField[]} fields @param {ViewField[]} visibleFields @param {number} groupIndex @returns {HTMLElement} */
279
401
  _createCardGroup(group, fields, visibleFields, groupIndex) {
280
402
  const options = this._cardOptions();
281
- 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));
282
407
  const list = h('ul', {
283
408
  class: 'zx-card-view__list',
284
- 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
285
413
  });
286
414
  for (const record of group.records) {
287
- const index = this._viewData.indexOf(record);
288
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);
289
419
  const card = createRecordCard(record, index, {
290
420
  fields,
291
421
  visibleFields,
@@ -302,15 +432,264 @@ export class CardView extends RecordView {
302
432
  headingLevel: this._cardHeadingLevel,
303
433
  message: (key, ...args) => this.msg(key, ...args)
304
434
  });
435
+ if (roving) this._makeCardRow(card, id);
305
436
  this._cardNodesById.set(id, card);
437
+ this._cardIdOfNode.set(card, id);
306
438
  list.append(card);
307
439
  }
308
- if (group.records.length === 0) {
309
- list.append(h('li', { class: 'zx-card-view__group-empty' }, this.msg('cardView.groupEmpty')));
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));
310
445
  }
311
446
  if (group.id === null) return list;
312
- return h('section', { class: 'zx-card-view__group', ariaLabelledby: headingId },
313
- 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));
314
693
  }
315
694
 
316
695
  /** @private @returns {HTMLElement} */
@@ -341,8 +720,12 @@ export class CardView extends RecordView {
341
720
  for (const [id, card] of this._cardNodesById) {
342
721
  const selected = this._isViewSelected(id);
343
722
  card.dataset.selected = String(selected);
344
- if (this.options.selectable) card.setAttribute('aria-description',
345
- this.msg(selected ? 'recordCard.selected' : 'recordCard.notSelected'));
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
+ }
346
729
  const checkbox = /** @type {HTMLInputElement|null} */ (card.querySelector('[data-record-selection]'));
347
730
  if (checkbox) checkbox.checked = selected;
348
731
  }
@@ -351,6 +734,13 @@ export class CardView extends RecordView {
351
734
  /** @private @param {MouseEvent} event @returns {void} */
352
735
  _cardClick(event) {
353
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
+ }
354
744
  const card = /** @type {HTMLElement|null} */ (target?.closest?.('.zx-record-card'));
355
745
  if (!card || !this.refs.content.contains(card)) return;
356
746
  const index = Number(card.dataset.recordIndex);
@@ -440,6 +830,106 @@ export class CardView extends RecordView {
440
830
  }
441
831
  }
442
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
+
443
933
  /**
444
934
  * Groups a cloned record list without mutating records or caller arrays. Explicit order entries
445
935
  * are always materialized, including empty groups; remaining values keep first-seen order.
@@ -45,6 +45,8 @@
45
45
 
46
46
  .zx-search__clear,
47
47
  .zx-search__submit {
48
+ /* Never squeezed below the control height by a narrow field. */
49
+ flex: none;
48
50
  display: inline-grid;
49
51
  place-items: center;
50
52
  min-block-size: 0;