@zeyos/zx 4.3.4 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +7 -0
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +195 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +28 -0
  6. package/dist/types/components/button/button.d.ts +8 -0
  7. package/dist/types/components/calendar/calendar.d.ts +19 -0
  8. package/dist/types/components/card/card.d.ts +7 -0
  9. package/dist/types/components/card-view/card-view.d.ts +11 -3
  10. package/dist/types/components/card-view/record-card.d.ts +7 -1
  11. package/dist/types/components/code-editor/code-editor.d.ts +160 -0
  12. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  13. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  14. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  15. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  16. package/dist/types/components/dialog/dialog.d.ts +29 -0
  17. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  18. package/dist/types/components/file-item/file-item.d.ts +234 -0
  19. package/dist/types/components/file-list/file-list.d.ts +171 -0
  20. package/dist/types/components/filter/filter.d.ts +91 -36
  21. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  22. package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
  23. package/dist/types/components/kanban-view/kanban-view.d.ts +170 -4
  24. package/dist/types/components/launcher/launcher.d.ts +19 -12
  25. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  26. package/dist/types/components/menu-button/menu-button.d.ts +4 -37
  27. package/dist/types/components/message/message.d.ts +7 -0
  28. package/dist/types/components/modal/modal.d.ts +48 -0
  29. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  30. package/dist/types/components/panel/panel.d.ts +7 -0
  31. package/dist/types/components/search/search.d.ts +7 -0
  32. package/dist/types/components/select/select.d.ts +4 -0
  33. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  34. package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
  35. package/dist/types/components/tabbox/tabbox.d.ts +7 -0
  36. package/dist/types/components/table/table.d.ts +144 -0
  37. package/dist/types/components/table-view/table-view.d.ts +11 -5
  38. package/dist/types/components/timebox/timebox.d.ts +7 -0
  39. package/dist/types/components/value-list/value-list.d.ts +8 -0
  40. package/dist/types/components/view/record-view.d.ts +96 -5
  41. package/dist/types/core/icons.d.ts +17 -0
  42. package/dist/types/index.d.ts +11 -0
  43. package/dist/types/internal/app-rail.d.ts +10 -0
  44. package/dist/types/internal/menu-items.d.ts +134 -0
  45. package/dist/zx-zeyos.esm.js +616 -77
  46. package/dist/zx-zeyos.esm.min.js +8 -1
  47. package/dist/zx.css +2085 -238
  48. package/dist/zx.esm.js +6704 -1542
  49. package/dist/zx.esm.min.js +10 -3
  50. package/dist/zx.global.js +6704 -1542
  51. package/dist/zx.global.min.js +10 -3
  52. package/dist/zx.min.css +1 -1
  53. package/docs/api.json +1479 -127
  54. package/docs/llms.md +315 -10
  55. package/docs/llms.txt +11 -5
  56. package/package.json +1 -1
  57. package/src/components/activity-item/activity-item.css +137 -0
  58. package/src/components/activity-item/activity-item.js +599 -0
  59. package/src/components/activity-list/activity-list.css +64 -0
  60. package/src/components/activity-list/activity-list.js +375 -0
  61. package/src/components/app-rail/app-rail.css +227 -19
  62. package/src/components/app-sidebar/app-sidebar.css +414 -24
  63. package/src/components/app-sidebar/app-sidebar.js +65 -5
  64. package/src/components/button/button.css +8 -0
  65. package/src/components/button/button.js +6 -1
  66. package/src/components/calendar/calendar.js +33 -3
  67. package/src/components/card/card.js +12 -1
  68. package/src/components/card-view/card-view.css +1 -0
  69. package/src/components/card-view/card-view.js +25 -9
  70. package/src/components/card-view/record-card.js +7 -3
  71. package/src/components/code-editor/code-editor.css +67 -0
  72. package/src/components/code-editor/code-editor.js +288 -0
  73. package/src/components/code-editor/field-adapter.js +34 -0
  74. package/src/components/context-menu/context-menu.css +80 -3
  75. package/src/components/context-menu/context-menu.js +22 -30
  76. package/src/components/date-picker/date-picker.js +22 -8
  77. package/src/components/date-picker/month-picker.js +3 -3
  78. package/src/components/date-picker/time-picker.js +20 -8
  79. package/src/components/dialog/dialog.js +88 -66
  80. package/src/components/dropdown/dropdown.js +2 -2
  81. package/src/components/entity-ref/entity-ref.css +182 -0
  82. package/src/components/entity-ref/entity-ref.js +496 -0
  83. package/src/components/file-item/file-item.css +88 -0
  84. package/src/components/file-item/file-item.js +371 -0
  85. package/src/components/file-list/file-list.css +51 -0
  86. package/src/components/file-list/file-list.js +320 -0
  87. package/src/components/filter/filter.css +27 -1
  88. package/src/components/filter/filter.js +199 -45
  89. package/src/components/filter-panel/filter-panel.css +104 -0
  90. package/src/components/filter-panel/filter-panel.js +964 -0
  91. package/src/components/kanban-view/kanban-card.js +239 -0
  92. package/src/components/kanban-view/kanban-view.css +194 -0
  93. package/src/components/kanban-view/kanban-view.js +251 -8
  94. package/src/components/launcher/launcher.js +59 -27
  95. package/src/components/list-toolbar/list-toolbar.css +189 -0
  96. package/src/components/list-toolbar/list-toolbar.js +851 -0
  97. package/src/components/menu-button/menu-button.css +80 -3
  98. package/src/components/menu-button/menu-button.js +21 -37
  99. package/src/components/message/message.css +5 -2
  100. package/src/components/message/message.js +13 -2
  101. package/src/components/modal/modal.css +15 -0
  102. package/src/components/modal/modal.js +141 -0
  103. package/src/components/navigation-bar/navigation-bar.css +77 -2
  104. package/src/components/navigation-bar/navigation-bar.js +171 -4
  105. package/src/components/panel/panel.js +13 -2
  106. package/src/components/search/search.js +17 -5
  107. package/src/components/select/field-adapter.js +4 -1
  108. package/src/components/select/select.js +3 -0
  109. package/src/components/sort-control/sort-control.css +17 -0
  110. package/src/components/sort-control/sort-control.js +140 -0
  111. package/src/components/stat-tile/stat-tile.css +213 -0
  112. package/src/components/stat-tile/stat-tile.js +590 -0
  113. package/src/components/tabbox/tabbox.js +12 -1
  114. package/src/components/table/table.css +51 -2
  115. package/src/components/table/table.js +298 -19
  116. package/src/components/table-view/table-view.css +8 -2
  117. package/src/components/table-view/table-view.js +21 -6
  118. package/src/components/tag-picker/tag-picker.css +3 -1
  119. package/src/components/timebox/timebox.js +18 -5
  120. package/src/components/value-list/value-list.js +22 -6
  121. package/src/components/view/record-view.css +5 -0
  122. package/src/components/view/record-view.js +100 -7
  123. package/src/core/icons.js +74 -5
  124. package/src/index.js +25 -0
  125. package/src/internal/app-rail.js +19 -1
  126. package/src/internal/menu-items.js +142 -0
  127. package/styles/print.css +192 -0
  128. package/styles/zx.css +11 -0
@@ -6,7 +6,11 @@ import { uid } from '../../core/util.js';
6
6
  import { badge } from '../badge/badge.js';
7
7
  import { createRecordCard, resolveRecordActions } from '../card-view/record-card.js';
8
8
  import { ContextMenu } from '../context-menu/context-menu.js';
9
- import { RecordView, readViewField, viewRecordId } from '../view/record-view.js';
9
+ import {
10
+ createKanbanAvatars, createKanbanCardHead, createKanbanProgress, resolveKanbanAssignees,
11
+ resolveKanbanIndicator, resolveKanbanProgress
12
+ } from './kanban-card.js';
13
+ import { RecordView, normalizeViewFieldControls, readViewField, viewRecordId } from '../view/record-view.js';
10
14
  import {
11
15
  absoluteKanbanIndex, allowsKanbanTransition, clampKanbanIndex, createKanbanHistory,
12
16
  evaluateKanbanMove, kanbanSearchTerms, locateKanbanRecord, matchesKanbanSearch, normalizeAxisId,
@@ -20,6 +24,12 @@ import {
20
24
  /** @typedef {import('../card-view/record-card.js').RecordCardAction} RecordCardAction */
21
25
  /** @typedef {import('../menu-button/menu-button.js').MenuItem} MenuItem */
22
26
  /** @typedef {import('./kanban-policy.js').KanbanRule} KanbanRule */
27
+ /** @typedef {import('./kanban-card.js').KanbanIndicator} KanbanIndicator */
28
+ /**
29
+ * A card indicator source: a field id, a reader, or a literal descriptor used for every card.
30
+ * @typedef {string|KanbanIndicator|((record:KanbanRecord,index:number)=>KanbanIndicator|Node|unknown)|null} KanbanIndicatorSource
31
+ */
32
+ /** @typedef {import('./kanban-card.js').KanbanAssignee} KanbanAssignee */
23
33
  /** @typedef {import('./kanban-policy.js').KanbanRejectReason} KanbanRejectReason */
24
34
  /** @typedef {import('./kanban-policy.js').KanbanMoveEvaluation} KanbanMoveEvaluation */
25
35
  /** @typedef {string|((record: KanbanRecord, index: number) => unknown)|null} KanbanAccessor */
@@ -116,10 +126,13 @@ import {
116
126
  * @property {{id:string,dir:'asc'|'desc'}|null} [sort=null] Shared initial sort.
117
127
  * @property {'local'|'server'} [sortMode='local'] Shared sort mode.
118
128
  * @property {false|'single'|'multi'} [selectable=false] Shared selection mode.
129
+ * @property {'checkbox'|'card'} [selectionTrigger='checkbox'] Select through a checkbox or the non-interactive card surface.
119
130
  * @property {unknown[]} [selection=[]] Initial record ids.
120
131
  * @property {string[]} [fieldOrder=[]] Shared field order.
121
132
  * @property {string[]} [hiddenFields=[]] Shared hidden fields.
122
- * @property {boolean} [fieldControls=true] Show shared field controls.
133
+ * @property {boolean|import('../view/record-view.js').ViewFieldControlsConfig} [fieldControls=true]
134
+ * Show shared field controls. Redeclared narrower than `RecordViewOptions` it fails declaration
135
+ * emit, because the two option types are intersected.
123
136
  * @property {string|Node|(() => string|Node)|null} [emptyText=null] Empty-board content.
124
137
  * @property {KanbanAccessor} [columnBy='status'] Column accessor.
125
138
  * @property {KanbanColumn[]|null} [columns=null] Explicit columns, or null to derive them.
@@ -134,6 +147,21 @@ import {
134
147
  * @property {'local'|'external'} [moveMode='local'] Whether accepted moves update local data.
135
148
  * @property {'card'|'handle'} [dragFrom='card'] Whether the whole card or only its handle starts a
136
149
  * pointer drag. Touch drags from the card body require a long press so the board still scrolls.
150
+ * @property {'keyboard'|'always'} [moveHandle='keyboard'] Move-handle presentation. `keyboard`
151
+ * keeps it announced and tabbable but out of sight until it takes focus, because the card itself
152
+ * is already the pointer drag source; `always` shows it alongside the card's other controls.
153
+ * `dragFrom:'handle'` overrides this — a drag source a pointer user cannot see is not a control.
154
+ * @property {string|((record:KanbanRecord,index:number)=>string|null)|null} [entityIcon=null] Icon
155
+ * naming what the record is, drawn before the identifier.
156
+ * @property {KanbanCardField} [identifier=null] Stable record key shown on the card head.
157
+ * @property {KanbanIndicatorSource} [status=null] Status indicator source.
158
+ * @property {Record<string,string>} [statusTones={}] Maps a status value to a semantic tone.
159
+ * @property {KanbanIndicatorSource} [priority=null] Priority indicator source.
160
+ * @property {Record<string,string>} [priorityTones={}] Maps a priority value to a semantic tone.
161
+ * @property {KanbanCardField} [progress=null] Completion source; a number or `{value,max,label}`.
162
+ * @property {number} [progressMax=100] Amount that counts as finished.
163
+ * @property {KanbanCardField} [assignees=null] Responsible people; a name, a list, or descriptors.
164
+ * @property {number} [maxAvatars=3] Faces drawn before the overflow chip.
137
165
  * @property {boolean} [multiMove=true] Move the whole selection when a selected card is picked up.
138
166
  * @property {boolean} [autoScroll=true] Scroll the board and columns while dragging near an edge.
139
167
  * @property {'warn'|'block'} [wipPolicy='warn'] Whether exceeding a limit warns or refuses.
@@ -151,7 +179,10 @@ import {
151
179
  * Render a per-column add control that emits `recordadd`.
152
180
  * @property {boolean|MenuItem[]|((context:KanbanCardContext)=>MenuItem[])} [contextMenu=false]
153
181
  * Card context menu. `true` offers move commands; an array or function appends application items.
154
- * @property {((context:KanbanCardContext)=>Node|null|undefined)|null} [renderCard=null] Replaces card content.
182
+ * @property {((context:KanbanCardContext)=>Node|null|undefined)|null} [renderCard=null] Replaces the
183
+ * shared record-card content — eyebrow, titles, and metadata. The preview, selection control,
184
+ * action group, move handle, rule badges, and any configured card head and footer still render,
185
+ * because each of those answers to its own option; clear those options to remove them.
155
186
  * @property {((context:KanbanHeaderContext)=>Node|null|undefined)|null} [renderColumnHeader=null] Replaces column header content.
156
187
  * @property {((context:KanbanHeaderContext)=>Node|null|undefined)|null} [renderSwimlaneHeader=null] Replaces lane header content.
157
188
  * @property {((context:KanbanHeaderContext)=>Node|null|undefined)|null} [renderColumnEmpty=null] Empty-column placeholder.
@@ -176,6 +207,18 @@ import {
176
207
  /** @typedef {{record:KanbanRecord,id:unknown,index:number,column:string,lane:string|null}} KanbanCardMeta */
177
208
  /** @typedef {{column:string,lane:string|null,list:HTMLElement}} KanbanSectionMeta */
178
209
  /** @typedef {{ids:unknown[],from:KanbanMovePoint,to:KanbanMovePoint}} KanbanKeyboardMove */
210
+ /** @typedef {((record:KanbanRecord,index:number)=>unknown)|null} KanbanCardReader */
211
+ /**
212
+ * @typedef {Object} KanbanCardSources
213
+ * @property {KanbanCardReader} identifier Record-key reader.
214
+ * @property {KanbanCardReader} status Status reader.
215
+ * @property {KanbanCardReader} priority Priority reader.
216
+ * @property {KanbanCardReader} progress Completion reader.
217
+ * @property {KanbanCardReader} assignees Responsible-people reader.
218
+ * @property {KanbanCardReader} entityIcon Entity-icon reader.
219
+ * @property {Set<string>} fieldIds Field ids the anatomy consumes.
220
+ * @property {boolean} any Whether any source is configured.
221
+ */
179
222
 
180
223
  const INTERACTIVE = 'a, button, input, select, textarea, summary, [contenteditable="true"]';
181
224
 
@@ -217,7 +260,14 @@ const DEFAULT_LABELS = Object.freeze({
217
260
  countLimit: '%count% records, work-in-progress limit %limit%',
218
261
  countLimitInLane: '%count% records in this lane, %total% of work-in-progress limit %limit% in the column',
219
262
  empty: 'No records',
220
- columnEmpty: '',
263
+ columnEmpty: 'Drop a card here',
264
+ identifier: 'Record',
265
+ priorityLabel: 'Priority',
266
+ statusLabel: 'Status',
267
+ progress: 'Progress',
268
+ progressValue: '%percent%%',
269
+ assignees: 'Assigned to %names%',
270
+ assigneeOverflow: '+%count%',
221
271
  selected: 'Selected',
222
272
  notSelected: 'Not selected',
223
273
  noMatches: 'No cards match the current search.',
@@ -247,6 +297,11 @@ const DEFAULT_LABELS = Object.freeze({
247
297
  });
248
298
 
249
299
  /**
300
+ * A field consumed by the card head or footer — the `identifier`, `status`, `priority`,
301
+ * `progress`, or `assignees` source — is dropped from the shared metadata list so the same value
302
+ * is never shown twice. `field.duplicate === true` opts it back in, extending the rule the shared
303
+ * record card already applies to title and subtitle fields.
304
+ *
250
305
  * Configurable record board with semantic columns, optional swim lanes, and equivalent pointer,
251
306
  * touch, keyboard, context-menu, and programmatic movement. Local moves clone records; callback
252
307
  * grouping remains fully usable for display and external moves but cannot be written safely by
@@ -265,6 +320,7 @@ export class KanbanView extends RecordView {
265
320
  /** @type {Readonly<KanbanViewOptions & RecordViewOptions>} */
266
321
  static defaults = {
267
322
  ...RecordView.defaults,
323
+ selectionTrigger: 'checkbox',
268
324
  columnBy: 'status',
269
325
  columns: null,
270
326
  swimlaneBy: null,
@@ -277,6 +333,17 @@ export class KanbanView extends RecordView {
277
333
  actions: [],
278
334
  moveMode: 'local',
279
335
  dragFrom: 'card',
336
+ moveHandle: 'keyboard',
337
+ entityIcon: null,
338
+ identifier: null,
339
+ status: null,
340
+ statusTones: {},
341
+ priority: null,
342
+ priorityTones: {},
343
+ progress: null,
344
+ progressMax: 100,
345
+ assignees: null,
346
+ maxAvatars: 3,
280
347
  multiMove: true,
281
348
  autoScroll: true,
282
349
  wipPolicy: 'warn',
@@ -354,13 +421,24 @@ export class KanbanView extends RecordView {
354
421
  this._sectionMeta = new WeakMap();
355
422
  this._cardsById = new Map();
356
423
  this._visibleIds = new Set();
424
+ this._cardSources = /** @type {KanbanCardSources} */ ({
425
+ identifier: null, status: null, priority: null, progress: null, assignees: null,
426
+ entityIcon: null, fieldIds: new Set(), any: false
427
+ });
428
+ this._fieldIndex = /** @type {Map<string, any>|null} */ (null);
429
+ this._cardFields = /** @type {{all:any[],metadata:any[]}|null} */ (null);
357
430
  this._kanbanMenu = /** @type {ContextMenu|null} */ (null);
358
431
  this._searchInput = /** @type {HTMLInputElement|null} */ (null);
359
432
  this._undoButton = /** @type {HTMLButtonElement|null} */ (null);
360
433
  this._redoButton = /** @type {HTMLButtonElement|null} */ (null);
361
434
 
362
435
  this._toolbar = h('div', { class: 'zx-record-view__toolbar zx-kanban-view__toolbar' });
363
- if (options.fieldControls) this._toolbar.append(this._createViewFieldControls(this._text('fields')));
436
+ // A label the host passed in the object form wins; otherwise Kanban's own
437
+ // message bag supplies it, which is where this label has always come from.
438
+ if (options.fieldControls) {
439
+ const configured = normalizeViewFieldControls(options.fieldControls).label;
440
+ this._toolbar.append(this._createViewFieldControls(configured ?? this._text('fields')));
441
+ }
364
442
  if (options.searchControl) this._toolbar.append(this._createSearchControl());
365
443
  if (options.historyControls) this._toolbar.append(this._createHistoryControls());
366
444
  this._board = h('div', {
@@ -881,8 +959,12 @@ export class KanbanView extends RecordView {
881
959
  id: String(/** @type {HTMLElement} */ (activeCollapse).dataset.kanbanId),
882
960
  lane: activeSection ? this._sectionMeta.get(activeSection)?.lane ?? null : null
883
961
  } : null;
962
+ // Any change that alters field descriptors also refreshes, so this is the invalidation point.
963
+ this._fieldIndex = null;
964
+ this._cardFields = null;
884
965
  const columns = this._resolvedColumns();
885
966
  const lanes = this._resolvedSwimlanes();
967
+ this._resolveCardSources();
886
968
  this._cardMeta = new WeakMap();
887
969
  this._sectionMeta = new WeakMap();
888
970
  this._cardsById = new Map();
@@ -1034,16 +1116,21 @@ export class KanbanView extends RecordView {
1034
1116
  const grabbed = Boolean(this._keyboardMove?.ids.some((candidate) => Object.is(candidate, id)));
1035
1117
  const rules = resolveKanbanRules(this._kanbanRules, record, { index, column, lane });
1036
1118
  const multiple = grabbed && this._keyboardMove ? this._keyboardMove.ids.length : 1;
1119
+ // A handle that is the only drag source must be visible, whatever `moveHandle` asks for.
1120
+ const reveal = options.moveHandle === 'always' || options.dragFrom === 'handle'
1121
+ ? 'always' : 'keyboard';
1037
1122
  const handle = h('button', {
1038
1123
  class: 'zx-kanban-view__move-handle', type: 'button',
1039
1124
  ariaPressed: String(grabbed),
1125
+ dataset: { reveal },
1040
1126
  ariaLabel: multiple > 1
1041
1127
  ? this._text('moveMultiple', { count: multiple })
1042
1128
  : this._text('move', { name: this._recordName(record, index) })
1043
1129
  }, icon('drag', { size: 13 }));
1130
+ const cardFields = this._resolvedCardFields();
1044
1131
  const card = createRecordCard(record, index, {
1045
- fields: this.getFields(),
1046
- visibleFields: this.getVisibleFields(),
1132
+ fields: cardFields.all,
1133
+ visibleFields: cardFields.metadata,
1047
1134
  titleField: options.titleField,
1048
1135
  subtitleField: options.subtitleField,
1049
1136
  preview: options.preview ?? undefined,
@@ -1051,6 +1138,7 @@ export class KanbanView extends RecordView {
1051
1138
  link: options.link ?? undefined,
1052
1139
  actions: options.actions,
1053
1140
  selectable: options.selectable,
1141
+ selectionTrigger: options.selectionTrigger,
1054
1142
  selected: this._isViewSelected(id),
1055
1143
  variant: options.variant,
1056
1144
  headingLevel: hasLanes ? 4 : 3,
@@ -1060,6 +1148,7 @@ export class KanbanView extends RecordView {
1060
1148
  if (grabbed) card.dataset.grabbed = 'true';
1061
1149
  this._applyCardContent(card, { record, id, index, column, lane, rules, selected: this._isViewSelected(id) });
1062
1150
  this._applyCardRules(card, rules);
1151
+ this._applyCardAnatomy(card, record, index);
1063
1152
  this._applyCardDescription(card, id);
1064
1153
  const meta = { record, id, index, column, lane };
1065
1154
  this._cardMeta.set(card, meta);
@@ -1067,6 +1156,137 @@ export class KanbanView extends RecordView {
1067
1156
  return card;
1068
1157
  }
1069
1158
 
1159
+ /**
1160
+ * Builds the two field arrays every card needs, once per refresh.
1161
+ * @returns {{all:import('../view/record-view.js').ViewField[],metadata:import('../view/record-view.js').ViewField[]}}
1162
+ */
1163
+ _resolvedCardFields() {
1164
+ if (!this._cardFields) {
1165
+ this._cardFields = { all: this.getFields(), metadata: this._cardMetadataFields() };
1166
+ }
1167
+ return this._cardFields;
1168
+ }
1169
+
1170
+ /**
1171
+ * Returns the fields the shared metadata list may show. A field the card head or footer already
1172
+ * projects is dropped, because repeating it as a labelled row is the same value twice; a field
1173
+ * marked `duplicate` opts back in, matching how title and subtitle already behave.
1174
+ * @returns {import('../view/record-view.js').ViewField[]} Metadata fields.
1175
+ */
1176
+ _cardMetadataFields() {
1177
+ const consumed = this._cardSources?.fieldIds;
1178
+ const visible = this.getVisibleFields();
1179
+ if (!consumed?.size) return visible;
1180
+ return visible.filter((field) => !consumed.has(field.id) || field.duplicate === true);
1181
+ }
1182
+
1183
+ /**
1184
+ * Resolves the card-anatomy readers once per refresh. A field id would otherwise be looked up
1185
+ * through `getFields()` — which clones every descriptor — once per source per card, so a board
1186
+ * with six anatomy sources rebuilt the whole field array six times for every card it drew.
1187
+ * @returns {void}
1188
+ */
1189
+ _resolveCardSources() {
1190
+ const options = this._kanbanOptions();
1191
+ const fields = this.getFields();
1192
+ /** @type {Set<string>} */
1193
+ const fieldIds = new Set();
1194
+ /** @param {unknown} source @returns {((record:KanbanRecord,index:number)=>unknown)|null} */
1195
+ const reader = (source) => {
1196
+ if (source == null) return null;
1197
+ if (typeof source === 'function') return /** @type {any} */ (source);
1198
+ // A literal descriptor is content, not an accessor: reading it as a property name would
1199
+ // silently resolve undefined and render nothing.
1200
+ if (typeof source === 'object') return () => source;
1201
+ const id = String(source);
1202
+ const field = fields.find((candidate) => candidate.id === id);
1203
+ fieldIds.add(id);
1204
+ return field
1205
+ ? (record, index) => readViewField(field, record, index) : (record) => record?.[id];
1206
+ };
1207
+ const sources = {
1208
+ identifier: reader(options.identifier),
1209
+ status: reader(options.status),
1210
+ priority: reader(options.priority),
1211
+ progress: reader(options.progress),
1212
+ assignees: reader(options.assignees),
1213
+ entityIcon: typeof options.entityIcon === 'function' ? options.entityIcon
1214
+ : options.entityIcon == null ? null : () => options.entityIcon
1215
+ };
1216
+ this._cardSources = /** @type {KanbanCardSources} */ ({
1217
+ ...sources,
1218
+ fieldIds,
1219
+ any: Object.values(sources).some(Boolean)
1220
+ });
1221
+ }
1222
+
1223
+ /**
1224
+ * Adds the two Kanban-owned regions that bracket the shared record-card anatomy: a head naming
1225
+ * what the record is and how urgent it is, and a footer carrying completion and the people
1226
+ * responsible. Each part is omitted when its option is unset, so a board that configures none of
1227
+ * them renders exactly the card it rendered before.
1228
+ * @param {HTMLElement} card Card element.
1229
+ * @param {KanbanRecord} record Record.
1230
+ * @param {number} index Display index.
1231
+ * @returns {void}
1232
+ */
1233
+ _applyCardAnatomy(card, record, index) {
1234
+ const options = this._kanbanOptions();
1235
+ const sources = this._cardSources;
1236
+ if (!sources?.any) return;
1237
+ const body = card.querySelector('.zx-record-card__body');
1238
+ if (!body) return;
1239
+ const entityIcon = sources.entityIcon?.(record, index);
1240
+ const identifier = sources.identifier?.(record, index);
1241
+ const head = createKanbanCardHead({
1242
+ entityIcon: entityIcon == null || entityIcon === '' ? null : String(entityIcon),
1243
+ identifier: identifier == null ? '' : String(identifier),
1244
+ identifierPrefix: this._text('identifier'),
1245
+ flags: [
1246
+ {
1247
+ indicator: this._cardIndicator(sources.status, options.statusTones, record, index),
1248
+ prefix: this._text('statusLabel')
1249
+ },
1250
+ {
1251
+ indicator: this._cardIndicator(sources.priority, options.priorityTones, record, index),
1252
+ prefix: this._text('priorityLabel')
1253
+ }
1254
+ ]
1255
+ });
1256
+ if (head) body.prepend(head);
1257
+
1258
+ const footer = [];
1259
+ const progress = resolveKanbanProgress(
1260
+ sources.progress?.(record, index), Number(options.progressMax));
1261
+ if (progress) {
1262
+ footer.push(createKanbanProgress(progress,
1263
+ progress.label ?? this._text('progress'),
1264
+ this._text('progressValue', { percent: progress.percent })));
1265
+ }
1266
+ const people = resolveKanbanAssignees(sources.assignees?.(record, index));
1267
+ const avatars = createKanbanAvatars(people, Number(options.maxAvatars),
1268
+ this._text('assignees', { names: people.map((person) => person.name).filter(Boolean).join(', ') }),
1269
+ (count) => this._text('assigneeOverflow', { count }));
1270
+ if (avatars) footer.push(avatars);
1271
+ if (footer.length) body.append(h('div', { class: 'zx-kanban-view__card-footer' }, footer));
1272
+ }
1273
+
1274
+ /**
1275
+ * Resolves one indicator from an already-resolved reader. A reader may return a ready-made
1276
+ * `Node`, which is adopted rather than described, so an application keeps full control.
1277
+ * @param {((record:KanbanRecord,index:number)=>unknown)|null} source Resolved reader.
1278
+ * @param {unknown} tones Value-to-tone map.
1279
+ * @param {KanbanRecord} record Record.
1280
+ * @param {number} index Display index.
1281
+ * @returns {KanbanIndicator|Node|null} Indicator, node, or null.
1282
+ */
1283
+ _cardIndicator(source, tones, record, index) {
1284
+ if (!source) return null;
1285
+ const value = source(record, index);
1286
+ if (value && typeof value === 'object' && 'nodeType' in value) return /** @type {Node} */ (value);
1287
+ return resolveKanbanIndicator(value, /** @type {Record<string,string>} */ (tones ?? {}));
1288
+ }
1289
+
1070
1290
  /**
1071
1291
  * Hands card content to `renderCard` while keeping the managed shell: the list item, its
1072
1292
  * selection control, the action group, and the move handle are never given away, because they
@@ -1220,10 +1440,24 @@ export class KanbanView extends RecordView {
1220
1440
  _readAxis(accessor, record, index) {
1221
1441
  if (typeof accessor === 'function') return accessor(record, index);
1222
1442
  if (accessor == null) return null;
1223
- const field = this.getFields().find((candidate) => candidate.id === accessor);
1443
+ const field = this._axisField(String(accessor));
1224
1444
  return field ? readViewField(field, record, index) : record?.[accessor];
1225
1445
  }
1226
1446
 
1447
+ /**
1448
+ * Resolves one field descriptor by id through a per-refresh index. `getFields()` rebuilds a Map
1449
+ * and clones every descriptor on each call, and a string column or lane accessor is read once
1450
+ * per record per column per lane — which made this the most expensive line in a board render.
1451
+ * @param {string} id Field id.
1452
+ * @returns {import('../view/record-view.js').ViewField|undefined} Descriptor, if any.
1453
+ */
1454
+ _axisField(id) {
1455
+ if (!this._fieldIndex) {
1456
+ this._fieldIndex = new Map(this.getFields().map((field) => [field.id, field]));
1457
+ }
1458
+ return this._fieldIndex.get(id);
1459
+ }
1460
+
1227
1461
  /** @param {string} column @param {string|null} lane @returns {{record:KanbanRecord,index:number}[]} */
1228
1462
  _recordsIn(column, lane) {
1229
1463
  const options = this._kanbanOptions();
@@ -1689,6 +1923,11 @@ export class KanbanView extends RecordView {
1689
1923
  }
1690
1924
  if (target?.closest('.zx-kanban-view__move-handle, .zx-record-card__selection, [data-record-select]')) return;
1691
1925
  if (target?.closest(INTERACTIVE) || hasTextSelection()) return;
1926
+ if (this.options.selectable && this._kanbanOptions().selectionTrigger === 'card') {
1927
+ this.toggleSelection(meta.id, { range: event.shiftKey });
1928
+ /** @type {HTMLElement|null} */ (target?.closest('.zx-record-card'))?.focus({ preventScroll: true });
1929
+ return;
1930
+ }
1692
1931
  this.emit('recordclick', { ...meta, event });
1693
1932
  if (this.options.selectable === 'single' && !this._isViewSelected(meta.id)) {
1694
1933
  this.setSelection([meta.id]);
@@ -1724,6 +1963,10 @@ export class KanbanView extends RecordView {
1724
1963
  if (!cardMeta || target !== card) return;
1725
1964
  if (event.key === 'Enter') {
1726
1965
  event.preventDefault();
1966
+ if (this.options.selectable && this._kanbanOptions().selectionTrigger === 'card') {
1967
+ this.toggleSelection(cardMeta.id);
1968
+ return;
1969
+ }
1727
1970
  this.emit('recordclick', { ...cardMeta, event });
1728
1971
  if (this.options.selectable === 'single') this.setSelection([cardMeta.id]);
1729
1972
  } else if (event.key === ' ' && this.options.selectable !== false) {
@@ -42,12 +42,12 @@ import { uid } from '../../core/util.js';
42
42
  * @property {number} [debounce=250] Source-query delay in milliseconds.
43
43
  * @property {number} [minQuery=0] Global source minimum query length.
44
44
  * @property {number} [maxResults=100] Maximum combined results.
45
- * @property {string} [placeholder='Search applications and records'] Search-field placeholder.
46
- * @property {string} [label='Launcher'] Dialog and field label.
47
- * @property {string} [closeLabel='Close launcher'] Close-button accessible label.
48
- * @property {string} [resultsLabel='Launcher results'] Results-region accessible label.
49
- * @property {string} [emptyText='No results'] Empty-state text.
50
- * @property {string} [loadingText='Searching…'] Loading-state text.
45
+ * @property {string} [placeholder] Search-field placeholder. Omitted resolves `launcher.placeholder`, English `Search applications and records`.
46
+ * @property {string} [label] Dialog and field label. Omitted resolves `launcher.label`, English `Launcher`.
47
+ * @property {string} [closeLabel] Close-button accessible label. Omitted resolves `launcher.close`, English `Close launcher`.
48
+ * @property {string} [resultsLabel] Results-region accessible label. Omitted resolves `launcher.results`, English `Launcher results`.
49
+ * @property {string} [emptyText] Empty-state text. Omitted resolves `launcher.empty`, English `No results`.
50
+ * @property {string} [loadingText] Loading-state text. Omitted resolves `launcher.loading`, English `Searching…`.
51
51
  * @property {'mod+k'|false} [shortcut='mod+k'] Optional global keyboard shortcut.
52
52
  * @property {false|{move?: string, open?: string, close?: string}} [hints] Visible keyboard hints, or false.
53
53
  * @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns an internally created launcher; defaults to the opener.
@@ -80,14 +80,20 @@ export class Launcher extends Component {
80
80
  debounce: 250,
81
81
  minQuery: 0,
82
82
  maxResults: 100,
83
- placeholder: 'Search applications and records',
84
- label: 'Launcher',
85
- closeLabel: 'Close launcher',
86
- resultsLabel: 'Launcher results',
87
- emptyText: 'No results',
88
- loadingText: 'Searching…',
83
+ /*
84
+ * The six labels default to null rather than to their English text so that an application
85
+ * which installs a translator and passes no labels still gets a translated launcher. The
86
+ * documented default is what `_message()` falls back to, so an untranslated application
87
+ * renders exactly the same words as before.
88
+ */
89
+ placeholder: null,
90
+ label: null,
91
+ closeLabel: null,
92
+ resultsLabel: null,
93
+ emptyText: null,
94
+ loadingText: null,
89
95
  shortcut: 'mod+k',
90
- hints: { move: 'Move', open: 'Open', close: 'Close' },
96
+ hints: null,
91
97
  scope: null
92
98
  };
93
99
 
@@ -126,6 +132,7 @@ export class Launcher extends Component {
126
132
  this._destroyed = false;
127
133
 
128
134
  const listId = uid('zx-launcher-results');
135
+ const label = String(this.options.label ?? this._message('launcher.label', 'Launcher'));
129
136
  const input = h('input', {
130
137
  ref: 'input',
131
138
  class: 'zx-launcher__input',
@@ -136,15 +143,15 @@ export class Launcher extends Component {
136
143
  ariaAutocomplete: 'list',
137
144
  ariaControls: listId,
138
145
  ariaExpanded: 'false',
139
- ariaLabel: this.options.label,
140
- placeholder: this.options.placeholder,
146
+ ariaLabel: label,
147
+ placeholder: this.options.placeholder ?? this._message('launcher.placeholder', 'Search applications and records'),
141
148
  value: this._query
142
149
  });
143
150
  const close = h('button', {
144
151
  ref: 'close',
145
152
  class: 'zx-icon-btn zx-launcher__close',
146
153
  type: 'button',
147
- ariaLabel: this.options.closeLabel
154
+ ariaLabel: this.options.closeLabel ?? this._message('launcher.close', 'Close launcher')
148
155
  }, icon('x'));
149
156
  const status = h('div', {
150
157
  ref: 'status',
@@ -157,14 +164,18 @@ export class Launcher extends Component {
157
164
  class: 'zx-launcher__results',
158
165
  id: listId,
159
166
  role: 'listbox',
160
- ariaLabel: this.options.resultsLabel
167
+ ariaLabel: this.options.resultsLabel ?? this._message('launcher.results', 'Launcher results')
161
168
  });
162
169
  const shortcut = this.options.shortcut === 'mod+k' ? h('kbd', {
163
170
  class: 'zx-launcher__shortcut',
164
171
  ariaHidden: 'true'
165
172
  }, launcherShortcutLabel()) : null;
166
- const footer = launcherHints(this.options.hints);
167
- dialog.setAttribute('aria-label', String(this.options.label));
173
+ const footer = launcherHints(this.options.hints, {
174
+ move: this._message('launcher.hintMove', 'Move'),
175
+ open: this._message('launcher.hintOpen', 'Open'),
176
+ close: this._message('launcher.hintClose', 'Close')
177
+ });
178
+ dialog.setAttribute('aria-label', label);
168
179
  dialog.replaceChildren(
169
180
  h('div', { class: 'zx-launcher__surface' },
170
181
  h('div', {
@@ -458,9 +469,15 @@ export class Launcher extends Component {
458
469
 
459
470
  /** @returns {void} */
460
471
  _renderStatus() {
461
- if (this._busy) this.refs.status.textContent = String(this.options.loadingText);
462
- else if (this._results.length === 0) this.refs.status.textContent = String(this.options.emptyText);
463
- else this.refs.status.textContent = '';
472
+ if (this._busy) {
473
+ this.refs.status.textContent = String(this.options.loadingText
474
+ ?? this._message('launcher.loading', 'Searching…'));
475
+ } else if (this._results.length === 0) {
476
+ this.refs.status.textContent = String(this.options.emptyText
477
+ ?? this._message('launcher.empty', 'No results'));
478
+ } else {
479
+ this.refs.status.textContent = '';
480
+ }
464
481
  this.refs.status.hidden = !this.refs.status.textContent;
465
482
  }
466
483
 
@@ -566,6 +583,17 @@ export class Launcher extends Component {
566
583
  event.preventDefault();
567
584
  this.open();
568
585
  }
586
+
587
+ /**
588
+ * Resolves a message through the host translator, falling back to the built-in English text.
589
+ * @param {string} key Message key.
590
+ * @param {string} fallback English default.
591
+ * @returns {string}
592
+ */
593
+ _message(key, fallback) {
594
+ const message = this.msg(key);
595
+ return message === key ? fallback : message;
596
+ }
569
597
  }
570
598
 
571
599
  /** @returns {string} */
@@ -575,14 +603,18 @@ function launcherShortcutLabel() {
575
603
  return /mac|iphone|ipad/i.test(platform) ? '⌘ K' : 'Ctrl K';
576
604
  }
577
605
 
578
- /** @param {LauncherOptions['hints']} hints @returns {HTMLElement|null} */
579
- function launcherHints(hints) {
606
+ /**
607
+ * @param {LauncherOptions['hints']} hints Host-supplied hint labels, or false to omit the row.
608
+ * @param {{move: string, open: string, close: string}} defaults Resolved built-in labels.
609
+ * @returns {HTMLElement|null}
610
+ */
611
+ function launcherHints(hints, defaults) {
580
612
  if (hints === false) return null;
581
613
  const labels = hints && typeof hints === 'object' ? hints : {};
582
614
  return h('footer', { class: 'zx-launcher__hints', ariaHidden: 'true' },
583
- h('span', {}, h('kbd', {}, '↑↓←→'), labels.move ?? 'Move'),
584
- h('span', {}, h('kbd', {}, '↵'), labels.open ?? 'Open'),
585
- h('span', {}, h('kbd', {}, 'Esc'), labels.close ?? 'Close'));
615
+ h('span', {}, h('kbd', {}, '↑↓←→'), labels.move ?? defaults.move),
616
+ h('span', {}, h('kbd', {}, '↵'), labels.open ?? defaults.open),
617
+ h('span', {}, h('kbd', {}, 'Esc'), labels.close ?? defaults.close));
586
618
  }
587
619
 
588
620
  /** @param {LauncherItem} item @returns {Node|null} */