@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.
- package/CHANGELOG.md +34 -0
- package/dist/types/components/card-view/card-view.d.ts +236 -0
- package/dist/zx.css +56 -0
- package/dist/zx.esm.js +453 -30
- package/dist/zx.esm.min.js +6 -6
- package/dist/zx.global.js +453 -30
- package/dist/zx.global.min.js +6 -6
- package/dist/zx.min.css +1 -1
- package/docs/api.json +41 -0
- package/docs/llms.md +30 -2
- package/package.json +1 -1
- package/src/components/card-view/card-view.css +71 -0
- package/src/components/card-view/card-view.js +505 -15
- package/src/components/search/search.css +2 -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
|
|
@@ -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) =>
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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.
|
|
345
|
-
this.
|
|
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.
|