@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.
- package/CHANGELOG.md +198 -0
- package/README.md +2 -1
- package/dist/types/components/account-menu/account-menu.d.ts +8 -0
- package/dist/types/components/activity-list/activity-list.d.ts +9 -1
- package/dist/types/components/app-icon/app-icon.d.ts +14 -2
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +288 -14
- package/dist/types/components/calendar/calendar.d.ts +67 -5
- package/dist/types/components/card-view/card-view.d.ts +243 -4
- package/dist/types/components/card-view/record-card.d.ts +11 -0
- package/dist/types/components/context-menu/context-menu.d.ts +6 -2
- package/dist/types/components/data-filter/data-filter.d.ts +4 -4
- package/dist/types/components/dropdown/dropdown.d.ts +22 -7
- package/dist/types/components/field/field.d.ts +30 -0
- package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
- package/dist/types/components/fieldset/fieldset.d.ts +7 -0
- package/dist/types/components/file-list/file-list.d.ts +35 -3
- package/dist/types/components/filter/filter.d.ts +127 -3
- package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
- package/dist/types/components/launcher/launcher.d.ts +8 -0
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
- package/dist/types/components/menu-button/menu-button.d.ts +6 -0
- package/dist/types/components/message/message.d.ts +14 -1
- package/dist/types/components/modal/modal.d.ts +30 -0
- package/dist/types/components/module-bar/module-bar.d.ts +395 -0
- package/dist/types/components/pagination/pagination.d.ts +17 -3
- package/dist/types/components/select/select.d.ts +6 -0
- package/dist/types/components/sheet/sheet.d.ts +15 -0
- package/dist/types/components/table/table.d.ts +265 -17
- package/dist/types/components/view/record-view.d.ts +59 -2
- package/dist/types/components/view-settings/view-settings.d.ts +879 -0
- package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
- package/dist/types/core/component.d.ts +12 -2
- package/dist/types/core/i18n.d.ts +27 -3
- package/dist/types/core/keyboard.d.ts +98 -1
- package/dist/types/core/messages.d.ts +382 -0
- package/dist/types/core/overlay-host.d.ts +28 -1
- package/dist/types/core/overlay-stack.d.ts +260 -0
- package/dist/types/index.d.ts +5 -2
- package/dist/types/internal/app-rail.d.ts +38 -0
- package/dist/types/internal/menu-items.d.ts +71 -3
- package/dist/types/zeyos/index.d.ts +1 -0
- package/dist/types/zeyos/launcher.d.ts +9 -0
- package/dist/types/zeyos/module-bar.d.ts +176 -0
- package/dist/types/zeyos/query.d.ts +31 -5
- package/dist/types/zeyos/table.d.ts +245 -8
- package/dist/types/zeyos/view.d.ts +16 -2
- package/dist/zx-zeyos.esm.js +4422 -430
- package/dist/zx-zeyos.esm.min.js +5 -5
- package/dist/zx.css +1472 -133
- package/dist/zx.esm.js +7433 -854
- package/dist/zx.esm.min.js +8 -8
- package/dist/zx.global.js +7433 -854
- package/dist/zx.global.min.js +8 -8
- package/dist/zx.min.css +1 -1
- package/docs/api.json +937 -119
- package/docs/llms.md +531 -63
- package/docs/llms.txt +6 -2
- package/package.json +1 -1
- package/src/components/account-menu/account-menu.js +12 -0
- package/src/components/activity-list/activity-list.js +27 -1
- package/src/components/app-icon/app-icon.css +13 -2
- package/src/components/app-icon/app-icon.js +11 -1
- package/src/components/app-rail/app-rail.css +19 -0
- package/src/components/app-sidebar/app-sidebar.css +204 -3
- package/src/components/app-sidebar/app-sidebar.js +646 -40
- package/src/components/breadcrumb/breadcrumb.css +1 -1
- package/src/components/calendar/calendar.css +1 -1
- package/src/components/calendar/calendar.js +26 -52
- package/src/components/card/card.css +2 -2
- package/src/components/card-view/card-view.css +76 -5
- package/src/components/card-view/card-view.js +514 -20
- package/src/components/card-view/record-card.js +14 -3
- package/src/components/code-editor/code-editor.css +1 -1
- package/src/components/code-editor/field-adapter.js +9 -2
- package/src/components/context-menu/context-menu.js +8 -2
- package/src/components/copy/copy-input.css +1 -1
- package/src/components/data-filter/data-filter.js +2 -2
- package/src/components/date-picker/time-picker.css +2 -2
- package/src/components/dock/dock.css +5 -4
- package/src/components/dropdown/dropdown.js +157 -72
- package/src/components/field/field.js +33 -3
- package/src/components/fieldset/external-adapter.js +363 -0
- package/src/components/fieldset/fieldset.js +10 -59
- package/src/components/file-list/file-list.js +144 -21
- package/src/components/filter/filter.css +86 -2
- package/src/components/filter/filter.js +300 -8
- package/src/components/filter-panel/filter-panel.js +149 -32
- package/src/components/finder/finder.css +1 -1
- package/src/components/kanban-view/kanban-view.css +3 -3
- package/src/components/kanban-view/kanban-view.js +5 -2
- package/src/components/launcher/launcher.css +1 -1
- package/src/components/launcher/launcher.js +29 -3
- package/src/components/list-toolbar/list-toolbar.js +39 -13
- package/src/components/master-panel/master-panel.js +0 -1
- package/src/components/menu-button/menu-button.css +63 -0
- package/src/components/menu-button/menu-button.js +97 -11
- package/src/components/message/message.js +60 -20
- package/src/components/modal/modal.css +1 -1
- package/src/components/modal/modal.js +66 -13
- package/src/components/module-bar/module-bar.css +486 -0
- package/src/components/module-bar/module-bar.js +1074 -0
- package/src/components/multi-value-editor/multi-value-editor.css +1 -1
- package/src/components/number-field/field-adapter.js +2 -1
- package/src/components/pagination/pagination.css +5 -5
- package/src/components/pagination/pagination.js +20 -4
- package/src/components/panel/panel.css +3 -2
- package/src/components/questionnaire/questionnaire.css +4 -4
- package/src/components/rating/field-adapter.js +2 -1
- package/src/components/rating/rating.css +1 -1
- package/src/components/search/search.css +6 -1
- package/src/components/select/select.js +9 -0
- package/src/components/sheet/sheet.css +1 -1
- package/src/components/sheet/sheet.js +74 -32
- package/src/components/slider/field-adapter.js +6 -1
- package/src/components/slider/slider.css +2 -2
- package/src/components/split-view/split-view.css +1 -1
- package/src/components/stat-tile/stat-tile.css +1 -1
- package/src/components/stepper/stepper.css +1 -1
- package/src/components/tabbox/tabbox.css +9 -5
- package/src/components/table/table.css +86 -11
- package/src/components/table/table.js +628 -57
- package/src/components/tag-picker/field-adapter.js +2 -1
- package/src/components/tooltip/tooltip.js +35 -17
- package/src/components/tree/tree.css +1 -1
- package/src/components/view/record-view.css +1 -1
- package/src/components/view/record-view.js +178 -18
- package/src/components/view-settings/view-settings.css +285 -0
- package/src/components/view-settings/view-settings.js +1830 -0
- package/src/components/workspace-switcher/workspace-switcher.css +188 -0
- package/src/components/workspace-switcher/workspace-switcher.js +463 -0
- package/src/core/component.js +88 -17
- package/src/core/fontawesome.js +5 -1
- package/src/core/i18n.js +47 -4
- package/src/core/icons.js +12 -2
- package/src/core/keyboard.js +206 -11
- package/src/core/messages.js +427 -0
- package/src/core/overlay-host.js +64 -9
- package/src/core/overlay-stack.js +487 -0
- package/src/core/position.js +22 -3
- package/src/index.js +5 -2
- package/src/internal/app-rail.js +134 -53
- package/src/internal/menu-items.js +61 -3
- package/src/zeyos/form.js +5 -3
- package/src/zeyos/index.js +3 -0
- package/src/zeyos/launcher.js +8 -3
- package/src/zeyos/module-bar.js +181 -0
- package/src/zeyos/query.js +44 -13
- package/src/zeyos/table.js +220 -80
- package/src/zeyos/view.js +43 -93
- package/styles/base.css +16 -1
- package/styles/print.css +1 -1
- package/styles/tokens/dark.css +4 -0
- package/styles/tokens/semantic.css +56 -28
- 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=
|
|
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:
|
|
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 || '
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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.
|
|
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 ||
|
|
958
|
+
label: id || ungroupedLabel,
|
|
465
959
|
records: grouped
|
|
466
960
|
}));
|
|
467
961
|
}
|