@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 CHANGED
@@ -8,6 +8,40 @@ and friends remain the complete record.
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 5.3.0 — 2026-10-29
12
+
13
+ The record collection's toolbar, chips, drawers and pager become one shared composition in the
14
+ layout kit, and the CRM and the issue tracker are rebuilt on it.
15
+
16
+ ### Added
17
+
18
+ - `CardView` `keyboard: 'roving'` — the APG grid keyboard model the `Table` already has: the
19
+ collection is one Tab stop, arrows move between cards and group headings by the rendered rows,
20
+ Home/End and Page keys, Enter activates, Space and Shift+Arrow select, Ctrl/Cmd+A selects all;
21
+ focus is kept by record id across re-renders. `'tabs'`, the default, is unchanged.
22
+ - `CardView` `collapsibleGroups`, `collapsedGroups`, `setGroupCollapsed()`, `getCollapsedGroups()`
23
+ and the `groupcollapse` event; `renderGroupHeading(context)` renders a group's heading with the
24
+ id, label, count, records, collapsed state and a `toggle()`.
25
+
26
+ ### Fixed
27
+
28
+ - A narrow `Search` field squeezed its submit and clear buttons below the control height.
29
+
30
+ ### Website
31
+
32
+ - The record collection's composition — toolbar, chip strip and chip editors, the Filters and View
33
+ settings drawers, page slicing, grouping, pagination, board axes and the keyboard map — is one
34
+ shared layout-kit module, with `other` buckets that write back, a base filter for tabs and
35
+ scopes, a split empty state, a `rendered` event and one-line toolbars beside an open drawer.
36
+ - **CRM**: companies, people, deals and the curated lists use the shared overview: inline search,
37
+ the Filter, Sort and View drawers, the five-way shape switch, chips and the pager under the saved
38
+ view tabs. Companies gain cards and a stage board, people gain cards; owner and tier have
39
+ Unassigned and No tier buckets so no record drops out of a grouped view; the ARR total rides the
40
+ count line and the board columns keep their totals.
41
+ - **Issue tracker**: the list is a roving `CardView` in the tracker's dense row anatomy with
42
+ collapsible group headings carrying status glyphs and counts and "New issue in <group>" on each;
43
+ the board takes its swim lanes from the grouping; the fixed tabs stay in the header.
44
+
11
45
  ## 5.2.0 — 2026-10-29
12
46
 
13
47
  A record collection pattern with a five-way view switch and filter chips, two components to go
@@ -113,7 +113,60 @@ export type CardViewOptions = {
113
113
  * `cardView.label`, "Card view".
114
114
  */
115
115
  label?: string | null;
116
+ /**
117
+ * Keyboard model. `'tabs'` makes every card, title
118
+ * link, checkbox, and action its own Tab stop. `'roving'` — recommended for lists — follows the
119
+ * APG Grid pattern with row focus: the collection is one Tab stop, the arrow keys, Home/End, and
120
+ * PageUp/PageDown move between cards, Enter activates, Space selects, and the controls inside a
121
+ * card stay pointer targets with `tabindex="-1"`.
122
+ */
123
+ keyboard?: 'tabs' | 'roving';
124
+ /**
125
+ * Whether group headings carry a toggle that hides
126
+ * and shows the group's cards.
127
+ */
128
+ collapsibleGroups?: boolean;
129
+ /**
130
+ * Group ids that start collapsed.
131
+ */
132
+ collapsedGroups?: unknown[];
133
+ /**
134
+ * Content of a group heading in place of its label. With `collapsibleGroups` it is placed inside
135
+ * the toggle button, so it must not contain interactive elements.
136
+ */
137
+ renderGroupHeading?: ((context: CardGroupHeadingContext) => Node | string | null | undefined) | null;
116
138
  onrecordaction?: (event: CustomEvent<Record<string, unknown>>) => void;
139
+ ongroupcollapse?: (event: CustomEvent<{
140
+ id: string;
141
+ collapsed: boolean;
142
+ collapsedGroups: string[];
143
+ }>) => void;
144
+ };
145
+ export type CardGroupHeadingContext = {
146
+ /**
147
+ * Normalized group id (`''` for records without a value).
148
+ */
149
+ id: string;
150
+ /**
151
+ * Default label.
152
+ */
153
+ label: string;
154
+ /**
155
+ * Records in the group.
156
+ */
157
+ count: number;
158
+ /**
159
+ * The group's records in display order.
160
+ */
161
+ records: ViewRecord[];
162
+ /**
163
+ * Whether the group is collapsed.
164
+ */
165
+ collapsed: boolean;
166
+ /**
167
+ * Collapses or expands the group.
168
+ */
169
+ toggle: () => void;
117
170
  };
118
171
  export type CardViewState = {
119
172
  /**
@@ -148,6 +201,10 @@ export type CardViewState = {
148
201
  * Surface treatment.
149
202
  */
150
203
  variant: 'outlined' | 'raised' | 'filled';
204
+ /**
205
+ * Collapsed group ids.
206
+ */
207
+ collapsedGroups: string[];
151
208
  };
152
209
  export type CardRecordGroup = {
153
210
  /**
@@ -206,7 +263,29 @@ export type CardRecordGroup = {
206
263
  * @property {1|2|3|4|5|6} [headingLevel=3] Per-card title heading level.
207
264
  * @property {string|null} [label=null] Accessible collection label; null resolves message
208
265
  * `cardView.label`, "Card view".
266
+ * @property {'tabs'|'roving'} [keyboard='tabs'] Keyboard model. `'tabs'` makes every card, title
267
+ * link, checkbox, and action its own Tab stop. `'roving'` — recommended for lists — follows the
268
+ * APG Grid pattern with row focus: the collection is one Tab stop, the arrow keys, Home/End, and
269
+ * PageUp/PageDown move between cards, Enter activates, Space selects, and the controls inside a
270
+ * card stay pointer targets with `tabindex="-1"`.
271
+ * @property {boolean} [collapsibleGroups=false] Whether group headings carry a toggle that hides
272
+ * and shows the group's cards.
273
+ * @property {unknown[]} [collapsedGroups=[]] Group ids that start collapsed.
274
+ * @property {((context:CardGroupHeadingContext)=>Node|string|null|undefined)|null} [renderGroupHeading=null]
275
+ * Content of a group heading in place of its label. With `collapsibleGroups` it is placed inside
276
+ * the toggle button, so it must not contain interactive elements.
209
277
  * @property {(event:CustomEvent<Record<string,unknown>>)=>void} [onrecordaction]
278
+ * @property {(event:CustomEvent<{id:string,collapsed:boolean,collapsedGroups:string[]}>)=>void} [ongroupcollapse]
279
+ */
280
+ /**
281
+ * What `renderGroupHeading` is given for one group.
282
+ * @typedef {Object} CardGroupHeadingContext
283
+ * @property {string} id Normalized group id (`''` for records without a value).
284
+ * @property {string} label Default label.
285
+ * @property {number} count Records in the group.
286
+ * @property {ViewRecord[]} records The group's records in display order.
287
+ * @property {boolean} collapsed Whether the group is collapsed.
288
+ * @property {() => void} toggle Collapses or expands the group.
210
289
  */
211
290
  /**
212
291
  * @typedef {Object} CardViewState
@@ -218,6 +297,7 @@ export type CardRecordGroup = {
218
297
  * @property {string} minCardWidth Responsive minimum card width.
219
298
  * @property {number|null} maxColumns Maximum cards per row.
220
299
  * @property {'outlined'|'raised'|'filled'} variant Surface treatment.
300
+ * @property {string[]} collapsedGroups Collapsed group ids.
221
301
  */
222
302
  /**
223
303
  * @typedef {Object} CardRecordGroup
@@ -228,7 +308,20 @@ export type CardRecordGroup = {
228
308
  /**
229
309
  * Responsive, schema-friendly record cards with shared fields, sorting, selection, state, groups,
230
310
  * safe preview media, native title links, and delegated actions.
311
+ *
312
+ * With `keyboard: 'roving'` the collection follows the WAI-ARIA APG Grid pattern with row focus,
313
+ * the model `Table` uses: the content is a `grid`, each card a focusable `row` (`aria-selected`
314
+ * when selectable, `aria-multiselectable` on the grid for `multi`), and the collection is a single
315
+ * Tab stop. ArrowUp/ArrowDown move to the card above or below — columns are measured from where the
316
+ * cards actually are, so a multi-column layout moves by visual row — ArrowLeft/ArrowRight within a
317
+ * row, Home/End to the first or last card, PageUp/PageDown by a visible page. Enter activates
318
+ * (`recordclick`), Space toggles selection in `selectable` modes (and activates otherwise),
319
+ * Shift+movement extends a `multi` selection, and Ctrl/Cmd+A selects every rendered card. A
320
+ * collapsible group heading is part of the same order: Enter or Space toggles it, ArrowLeft
321
+ * collapses, ArrowRight expands. The current card is kept by record id across `setData`, sorting,
322
+ * grouping, and collapsing, and focus is restored without scrolling.
231
323
  * @fires CardView#recordaction
324
+ * @fires CardView#groupcollapse
232
325
  * @extends {RecordView}
233
326
  */
234
327
  export declare class CardView extends RecordView {
@@ -242,6 +335,28 @@ export declare class CardView extends RecordView {
242
335
  _cardVariant: "filled" | "outlined" | "raised";
243
336
  _cardLoadingCount: number;
244
337
  _cardHeadingLevel: 1 | 2 | 3 | 4 | 5 | 6;
338
+ _cardRoving: boolean;
339
+ _cardIdPrefix: string;
340
+ _cardCollapsed: Set<string>;
341
+ /** @type {{kind:'card'|'group', id:unknown}|null} */
342
+ _cardCurrent: {
343
+ kind: 'card' | 'group';
344
+ id: unknown;
345
+ } | null;
346
+ _cardCurrentIndex: number;
347
+ /** @type {HTMLElement|null} */
348
+ _cardTabStop: HTMLElement | null;
349
+ /** @type {number|null} */
350
+ _cardStickyX: number | null;
351
+ _cardMoving: boolean;
352
+ /** @type {Map<string, HTMLElement>} */
353
+ _cardHeadersById: Map<string, HTMLElement>;
354
+ /** @type {Map<unknown, string>} */
355
+ _cardGroupOfId: Map<unknown, string>;
356
+ /** @type {Map<Element, string>} */
357
+ _cardSectionIds: Map<Element, string>;
358
+ /** @type {WeakMap<Element, unknown>} */
359
+ _cardIdOfNode: WeakMap<Element, unknown>;
245
360
  static cssName: string;
246
361
  /** @type {Readonly<CardViewOptions & RecordViewOptions>} */
247
362
  static defaults: Readonly<CardViewOptions & RecordViewOptions>;
@@ -264,6 +379,19 @@ export declare class CardView extends RecordView {
264
379
  setViewState(state: Partial<CardViewState> | null, options?: {
265
380
  silent?: boolean;
266
381
  }): this;
382
+ /**
383
+ * Collapses or expands one group. A collapsed group keeps its heading and hides its cards.
384
+ * @param {unknown} id Group id, as `groupBy` produces it.
385
+ * @param {boolean} [collapsed=true] Next state.
386
+ * @param {{silent?:boolean}} [options={}] Set `silent` to suppress `groupcollapse` and `statechange`.
387
+ * @returns {this}
388
+ * @fires CardView#groupcollapse
389
+ */
390
+ setGroupCollapsed(id: unknown, collapsed?: boolean, options?: {
391
+ silent?: boolean;
392
+ }): this;
393
+ /** Returns the collapsed group ids. @returns {string[]} */
394
+ getCollapsedGroups(): string[];
267
395
  /** Restores an enhanced target exactly, or removes an owned root. @returns {void} */
268
396
  destroy(): void;
269
397
  /** @protected @param {string} reason Refresh reason. @returns {void} */
@@ -278,6 +406,67 @@ export declare class CardView extends RecordView {
278
406
  private _renderCards;
279
407
  /** @private @param {CardRecordGroup} group @param {ViewField[]} fields @param {ViewField[]} visibleFields @param {number} groupIndex @returns {HTMLElement} */
280
408
  private _createCardGroup;
409
+ /**
410
+ * Builds a group heading: the label (or `renderGroupHeading` content), inside an APG
411
+ * disclosure button when the group can collapse.
412
+ * @private
413
+ * @param {CardRecordGroup} group Group.
414
+ * @param {string} headingId Heading id.
415
+ * @param {boolean} collapsible Whether the group can collapse.
416
+ * @param {boolean} collapsed Whether it is collapsed.
417
+ * @returns {HTMLElement}
418
+ */
419
+ private _createGroupHeader;
420
+ /**
421
+ * Turns a rendered card into a grid row for the roving model: row focus, `aria-selected` when
422
+ * selectable, and the controls inside it out of the Tab order.
423
+ * @private
424
+ * @param {HTMLElement} card Card element.
425
+ * @param {unknown} id Record id.
426
+ * @returns {void}
427
+ */
428
+ private _makeCardRow;
429
+ /**
430
+ * Moves the single Tab stop to the current item after a render — the same record or group by
431
+ * id; else the heading of the collapsed group that now hides the card; else the item now at its
432
+ * old position — and focuses it again, without scrolling, when the collection had focus.
433
+ * @private
434
+ * @param {boolean} hadFocus Whether focus was inside the collection before the render.
435
+ * @returns {void}
436
+ */
437
+ private _syncCardRoving;
438
+ /**
439
+ * @private
440
+ * @param {HTMLElement} item New Tab stop.
441
+ * @param {HTMLElement[]} [items] Every item, when already collected.
442
+ * @returns {void}
443
+ */
444
+ private _setCardTabStop;
445
+ /**
446
+ * While loading or empty there is no card to hold focus: the content takes it rather than
447
+ * letting it fall to the document, and gives the Tab order its old shape back afterwards.
448
+ * @private
449
+ * @param {boolean} hold Whether focus was inside and there is nothing to put it on.
450
+ * @returns {void}
451
+ */
452
+ private _syncCardEmptyFocus;
453
+ /** @private @returns {HTMLElement[]} Roving items in display order. */
454
+ private _cardItems;
455
+ /** @private @param {HTMLElement} item @returns {{kind:'card'|'group', id:unknown}|null} */
456
+ private _cardItemKey;
457
+ /** @private @param {{kind:'card'|'group', id:unknown}|null} key @returns {HTMLElement|null} */
458
+ private _cardItemFor;
459
+ /** @private @param {FocusEvent} event @returns {void} */
460
+ private _cardFocusIn;
461
+ /**
462
+ * The roving keyboard model; see the class description.
463
+ * @private
464
+ * @param {KeyboardEvent} event Key event.
465
+ * @returns {void}
466
+ */
467
+ private _cardRovingKeydown;
468
+ /** @private @param {HTMLElement[]} items @returns {unknown[]} Record ids of the card items. */
469
+ private _cardIdsOf;
281
470
  /** @private @returns {HTMLElement} */
282
471
  private _createCardSkeletons;
283
472
  /** @private @returns {HTMLElement} */
@@ -299,6 +488,53 @@ export declare class CardView extends RecordView {
299
488
  /** @private @returns {Readonly<CardViewOptions & RecordViewOptions>} */
300
489
  private _cardOptions;
301
490
  }
491
+ /**
492
+ * Sorts item rectangles into visual rows: items whose tops are within `tolerance` pixels share a
493
+ * row, rows run top to bottom, and items within a row left to right.
494
+ * @param {Array<{top:number,left:number}>} rects Item rectangles in display order.
495
+ * @param {number} [tolerance=4] Pixels two tops may differ by and still share a row.
496
+ * @returns {number[][]} Item indexes per row.
497
+ */
498
+ export declare function cardGridRows(rects: Array<{
499
+ top: number;
500
+ left: number;
501
+ }>, tolerance?: number): number[][];
502
+ /**
503
+ * How many columns the cards are laid out in: the length of the longest visual row.
504
+ * @param {Array<{top:number,left:number}>} rects Item rectangles.
505
+ * @returns {number} At least 1 for a non-empty list, 0 for an empty one.
506
+ */
507
+ export declare function cardColumnCount(rects: Array<{
508
+ top: number;
509
+ left: number;
510
+ }>): number;
511
+ /**
512
+ * Where a navigation key moves in a laid-out card grid. ArrowUp/ArrowDown go to the previous or
513
+ * next visual row and PageUp/PageDown `page` rows, landing on the item nearest to `x` (the column
514
+ * the reader is travelling in); ArrowLeft/ArrowRight move within the row and stop at its ends;
515
+ * Home/End go to the first or last item.
516
+ * @param {Array<{top:number,left:number}>} rects Item rectangles in display order.
517
+ * @param {number} index Current item.
518
+ * @param {string} key `KeyboardEvent.key`.
519
+ * @param {{page?:number, x?:number}} [options={}] Rows per page (default 10) and preferred x.
520
+ * @returns {number|null} Next item index, or null when the key is not a move.
521
+ */
522
+ export declare function cardGridTarget(rects: Array<{
523
+ top: number;
524
+ left: number;
525
+ }>, index: number, key: string, options?: {
526
+ page?: number;
527
+ x?: number;
528
+ }): number | null;
529
+ /**
530
+ * How many rows a PageUp or PageDown moves: the rows that fit in the visible height, less one so
531
+ * the row focus leaves stays in view. Falls back when either height is unknown.
532
+ * @param {number} viewport Visible height of the scroll region.
533
+ * @param {number} rowHeight Height of one row of cards.
534
+ * @param {number} [fallback=10] Rows to move when the heights are unusable.
535
+ * @returns {number} At least 1.
536
+ */
537
+ export declare function cardPageSize(viewport: number, rowHeight: number, fallback?: number): number;
302
538
  /**
303
539
  * Groups a cloned record list without mutating records or caller arrays. Explicit order entries
304
540
  * are always materialized, including empty groups; remaining values keep first-seen order.
package/dist/zx.css CHANGED
@@ -2881,6 +2881,7 @@ button.zx-panel__toggle:focus-visible {
2881
2881
  }
2882
2882
  .zx-search__clear,
2883
2883
  .zx-search__submit {
2884
+ flex: none;
2884
2885
  display: inline-grid;
2885
2886
  place-items: center;
2886
2887
  min-block-size: 0;
@@ -10472,6 +10473,61 @@ button.zx-stat-tile:focus-visible {
10472
10473
  font-weight: 600;
10473
10474
  line-height: 1.3;
10474
10475
  }
10476
+ .zx-card-view__group-header {
10477
+ border-radius: var(--zx-radius-sm);
10478
+ }
10479
+ .zx-card-view__group-header:focus-visible {
10480
+ outline: 2px solid transparent;
10481
+ box-shadow: inset var(--zx-focus-ring);
10482
+ }
10483
+ .zx-card-view__group-toggle {
10484
+ display: inline-flex;
10485
+ align-items: center;
10486
+ gap: var(--zx-space-1);
10487
+ min-block-size: 0;
10488
+ margin-inline-start: calc(-1 * var(--zx-space-1));
10489
+ padding: var(--zx-space-1);
10490
+ border: 0;
10491
+ border-radius: var(--zx-radius-sm);
10492
+ background: none;
10493
+ color: inherit;
10494
+ font: inherit;
10495
+ cursor: pointer;
10496
+ }
10497
+ .zx-card-view__group-toggle:hover {
10498
+ background: var(--zx-color-bg-hover);
10499
+ }
10500
+ .zx-card-view__group-toggle:focus-visible {
10501
+ outline: 2px solid transparent;
10502
+ box-shadow: var(--zx-focus-ring);
10503
+ }
10504
+ .zx-card-view__group-toggle > .zx-icon {
10505
+ flex: none;
10506
+ color: var(--zx-color-text-subtle);
10507
+ }
10508
+ .zx-card-view__group-toggle[aria-expanded=false] > .zx-icon {
10509
+ transform: rotate(-90deg);
10510
+ }
10511
+ .zx-card-view__group[data-collapsed=true] .zx-card-view__group-heading {
10512
+ margin-block-end: 0;
10513
+ }
10514
+ .zx-card-view__list[hidden] {
10515
+ display: none;
10516
+ }
10517
+ .zx-card-view[data-keyboard=roving] .zx-record-card:focus-visible {
10518
+ outline: 2px solid transparent;
10519
+ outline-offset: -2px;
10520
+ box-shadow: inset var(--zx-focus-ring);
10521
+ }
10522
+ .zx-card-view__content[data-empty-focus]:focus-visible {
10523
+ outline: 2px solid transparent;
10524
+ box-shadow: inset var(--zx-focus-ring);
10525
+ }
10526
+ @media (prefers-reduced-motion: no-preference) {
10527
+ .zx-card-view__group-toggle > .zx-icon {
10528
+ transition: transform var(--zx-dur-fast) var(--zx-ease);
10529
+ }
10530
+ }
10475
10531
  .zx-card-view__list {
10476
10532
  display: grid;
10477
10533
  grid-template-columns: repeat(auto-fill, minmax(min(100%, max(var(--zx-card-view-min-card-width), calc(var(--zx-card-view-column-basis) - var(--zx-card-view-grid-gap)))), 1fr));