@zeyos/zx 4.3.0 → 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 (136) hide show
  1. package/CHANGELOG.md +266 -1
  2. package/README.md +7 -3
  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-model.d.ts +22 -0
  8. package/dist/types/components/calendar/calendar.d.ts +19 -0
  9. package/dist/types/components/card/card.d.ts +7 -0
  10. package/dist/types/components/card-view/card-view.d.ts +12 -4
  11. package/dist/types/components/card-view/record-card.d.ts +53 -5
  12. package/dist/types/components/code-editor/code-editor.d.ts +160 -0
  13. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  14. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  15. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  16. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  17. package/dist/types/components/dialog/dialog.d.ts +29 -0
  18. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  19. package/dist/types/components/file-item/file-item.d.ts +234 -0
  20. package/dist/types/components/file-list/file-list.d.ts +171 -0
  21. package/dist/types/components/filter/filter.d.ts +91 -36
  22. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  23. package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
  24. package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
  25. package/dist/types/components/kanban-view/kanban-view.d.ts +837 -26
  26. package/dist/types/components/launcher/launcher.d.ts +19 -12
  27. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  28. package/dist/types/components/menu-button/menu-button.d.ts +4 -37
  29. package/dist/types/components/message/message.d.ts +7 -0
  30. package/dist/types/components/modal/modal.d.ts +48 -0
  31. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  32. package/dist/types/components/panel/panel.d.ts +7 -0
  33. package/dist/types/components/search/search.d.ts +7 -0
  34. package/dist/types/components/select/select.d.ts +4 -0
  35. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  36. package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
  37. package/dist/types/components/tabbox/tabbox.d.ts +7 -0
  38. package/dist/types/components/table/table.d.ts +144 -0
  39. package/dist/types/components/table-view/table-view.d.ts +11 -5
  40. package/dist/types/components/timebox/timebox.d.ts +7 -0
  41. package/dist/types/components/value-list/value-list.d.ts +8 -0
  42. package/dist/types/components/view/record-view.d.ts +96 -5
  43. package/dist/types/core/component.d.ts +6 -1
  44. package/dist/types/core/icons.d.ts +17 -0
  45. package/dist/types/index.d.ts +13 -1
  46. package/dist/types/internal/app-rail.d.ts +10 -0
  47. package/dist/types/internal/menu-items.d.ts +134 -0
  48. package/dist/zx-zeyos.esm.js +651 -88
  49. package/dist/zx-zeyos.esm.min.js +8 -1
  50. package/dist/zx.css +6266 -3900
  51. package/dist/zx.esm.js +8487 -1805
  52. package/dist/zx.esm.min.js +10 -3
  53. package/dist/zx.global.js +8487 -1805
  54. package/dist/zx.global.min.js +10 -3
  55. package/dist/zx.min.css +1 -1
  56. package/docs/api.json +1811 -247
  57. package/docs/llms.md +345 -40
  58. package/docs/llms.txt +11 -7
  59. package/package.json +1 -2
  60. package/src/components/activity-item/activity-item.css +137 -0
  61. package/src/components/activity-item/activity-item.js +599 -0
  62. package/src/components/activity-list/activity-list.css +64 -0
  63. package/src/components/activity-list/activity-list.js +375 -0
  64. package/src/components/app-rail/app-rail.css +227 -19
  65. package/src/components/app-sidebar/app-sidebar.css +414 -24
  66. package/src/components/app-sidebar/app-sidebar.js +65 -5
  67. package/src/components/button/button.css +8 -0
  68. package/src/components/button/button.js +6 -1
  69. package/src/components/calendar/calendar-model.js +45 -0
  70. package/src/components/calendar/calendar.css +30 -10
  71. package/src/components/calendar/calendar.js +41 -13
  72. package/src/components/card/card.js +12 -1
  73. package/src/components/card-view/card-view.css +212 -9
  74. package/src/components/card-view/card-view.js +32 -16
  75. package/src/components/card-view/record-card.js +98 -21
  76. package/src/components/code-editor/code-editor.css +67 -0
  77. package/src/components/code-editor/code-editor.js +288 -0
  78. package/src/components/code-editor/field-adapter.js +34 -0
  79. package/src/components/context-menu/context-menu.css +80 -3
  80. package/src/components/context-menu/context-menu.js +22 -30
  81. package/src/components/date-picker/date-picker.js +22 -8
  82. package/src/components/date-picker/month-picker.js +3 -3
  83. package/src/components/date-picker/time-picker.js +20 -8
  84. package/src/components/dialog/dialog.js +88 -66
  85. package/src/components/dropdown/dropdown.js +2 -2
  86. package/src/components/entity-ref/entity-ref.css +182 -0
  87. package/src/components/entity-ref/entity-ref.js +496 -0
  88. package/src/components/file-item/file-item.css +88 -0
  89. package/src/components/file-item/file-item.js +371 -0
  90. package/src/components/file-list/file-list.css +51 -0
  91. package/src/components/file-list/file-list.js +320 -0
  92. package/src/components/filter/filter.css +27 -1
  93. package/src/components/filter/filter.js +199 -45
  94. package/src/components/filter-panel/filter-panel.css +104 -0
  95. package/src/components/filter-panel/filter-panel.js +964 -0
  96. package/src/components/kanban-view/kanban-card.js +239 -0
  97. package/src/components/kanban-view/kanban-policy.js +412 -0
  98. package/src/components/kanban-view/kanban-view.css +674 -36
  99. package/src/components/kanban-view/kanban-view.js +1762 -240
  100. package/src/components/launcher/launcher.js +59 -27
  101. package/src/components/list-toolbar/list-toolbar.css +189 -0
  102. package/src/components/list-toolbar/list-toolbar.js +851 -0
  103. package/src/components/menu-button/menu-button.css +80 -3
  104. package/src/components/menu-button/menu-button.js +21 -37
  105. package/src/components/message/message.css +14 -12
  106. package/src/components/message/message.js +13 -2
  107. package/src/components/modal/modal.css +15 -0
  108. package/src/components/modal/modal.js +141 -0
  109. package/src/components/navigation-bar/navigation-bar.css +77 -2
  110. package/src/components/navigation-bar/navigation-bar.js +171 -4
  111. package/src/components/panel/panel.js +13 -2
  112. package/src/components/search/search.js +17 -5
  113. package/src/components/select/field-adapter.js +4 -1
  114. package/src/components/select/select.js +3 -0
  115. package/src/components/sort-control/sort-control.css +17 -0
  116. package/src/components/sort-control/sort-control.js +140 -0
  117. package/src/components/stat-tile/stat-tile.css +213 -0
  118. package/src/components/stat-tile/stat-tile.js +590 -0
  119. package/src/components/tabbox/tabbox.js +12 -1
  120. package/src/components/table/table.css +51 -2
  121. package/src/components/table/table.js +298 -19
  122. package/src/components/table-view/table-view.css +8 -2
  123. package/src/components/table-view/table-view.js +21 -6
  124. package/src/components/tag-picker/tag-picker.css +3 -1
  125. package/src/components/timebox/timebox.js +18 -5
  126. package/src/components/value-list/value-list.js +22 -6
  127. package/src/components/view/record-view.css +5 -0
  128. package/src/components/view/record-view.js +100 -7
  129. package/src/core/component.js +8 -3
  130. package/src/core/icons.js +74 -5
  131. package/src/index.js +33 -3
  132. package/src/internal/app-rail.js +19 -1
  133. package/src/internal/menu-items.js +142 -0
  134. package/styles/print.css +192 -0
  135. package/styles/zx.css +11 -0
  136. package/DESIGN-SYSTEM.md +0 -182
@@ -1,15 +1,37 @@
1
1
  // @ts-check
2
2
  import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
3
3
  import { icon } from '../../core/icons.js';
4
+ import { printf } from '../../core/i18n.js';
4
5
  import { uid } from '../../core/util.js';
6
+ import { badge } from '../badge/badge.js';
5
7
  import { createRecordCard, resolveRecordActions } from '../card-view/record-card.js';
6
- import { RecordView, readViewField, viewRecordId } from '../view/record-view.js';
8
+ import { ContextMenu } from '../context-menu/context-menu.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';
14
+ import {
15
+ absoluteKanbanIndex, allowsKanbanTransition, clampKanbanIndex, createKanbanHistory,
16
+ evaluateKanbanMove, kanbanSearchTerms, locateKanbanRecord, matchesKanbanSearch, normalizeAxisId,
17
+ normalizeKanbanRules, readKanbanAccessor, reorderKanbanRecords, resolveKanbanRules
18
+ } from './kanban-policy.js';
7
19
 
8
20
  /** @typedef {Record<string, any>} KanbanRecord */
9
21
  /** @typedef {import('../view/record-view.js').RecordViewOptions} RecordViewOptions */
10
22
  /** @typedef {import('../card-view/record-card.js').RecordCardPreview} RecordCardPreview */
11
23
  /** @typedef {import('../card-view/record-card.js').RecordCardLink} RecordCardLink */
12
24
  /** @typedef {import('../card-view/record-card.js').RecordCardAction} RecordCardAction */
25
+ /** @typedef {import('../menu-button/menu-button.js').MenuItem} MenuItem */
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 */
33
+ /** @typedef {import('./kanban-policy.js').KanbanRejectReason} KanbanRejectReason */
34
+ /** @typedef {import('./kanban-policy.js').KanbanMoveEvaluation} KanbanMoveEvaluation */
13
35
  /** @typedef {string|((record: KanbanRecord, index: number) => unknown)|null} KanbanAccessor */
14
36
  /** @typedef {string|((record: KanbanRecord, index: number) => unknown)|null} KanbanCardField */
15
37
 
@@ -34,7 +56,11 @@ import { RecordView, readViewField, viewRecordId } from '../view/record-view.js'
34
56
  * @property {string} id Stable column id.
35
57
  * @property {string} label Visible column label.
36
58
  * @property {unknown} [value] Value written by local moves; defaults to `id`.
37
- * @property {number|null} [limit=null] Advisory work-in-progress limit.
59
+ * @property {number|null} [limit=null] Work-in-progress limit across every lane.
60
+ * @property {Record<string, number>} [laneLimits] Per-lane limits inside this column.
61
+ * @property {'warn'|'block'} [wipPolicy] Overrides the board `wipPolicy` for this column.
62
+ * @property {string[]|null} [from=null] Origin column ids allowed to move in. `null`, an absent
63
+ * value, or `['*']` admits any origin; a move that stays in the column is never restricted.
38
64
  * @property {(record: KanbanRecord, context: KanbanMoveContext) => boolean} [accept] Eligibility
39
65
  * predicate. Returning false rejects the move before the cancelable event is emitted.
40
66
  */
@@ -44,6 +70,8 @@ import { RecordView, readViewField, viewRecordId } from '../view/record-view.js'
44
70
  * @property {string} id Stable lane id.
45
71
  * @property {string} label Visible lane label.
46
72
  * @property {unknown} [value] Value written by local moves; defaults to `id`.
73
+ * @property {number|null} [limit=null] Work-in-progress limit across every column in the lane.
74
+ * @property {string[]|null} [from=null] Origin lane ids allowed to move in.
47
75
  * @property {(record: KanbanRecord, context: KanbanMoveContext) => boolean} [accept] Eligibility
48
76
  * predicate. Returning false rejects the move before the cancelable event is emitted.
49
77
  */
@@ -67,6 +95,29 @@ import { RecordView, readViewField, viewRecordId } from '../view/record-view.js'
67
95
  * @property {string[]} collapsedSwimlanes Collapsed swim-lane ids.
68
96
  */
69
97
 
98
+ /**
99
+ * @typedef {Object} KanbanCardContext
100
+ * @property {KanbanRecord} record Record being rendered.
101
+ * @property {unknown} id Record id.
102
+ * @property {number} index Display index in the current record order.
103
+ * @property {string} column Column id.
104
+ * @property {string|null} lane Lane id, or null when lanes are disabled.
105
+ * @property {KanbanRule[]} rules Rules matching this record, in configured order.
106
+ * @property {boolean} selected Whether the record is selected.
107
+ */
108
+
109
+ /**
110
+ * @typedef {Object} KanbanHeaderContext
111
+ * @property {KanbanColumn} [column] Column being rendered.
112
+ * @property {KanbanSwimlane|null} [swimlane] Lane being rendered, or the lane a column belongs to.
113
+ * @property {number} count Records rendered in this section.
114
+ * @property {number} total Records this section holds before search and filters.
115
+ * @property {number} columnTotal Records in the whole column, across lanes.
116
+ * @property {number|null} limit Applicable work-in-progress limit.
117
+ * @property {'none'|'within'|'limit'|'exceeded'} wip Capacity state.
118
+ * @property {boolean} collapsed Whether the section is collapsed.
119
+ */
120
+
70
121
  /**
71
122
  * @typedef {Object} KanbanViewOptions
72
123
  * @property {import('../view/record-view.js').ViewField[]} [fields=[]] Shared field descriptors.
@@ -75,10 +126,13 @@ import { RecordView, readViewField, viewRecordId } from '../view/record-view.js'
75
126
  * @property {{id:string,dir:'asc'|'desc'}|null} [sort=null] Shared initial sort.
76
127
  * @property {'local'|'server'} [sortMode='local'] Shared sort mode.
77
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.
78
130
  * @property {unknown[]} [selection=[]] Initial record ids.
79
131
  * @property {string[]} [fieldOrder=[]] Shared field order.
80
132
  * @property {string[]} [hiddenFields=[]] Shared hidden fields.
81
- * @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.
82
136
  * @property {string|Node|(() => string|Node)|null} [emptyText=null] Empty-board content.
83
137
  * @property {KanbanAccessor} [columnBy='status'] Column accessor.
84
138
  * @property {KanbanColumn[]|null} [columns=null] Explicit columns, or null to derive them.
@@ -91,6 +145,49 @@ import { RecordView, readViewField, viewRecordId } from '../view/record-view.js'
91
145
  * @property {RecordCardLink|((record:KanbanRecord,index:number)=>RecordCardLink)|null} [link=null] Record-card primary link descriptor or reader.
92
146
  * @property {RecordCardAction[]|((record:KanbanRecord,index:number)=>RecordCardAction[])} [actions=[]] Card actions.
93
147
  * @property {'local'|'external'} [moveMode='local'] Whether accepted moves update local data.
148
+ * @property {'card'|'handle'} [dragFrom='card'] Whether the whole card or only its handle starts a
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.
165
+ * @property {boolean} [multiMove=true] Move the whole selection when a selected card is picked up.
166
+ * @property {boolean} [autoScroll=true] Scroll the board and columns while dragging near an edge.
167
+ * @property {'warn'|'block'} [wipPolicy='warn'] Whether exceeding a limit warns or refuses.
168
+ * @property {KanbanRule[]} [rules=[]] Ordered visual card rules.
169
+ * @property {string} [search=''] Board search text.
170
+ * @property {string[]|null} [searchFields=null] Field ids searched; null searches visible fields
171
+ * plus the title and subtitle sources.
172
+ * @property {((record:KanbanRecord,index:number)=>boolean)|null} [filter=null] Extra card predicate.
173
+ * @property {boolean} [searchControl=false] Render the built-in board search control.
174
+ * @property {boolean} [history=true] Record committed local moves for undo and redo.
175
+ * @property {number} [historyLimit=50] Maximum remembered move steps.
176
+ * @property {boolean} [historyControls=false] Render built-in undo and redo controls.
177
+ * @property {string|null} [columnHeight=null] CSS length turning each column into a scroll region.
178
+ * @property {boolean|((column:KanbanColumn,swimlane:KanbanSwimlane|null)=>boolean)} [allowAdd=false]
179
+ * Render a per-column add control that emits `recordadd`.
180
+ * @property {boolean|MenuItem[]|((context:KanbanCardContext)=>MenuItem[])} [contextMenu=false]
181
+ * Card context menu. `true` offers move commands; an array or function appends application items.
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.
186
+ * @property {((context:KanbanHeaderContext)=>Node|null|undefined)|null} [renderColumnHeader=null] Replaces column header content.
187
+ * @property {((context:KanbanHeaderContext)=>Node|null|undefined)|null} [renderSwimlaneHeader=null] Replaces lane header content.
188
+ * @property {((context:KanbanHeaderContext)=>Node|null|undefined)|null} [renderColumnEmpty=null] Empty-column placeholder.
189
+ * @property {((context:{records:KanbanRecord[],ids:unknown[],count:number})=>Node|null|undefined)|null} [renderDragPreview=null] Replaces the floating drag preview.
190
+ * @property {Record<string,string>} [labels={}] Overrides for every visible and announced string.
94
191
  * @property {string[]} [columnOrder=[]] Preferred column order.
95
192
  * @property {string[]} [swimlaneOrder=[]] Preferred swim-lane order.
96
193
  * @property {string[]} [collapsedColumns=[]] Initially collapsed columns.
@@ -100,21 +197,121 @@ import { RecordView, readViewField, viewRecordId } from '../view/record-view.js'
100
197
  * @property {'outlined'|'raised'|'filled'} [variant='outlined'] Shared record-card treatment.
101
198
  * @property {string} [label='Kanban board'] Accessible board label.
102
199
  * @property {(event: CustomEvent<Record<string,unknown>>) => void} [onrecordmove]
200
+ * @property {(event: CustomEvent<Record<string,unknown>>) => void} [onmovereject]
103
201
  * @property {(event: CustomEvent<Record<string,unknown>>) => void} [onrecordaction]
202
+ * @property {(event: CustomEvent<Record<string,unknown>>) => void} [onrecordadd]
203
+ * @property {(event: CustomEvent<{search:string}>) => void} [onsearchchange]
204
+ * @property {(event: CustomEvent<{canUndo:boolean,canRedo:boolean,depth:{undo:number,redo:number}}>) => void} [onhistorychange]
104
205
  */
105
206
 
106
207
  /** @typedef {{record:KanbanRecord,id:unknown,index:number,column:string,lane:string|null}} KanbanCardMeta */
107
208
  /** @typedef {{column:string,lane:string|null,list:HTMLElement}} KanbanSectionMeta */
108
- /** @typedef {{id:unknown,from:KanbanMovePoint,to:KanbanMovePoint}} KanbanKeyboardMove */
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
+ */
109
222
 
110
223
  const INTERACTIVE = 'a, button, input, select, textarea, summary, [contenteditable="true"]';
111
224
 
225
+ /** Distance a pointer must travel before a press becomes a drag rather than a click. */
226
+ const DRAG_THRESHOLD = 4;
227
+
228
+ /** Delay before a touch press on a card body becomes a drag instead of a scroll. */
229
+ const TOUCH_HOLD = 350;
230
+
231
+ /** Keystrokes are coalesced for this long before the built-in search control rebuilds the board. */
232
+ const SEARCH_DEBOUNCE = 120;
233
+
234
+ /** Distance from a scroll edge at which a drag starts auto-scrolling, and the speed it uses. */
235
+ const SCROLL_EDGE = 56;
236
+ const SCROLL_SPEED = 18;
237
+
112
238
  /**
113
- * Configurable record board with semantic columns, optional swim lanes, and equivalent pointer and
114
- * keyboard movement. Local moves clone records; callback grouping remains fully usable for display
115
- * and external moves but cannot be written safely by local cross-axis moves.
239
+ * Every user-visible and announced string, so a host can translate the board without patching it.
240
+ * Values run through `printf`, so named placeholders may be reordered or dropped by a translation.
241
+ */
242
+ const DEFAULT_LABELS = Object.freeze({
243
+ fields: 'Fields',
244
+ search: 'Search board',
245
+ searchPlaceholder: 'Search cards',
246
+ clearSearch: 'Clear search',
247
+ searchSummary: '%count% of %total% cards match.',
248
+ undo: 'Undo',
249
+ redo: 'Redo',
250
+ add: 'Add card',
251
+ addTo: 'Add card to %target%',
252
+ move: 'Move %name%',
253
+ moveMultiple: 'Move %count% selected cards',
254
+ collapseColumn: 'Collapse column %label%',
255
+ expandColumn: 'Expand column %label%',
256
+ collapseLane: 'Collapse swim lane %label%',
257
+ expandLane: 'Expand swim lane %label%',
258
+ count: '%count% records',
259
+ countFiltered: '%count% of %total% records',
260
+ countLimit: '%count% records, work-in-progress limit %limit%',
261
+ countLimitInLane: '%count% records in this lane, %total% of work-in-progress limit %limit% in the column',
262
+ empty: 'No records',
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%',
271
+ selected: 'Selected',
272
+ notSelected: 'Not selected',
273
+ noMatches: 'No cards match the current search.',
274
+ record: 'record',
275
+ grabbed: 'Grabbed %name%. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.',
276
+ grabbedMultiple: 'Grabbed %count% cards. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.',
277
+ target: 'Target %target%, position %position% of %total%.',
278
+ moved: 'Moved %name% to %target%.%wip%',
279
+ movedMultiple: 'Moved %count% cards to %target%.%wip%',
280
+ moveRequested: 'Move requested for %name% to %target%.%wip%',
281
+ moveCanceled: 'Move canceled for %name%.',
282
+ moveCanceledPlain: 'Move canceled.',
283
+ limitReached: ' Work-in-progress limit %limit% reached.',
284
+ limitExceeded: ' Work-in-progress limit %limit% exceeded.',
285
+ rejectAccept: 'Move rejected: %target% does not accept %name%.',
286
+ rejectTransition: 'Move rejected: %target% does not accept cards from %origin%.',
287
+ rejectWip: 'Move rejected: %target% is at its work-in-progress limit of %limit%.',
288
+ rejectDestination: 'Move rejected: the destination is not available.',
289
+ rejectGrouping: 'Move rejected: callback grouping requires external move mode.',
290
+ undone: 'Move undone.',
291
+ redone: 'Move redone.',
292
+ nothingToUndo: 'Nothing to undo.',
293
+ nothingToRedo: 'Nothing to redo.',
294
+ menuMoveTo: 'Move to %target%',
295
+ menuMoveToLane: 'Move to lane %target%',
296
+ dragPreview: '%count% cards'
297
+ });
298
+
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
+ *
305
+ * Configurable record board with semantic columns, optional swim lanes, and equivalent pointer,
306
+ * touch, keyboard, context-menu, and programmatic movement. Local moves clone records; callback
307
+ * grouping remains fully usable for display and external moves but cannot be written safely by
308
+ * local cross-axis moves.
116
309
  * @fires KanbanView#recordmove
310
+ * @fires KanbanView#movereject
117
311
  * @fires KanbanView#recordaction
312
+ * @fires KanbanView#recordadd
313
+ * @fires KanbanView#searchchange
314
+ * @fires KanbanView#historychange
118
315
  * @extends {RecordView}
119
316
  */
120
317
  export class KanbanView extends RecordView {
@@ -123,6 +320,7 @@ export class KanbanView extends RecordView {
123
320
  /** @type {Readonly<KanbanViewOptions & RecordViewOptions>} */
124
321
  static defaults = {
125
322
  ...RecordView.defaults,
323
+ selectionTrigger: 'checkbox',
126
324
  columnBy: 'status',
127
325
  columns: null,
128
326
  swimlaneBy: null,
@@ -134,6 +332,38 @@ export class KanbanView extends RecordView {
134
332
  link: null,
135
333
  actions: [],
136
334
  moveMode: 'local',
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,
347
+ multiMove: true,
348
+ autoScroll: true,
349
+ wipPolicy: 'warn',
350
+ rules: [],
351
+ search: '',
352
+ searchFields: null,
353
+ filter: null,
354
+ searchControl: false,
355
+ history: true,
356
+ historyLimit: 50,
357
+ historyControls: false,
358
+ columnHeight: null,
359
+ allowAdd: false,
360
+ contextMenu: false,
361
+ renderCard: null,
362
+ renderColumnHeader: null,
363
+ renderSwimlaneHeader: null,
364
+ renderColumnEmpty: null,
365
+ renderDragPreview: null,
366
+ labels: {},
137
367
  columnOrder: [],
138
368
  swimlaneOrder: [],
139
369
  collapsedColumns: [],
@@ -170,16 +400,47 @@ export class KanbanView extends RecordView {
170
400
  this._kanbanSwimlaneOrder = normalizeOrder(options.swimlaneOrder);
171
401
  this._collapsedColumns = new Set(normalizeOrder(options.collapsedColumns));
172
402
  this._collapsedSwimlanes = new Set(normalizeOrder(options.collapsedSwimlanes));
403
+ this._kanbanRules = normalizeKanbanRules(options.rules);
404
+ this._kanbanSearch = options.search == null ? '' : String(options.search);
405
+ this._kanbanFilter = typeof options.filter === 'function' ? options.filter : null;
406
+ this._kanbanHistory = createKanbanHistory(options.historyLimit);
173
407
  this._keyboardMove = /** @type {KanbanKeyboardMove|null} */ (null);
174
- this._dragId = null;
408
+ this._dragIds = /** @type {unknown[]} */ ([]);
175
409
  this._dragDestination = /** @type {KanbanMovePoint|null} */ (null);
410
+ this._pointerStart = /** @type {{x:number,y:number,id:number,ids:unknown[],touch:boolean}|null} */ (null);
411
+ this._pointerHold = 0;
412
+ this._dragFrame = 0;
413
+ this._dragPreview = /** @type {HTMLElement|null} */ (null);
414
+ this._dragPointer = /** @type {number|null} */ (null);
415
+ this._dragCaptured = false;
416
+ this._searchTimer = 0;
417
+ this._dragPoint = { x: 0, y: 0 };
418
+ this._suppressClick = false;
176
419
  this._announcementToken = 0;
177
420
  this._cardMeta = new WeakMap();
178
421
  this._sectionMeta = new WeakMap();
179
422
  this._cardsById = new Map();
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);
430
+ this._kanbanMenu = /** @type {ContextMenu|null} */ (null);
431
+ this._searchInput = /** @type {HTMLInputElement|null} */ (null);
432
+ this._undoButton = /** @type {HTMLButtonElement|null} */ (null);
433
+ this._redoButton = /** @type {HTMLButtonElement|null} */ (null);
180
434
 
181
435
  this._toolbar = h('div', { class: 'zx-record-view__toolbar zx-kanban-view__toolbar' });
182
- if (options.fieldControls) this._toolbar.append(this._createViewFieldControls());
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
+ }
442
+ if (options.searchControl) this._toolbar.append(this._createSearchControl());
443
+ if (options.historyControls) this._toolbar.append(this._createHistoryControls());
183
444
  this._board = h('div', {
184
445
  class: 'zx-kanban-view__board', role: 'region', ariaLabel: options.label
185
446
  });
@@ -187,16 +448,24 @@ export class KanbanView extends RecordView {
187
448
  class: 'zx-kanban-view__live', role: 'status', ariaLive: 'polite', ariaAtomic: 'true'
188
449
  });
189
450
  root.replaceChildren(this._toolbar, this._board, this._live);
451
+ if (options.columnHeight) {
452
+ root.style.setProperty('--zx-kanban-column-height', String(options.columnHeight));
453
+ root.dataset.columnScroll = 'true';
454
+ }
190
455
 
191
456
  this.listen(this._board, 'click', (event) => this._handleBoardClick(/** @type {MouseEvent} */ (event)));
192
457
  this.listen(this._board, 'dblclick', (event) => this._handleBoardDoubleClick(/** @type {MouseEvent} */ (event)));
193
458
  this.listen(this._board, 'keydown', (event) => this._handleBoardKeydown(/** @type {KeyboardEvent} */ (event)));
194
459
  this.listen(this._board, 'error', (event) => this._handlePreviewError(event), { capture: true });
195
- this.listen(this._board, 'dragstart', (event) => this._handleDragStart(/** @type {DragEvent} */ (event)));
196
- this.listen(this._board, 'dragover', (event) => this._handleDragOver(/** @type {DragEvent} */ (event)));
197
- this.listen(this._board, 'drop', (event) => this._handleDrop(/** @type {DragEvent} */ (event)));
198
- this.listen(this._board, 'dragend', () => this._clearDrag());
460
+ this.listen(this._board, 'pointerdown', (event) => this._handlePointerDown(/** @type {PointerEvent} */ (event)));
461
+ this.listen(this._board, 'pointermove', (event) => this._handlePointerMove(/** @type {PointerEvent} */ (event)));
462
+ this.listen(this._board, 'pointerup', (event) => this._handlePointerUp(/** @type {PointerEvent} */ (event)));
463
+ this.listen(this._board, 'pointercancel', () => this._clearDrag());
464
+ // Native drag would fight the pointer path and cannot serve touch at all; suppress the
465
+ // browser's own text and image dragging so only one movement model is ever in flight.
466
+ this.listen(this._board, 'dragstart', (event) => event.preventDefault());
199
467
  this._refreshView('init');
468
+ if (options.contextMenu) this._createContextMenu();
200
469
  return root;
201
470
  } catch (error) {
202
471
  if (this._kanbanSnapshot) restoreTarget(root, this._kanbanSnapshot);
@@ -216,7 +485,13 @@ export class KanbanView extends RecordView {
216
485
  */
217
486
  setColumns(columns) {
218
487
  this._kanbanColumns = columns === null ? null : normalizeKanbanDescriptors(columns, 'column');
219
- this._kanbanColumnOrder = reconcileKanbanOrder(this._rawColumns(), this._kanbanColumnOrder);
488
+ const descriptors = this._rawColumns();
489
+ this._kanbanColumnOrder = reconcileKanbanPreference(
490
+ descriptors, this._kanbanColumnOrder, this._kanbanColumns === null);
491
+ if (this._kanbanColumns !== null) {
492
+ const ids = new Set(descriptors.map((column) => column.id));
493
+ this._collapsedColumns = new Set([...this._collapsedColumns].filter((id) => ids.has(id)));
494
+ }
220
495
  this._refreshView('columns');
221
496
  this._emitKanbanState('columns');
222
497
  return this;
@@ -234,7 +509,14 @@ export class KanbanView extends RecordView {
234
509
  */
235
510
  setSwimlanes(lanes) {
236
511
  this._kanbanSwimlanes = lanes === null ? null : normalizeKanbanDescriptors(lanes, 'swimlane');
237
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(this._rawSwimlanes(), this._kanbanSwimlaneOrder);
512
+ const descriptors = this._rawSwimlanes();
513
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
514
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(
515
+ descriptors, this._kanbanSwimlaneOrder, preserveUnknown);
516
+ if (!preserveUnknown) {
517
+ const ids = new Set(descriptors.map((lane) => lane.id));
518
+ this._collapsedSwimlanes = new Set([...this._collapsedSwimlanes].filter((id) => ids.has(id)));
519
+ }
238
520
  this._refreshView('swimlanes');
239
521
  this._emitKanbanState('swimlanes');
240
522
  return this;
@@ -252,9 +534,12 @@ export class KanbanView extends RecordView {
252
534
  * @returns {this}
253
535
  */
254
536
  setColumnOrder(order, options = {}) {
255
- const next = reconcileKanbanOrder(this._rawColumns(), order);
256
- if (sameArray(next, this.getColumnOrder())) return this;
257
- this._kanbanColumnOrder = next;
537
+ const descriptors = this._rawColumns();
538
+ const current = this.getColumnOrder();
539
+ const next = reconcileKanbanOrder(descriptors, order);
540
+ if (sameArray(next, current)) return this;
541
+ this._kanbanColumnOrder = mergeKanbanResolvedOrder(
542
+ this._kanbanColumnOrder, descriptors, next, this._kanbanColumns === null);
258
543
  this._refreshView('columnorder');
259
544
  if (!options.silent) this._emitKanbanState('columnorder');
260
545
  return this;
@@ -283,9 +568,13 @@ export class KanbanView extends RecordView {
283
568
  * @returns {this}
284
569
  */
285
570
  setSwimlaneOrder(order, options = {}) {
286
- const next = reconcileKanbanOrder(this._rawSwimlanes(), order);
287
- if (sameArray(next, this.getSwimlaneOrder())) return this;
288
- this._kanbanSwimlaneOrder = next;
571
+ const descriptors = this._rawSwimlanes();
572
+ const current = this.getSwimlaneOrder();
573
+ const next = reconcileKanbanOrder(descriptors, order);
574
+ if (sameArray(next, current)) return this;
575
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
576
+ this._kanbanSwimlaneOrder = mergeKanbanResolvedOrder(
577
+ this._kanbanSwimlaneOrder, descriptors, next, preserveUnknown);
289
578
  this._refreshView('swimlaneorder');
290
579
  if (!options.silent) this._emitKanbanState('swimlaneorder');
291
580
  return this;
@@ -361,19 +650,38 @@ export class KanbanView extends RecordView {
361
650
  }
362
651
 
363
652
  /**
364
- * Proposes and, in local mode, commits a record move. Destination acceptance is checked before
365
- * a cancelable `recordmove` event. WIP limits are reported but remain advisory.
653
+ * Proposes and, in local mode, commits a record move. Equivalent to `moveRecords([id], …)`.
366
654
  * @param {unknown} id Record id.
367
655
  * @param {KanbanDestination} destination Destination.
368
656
  * @returns {this}
369
657
  * @fires KanbanView#recordmove
658
+ * @fires KanbanView#movereject
370
659
  * @fires RecordView#datachange
371
660
  */
372
661
  moveRecord(id, destination) {
662
+ return this.moveRecords([id], destination);
663
+ }
664
+
665
+ /**
666
+ * Proposes and, in local mode, commits an atomic move of one or more records. Destination
667
+ * eligibility, transition rules, and work-in-progress policy are all checked before a single
668
+ * cancelable `recordmove` event; the move commits completely or not at all, and the moving
669
+ * records keep their relative order at the destination.
670
+ * @param {unknown[]} ids Record ids, in any order.
671
+ * @param {KanbanDestination} destination Destination.
672
+ * @returns {this}
673
+ * @fires KanbanView#recordmove
674
+ * @fires KanbanView#movereject
675
+ * @fires RecordView#datachange
676
+ */
677
+ moveRecords(ids, destination) {
373
678
  const options = this._kanbanOptions();
374
- const record = this.getRecord(id);
375
- const from = this._recordLocation(id);
376
- if (!record || !from || !destination || typeof destination !== 'object') return this;
679
+ if (!Array.isArray(ids) || !destination || typeof destination !== 'object') return this;
680
+ const moving = this._orderedIds(ids);
681
+ if (!moving.length) return this;
682
+ const records = moving.map((id) => /** @type {KanbanRecord} */ (this.getRecord(id)));
683
+ const origins = moving.map((id) => /** @type {KanbanMovePoint} */ (this._recordLocation(id)));
684
+ const from = origins[0];
377
685
  const columns = this._resolvedColumns();
378
686
  const lanes = this._resolvedSwimlanes();
379
687
  const columnId = destination.column == null ? from.column : String(destination.column);
@@ -381,35 +689,42 @@ export class KanbanView extends RecordView {
381
689
  ? null : destination.lane === undefined ? from.lane : normalizeAxisId(destination.lane);
382
690
  const column = columns.find((candidate) => candidate.id === columnId);
383
691
  const lane = options.swimlaneBy == null
384
- ? null : lanes.find((candidate) => candidate.id === laneId);
692
+ ? null : lanes.find((candidate) => candidate.id === laneId) ?? null;
385
693
  if (!column || options.swimlaneBy != null && !lane) {
386
- this._announce('Move rejected: the destination is not available.');
694
+ this._rejectMove('destination', { ids: moving, records, from, to: null, column: null, swimlane: null });
387
695
  return this;
388
696
  }
389
- const destinationCount = this._recordsIn(columnId, laneId)
390
- .filter((candidate) => !Object.is(this._viewRecordId(candidate.record), id)).length;
391
- const destinationColumnCount = this._viewData.filter((candidate, candidateIndex) =>
392
- normalizeAxisId(this._readAxis(options.columnBy, candidate, candidateIndex)) === columnId
393
- && !Object.is(this._viewRecordId(candidate), id)).length;
394
- const index = normalizeMoveIndex(destination.index, destinationCount);
697
+
698
+ const excluded = new Set(moving);
699
+ const cellCount = this._recordsIn(columnId, laneId)
700
+ .filter((entry) => !excluded.has(this._viewRecordId(entry.record))).length;
701
+ const index = clampKanbanIndex(destination.index, cellCount);
395
702
  const to = { column: columnId, lane: laneId, index };
396
- if (sameMovePoint(from, to)) return this;
397
- const context = { id, from: { ...from }, to: { ...to }, column: { ...column }, swimlane: lane ? { ...lane } : null };
398
- if (column.accept && column.accept(record, context) === false
399
- || lane?.accept && lane.accept(record, context) === false) {
400
- this._announce(`Move rejected for ${this._recordName(record, from.index)}.`);
703
+ if (moving.length === 1 && sameMovePoint(from, to)) return this;
704
+ const context = {
705
+ id: moving[0], from: { ...from }, to: { ...to },
706
+ column: { ...column }, swimlane: lane ? { ...lane } : null
707
+ };
708
+ const evaluation = this._evaluateMove(records, origins, column, lane, to, context, excluded);
709
+ if (!evaluation.allowed) {
710
+ this._rejectMove(/** @type {KanbanRejectReason} */ (evaluation.reason), {
711
+ ids: moving, records, from, to, column, swimlane: lane, evaluation
712
+ });
401
713
  return this;
402
714
  }
403
715
 
404
- let movedRecord = record;
405
- if (options.moveMode === 'local') {
406
- movedRecord = this._localMovedRecord(record, from, to, column, lane);
407
- if (!movedRecord) {
408
- this._announce('Move rejected: callback grouping requires external move mode.');
716
+ const entries = [];
717
+ for (let position = 0; position < moving.length; position += 1) {
718
+ const record = records[position];
719
+ const moved = options.moveMode === 'local'
720
+ ? this._localMovedRecord(record, origins[position], to, column, lane) : record;
721
+ if (!moved) {
722
+ this._rejectMove('grouping', { ids: moving, records, from, to, column, swimlane: lane });
409
723
  return this;
410
724
  }
725
+ entries.push({ id: moving[position], record: moved });
411
726
  }
412
- const next = reorderKanbanData(this._viewData, id, movedRecord, {
727
+ const next = reorderKanbanRecords(this._viewData, entries, {
413
728
  recordId: options.recordId,
414
729
  columnBy: (candidate, candidateIndex) => this._readAxis(options.columnBy, candidate, candidateIndex),
415
730
  swimlaneBy: options.swimlaneBy == null ? null
@@ -417,39 +732,161 @@ export class KanbanView extends RecordView {
417
732
  destination: to
418
733
  });
419
734
  if (!next) {
420
- this._announce('Move rejected: the destination could not be resolved.');
735
+ this._rejectMove('destination', { ids: moving, records, from, to, column, swimlane: lane });
421
736
  return this;
422
737
  }
423
- const finalCount = destinationColumnCount + 1;
424
- const limitExceeded = column.limit != null && finalCount > column.limit;
738
+
739
+ const wip = this._wipAnnouncement(evaluation);
425
740
  const moveEvent = this.emit('recordmove', {
426
- record, id, from: { ...from }, to: { ...next.to }, column: { ...column },
427
- swimlane: lane ? { ...lane } : null, limitExceeded
428
- });
741
+ record: records[0], id: moving[0], records: [...records], ids: [...moving],
742
+ moves: next.moves.map((move) => ({ id: move.id, from: { ...move.from } })),
743
+ from: { ...from }, to: { ...next.to }, column: { ...column },
744
+ swimlane: lane ? { ...lane } : null, limitExceeded: evaluation.limitExceeded
745
+ }, { honorDomCancellation: true });
429
746
  if (moveEvent.defaultPrevented) {
430
- this._announce(`Move canceled for ${this._recordName(record, from.index)}.`);
747
+ this._announce(this._text('moveCanceled', { name: this._movingName(records) }));
431
748
  return this;
432
749
  }
433
750
  if (options.moveMode === 'external') {
434
- this._announce(`Move requested for ${this._recordName(record, from.index)} to ${this._targetName(next.to)}.`);
751
+ this._announce(this._text('moveRequested', {
752
+ name: this._movingName(records), target: this._targetName(next.to), wip
753
+ }));
435
754
  return this;
436
755
  }
437
- this._viewData = next.records;
438
- this._refreshView('move');
439
- this.emit('datachange', { records: this.getData() });
440
- this._announce(`Moved ${this._recordName(movedRecord, next.to.index)} to ${this._targetName(next.to)}.`);
441
- queueMicrotask(() => this._moveHandle(id)?.focus());
756
+ this._commitLocalRecords(next.records);
757
+ this._announce(moving.length > 1
758
+ ? this._text('movedMultiple', { count: moving.length, target: this._targetName(next.to), wip })
759
+ : this._text('moved', {
760
+ name: this._recordName(entries[0].record, next.to.index), target: this._targetName(next.to), wip
761
+ }));
762
+ queueMicrotask(() => this._moveHandle(moving[0])?.focus());
442
763
  return this;
443
764
  }
444
765
 
766
+ /** Whether a committed local move can be reverted. @returns {boolean} */
767
+ canUndo() {
768
+ return this._kanbanOptions().history !== false && this._kanbanHistory.canUndo();
769
+ }
770
+
771
+ /** Whether a reverted local move can be reapplied. @returns {boolean} */
772
+ canRedo() {
773
+ return this._kanbanOptions().history !== false && this._kanbanHistory.canRedo();
774
+ }
775
+
776
+ /**
777
+ * Reverts the last committed local move, restoring the exact record order and selection.
778
+ * @returns {this}
779
+ * @fires RecordView#datachange
780
+ * @fires KanbanView#historychange
781
+ */
782
+ undo() {
783
+ return this._travelHistory('undo');
784
+ }
785
+
786
+ /**
787
+ * Reapplies the last reverted local move.
788
+ * @returns {this}
789
+ * @fires RecordView#datachange
790
+ * @fires KanbanView#historychange
791
+ */
792
+ redo() {
793
+ return this._travelHistory('redo');
794
+ }
795
+
796
+ /** Empties both history stacks. @returns {this} @fires KanbanView#historychange */
797
+ clearHistory() {
798
+ this._kanbanHistory.clear();
799
+ this._syncHistoryControls();
800
+ this._emitHistory();
801
+ return this;
802
+ }
803
+
804
+ /** Returns the current board search text. @returns {string} */
805
+ getSearch() {
806
+ return this._kanbanSearch;
807
+ }
808
+
809
+ /**
810
+ * Narrows visible cards to those matching a search. Hidden records keep their position in the
811
+ * data, so a card dropped between two visible cards still lands between them.
812
+ * @param {string} search Search text.
813
+ * @param {{silent?:boolean}} [options={}] Event behavior.
814
+ * @returns {this}
815
+ * @fires KanbanView#searchchange
816
+ */
817
+ setSearch(search, options = {}) {
818
+ const next = search == null ? '' : String(search);
819
+ if (next === this._kanbanSearch) return this;
820
+ this._kanbanSearch = next;
821
+ if (this._searchInput && this._searchInput.value !== next) this._searchInput.value = next;
822
+ this._refreshView('search');
823
+ if (!options.silent) this.emit('searchchange', { search: next });
824
+ return this;
825
+ }
826
+
827
+ /**
828
+ * Replaces the extra card predicate. Pass null to clear it.
829
+ * @param {((record:KanbanRecord,index:number)=>boolean)|null} filter Predicate.
830
+ * @returns {this}
831
+ */
832
+ setFilter(filter) {
833
+ if (filter != null && typeof filter !== 'function') {
834
+ throw new TypeError('Kanban filter must be a function or null');
835
+ }
836
+ this._kanbanFilter = filter ?? null;
837
+ this._refreshView('filter');
838
+ return this;
839
+ }
840
+
841
+ /** Returns the records currently rendered, in board order. @returns {KanbanRecord[]} */
842
+ getVisibleRecords() {
843
+ return this._viewData.filter((record) => this._visibleIds.has(this._viewRecordId(record)));
844
+ }
845
+
846
+ /**
847
+ * Replaces every record. History is dropped because its snapshots describe records the host has
848
+ * just replaced, and reinstating them would resurrect data the application no longer owns.
849
+ * @param {KanbanRecord[]} records New records.
850
+ * @returns {this}
851
+ */
852
+ setData(records) {
853
+ this._resetHistory();
854
+ return super.setData(records);
855
+ }
856
+
857
+ /** @param {KanbanRecord[]} records Records to append. @returns {this} */
858
+ addData(records) {
859
+ this._resetHistory();
860
+ return super.addData(records);
861
+ }
862
+
863
+ /** @param {unknown} id Existing record id. @param {KanbanRecord} record Replacement. @returns {this} */
864
+ updateRecord(id, record) {
865
+ this._resetHistory();
866
+ return super.updateRecord(id, record);
867
+ }
868
+
869
+ /** @param {unknown} id Record id. @returns {this} */
870
+ removeRecord(id) {
871
+ this._resetHistory();
872
+ return super.removeRecord(id);
873
+ }
874
+
445
875
  /** Returns common plus board-specific serializable configuration. @returns {KanbanViewState} */
446
876
  getViewState() {
877
+ // Resolve once so newly derived axes join the preference without discarding saved axes that are
878
+ // temporarily absent from an empty or partial result set.
879
+ this.getColumnOrder();
880
+ this.getSwimlaneOrder();
881
+ const derivedColumns = this._kanbanColumns === null;
882
+ const derivedSwimlanes = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
447
883
  return {
448
884
  ...super.getViewState(),
449
- columnOrder: this.getColumnOrder(),
450
- swimlaneOrder: this.getSwimlaneOrder(),
451
- collapsedColumns: this.getCollapsedColumns(),
452
- collapsedSwimlanes: this.getCollapsedSwimlanes()
885
+ columnOrder: derivedColumns ? [...this._kanbanColumnOrder] : this.getColumnOrder(),
886
+ swimlaneOrder: derivedSwimlanes ? [...this._kanbanSwimlaneOrder] : this.getSwimlaneOrder(),
887
+ collapsedColumns: derivedColumns ? [...this._collapsedColumns] : this.getCollapsedColumns(),
888
+ collapsedSwimlanes: derivedSwimlanes
889
+ ? [...this._collapsedSwimlanes] : this.getCollapsedSwimlanes()
453
890
  };
454
891
  }
455
892
 
@@ -462,17 +899,25 @@ export class KanbanView extends RecordView {
462
899
  setViewState(state, options = {}) {
463
900
  const source = state && typeof state === 'object' ? state : {};
464
901
  super.setViewState(source, { silent: true });
465
- this._kanbanColumnOrder = reconcileKanbanOrder(
466
- this._rawColumns(), Array.isArray(source.columnOrder) ? source.columnOrder : this._kanbanColumnOrder);
467
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(
468
- this._rawSwimlanes(), Array.isArray(source.swimlaneOrder) ? source.swimlaneOrder : this._kanbanSwimlaneOrder);
469
- const columnIds = new Set(this._rawColumns().map((column) => column.id));
470
- const laneIds = new Set(this._rawSwimlanes().map((lane) => lane.id));
902
+ const columns = this._rawColumns();
903
+ const lanes = this._rawSwimlanes();
904
+ const derivedColumns = this._kanbanColumns === null;
905
+ const derivedSwimlanes = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
906
+ this._kanbanColumnOrder = reconcileKanbanPreference(columns,
907
+ Array.isArray(source.columnOrder) ? source.columnOrder : this._kanbanColumnOrder,
908
+ derivedColumns);
909
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(lanes,
910
+ Array.isArray(source.swimlaneOrder) ? source.swimlaneOrder : this._kanbanSwimlaneOrder,
911
+ derivedSwimlanes);
912
+ const columnIds = new Set(columns.map((column) => column.id));
913
+ const laneIds = new Set(lanes.map((lane) => lane.id));
471
914
  if (Array.isArray(source.collapsedColumns)) {
472
- this._collapsedColumns = new Set(source.collapsedColumns.map(String).filter((id) => columnIds.has(id)));
915
+ const collapsed = normalizeOrder(source.collapsedColumns);
916
+ this._collapsedColumns = new Set(derivedColumns ? collapsed : collapsed.filter((id) => columnIds.has(id)));
473
917
  }
474
918
  if (Array.isArray(source.collapsedSwimlanes)) {
475
- this._collapsedSwimlanes = new Set(source.collapsedSwimlanes.map(String).filter((id) => laneIds.has(id)));
919
+ const collapsed = normalizeOrder(source.collapsedSwimlanes);
920
+ this._collapsedSwimlanes = new Set(derivedSwimlanes ? collapsed : collapsed.filter((id) => laneIds.has(id)));
476
921
  }
477
922
  this._refreshView('state');
478
923
  if (!options.silent) this._emitKanbanState('restore');
@@ -483,6 +928,11 @@ export class KanbanView extends RecordView {
483
928
  destroy() {
484
929
  if (this._kanbanDestroyed) return;
485
930
  this._kanbanDestroyed = true;
931
+ this._clearDrag();
932
+ clearTimeout(this._pointerHold);
933
+ clearTimeout(this._searchTimer);
934
+ this._kanbanMenu?.destroy();
935
+ this._kanbanMenu = null;
486
936
  const root = this.el;
487
937
  const snapshot = this._kanbanSnapshot;
488
938
  super.destroy();
@@ -498,25 +948,56 @@ export class KanbanView extends RecordView {
498
948
  }
499
949
  if (_reason === 'loading') return;
500
950
  const options = this._kanbanOptions();
951
+ const activeElement = document.activeElement;
501
952
  const activeMeta = this._metaForTarget(document.activeElement);
502
953
  const restoreHandle = Boolean(document.activeElement?.classList?.contains('zx-kanban-view__move-handle'));
954
+ const activeCollapse = activeElement instanceof Element
955
+ ? activeElement.closest('[data-kanban-collapse]') : null;
956
+ const activeSection = activeCollapse?.closest('.zx-kanban-view__column');
957
+ const activeCollapseFocus = activeCollapse && this._board.contains(activeCollapse) ? {
958
+ axis: /** @type {'column'|'swimlane'} */ (/** @type {HTMLElement} */ (activeCollapse).dataset.kanbanCollapse),
959
+ id: String(/** @type {HTMLElement} */ (activeCollapse).dataset.kanbanId),
960
+ lane: activeSection ? this._sectionMeta.get(activeSection)?.lane ?? null : null
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;
503
965
  const columns = this._resolvedColumns();
504
966
  const lanes = this._resolvedSwimlanes();
967
+ this._resolveCardSources();
505
968
  this._cardMeta = new WeakMap();
506
969
  this._sectionMeta = new WeakMap();
507
970
  this._cardsById = new Map();
971
+ this._resolveVisibility();
508
972
  const content = [];
509
973
  if (options.swimlaneBy == null) {
510
974
  content.push(this._renderColumns(columns, null, false));
511
975
  } else {
512
976
  lanes.forEach((lane) => content.push(this._renderLane(lane, columns)));
513
977
  }
514
- const shownCards = this._cardsById.size;
515
- if (shownCards === 0 && !this._viewData.length) content.push(this._emptyContent());
978
+ if (!this._viewData.length) content.push(this._emptyContent(this._text('empty')));
979
+ else if (!this._visibleIds.size) content.push(this._emptyContent(this._text('noMatches')));
516
980
  this._board.replaceChildren(...content);
517
- if (this._keyboardMove && !this.getRecord(this._keyboardMove.id)) this._keyboardMove = null;
981
+ if (this._keyboardMove) {
982
+ this._keyboardMove.ids = this._keyboardMove.ids.filter((id) => this.getRecord(id));
983
+ if (!this._keyboardMove.ids.length) this._keyboardMove = null;
984
+ }
518
985
  this._syncKeyboardTarget();
519
- if (activeMeta) queueMicrotask(() => {
986
+ if (activeCollapseFocus || activeMeta) queueMicrotask(() => {
987
+ if (activeCollapseFocus) {
988
+ this._collapseControl(activeCollapseFocus.axis, activeCollapseFocus.id,
989
+ activeCollapseFocus.lane)?.focus();
990
+ return;
991
+ }
992
+ if (!activeMeta) return;
993
+ if (activeMeta.lane != null && this._collapsedSwimlanes.has(activeMeta.lane)) {
994
+ this._collapseControl('swimlane', activeMeta.lane)?.focus();
995
+ return;
996
+ }
997
+ if (this._collapsedColumns.has(activeMeta.column)) {
998
+ this._collapseControl('column', activeMeta.column, activeMeta.lane)?.focus();
999
+ return;
1000
+ }
520
1001
  const card = this._cardsById.get(activeMeta.id);
521
1002
  const target = restoreHandle ? card?.querySelector('.zx-kanban-view__move-handle') : card;
522
1003
  if (target?.isConnected) /** @type {HTMLElement} */ (target).focus();
@@ -529,21 +1010,29 @@ export class KanbanView extends RecordView {
529
1010
  const collapsed = this._collapsedSwimlanes.has(lane.id);
530
1011
  const headingId = uid('zx-kanban-lane');
531
1012
  const bodyId = uid('zx-kanban-lane-body');
532
- const count = this._viewData.filter((record, index) =>
533
- normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === lane.id).length;
1013
+ const entries = this._viewData.filter((record, index) =>
1014
+ normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === lane.id);
1015
+ const total = entries.length;
1016
+ const count = entries.filter((record) => this._visibleIds.has(this._viewRecordId(record))).length;
534
1017
  const toggle = h('button', {
535
1018
  class: 'zx-kanban-view__collapse', type: 'button', ariaExpanded: String(!collapsed),
536
- ariaControls: bodyId, ariaLabel: `${collapsed ? 'Expand' : 'Collapse'} swim lane ${lane.label}`,
1019
+ ariaControls: bodyId, ariaLabel: this._text(collapsed ? 'expandLane' : 'collapseLane', { label: lane.label }),
537
1020
  dataset: { kanbanCollapse: 'swimlane', kanbanId: lane.id }
538
1021
  }, icon(collapsed ? 'chevron-right' : 'chevron-down', { size: 13 }));
539
- const heading = h('h2', { class: 'zx-kanban-view__lane-heading', id: headingId },
540
- toggle, h('span', {}, lane.label), options.showCounts
541
- ? h('span', { class: 'zx-kanban-view__count', ariaLabel: `${count} records` }, String(count)) : null);
1022
+ const limit = lane.limit ?? null;
1023
+ const wip = wipState(total, limit);
1024
+ const custom = this._renderHook('renderSwimlaneHeader', {
1025
+ swimlane: { ...lane }, count, total, columnTotal: total, limit, wip, collapsed
1026
+ });
1027
+ const heading = h('h2', { class: 'zx-kanban-view__lane-heading', id: headingId }, toggle,
1028
+ custom ?? [h('span', {}, lane.label),
1029
+ options.showCounts || limit != null ? this._countIndicator(count, total, limit) : null]);
542
1030
  const body = this._renderColumns(columns, lane, true);
543
1031
  body.id = bodyId;
544
1032
  body.hidden = collapsed;
545
1033
  return h('section', {
546
- class: 'zx-kanban-view__lane', ariaLabelledby: headingId, dataset: { collapsed: String(collapsed) }
1034
+ class: 'zx-kanban-view__lane', ariaLabelledby: headingId,
1035
+ dataset: { collapsed: String(collapsed), wip }
547
1036
  }, heading, body);
548
1037
  }
549
1038
 
@@ -571,34 +1060,51 @@ export class KanbanView extends RecordView {
571
1060
  const collapsed = this._collapsedColumns.has(column.id);
572
1061
  const headingId = uid('zx-kanban-column');
573
1062
  const listId = uid('zx-kanban-column-list');
574
- const columnCount = this._viewData.filter((record, index) =>
575
- normalizeAxisId(this._readAxis(options.columnBy, record, index)) === column.id).length;
576
- const wip = column.limit == null ? 'none'
577
- : columnCount > column.limit ? 'exceeded' : columnCount === column.limit ? 'limit' : 'within';
1063
+ const columnEntries = this._viewData.filter((record, index) =>
1064
+ normalizeAxisId(this._readAxis(options.columnBy, record, index)) === column.id);
1065
+ const columnTotal = columnEntries.length;
1066
+ const visible = entries.filter((entry) => this._visibleIds.has(this._viewRecordId(entry.record)));
1067
+ const laneLimit = lane ? readColumnLaneLimit(column, lane.id) : null;
1068
+ const limit = laneLimit ?? column.limit ?? null;
1069
+ const wip = wipState(laneLimit == null ? columnTotal : entries.length, limit);
578
1070
  const toggle = h('button', {
579
1071
  class: 'zx-kanban-view__collapse', type: 'button', ariaExpanded: String(!collapsed),
580
- ariaControls: listId, ariaLabel: `${collapsed ? 'Expand' : 'Collapse'} column ${column.label}`,
1072
+ ariaControls: listId, ariaLabel: this._text(collapsed ? 'expandColumn' : 'collapseColumn', { label: column.label }),
581
1073
  dataset: { kanbanCollapse: 'column', kanbanId: column.id }
582
1074
  }, icon(collapsed ? 'chevron-right' : 'chevron-down', { size: 13 }));
583
- const indicator = options.showCounts || column.limit != null ? h('span', {
584
- class: 'zx-kanban-view__count',
585
- ariaLabel: column.limit == null ? `${entries.length} records`
586
- : hasLanes ? `${entries.length} records in this lane, ${columnCount} of work-in-progress limit ${column.limit} in the column`
587
- : `${columnCount} records, work-in-progress limit ${column.limit}`
588
- }, column.limit == null ? String(entries.length)
589
- : hasLanes ? `${entries.length} · ${columnCount} / ${column.limit}` : `${columnCount} / ${column.limit}`) : null;
1075
+ const custom = this._renderHook('renderColumnHeader', {
1076
+ column: { ...column }, swimlane: lane ? { ...lane } : null,
1077
+ count: visible.length, total: entries.length, columnTotal, limit, wip, collapsed
1078
+ });
1079
+ const indicator = options.showCounts || limit != null
1080
+ ? this._columnIndicator(visible.length, entries.length, columnTotal, limit, hasLanes && laneLimit == null)
1081
+ : null;
590
1082
  const heading = h(hasLanes ? 'h3' : 'h2', {
591
1083
  class: 'zx-kanban-view__column-heading', id: headingId
592
- }, toggle, h('span', { class: 'zx-kanban-view__column-label' }, column.label), indicator);
1084
+ }, toggle, custom ?? [h('span', { class: 'zx-kanban-view__column-label' }, column.label), indicator]);
593
1085
  const list = h('ul', {
594
1086
  class: 'zx-kanban-view__cards', id: listId, ariaLabel: `${column.label}${lane ? `, ${lane.label}` : ''}`
595
1087
  });
596
- entries.forEach((entry) => list.append(this._renderCard(entry.record, entry.index, column.id, lane?.id ?? null, hasLanes)));
1088
+ visible.forEach((entry) =>
1089
+ list.append(this._renderCard(entry.record, entry.index, column.id, lane?.id ?? null, hasLanes)));
1090
+ if (!visible.length) {
1091
+ const placeholder = this._renderHook('renderColumnEmpty', {
1092
+ column: { ...column }, swimlane: lane ? { ...lane } : null,
1093
+ count: 0, total: entries.length, columnTotal, limit, wip, collapsed
1094
+ }) ?? this._placeholderText();
1095
+ if (placeholder) list.append(h('li', { class: 'zx-kanban-view__column-empty', role: 'presentation' }, placeholder));
1096
+ }
597
1097
  list.hidden = collapsed;
1098
+ const add = this._allowsAdd(column, lane) ? h('button', {
1099
+ class: 'zx-kanban-view__add', type: 'button',
1100
+ ariaLabel: this._text('addTo', { target: `${column.label}${lane ? `, ${lane.label}` : ''}` }),
1101
+ dataset: { kanbanAdd: column.id, kanbanLane: lane?.id ?? '' }
1102
+ }, icon('plus', { size: 13 }), h('span', {}, this._text('add'))) : null;
1103
+ if (add) add.hidden = collapsed;
598
1104
  const section = h('section', {
599
1105
  class: 'zx-kanban-view__column', ariaLabelledby: headingId,
600
1106
  dataset: { column: column.id, collapsed: String(collapsed), wip }
601
- }, heading, list);
1107
+ }, heading, list, add);
602
1108
  this._sectionMeta.set(section, { column: column.id, lane: lane?.id ?? null, list });
603
1109
  return section;
604
1110
  }
@@ -607,14 +1113,24 @@ export class KanbanView extends RecordView {
607
1113
  _renderCard(record, index, column, lane, hasLanes) {
608
1114
  const options = this._kanbanOptions();
609
1115
  const id = this._viewRecordId(record);
610
- const grabbed = Boolean(this._keyboardMove && Object.is(this._keyboardMove.id, id));
1116
+ const grabbed = Boolean(this._keyboardMove?.ids.some((candidate) => Object.is(candidate, id)));
1117
+ const rules = resolveKanbanRules(this._kanbanRules, record, { index, column, lane });
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';
611
1122
  const handle = h('button', {
612
- class: 'zx-kanban-view__move-handle', type: 'button', draggable: true,
613
- ariaPressed: String(grabbed), ariaLabel: `Move ${this._recordName(record, index)}`
1123
+ class: 'zx-kanban-view__move-handle', type: 'button',
1124
+ ariaPressed: String(grabbed),
1125
+ dataset: { reveal },
1126
+ ariaLabel: multiple > 1
1127
+ ? this._text('moveMultiple', { count: multiple })
1128
+ : this._text('move', { name: this._recordName(record, index) })
614
1129
  }, icon('drag', { size: 13 }));
1130
+ const cardFields = this._resolvedCardFields();
615
1131
  const card = createRecordCard(record, index, {
616
- fields: this.getFields(),
617
- visibleFields: this.getVisibleFields(),
1132
+ fields: cardFields.all,
1133
+ visibleFields: cardFields.metadata,
618
1134
  titleField: options.titleField,
619
1135
  subtitleField: options.subtitleField,
620
1136
  preview: options.preview ?? undefined,
@@ -622,6 +1138,7 @@ export class KanbanView extends RecordView {
622
1138
  link: options.link ?? undefined,
623
1139
  actions: options.actions,
624
1140
  selectable: options.selectable,
1141
+ selectionTrigger: options.selectionTrigger,
625
1142
  selected: this._isViewSelected(id),
626
1143
  variant: options.variant,
627
1144
  headingLevel: hasLanes ? 4 : 3,
@@ -629,20 +1146,262 @@ export class KanbanView extends RecordView {
629
1146
  });
630
1147
  card.tabIndex = 0;
631
1148
  if (grabbed) card.dataset.grabbed = 'true';
1149
+ this._applyCardContent(card, { record, id, index, column, lane, rules, selected: this._isViewSelected(id) });
1150
+ this._applyCardRules(card, rules);
1151
+ this._applyCardAnatomy(card, record, index);
1152
+ this._applyCardDescription(card, id);
632
1153
  const meta = { record, id, index, column, lane };
633
1154
  this._cardMeta.set(card, meta);
634
1155
  this._cardsById.set(id, card);
635
1156
  return card;
636
1157
  }
637
1158
 
638
- /** @returns {HTMLElement} */
639
- _emptyContent() {
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
+
1290
+ /**
1291
+ * Hands card content to `renderCard` while keeping the managed shell: the list item, its
1292
+ * selection control, the action group, and the move handle are never given away, because they
1293
+ * carry the drop, focus, and movement semantics the board is responsible for.
1294
+ * @param {HTMLElement} card Card built from the shared anatomy.
1295
+ * @param {KanbanCardContext} context Card context.
1296
+ * @returns {void}
1297
+ */
1298
+ _applyCardContent(card, context) {
1299
+ const custom = this._renderHook('renderCard', context);
1300
+ if (!custom) return;
1301
+ const body = card.querySelector('.zx-record-card__body');
1302
+ const titles = card.querySelector('.zx-record-card__titles');
1303
+ if (!body || !titles) return;
1304
+ titles.replaceChildren(custom);
1305
+ body.querySelector('.zx-record-card__eyebrow')?.remove();
1306
+ body.querySelector('.zx-record-card__metadata')?.remove();
1307
+ // The generated heading carried the accessible name; a replaced body must supply its own.
1308
+ card.removeAttribute('aria-labelledby');
1309
+ card.setAttribute('aria-label', this._recordName(context.record, context.index));
1310
+ card.dataset.customCard = 'true';
1311
+ }
1312
+
1313
+ /**
1314
+ * Applies ordered card rules. The first match owns the marker tone; every match with a label adds
1315
+ * a badge, and rule descriptions join the accessible description so the marker is never the only
1316
+ * carrier of the meaning.
1317
+ * @param {HTMLElement} card Card element.
1318
+ * @param {KanbanRule[]} rules Matching rules.
1319
+ * @returns {void}
1320
+ */
1321
+ _applyCardRules(card, rules) {
1322
+ if (!rules.length) return;
1323
+ card.dataset.rule = rules.map((rule) => rule.id).join(' ');
1324
+ card.dataset.ruleTone = rules[0].tone;
1325
+ const badges = rules.filter((rule) => rule.label);
1326
+ if (badges.length) {
1327
+ const list = h('p', { class: 'zx-kanban-view__rules' }, badges.map((rule) => badge({
1328
+ label: rule.label, icon: rule.icon ?? undefined, kind: rule.tone, size: 'sm', variant: 'soft'
1329
+ })));
1330
+ card.querySelector('.zx-record-card__body')?.append(list);
1331
+ }
1332
+ const descriptions = rules.map((rule) => rule.description).filter(Boolean).join('. ');
1333
+ if (descriptions) card.dataset.ruleDescription = descriptions;
1334
+ }
1335
+
1336
+ /**
1337
+ * Composes one card's accessible description from its selection state and its rules, so that
1338
+ * refreshing selection cannot quietly drop what a rule contributed.
1339
+ * @param {HTMLElement} card Card element.
1340
+ * @param {unknown} id Record id.
1341
+ * @returns {void}
1342
+ */
1343
+ _applyCardDescription(card, id) {
1344
+ const parts = [];
1345
+ if (this.options.selectable) {
1346
+ parts.push(this._text(this._isViewSelected(id) ? 'selected' : 'notSelected'));
1347
+ }
1348
+ if (card.dataset.ruleDescription) parts.push(card.dataset.ruleDescription);
1349
+ if (parts.length) card.setAttribute('aria-description', parts.join('. '));
1350
+ else card.removeAttribute('aria-description');
1351
+ }
1352
+
1353
+ /** @param {string} text Board-level empty message. @returns {HTMLElement} */
1354
+ _emptyContent(text) {
640
1355
  const supplied = typeof this.options.emptyText === 'function' ? this.options.emptyText() : this.options.emptyText;
641
- const content = supplied ?? 'No records';
1356
+ const content = supplied ?? text;
642
1357
  return h('div', { class: 'zx-kanban-view__empty', role: 'status' },
643
1358
  content && typeof content === 'object' && 'nodeType' in content ? /** @type {Node} */ (content) : String(content));
644
1359
  }
645
1360
 
1361
+ /** @returns {string} Per-column placeholder text, or an empty string for none. */
1362
+ _placeholderText() {
1363
+ return this._text('columnEmpty');
1364
+ }
1365
+
1366
+ /**
1367
+ * @param {number} count Visible records.
1368
+ * @param {number} total Records before search and filters.
1369
+ * @param {number|null} limit Applicable limit.
1370
+ * @returns {HTMLElement}
1371
+ */
1372
+ _countIndicator(count, total, limit) {
1373
+ const filtered = count !== total;
1374
+ return h('span', {
1375
+ class: 'zx-kanban-view__count',
1376
+ ariaLabel: limit != null ? this._text('countLimit', { count: total, limit })
1377
+ : filtered ? this._text('countFiltered', { count, total }) : this._text('count', { count })
1378
+ }, limit != null ? `${total} / ${limit}`
1379
+ : filtered ? `${count} / ${total}` : String(count));
1380
+ }
1381
+
1382
+ /**
1383
+ * @param {number} count Visible records in this column and lane.
1384
+ * @param {number} total Records in this column and lane before search and filters.
1385
+ * @param {number} columnTotal Records across the whole column.
1386
+ * @param {number|null} limit Applicable limit.
1387
+ * @param {boolean} columnScoped Whether the limit counts the column rather than this lane.
1388
+ * @returns {HTMLElement}
1389
+ */
1390
+ _columnIndicator(count, total, columnTotal, limit, columnScoped) {
1391
+ const filtered = count !== total;
1392
+ const label = limit == null
1393
+ ? filtered ? this._text('countFiltered', { count, total }) : this._text('count', { count })
1394
+ : columnScoped
1395
+ ? this._text('countLimitInLane', { count: total, total: columnTotal, limit })
1396
+ : this._text('countLimit', { count: total, limit });
1397
+ // A limit measures real capacity, so it is always shown against the unfiltered count; only the
1398
+ // limitless indicator reports how much of the column a search is currently hiding.
1399
+ const text = limit == null
1400
+ ? filtered ? `${count} / ${total}` : String(count)
1401
+ : columnScoped ? `${total} · ${columnTotal} / ${limit}` : `${total} / ${limit}`;
1402
+ return h('span', { class: 'zx-kanban-view__count', ariaLabel: label }, text);
1403
+ }
1404
+
646
1405
  /** @returns {KanbanColumn[]} */
647
1406
  _rawColumns() {
648
1407
  const options = this._kanbanOptions();
@@ -654,7 +1413,8 @@ export class KanbanView extends RecordView {
654
1413
  /** @returns {KanbanColumn[]} */
655
1414
  _resolvedColumns() {
656
1415
  const columns = this._rawColumns();
657
- this._kanbanColumnOrder = reconcileKanbanOrder(columns, this._kanbanColumnOrder);
1416
+ this._kanbanColumnOrder = reconcileKanbanPreference(
1417
+ columns, this._kanbanColumnOrder, this._kanbanColumns === null);
658
1418
  return orderDescriptors(columns, this._kanbanColumnOrder);
659
1419
  }
660
1420
 
@@ -670,7 +1430,9 @@ export class KanbanView extends RecordView {
670
1430
  /** @returns {KanbanSwimlane[]} */
671
1431
  _resolvedSwimlanes() {
672
1432
  const lanes = this._rawSwimlanes();
673
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(lanes, this._kanbanSwimlaneOrder);
1433
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
1434
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(
1435
+ lanes, this._kanbanSwimlaneOrder, preserveUnknown);
674
1436
  return orderDescriptors(lanes, this._kanbanSwimlaneOrder);
675
1437
  }
676
1438
 
@@ -678,10 +1440,24 @@ export class KanbanView extends RecordView {
678
1440
  _readAxis(accessor, record, index) {
679
1441
  if (typeof accessor === 'function') return accessor(record, index);
680
1442
  if (accessor == null) return null;
681
- const field = this.getFields().find((candidate) => candidate.id === accessor);
1443
+ const field = this._axisField(String(accessor));
682
1444
  return field ? readViewField(field, record, index) : record?.[accessor];
683
1445
  }
684
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
+
685
1461
  /** @param {string} column @param {string|null} lane @returns {{record:KanbanRecord,index:number}[]} */
686
1462
  _recordsIn(column, lane) {
687
1463
  const options = this._kanbanOptions();
@@ -700,13 +1476,88 @@ export class KanbanView extends RecordView {
700
1476
  const options = this._kanbanOptions();
701
1477
  const index = this._viewData.findIndex((record) => Object.is(this._viewRecordId(record), id));
702
1478
  if (index < 0) return null;
703
- const record = this._viewData[index];
704
- const column = normalizeAxisId(this._readAxis(options.columnBy, record, index));
705
- const lane = options.swimlaneBy == null
706
- ? null : normalizeAxisId(this._readAxis(options.swimlaneBy, record, index));
707
- const within = this._recordsIn(column, lane)
708
- .findIndex((entry) => Object.is(this._viewRecordId(entry.record), id));
709
- return { column, lane, index: within };
1479
+ return locateKanbanRecord(this._viewData, index, {
1480
+ recordId: options.recordId,
1481
+ columnBy: (record, recordIndex) => this._readAxis(options.columnBy, record, recordIndex),
1482
+ swimlaneBy: options.swimlaneBy == null ? null
1483
+ : (record, recordIndex) => this._readAxis(options.swimlaneBy, record, recordIndex)
1484
+ });
1485
+ }
1486
+
1487
+ /**
1488
+ * Recomputes which records survive the search and the filter predicate.
1489
+ * @returns {void}
1490
+ */
1491
+ _resolveVisibility() {
1492
+ const terms = kanbanSearchTerms(this._kanbanSearch);
1493
+ const filter = this._kanbanFilter;
1494
+ // Field descriptors are cloned on every read, so they are resolved once for the whole pass
1495
+ // rather than per record — a search runs on every keystroke.
1496
+ const sources = terms.length ? this._searchSources() : null;
1497
+ this._visibleIds = new Set();
1498
+ this._viewData.forEach((record, index) => {
1499
+ if (filter && filter(record, index) !== true) return;
1500
+ if (sources && !matchesKanbanSearch(this._searchableText(record, index, sources), terms)) return;
1501
+ this._visibleIds.add(this._viewRecordId(record));
1502
+ });
1503
+ }
1504
+
1505
+ /**
1506
+ * Resolves what a search looks at, once. `searchFields` names them explicitly; otherwise the
1507
+ * visible fields are searched, plus the title and subtitle sources — a card is findable by the
1508
+ * name it shows even when that field is hidden from the metadata list.
1509
+ * @returns {{fields:import('../view/record-view.js').ViewField[],readers:((record:KanbanRecord,index:number)=>unknown)[]}}
1510
+ */
1511
+ _searchSources() {
1512
+ const options = this._kanbanOptions();
1513
+ const configured = Array.isArray(options.searchFields) ? options.searchFields.map(String) : null;
1514
+ const all = this.getFields();
1515
+ if (configured) return { fields: all.filter((field) => configured.includes(field.id)), readers: [] };
1516
+ const fields = this.getVisibleFields();
1517
+ const known = new Set(fields.map((field) => field.id));
1518
+ const readers = [];
1519
+ for (const source of [options.titleField, options.subtitleField]) {
1520
+ if (source == null) continue;
1521
+ if (typeof source === 'function') {
1522
+ readers.push(source);
1523
+ continue;
1524
+ }
1525
+ if (known.has(source)) continue;
1526
+ const field = all.find((candidate) => candidate.id === source);
1527
+ if (field) fields.push(field);
1528
+ else readers.push((record) => record?.[source]);
1529
+ }
1530
+ return { fields, readers };
1531
+ }
1532
+
1533
+ /**
1534
+ * Builds the text one record is searched through. Field `render` output is deliberately ignored:
1535
+ * it may be a DOM node, and matching what a formatter produced rather than what the record holds
1536
+ * would make the same query behave differently in two views over the same data.
1537
+ * @param {KanbanRecord} record Record.
1538
+ * @param {number} index Display index.
1539
+ * @param {{fields:import('../view/record-view.js').ViewField[],readers:((record:KanbanRecord,index:number)=>unknown)[]}} sources Resolved sources.
1540
+ * @returns {string} Searchable text.
1541
+ */
1542
+ _searchableText(record, index, sources) {
1543
+ const parts = sources.fields.map((field) => stringifyValue(readViewField(field, record, index)));
1544
+ for (const reader of sources.readers) parts.push(stringifyValue(reader(record, index)));
1545
+ return parts.filter(Boolean).join(' ');
1546
+ }
1547
+
1548
+ /**
1549
+ * Translates a position among rendered cards into a position among all records in one bucket.
1550
+ * @param {string} column Column id.
1551
+ * @param {string|null} lane Lane id.
1552
+ * @param {number} visibleIndex Position among visible cards.
1553
+ * @param {Set<unknown>} excluded Ids being moved, which the visible list already omits.
1554
+ * @returns {number} Absolute position.
1555
+ */
1556
+ _absoluteIndex(column, lane, visibleIndex, excluded) {
1557
+ const entries = this._recordsIn(column, lane)
1558
+ .filter((entry) => !excluded.has(this._viewRecordId(entry.record)));
1559
+ return absoluteKanbanIndex(
1560
+ entries.map((entry) => this._visibleIds.has(this._viewRecordId(entry.record))), visibleIndex);
710
1561
  }
711
1562
 
712
1563
  /**
@@ -731,8 +1582,301 @@ export class KanbanView extends RecordView {
731
1582
  return clone;
732
1583
  }
733
1584
 
1585
+ /**
1586
+ * Runs transition, eligibility, and capacity policy for a whole move. Every distinct origin is
1587
+ * checked, so a multi-card move drawn from two columns cannot slip past a transition rule that
1588
+ * only the second origin violates.
1589
+ * @param {KanbanRecord[]} records Moving records.
1590
+ * @param {KanbanMovePoint[]} origins Their origins.
1591
+ * @param {KanbanColumn} column Destination column.
1592
+ * @param {KanbanSwimlane|null} lane Destination lane.
1593
+ * @param {KanbanMovePoint} to Destination point.
1594
+ * @param {KanbanMoveContext} context Predicate context.
1595
+ * @param {Set<unknown>} excluded Moving ids, excluded from destination counts.
1596
+ * @returns {KanbanMoveEvaluation} Decision.
1597
+ */
1598
+ _evaluateMove(records, origins, column, lane, to, context, excluded) {
1599
+ const options = this._kanbanOptions();
1600
+ for (const origin of origins) {
1601
+ const blocked = allowsKanbanTransition(column, lane, origin);
1602
+ if (blocked) {
1603
+ return {
1604
+ allowed: false, reason: blocked, limit: null, count: 0,
1605
+ limitExceeded: false, limitReached: false,
1606
+ policy: column.wipPolicy ?? (options.wipPolicy === 'block' ? 'block' : 'warn')
1607
+ };
1608
+ }
1609
+ }
1610
+ const excludes = (record) => !excluded.has(this._viewRecordId(record));
1611
+ const columnCount = this._viewData.filter((record, index) =>
1612
+ normalizeAxisId(this._readAxis(options.columnBy, record, index)) === to.column && excludes(record)).length;
1613
+ const laneCount = options.swimlaneBy == null ? 0 : this._viewData.filter((record, index) =>
1614
+ normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === to.lane && excludes(record)).length;
1615
+ const cellCount = this._recordsIn(to.column, to.lane).filter((entry) => excludes(entry.record)).length;
1616
+ return evaluateKanbanMove({
1617
+ records, column, swimlane: lane, context, from: origins[0],
1618
+ columnCount, laneCount, cellCount, policy: options.wipPolicy === 'block' ? 'block' : 'warn'
1619
+ });
1620
+ }
1621
+
1622
+ /**
1623
+ * Announces and reports a refused move. Nothing is written and no `recordmove` is emitted, so a
1624
+ * listener can neither observe nor undo a move that never happened.
1625
+ * @param {KanbanRejectReason} reason Refusal reason.
1626
+ * @param {{ids:unknown[],records:KanbanRecord[],from:KanbanMovePoint|null,to:KanbanMovePoint|null,column:KanbanColumn|null,swimlane:KanbanSwimlane|null,evaluation?:KanbanMoveEvaluation}} detail Context.
1627
+ * @returns {void}
1628
+ * @fires KanbanView#movereject
1629
+ */
1630
+ _rejectMove(reason, detail) {
1631
+ const target = detail.to ? this._targetName(detail.to) : '';
1632
+ const name = this._movingName(detail.records);
1633
+ const message = reason === 'destination' ? this._text('rejectDestination')
1634
+ : reason === 'grouping' ? this._text('rejectGrouping')
1635
+ : reason === 'transition' || reason === 'lane-transition'
1636
+ ? this._text('rejectTransition', { target, origin: detail.from ? this._targetName(detail.from) : '' })
1637
+ : reason === 'wip' || reason === 'lane-wip'
1638
+ ? this._text('rejectWip', { target, limit: detail.evaluation?.limit ?? '' })
1639
+ : this._text('rejectAccept', { target, name });
1640
+ this._announce(message);
1641
+ this.emit('movereject', {
1642
+ reason, ids: [...detail.ids], records: [...detail.records],
1643
+ record: detail.records[0] ?? null, id: detail.ids[0],
1644
+ from: detail.from ? { ...detail.from } : null, to: detail.to ? { ...detail.to } : null,
1645
+ column: detail.column ? { ...detail.column } : null,
1646
+ swimlane: detail.swimlane ? { ...detail.swimlane } : null,
1647
+ limit: detail.evaluation?.limit ?? null, count: detail.evaluation?.count ?? 0, message
1648
+ });
1649
+ }
1650
+
1651
+ /**
1652
+ * Installs a committed local record order, remembering the previous one for undo.
1653
+ * @param {KanbanRecord[]} records Next record order.
1654
+ * @returns {void}
1655
+ */
1656
+ _commitLocalRecords(records) {
1657
+ const options = this._kanbanOptions();
1658
+ if (options.history !== false) {
1659
+ this._kanbanHistory.push({ records: [...this._viewData], selection: this.getSelectionIds() });
1660
+ this._syncHistoryControls();
1661
+ }
1662
+ this._viewData = records;
1663
+ // A manual bucket position and a local sort cannot both be authoritative. Clear the local sort
1664
+ // on an accepted local move so the visible order and serialized state remain truthful.
1665
+ if (options.sortMode === 'local' && this.getSort()) this.setSort(null);
1666
+ else this._refreshView('move');
1667
+ this.emit('datachange', { records: this.getData() });
1668
+ if (options.history !== false) this._emitHistory();
1669
+ }
1670
+
1671
+ /**
1672
+ * @param {'undo'|'redo'} direction Travel direction.
1673
+ * @returns {this}
1674
+ */
1675
+ _travelHistory(direction) {
1676
+ if (this._kanbanOptions().history === false) return this;
1677
+ const current = { records: [...this._viewData], selection: this.getSelectionIds() };
1678
+ const entry = direction === 'undo'
1679
+ ? this._kanbanHistory.undo(current) : this._kanbanHistory.redo(current);
1680
+ if (!entry) {
1681
+ this._announce(this._text(direction === 'undo' ? 'nothingToUndo' : 'nothingToRedo'));
1682
+ return this;
1683
+ }
1684
+ this._viewData = [...entry.records];
1685
+ this._refreshView(direction);
1686
+ this.setSelection(entry.selection);
1687
+ this._syncHistoryControls();
1688
+ this.emit('datachange', { records: this.getData() });
1689
+ this._emitHistory();
1690
+ this._announce(this._text(direction === 'undo' ? 'undone' : 'redone'));
1691
+ return this;
1692
+ }
1693
+
1694
+ /** Drops both stacks without announcing, for host-driven data replacement. @returns {void} */
1695
+ _resetHistory() {
1696
+ if (!this._kanbanHistory) return;
1697
+ const had = this._kanbanHistory.canUndo() || this._kanbanHistory.canRedo();
1698
+ this._kanbanHistory.clear();
1699
+ this._syncHistoryControls();
1700
+ if (had) this._emitHistory();
1701
+ }
1702
+
1703
+ /** @returns {void} */
1704
+ _emitHistory() {
1705
+ this.emit('historychange', {
1706
+ canUndo: this.canUndo(), canRedo: this.canRedo(), depth: this._kanbanHistory.depth()
1707
+ });
1708
+ }
1709
+
1710
+ /** @returns {void} */
1711
+ _syncHistoryControls() {
1712
+ if (this._undoButton) this._undoButton.disabled = !this.canUndo();
1713
+ if (this._redoButton) this._redoButton.disabled = !this.canRedo();
1714
+ }
1715
+
1716
+ /** @returns {HTMLElement} Built-in search control. */
1717
+ _createSearchControl() {
1718
+ const input = /** @type {HTMLInputElement} */ (h('input', {
1719
+ class: 'zx-kanban-view__search-input', type: 'search',
1720
+ value: this._kanbanSearch, ariaLabel: this._text('search'),
1721
+ placeholder: this._text('searchPlaceholder')
1722
+ }));
1723
+ this._searchInput = input;
1724
+ // Every keystroke would otherwise rebuild every card. The public setSearch stays immediate.
1725
+ this.listen(input, 'input', () => {
1726
+ clearTimeout(this._searchTimer);
1727
+ this._searchTimer = setTimeout(() => {
1728
+ this._searchTimer = 0;
1729
+ if (!this._kanbanDestroyed) this.setSearch(input.value);
1730
+ }, SEARCH_DEBOUNCE);
1731
+ });
1732
+ this.listen(input, 'keydown', (event) => {
1733
+ if (/** @type {KeyboardEvent} */ (event).key !== 'Escape' || !input.value) return;
1734
+ event.stopPropagation();
1735
+ clearTimeout(this._searchTimer);
1736
+ this._searchTimer = 0;
1737
+ this.setSearch('');
1738
+ });
1739
+ return h('div', { class: 'zx-kanban-view__search' }, icon('search', { size: 13 }), input);
1740
+ }
1741
+
1742
+ /** @returns {HTMLElement} Built-in history controls. */
1743
+ _createHistoryControls() {
1744
+ this._undoButton = /** @type {HTMLButtonElement} */ (h('button', {
1745
+ class: 'zx-button zx-button--sm', type: 'button', disabled: true,
1746
+ dataset: { kanbanHistory: 'undo' }
1747
+ }, this._text('undo')));
1748
+ this._redoButton = /** @type {HTMLButtonElement} */ (h('button', {
1749
+ class: 'zx-button zx-button--sm', type: 'button', disabled: true,
1750
+ dataset: { kanbanHistory: 'redo' }
1751
+ }, this._text('redo')));
1752
+ const group = h('div', { class: 'zx-kanban-view__history', role: 'group' },
1753
+ this._undoButton, this._redoButton);
1754
+ this.listen(group, 'click', (event) => {
1755
+ const control = /** @type {Element|null} */ (event.target)?.closest?.('[data-kanban-history]');
1756
+ if (!control) return;
1757
+ if (/** @type {HTMLElement} */ (control).dataset.kanbanHistory === 'undo') this.undo();
1758
+ else this.redo();
1759
+ });
1760
+ return group;
1761
+ }
1762
+
1763
+ /** Attaches the card context menu. @returns {void} */
1764
+ _createContextMenu() {
1765
+ this._kanbanMenu = new ContextMenu(this._board, {
1766
+ selector: '.zx-record-card',
1767
+ items: (context) => this._menuItems(context),
1768
+ onselect: ({ detail }) => this._handleMenuSelect(detail)
1769
+ });
1770
+ }
1771
+
1772
+ /**
1773
+ * Builds the menu for one card: every destination the same policy path would accept, plus any
1774
+ * application items. Destinations the board would refuse are shown disabled rather than hidden,
1775
+ * so a rule is discoverable instead of silently removing a command.
1776
+ * @param {Element|null} context Card element the menu opened on.
1777
+ * @returns {(MenuItem|'-')[]} Menu entries.
1778
+ */
1779
+ _menuItems(context) {
1780
+ const meta = context ? this._cardMeta.get(context) : null;
1781
+ if (!meta) return [];
1782
+ const options = this._kanbanOptions();
1783
+ const ids = this._movingIds(meta.id);
1784
+ /** @type {(MenuItem|'-')[]} */
1785
+ const items = [];
1786
+ for (const column of this._resolvedColumns()) {
1787
+ if (column.id === meta.column) continue;
1788
+ items.push({
1789
+ label: this._text('menuMoveTo', { target: column.label }),
1790
+ value: { kind: 'column', id: column.id, ids },
1791
+ disabled: !this._canMove(ids, { column: column.id })
1792
+ });
1793
+ }
1794
+ if (options.swimlaneBy != null) {
1795
+ for (const lane of this._resolvedSwimlanes()) {
1796
+ if (lane.id === meta.lane) continue;
1797
+ items.push({
1798
+ label: this._text('menuMoveToLane', { target: lane.label }),
1799
+ value: { kind: 'lane', id: lane.id, ids },
1800
+ disabled: !this._canMove(ids, { lane: lane.id })
1801
+ });
1802
+ }
1803
+ }
1804
+ const extra = typeof options.contextMenu === 'function'
1805
+ ? options.contextMenu({
1806
+ record: meta.record, id: meta.id, index: meta.index, column: meta.column, lane: meta.lane,
1807
+ rules: resolveKanbanRules(this._kanbanRules, meta.record, meta), selected: this._isViewSelected(meta.id)
1808
+ })
1809
+ : Array.isArray(options.contextMenu) ? options.contextMenu : [];
1810
+ if (Array.isArray(extra) && extra.length) items.push('-', ...extra);
1811
+ return items;
1812
+ }
1813
+
1814
+ /**
1815
+ * @param {{value:unknown,item:MenuItem,context:Element|null}} detail Selection detail.
1816
+ * @returns {void}
1817
+ */
1818
+ _handleMenuSelect(detail) {
1819
+ const value = /** @type {{kind?:string,id?:string,ids?:unknown[]}|null} */ (detail.value);
1820
+ if (!value || typeof value !== 'object' || !value.kind || !Array.isArray(value.ids)) return;
1821
+ this.moveRecords(value.ids, value.kind === 'lane' ? { lane: value.id } : { column: value.id });
1822
+ }
1823
+
1824
+ /**
1825
+ * Dry-runs the policy path for a destination without emitting or committing anything.
1826
+ * @param {unknown[]} ids Moving ids.
1827
+ * @param {KanbanDestination} destination Destination.
1828
+ * @returns {boolean} Whether the move would be allowed.
1829
+ */
1830
+ _canMove(ids, destination) {
1831
+ const options = this._kanbanOptions();
1832
+ const moving = this._orderedIds(ids);
1833
+ if (!moving.length) return false;
1834
+ const records = moving.map((id) => /** @type {KanbanRecord} */ (this.getRecord(id)));
1835
+ const origins = moving.map((id) => /** @type {KanbanMovePoint} */ (this._recordLocation(id)));
1836
+ const from = origins[0];
1837
+ const columnId = destination.column == null ? from.column : String(destination.column);
1838
+ const laneId = options.swimlaneBy == null
1839
+ ? null : destination.lane === undefined ? from.lane : normalizeAxisId(destination.lane);
1840
+ const column = this._resolvedColumns().find((candidate) => candidate.id === columnId);
1841
+ const lane = options.swimlaneBy == null
1842
+ ? null : this._resolvedSwimlanes().find((candidate) => candidate.id === laneId) ?? null;
1843
+ if (!column || options.swimlaneBy != null && !lane) return false;
1844
+ const excluded = new Set(moving);
1845
+ const to = { column: columnId, lane: laneId, index: 0 };
1846
+ const context = {
1847
+ id: moving[0], from: { ...from }, to: { ...to },
1848
+ column: { ...column }, swimlane: lane ? { ...lane } : null
1849
+ };
1850
+ return this._evaluateMove(records, origins, column, lane, to, context, excluded).allowed;
1851
+ }
1852
+
1853
+ /**
1854
+ * Resolves the ids one gesture moves: the whole selection when the grabbed card belongs to it,
1855
+ * otherwise just that card. Picking up an unselected card never drags the selection along.
1856
+ * @param {unknown} id Grabbed record id.
1857
+ * @returns {unknown[]} Ids to move, in board order.
1858
+ */
1859
+ _movingIds(id) {
1860
+ const options = this._kanbanOptions();
1861
+ if (options.multiMove === false || options.selectable !== 'multi' || !this._isViewSelected(id)) return [id];
1862
+ const selection = this.getSelectionIds();
1863
+ return selection.length > 1 ? selection : [id];
1864
+ }
1865
+
1866
+ /** @param {unknown[]} ids Requested ids. @returns {unknown[]} Existing ids in board order. */
1867
+ _orderedIds(ids) {
1868
+ const requested = new Set(ids);
1869
+ return this._viewData
1870
+ .map((record) => this._viewRecordId(record))
1871
+ .filter((id) => requested.has(id));
1872
+ }
1873
+
734
1874
  /** @param {MouseEvent} event @returns {void} */
735
1875
  _handleBoardClick(event) {
1876
+ if (this._suppressClick) {
1877
+ this._suppressClick = false;
1878
+ return;
1879
+ }
736
1880
  const target = elementTarget(event);
737
1881
  const collapse = target?.closest('[data-kanban-collapse]');
738
1882
  if (collapse) {
@@ -741,6 +1885,19 @@ export class KanbanView extends RecordView {
741
1885
  else this.toggleSwimlane(String(element.dataset.kanbanId));
742
1886
  return;
743
1887
  }
1888
+ const add = target?.closest('[data-kanban-add]');
1889
+ if (add) {
1890
+ const element = /** @type {HTMLElement} */ (add);
1891
+ const columnId = String(element.dataset.kanbanAdd);
1892
+ const laneId = element.dataset.kanbanLane || null;
1893
+ this.emit('recordadd', {
1894
+ column: this._resolvedColumns().find((candidate) => candidate.id === columnId) ?? null,
1895
+ swimlane: laneId == null ? null
1896
+ : this._resolvedSwimlanes().find((candidate) => candidate.id === laneId) ?? null,
1897
+ columnId, laneId, event
1898
+ });
1899
+ return;
1900
+ }
744
1901
  const meta = this._metaForTarget(target);
745
1902
  if (!meta) return;
746
1903
  const selection = /** @type {HTMLInputElement|null} */ (target?.closest('input[data-record-selection]'));
@@ -766,6 +1923,11 @@ export class KanbanView extends RecordView {
766
1923
  }
767
1924
  if (target?.closest('.zx-kanban-view__move-handle, .zx-record-card__selection, [data-record-select]')) return;
768
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
+ }
769
1931
  this.emit('recordclick', { ...meta, event });
770
1932
  if (this.options.selectable === 'single' && !this._isViewSelected(meta.id)) {
771
1933
  this.setSelection([meta.id]);
@@ -782,7 +1944,14 @@ export class KanbanView extends RecordView {
782
1944
 
783
1945
  /** @param {KeyboardEvent} event @returns {void} */
784
1946
  _handleBoardKeydown(event) {
1947
+ if (this._dragIds.length && event.key === 'Escape') {
1948
+ event.preventDefault();
1949
+ this._clearDrag();
1950
+ this._announce(this._text('moveCanceledPlain'));
1951
+ return;
1952
+ }
785
1953
  const target = elementTarget(event);
1954
+ if (this._handleHistoryKeydown(event, target)) return;
786
1955
  const handle = target?.closest('.zx-kanban-view__move-handle');
787
1956
  const meta = this._metaForTarget(handle);
788
1957
  if (handle && meta) {
@@ -794,6 +1963,10 @@ export class KanbanView extends RecordView {
794
1963
  if (!cardMeta || target !== card) return;
795
1964
  if (event.key === 'Enter') {
796
1965
  event.preventDefault();
1966
+ if (this.options.selectable && this._kanbanOptions().selectionTrigger === 'card') {
1967
+ this.toggleSelection(cardMeta.id);
1968
+ return;
1969
+ }
797
1970
  this.emit('recordclick', { ...cardMeta, event });
798
1971
  if (this.options.selectable === 'single') this.setSelection([cardMeta.id]);
799
1972
  } else if (event.key === ' ' && this.options.selectable !== false) {
@@ -802,42 +1975,73 @@ export class KanbanView extends RecordView {
802
1975
  }
803
1976
  }
804
1977
 
1978
+ /**
1979
+ * @param {KeyboardEvent} event Keyboard event.
1980
+ * @param {Element|null} target Event target.
1981
+ * @returns {boolean} Whether the event was handled as a history command.
1982
+ */
1983
+ _handleHistoryKeydown(event, target) {
1984
+ if (this._kanbanOptions().history === false || !(event.ctrlKey || event.metaKey)) return false;
1985
+ // Text entry owns its own undo stack; never take Ctrl+Z away from a field on the board.
1986
+ if (target?.closest('input, textarea, [contenteditable="true"]')) return false;
1987
+ // A key the board cannot act on belongs to the browser or the application, not to an
1988
+ // announcement saying there was nothing to do.
1989
+ const key = event.key.toLowerCase();
1990
+ if (key === 'z' && !event.shiftKey && this.canUndo()) {
1991
+ event.preventDefault();
1992
+ this.undo();
1993
+ return true;
1994
+ }
1995
+ if ((key === 'y' || key === 'z' && event.shiftKey) && this.canRedo()) {
1996
+ event.preventDefault();
1997
+ this.redo();
1998
+ return true;
1999
+ }
2000
+ return false;
2001
+ }
2002
+
805
2003
  /** @param {KeyboardEvent} event @param {HTMLElement} handle @param {KanbanCardMeta} meta @returns {void} */
806
2004
  _handleMoveKeydown(event, handle, meta) {
807
2005
  if (event.key === ' ' || event.key === 'Enter') {
808
2006
  event.preventDefault();
809
- if (this._keyboardMove && Object.is(this._keyboardMove.id, meta.id)) {
810
- const destination = { ...this._keyboardMove.to };
2007
+ if (this._keyboardMove?.ids.some((candidate) => Object.is(candidate, meta.id))) {
2008
+ const move = this._keyboardMove;
811
2009
  this._keyboardMove = null;
812
2010
  handle.setAttribute('aria-pressed', 'false');
813
2011
  const card = /** @type {HTMLElement|null} */ (handle.closest('.zx-record-card'));
814
2012
  if (card) delete card.dataset.grabbed;
815
2013
  this._syncKeyboardTarget();
816
- this.moveRecord(meta.id, destination);
2014
+ this._commitInteractionMove(move.ids, move.to);
817
2015
  } else {
818
2016
  const from = this._recordLocation(meta.id);
819
2017
  if (!from) return;
820
- this._keyboardMove = { id: meta.id, from: { ...from }, to: { ...from } };
2018
+ const ids = this._movingIds(meta.id);
2019
+ const start = { ...from, index: this._visibleIndex(meta.id) };
2020
+ this._keyboardMove = { ids, from: { ...from }, to: start };
821
2021
  handle.setAttribute('aria-pressed', 'true');
822
2022
  const card = handle.closest('.zx-record-card');
823
2023
  if (card) /** @type {HTMLElement} */ (card).dataset.grabbed = 'true';
824
2024
  this._syncKeyboardTarget();
825
- this._announce(`Grabbed ${this._recordName(meta.record, meta.index)}. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.`);
2025
+ this._announce(ids.length > 1
2026
+ ? this._text('grabbedMultiple', { count: ids.length })
2027
+ : this._text('grabbed', { name: this._recordName(meta.record, meta.index) }));
826
2028
  }
827
2029
  return;
828
2030
  }
829
2031
  if (event.key === 'Escape' && this._keyboardMove) {
830
2032
  event.preventDefault();
831
- const record = this.getRecord(this._keyboardMove.id);
2033
+ const records = this._keyboardMove.ids.map((id) => this.getRecord(id)).filter(Boolean);
832
2034
  this._keyboardMove = null;
833
2035
  handle.setAttribute('aria-pressed', 'false');
834
2036
  const card = /** @type {HTMLElement|null} */ (handle.closest('.zx-record-card'));
835
2037
  if (card) delete card.dataset.grabbed;
836
2038
  this._syncKeyboardTarget();
837
- this._announce(`Move canceled${record ? ` for ${this._recordName(record, meta.index)}` : ''}.`);
2039
+ this._announce(records.length
2040
+ ? this._text('moveCanceled', { name: this._movingName(/** @type {KanbanRecord[]} */ (records)) })
2041
+ : this._text('moveCanceledPlain'));
838
2042
  return;
839
2043
  }
840
- if (!this._keyboardMove || !Object.is(this._keyboardMove.id, meta.id)
2044
+ if (!this._keyboardMove || !this._keyboardMove.ids.some((candidate) => Object.is(candidate, meta.id))
841
2045
  || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
842
2046
  event.preventDefault();
843
2047
  const move = this._keyboardMove;
@@ -856,11 +2060,12 @@ export class KanbanView extends RecordView {
856
2060
  } else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
857
2061
  move.to.index += event.key === 'ArrowUp' ? -1 : 1;
858
2062
  }
859
- const targetCount = this._recordsIn(move.to.column, move.to.lane)
860
- .filter((entry) => !Object.is(this._viewRecordId(entry.record), move.id)).length;
861
- move.to.index = normalizeMoveIndex(move.to.index, targetCount);
2063
+ const targetCount = this._visibleCount(move.to.column, move.to.lane, new Set(move.ids));
2064
+ move.to.index = clampKanbanIndex(move.to.index, targetCount);
862
2065
  this._syncKeyboardTarget();
863
- this._announce(`Target ${this._targetName(move.to)}, position ${move.to.index + 1} of ${targetCount + 1}.`);
2066
+ this._announce(this._text('target', {
2067
+ target: this._targetName(move.to), position: move.to.index + 1, total: targetCount + 1
2068
+ }));
864
2069
  }
865
2070
 
866
2071
  /** @param {Event} event @returns {void} */
@@ -872,78 +2077,229 @@ export class KanbanView extends RecordView {
872
2077
  image.hidden = true;
873
2078
  }
874
2079
 
875
- /** @param {DragEvent} event @returns {void} */
876
- _handleDragStart(event) {
877
- const handle = elementTarget(event)?.closest('.zx-kanban-view__move-handle');
878
- const meta = this._metaForTarget(handle);
879
- if (!handle || !meta) {
880
- event.preventDefault();
881
- return;
2080
+ /**
2081
+ * Records a candidate drag. Nothing moves yet: a press that never travels stays a click, and a
2082
+ * touch on the card body stays a scroll until the long press elapses.
2083
+ * @param {PointerEvent} event Pointer event.
2084
+ * @returns {void}
2085
+ */
2086
+ _handlePointerDown(event) {
2087
+ this._suppressClick = false;
2088
+ if (event.button != null && event.button !== 0) return;
2089
+ const target = elementTarget(event);
2090
+ const handle = target?.closest('.zx-kanban-view__move-handle');
2091
+ const meta = this._metaForTarget(handle ?? target);
2092
+ if (!meta) return;
2093
+ if (!handle) {
2094
+ if (this._kanbanOptions().dragFrom !== 'card') return;
2095
+ if (target?.closest(INTERACTIVE) || target?.closest('.zx-record-card__selection')) return;
882
2096
  }
883
- this._dragId = meta.id;
884
- const card = handle.closest('.zx-record-card');
885
- if (card) /** @type {HTMLElement} */ (card).dataset.dragging = 'true';
886
- event.dataTransfer?.setData('text/plain', 'zx-kanban-record');
887
- if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
2097
+ const ids = this._movingIds(meta.id);
2098
+ this._pointerStart = {
2099
+ x: event.clientX, y: event.clientY, id: event.pointerId, ids,
2100
+ touch: event.pointerType === 'touch'
2101
+ };
2102
+ this._dragPoint = { x: event.clientX, y: event.clientY };
2103
+ clearTimeout(this._pointerHold);
2104
+ if (event.pointerType !== 'touch' || handle) return;
2105
+ // A finger resting on a card must be allowed to scroll the board; only a deliberate hold that
2106
+ // never travels — so the browser has not begun panning — turns into a drag.
2107
+ this._pointerHold = setTimeout(() => this._beginDrag(this._dragPoint.x, this._dragPoint.y), TOUCH_HOLD);
888
2108
  }
889
2109
 
890
- /** @param {DragEvent} event @returns {void} */
891
- _handleDragOver(event) {
892
- if (this._dragId == null) return;
893
- const section = elementTarget(event)?.closest('.zx-kanban-view__column');
894
- const sectionMeta = section ? this._sectionMeta.get(section) : null;
895
- if (!sectionMeta) return;
896
- event.preventDefault();
897
- const destination = this._pointerDestination(event, sectionMeta);
898
- this._dragDestination = destination;
899
- this._clearDropMarkers();
900
- const card = elementTarget(event)?.closest('.zx-record-card');
901
- const cardMeta = card ? this._cardMeta.get(card) : null;
902
- if (card && cardMeta && !Object.is(cardMeta.id, this._dragId)) {
903
- const rectangle = card.getBoundingClientRect();
904
- /** @type {HTMLElement} */ (card).dataset.drop = event.clientY >= rectangle.top + rectangle.height / 2
905
- ? 'after' : 'before';
906
- } else {
907
- sectionMeta.list.dataset.drop = 'append';
2110
+ /** @param {PointerEvent} event @returns {void} */
2111
+ _handlePointerMove(event) {
2112
+ const start = this._pointerStart;
2113
+ if (!this._dragIds.length) {
2114
+ if (!start || event.pointerId !== start.id) return;
2115
+ const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y);
2116
+ this._dragPoint = { x: event.clientX, y: event.clientY };
2117
+ if (distance <= DRAG_THRESHOLD) return;
2118
+ if (start.touch && this._pointerHold) {
2119
+ // The finger travelled before the hold elapsed: this is a scroll, not a drag.
2120
+ clearTimeout(this._pointerHold);
2121
+ this._pointerHold = 0;
2122
+ this._pointerStart = null;
2123
+ return;
2124
+ }
2125
+ this._beginDrag(event.clientX, event.clientY);
2126
+ if (!this._dragIds.length) return;
908
2127
  }
909
- if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
2128
+ // A second finger landing on the board must not steer a drag it did not start.
2129
+ if (this._dragPointer != null && event.pointerId !== this._dragPointer) return;
2130
+ event.preventDefault();
2131
+ this._dragPoint = { x: event.clientX, y: event.clientY };
2132
+ this._positionDragPreview();
2133
+ this._updateDropTarget(event.clientX, event.clientY);
910
2134
  }
911
2135
 
912
- /** @param {DragEvent} event @returns {void} */
913
- _handleDrop(event) {
914
- if (this._dragId == null || !this._dragDestination) return;
2136
+ /** @param {PointerEvent} event @returns {void} */
2137
+ _handlePointerUp(event) {
2138
+ if (this._dragIds.length && this._dragPointer != null && event.pointerId !== this._dragPointer) return;
2139
+ clearTimeout(this._pointerHold);
2140
+ this._pointerHold = 0;
2141
+ this._pointerStart = null;
2142
+ if (!this._dragIds.length) return;
915
2143
  event.preventDefault();
916
- const id = this._dragId;
917
- const destination = { ...this._dragDestination };
2144
+ const ids = [...this._dragIds];
2145
+ const destination = this._dragDestination ? { ...this._dragDestination } : null;
918
2146
  this._clearDrag();
919
- this.moveRecord(id, destination);
920
- }
921
-
922
- /** @param {DragEvent} event @param {KanbanSectionMeta} section @returns {KanbanMovePoint} */
923
- _pointerDestination(event, section) {
924
- const cards = [...section.list.querySelectorAll(':scope > .zx-record-card')]
925
- .filter((card) => !Object.is(this._cardMeta.get(card)?.id, this._dragId));
926
- const target = elementTarget(event)?.closest('.zx-record-card');
927
- const targetMeta = target ? this._cardMeta.get(target) : null;
928
- if (target && targetMeta && Object.is(targetMeta.id, this._dragId)) {
929
- const from = this._recordLocation(this._dragId);
930
- return { column: section.column, lane: section.lane, index: from?.index ?? cards.length };
2147
+ this._suppressClick = true;
2148
+ if (destination) this._commitInteractionMove(ids, destination);
2149
+ }
2150
+
2151
+ /**
2152
+ * Starts a pointer drag: the floating preview appears, the board stops selecting text, and the
2153
+ * auto-scroll loop begins.
2154
+ * @param {number} x Pointer client x.
2155
+ * @param {number} y Pointer client y.
2156
+ * @returns {void}
2157
+ */
2158
+ _beginDrag(x, y) {
2159
+ const start = this._pointerStart;
2160
+ if (!start || this._dragIds.length) return;
2161
+ this._pointerHold = 0;
2162
+ this._dragIds = start.ids.filter((id) => this.getRecord(id));
2163
+ if (!this._dragIds.length) return;
2164
+ this._dragPoint = { x, y };
2165
+ this._board.dataset.dragging = 'true';
2166
+ for (const id of this._dragIds) {
2167
+ const card = this._cardsById.get(id);
2168
+ if (card) card.dataset.dragging = 'true';
931
2169
  }
932
- const targetIndex = target ? cards.indexOf(target) : -1;
933
- if (targetIndex < 0) return { column: section.column, lane: section.lane, index: cards.length };
934
- const rectangle = target.getBoundingClientRect();
935
- return {
936
- column: section.column, lane: section.lane,
937
- index: targetIndex + (event.clientY >= rectangle.top + rectangle.height / 2 ? 1 : 0)
938
- };
2170
+ this._dragPointer = start.id;
2171
+ try {
2172
+ this._board.setPointerCapture(start.id);
2173
+ this._dragCaptured = true;
2174
+ } catch {
2175
+ // Capture is an enhancement; the drag still tracks through board-level pointer events.
2176
+ }
2177
+ globalThis.getSelection?.()?.removeAllRanges();
2178
+ this._dragPreview = this._createDragPreview();
2179
+ this.el.append(this._dragPreview);
2180
+ this._positionDragPreview();
2181
+ this._updateDropTarget(x, y);
2182
+ if (this._kanbanOptions().autoScroll !== false) this._scheduleAutoScroll();
2183
+ }
2184
+
2185
+ /** @returns {HTMLElement} Floating drag preview. */
2186
+ _createDragPreview() {
2187
+ const records = this._dragIds.map((id) => /** @type {KanbanRecord} */ (this.getRecord(id)));
2188
+ const custom = this._renderHook('renderDragPreview', {
2189
+ records, ids: [...this._dragIds], count: this._dragIds.length
2190
+ });
2191
+ const content = custom ?? (this._dragIds.length > 1
2192
+ ? this._text('dragPreview', { count: this._dragIds.length })
2193
+ : this._movingName(records));
2194
+ return h('div', {
2195
+ class: 'zx-kanban-view__drag-preview', ariaHidden: 'true',
2196
+ dataset: { count: String(this._dragIds.length) }
2197
+ }, content);
939
2198
  }
940
2199
 
941
2200
  /** @returns {void} */
942
- _clearDrag() {
2201
+ _positionDragPreview() {
2202
+ if (!this._dragPreview) return;
2203
+ this._dragPreview.style.transform =
2204
+ `translate3d(${Math.round(this._dragPoint.x)}px, ${Math.round(this._dragPoint.y)}px, 0)`;
2205
+ }
2206
+
2207
+ /**
2208
+ * Resolves and marks the drop destination under the pointer. Pointer capture retargets events to
2209
+ * the board, so the element under the pointer is looked up rather than read off the event.
2210
+ * @param {number} x Pointer client x.
2211
+ * @param {number} y Pointer client y.
2212
+ * @returns {void}
2213
+ */
2214
+ _updateDropTarget(x, y) {
2215
+ const element = document.elementFromPoint(x, y);
2216
+ const section = element?.closest('.zx-kanban-view__column');
2217
+ const sectionMeta = section && this._board.contains(section) ? this._sectionMeta.get(section) : null;
943
2218
  this._clearDropMarkers();
944
- for (const card of this._board.querySelectorAll('[data-dragging]')) delete /** @type {HTMLElement} */ (card).dataset.dragging;
945
- this._dragId = null;
2219
+ if (!sectionMeta || this._collapsedColumns.has(sectionMeta.column)) {
2220
+ this._dragDestination = null;
2221
+ return;
2222
+ }
2223
+ // The insertion point is derived from card geometry rather than from whatever element the
2224
+ // pointer happens to be over, so hovering a card already in flight, a gap, or the empty-column
2225
+ // placeholder all resolve to the same well-defined slot.
2226
+ const cards = this._draggableCards(sectionMeta.list);
2227
+ const index = cards.findIndex((card) => {
2228
+ const rectangle = card.getBoundingClientRect();
2229
+ return y < rectangle.top + rectangle.height / 2;
2230
+ });
2231
+ const position = index < 0 ? cards.length : index;
2232
+ if (cards[position]) cards[position].dataset.drop = 'before';
2233
+ else if (cards.length) cards[cards.length - 1].dataset.drop = 'after';
2234
+ else sectionMeta.list.dataset.drop = 'append';
2235
+ this._dragDestination = { column: sectionMeta.column, lane: sectionMeta.lane, index: position };
2236
+ }
2237
+
2238
+ /** @returns {void} */
2239
+ _scheduleAutoScroll() {
2240
+ if (this._dragFrame) return;
2241
+ this._dragFrame = requestAnimationFrame(() => {
2242
+ this._dragFrame = 0;
2243
+ if (!this._dragIds.length) return;
2244
+ this._autoScroll();
2245
+ this._scheduleAutoScroll();
2246
+ });
2247
+ }
2248
+
2249
+ /** Scrolls the board horizontally and the hovered column vertically near their edges. @returns {void} */
2250
+ _autoScroll() {
2251
+ const { x, y } = this._dragPoint;
2252
+ const board = this._board.getBoundingClientRect();
2253
+ if (x < board.left + SCROLL_EDGE) this._board.scrollLeft -= SCROLL_SPEED;
2254
+ else if (x > board.right - SCROLL_EDGE) this._board.scrollLeft += SCROLL_SPEED;
2255
+ const list = document.elementFromPoint(x, y)?.closest('.zx-kanban-view__cards');
2256
+ if (!list || !this._board.contains(list) || list.scrollHeight <= list.clientHeight) return;
2257
+ const bounds = list.getBoundingClientRect();
2258
+ if (y < bounds.top + SCROLL_EDGE) list.scrollTop -= SCROLL_SPEED;
2259
+ else if (y > bounds.bottom - SCROLL_EDGE) list.scrollTop += SCROLL_SPEED;
2260
+ }
2261
+
2262
+ /**
2263
+ * Commits a move whose index came from rendered cards. Search and filters hide records without
2264
+ * removing them, so the visible position is translated back before anything is proposed.
2265
+ * @param {unknown[]} ids Moving ids.
2266
+ * @param {KanbanMovePoint} point Destination in visible-card space.
2267
+ * @returns {void}
2268
+ */
2269
+ _commitInteractionMove(ids, point) {
2270
+ const excluded = new Set(ids);
2271
+ this.moveRecords(ids, {
2272
+ column: point.column,
2273
+ lane: point.lane,
2274
+ index: this._absoluteIndex(point.column, point.lane, point.index, excluded)
2275
+ });
2276
+ }
2277
+
2278
+ /** @returns {void} */
2279
+ _clearDrag() {
2280
+ if (this._dragFrame) cancelAnimationFrame(this._dragFrame);
2281
+ this._dragFrame = 0;
2282
+ clearTimeout(this._pointerHold);
2283
+ this._pointerHold = 0;
2284
+ this._pointerStart = null;
2285
+ this._dragPreview?.remove();
2286
+ this._dragPreview = null;
2287
+ this._dragIds = [];
946
2288
  this._dragDestination = null;
2289
+ if (this._board) {
2290
+ if (this._dragCaptured && this._dragPointer != null) {
2291
+ try {
2292
+ this._board.releasePointerCapture(this._dragPointer);
2293
+ } catch {
2294
+ // The capture is already gone, which is exactly the state this wanted.
2295
+ }
2296
+ }
2297
+ this._clearDropMarkers();
2298
+ delete this._board.dataset.dragging;
2299
+ for (const card of this._board.querySelectorAll('[data-dragging]')) delete /** @type {HTMLElement} */ (card).dataset.dragging;
2300
+ }
2301
+ this._dragPointer = null;
2302
+ this._dragCaptured = false;
947
2303
  }
948
2304
 
949
2305
  /** @returns {void} */
@@ -951,6 +2307,31 @@ export class KanbanView extends RecordView {
951
2307
  for (const element of this._board.querySelectorAll('[data-drop]')) delete /** @type {HTMLElement} */ (element).dataset.drop;
952
2308
  }
953
2309
 
2310
+ /** @param {HTMLElement} list Card list. @returns {HTMLElement[]} Cards excluding the ones in flight. */
2311
+ _draggableCards(list) {
2312
+ const moving = new Set(this._dragIds.length ? this._dragIds : this._keyboardMove?.ids ?? []);
2313
+ return /** @type {HTMLElement[]} */ ([...list.querySelectorAll(':scope > .zx-record-card')]
2314
+ .filter((card) => !hasId(moving, this._cardMeta.get(card)?.id)));
2315
+ }
2316
+
2317
+ /** @param {string} column @param {string|null} lane @param {Set<unknown>} excluded @returns {number} */
2318
+ _visibleCount(column, lane, excluded) {
2319
+ return this._recordsIn(column, lane).filter((entry) => {
2320
+ const id = this._viewRecordId(entry.record);
2321
+ return !hasId(excluded, id) && this._visibleIds.has(id);
2322
+ }).length;
2323
+ }
2324
+
2325
+ /** @param {unknown} id Record id. @returns {number} Position among visible cards in its bucket. */
2326
+ _visibleIndex(id) {
2327
+ const location = this._recordLocation(id);
2328
+ if (!location) return 0;
2329
+ const visible = this._recordsIn(location.column, location.lane)
2330
+ .filter((entry) => this._visibleIds.has(this._viewRecordId(entry.record)));
2331
+ const position = visible.findIndex((entry) => Object.is(this._viewRecordId(entry.record), id));
2332
+ return position < 0 ? visible.length : position;
2333
+ }
2334
+
954
2335
  /** @returns {void} */
955
2336
  _syncKeyboardTarget() {
956
2337
  for (const element of this._board.querySelectorAll('[data-keyboard-target]')) {
@@ -965,10 +2346,9 @@ export class KanbanView extends RecordView {
965
2346
  });
966
2347
  const sectionMeta = section ? this._sectionMeta.get(section) : null;
967
2348
  if (!sectionMeta) return;
968
- const cards = [...sectionMeta.list.querySelectorAll(':scope > .zx-record-card')]
969
- .filter((card) => !Object.is(this._cardMeta.get(card)?.id, move.id));
2349
+ const cards = this._draggableCards(sectionMeta.list);
970
2350
  const target = cards[move.to.index];
971
- if (target) /** @type {HTMLElement} */ (target).dataset.keyboardTarget = 'before';
2351
+ if (target) target.dataset.keyboardTarget = 'before';
972
2352
  else sectionMeta.list.dataset.keyboardTarget = 'append';
973
2353
  }
974
2354
 
@@ -977,7 +2357,7 @@ export class KanbanView extends RecordView {
977
2357
  for (const [id, card] of this._cardsById) {
978
2358
  const selected = this._isViewSelected(id);
979
2359
  card.dataset.selected = String(selected);
980
- if (this.options.selectable) card.setAttribute('aria-selected', String(selected));
2360
+ this._applyCardDescription(card, id);
981
2361
  const input = /** @type {HTMLInputElement|null} */ (card.querySelector('[data-record-selection]'));
982
2362
  if (input) input.checked = selected;
983
2363
  }
@@ -994,15 +2374,61 @@ export class KanbanView extends RecordView {
994
2374
  return /** @type {HTMLElement|null} */ (this._cardsById.get(id)?.querySelector('.zx-kanban-view__move-handle') ?? null);
995
2375
  }
996
2376
 
2377
+ /**
2378
+ * Resolves a collapse control after a board refresh, retaining the same lane copy for columns.
2379
+ * @param {'column'|'swimlane'} axis Collapse axis.
2380
+ * @param {string} id Descriptor id.
2381
+ * @param {string|null} [lane=null] Column copy's lane.
2382
+ * @returns {HTMLElement|null}
2383
+ */
2384
+ _collapseControl(axis, id, lane = null) {
2385
+ return /** @type {HTMLElement|null} */ ([...this._board.querySelectorAll('[data-kanban-collapse]')]
2386
+ .find((candidate) => {
2387
+ const control = /** @type {HTMLElement} */ (candidate);
2388
+ if (control.dataset.kanbanCollapse !== axis || control.dataset.kanbanId !== id) return false;
2389
+ if (axis !== 'column') return true;
2390
+ const section = control.closest('.zx-kanban-view__column');
2391
+ return section ? this._sectionMeta.get(section)?.lane === lane : false;
2392
+ }) ?? null);
2393
+ }
2394
+
2395
+ /**
2396
+ * Runs one presentation hook, ignoring anything that is not a node so a hook cannot smuggle
2397
+ * markup in as a string.
2398
+ * @param {'renderCard'|'renderColumnHeader'|'renderSwimlaneHeader'|'renderColumnEmpty'|'renderDragPreview'} name Hook name.
2399
+ * @param {any} context Hook context.
2400
+ * @returns {Node|null} Node to adopt, or null for the default presentation.
2401
+ */
2402
+ _renderHook(name, context) {
2403
+ const hook = this._kanbanOptions()[name];
2404
+ if (typeof hook !== 'function') return null;
2405
+ const result = hook(context);
2406
+ return result && typeof result === 'object' && 'nodeType' in result ? /** @type {Node} */ (result) : null;
2407
+ }
2408
+
2409
+ /** @param {KanbanColumn} column @param {KanbanSwimlane|null} lane @returns {boolean} */
2410
+ _allowsAdd(column, lane) {
2411
+ const allow = this._kanbanOptions().allowAdd;
2412
+ if (typeof allow === 'function') return allow(column, lane) === true;
2413
+ return allow === true;
2414
+ }
2415
+
997
2416
  /** @param {KanbanRecord} record @param {number} index @returns {string} */
998
2417
  _recordName(record, index) {
999
2418
  const source = this._kanbanOptions().titleField;
1000
2419
  const value = typeof source === 'function' ? source(record, index) : typeof source === 'string'
1001
2420
  ? this._readAxis(source, record, index) : this._viewRecordId(record);
1002
2421
  if (value && typeof value === 'object' && 'nodeType' in value) {
1003
- return /** @type {Node} */ (value).textContent?.trim() || 'record';
2422
+ return /** @type {Node} */ (value).textContent?.trim() || this._text('record');
1004
2423
  }
1005
- return String(value ?? 'record');
2424
+ return String(value ?? this._text('record'));
2425
+ }
2426
+
2427
+ /** @param {KanbanRecord[]} records Moving records. @returns {string} */
2428
+ _movingName(records) {
2429
+ if (!records.length) return this._text('record');
2430
+ if (records.length === 1) return this._recordName(records[0], this._viewData.indexOf(records[0]));
2431
+ return this._text('dragPreview', { count: records.length });
1006
2432
  }
1007
2433
 
1008
2434
  /** @param {KanbanMovePoint} point @returns {string} */
@@ -1013,6 +2439,26 @@ export class KanbanView extends RecordView {
1013
2439
  return `${column?.label ?? point.column}${lane ? `, ${lane.label}` : ''}`;
1014
2440
  }
1015
2441
 
2442
+ /** @param {KanbanMoveEvaluation} evaluation Capacity report. @returns {string} */
2443
+ _wipAnnouncement(evaluation) {
2444
+ if (evaluation.limit == null) return '';
2445
+ if (evaluation.limitExceeded) return this._text('limitExceeded', { limit: evaluation.limit });
2446
+ return evaluation.limitReached ? this._text('limitReached', { limit: evaluation.limit }) : '';
2447
+ }
2448
+
2449
+ /**
2450
+ * Resolves one localizable string.
2451
+ * @param {keyof typeof DEFAULT_LABELS|string} key Label key.
2452
+ * @param {Record<string, unknown>} [args] Placeholder values.
2453
+ * @returns {string} Resolved text.
2454
+ */
2455
+ _text(key, args) {
2456
+ const overrides = this._kanbanOptions().labels;
2457
+ const source = overrides && typeof overrides === 'object' && key in overrides
2458
+ ? overrides[key] : /** @type {Record<string,string>} */ (DEFAULT_LABELS)[key];
2459
+ return printf(source == null ? String(key) : String(source), args);
2460
+ }
2461
+
1016
2462
  /** @param {string} message @returns {void} */
1017
2463
  _announce(message) {
1018
2464
  const token = ++this._announcementToken;
@@ -1053,15 +2499,28 @@ export function normalizeKanbanDescriptors(descriptors, axis = 'column') {
1053
2499
  if (source.accept != null && typeof source.accept !== 'function') {
1054
2500
  throw new TypeError(`Kanban ${axis} ${id} accept must be a function`);
1055
2501
  }
2502
+ if (source.from != null && !Array.isArray(source.from)) {
2503
+ throw new TypeError(`Kanban ${axis} ${id} from must be an array or null`);
2504
+ }
1056
2505
  seen.add(id);
1057
2506
  const normalized = /** @type {Record<string, any>} */ ({
1058
2507
  ...source,
1059
2508
  id,
1060
2509
  label: String(source.label ?? axisLabel(source.id)),
2510
+ limit: normalizeLimit(source.limit),
2511
+ from: Array.isArray(source.from) ? source.from.map(String) : null,
1061
2512
  ...(Object.prototype.hasOwnProperty.call(source, 'value') ? {} : { value: source.id })
1062
2513
  });
1063
- if (axis === 'column') normalized.limit = normalizeLimit(source.limit);
1064
- else delete normalized.limit;
2514
+ if (axis === 'column') {
2515
+ const laneLimits = normalizeLaneLimits(source.laneLimits);
2516
+ if (laneLimits) normalized.laneLimits = laneLimits;
2517
+ if (source.wipPolicy === 'block' || source.wipPolicy === 'warn') {
2518
+ normalized.wipPolicy = source.wipPolicy;
2519
+ }
2520
+ } else {
2521
+ delete normalized.laneLimits;
2522
+ delete normalized.wipPolicy;
2523
+ }
1065
2524
  return normalized;
1066
2525
  }));
1067
2526
  }
@@ -1099,8 +2558,24 @@ export function reconcileKanbanOrder(descriptors, order) {
1099
2558
  }
1100
2559
 
1101
2560
  /**
1102
- * Reorders records around an already-cloned moved record. The input array and every input record
1103
- * remain untouched; destination index is relative to records in that one column/lane.
2561
+ * Reconciles the visible descriptor order while optionally retaining preferences for derived axes
2562
+ * that are absent from the current result set. This lets saved state be applied before an async
2563
+ * query supplies the records from which columns or lanes are derived.
2564
+ * @param {{id:string}[]} descriptors Available descriptors.
2565
+ * @param {unknown} order Preferred order.
2566
+ * @param {boolean} [preserveUnknown=false] Retain temporarily unavailable ids.
2567
+ * @returns {string[]}
2568
+ */
2569
+ export function reconcileKanbanPreference(descriptors, order, preserveUnknown = false) {
2570
+ if (!preserveUnknown) return reconcileKanbanOrder(descriptors, order);
2571
+ const preferred = normalizeOrder(order);
2572
+ const available = descriptors.map((descriptor) => descriptor.id);
2573
+ return [...preferred, ...available.filter((id) => !preferred.includes(id))];
2574
+ }
2575
+
2576
+ /**
2577
+ * Reorders records around one already-cloned moved record. Retained as the single-record form of
2578
+ * `reorderKanbanRecords`; the input array and every input record remain untouched.
1104
2579
  * @param {KanbanRecord[]} records Records.
1105
2580
  * @param {unknown} id Moving record id.
1106
2581
  * @param {KanbanRecord} movedRecord Cloned record carrying its destination axis values.
@@ -1108,46 +2583,9 @@ export function reconcileKanbanOrder(descriptors, order) {
1108
2583
  * @returns {{records:KanbanRecord[],from:KanbanMovePoint,to:KanbanMovePoint}|null}
1109
2584
  */
1110
2585
  export function reorderKanbanData(records, id, movedRecord, options) {
1111
- if (!Array.isArray(records) || !movedRecord || typeof movedRecord !== 'object') return null;
1112
- const sourceIndex = records.findIndex((record) => Object.is(viewRecordId(record, options.recordId), id));
1113
- if (sourceIndex < 0) return null;
1114
- const source = records[sourceIndex];
1115
- const sourceColumn = normalizeAxisId(readKanbanAccessor(source, sourceIndex, options.columnBy));
1116
- const sourceLane = options.swimlaneBy == null ? null
1117
- : normalizeAxisId(readKanbanAccessor(source, sourceIndex, options.swimlaneBy));
1118
- const sourceGroup = records.filter((record, index) =>
1119
- normalizeAxisId(readKanbanAccessor(record, index, options.columnBy)) === sourceColumn
1120
- && (options.swimlaneBy == null
1121
- || normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy)) === sourceLane));
1122
- const from = {
1123
- column: sourceColumn, lane: sourceLane,
1124
- index: sourceGroup.findIndex((record) => Object.is(viewRecordId(record, options.recordId), id))
1125
- };
1126
- const next = records.filter((_, index) => index !== sourceIndex);
1127
- const destinationRecords = next.filter((record, index) =>
1128
- normalizeAxisId(readKanbanAccessor(record, index, options.columnBy)) === options.destination.column
1129
- && (options.swimlaneBy == null
1130
- || normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy)) === options.destination.lane));
1131
- const targetIndex = normalizeMoveIndex(options.destination.index, destinationRecords.length);
1132
- let insertion = next.length;
1133
- if (destinationRecords[targetIndex]) insertion = next.indexOf(destinationRecords[targetIndex]);
1134
- else if (destinationRecords.length) insertion = next.indexOf(destinationRecords.at(-1)) + 1;
1135
- next.splice(insertion, 0, movedRecord);
1136
- return {
1137
- records: next,
1138
- from,
1139
- to: { column: options.destination.column, lane: options.swimlaneBy == null ? null : options.destination.lane, index: targetIndex }
1140
- };
1141
- }
1142
-
1143
- /** @param {KanbanRecord} record @param {number} index @param {KanbanAccessor} accessor @returns {unknown} */
1144
- function readKanbanAccessor(record, index, accessor) {
1145
- return typeof accessor === 'function' ? accessor(record, index) : accessor == null ? null : record?.[accessor];
1146
- }
1147
-
1148
- /** @param {unknown} value @returns {string} */
1149
- function normalizeAxisId(value) {
1150
- return value == null ? '' : String(value);
2586
+ if (!movedRecord || typeof movedRecord !== 'object') return null;
2587
+ const result = reorderKanbanRecords(records, [{ id, record: movedRecord }], options);
2588
+ return result ? { records: result.records, from: result.moves[0].from, to: result.to } : null;
1151
2589
  }
1152
2590
 
1153
2591
  /** @param {unknown} value @returns {string} */
@@ -1164,10 +2602,33 @@ function normalizeLimit(value) {
1164
2602
  return Math.floor(number);
1165
2603
  }
1166
2604
 
1167
- /** @param {unknown} value @param {number} maximum @returns {number} */
1168
- function normalizeMoveIndex(value, maximum) {
1169
- if (value == null || !Number.isFinite(Number(value))) return maximum;
1170
- return Math.max(0, Math.min(maximum, Math.trunc(Number(value))));
2605
+ /** @param {unknown} value @returns {Record<string, number>|undefined} */
2606
+ function normalizeLaneLimits(value) {
2607
+ if (value == null) return undefined;
2608
+ if (typeof value !== 'object' || Array.isArray(value)) {
2609
+ throw new TypeError('Kanban column laneLimits must be an object');
2610
+ }
2611
+ /** @type {Record<string, number>} */
2612
+ const result = {};
2613
+ for (const [lane, limit] of Object.entries(/** @type {Record<string, unknown>} */ (value))) {
2614
+ const normalized = normalizeLimit(limit);
2615
+ if (normalized != null) result[lane] = normalized;
2616
+ }
2617
+ return result;
2618
+ }
2619
+
2620
+ /** @param {KanbanColumn} column @param {string} lane @returns {number|null} */
2621
+ function readColumnLaneLimit(column, lane) {
2622
+ const limits = column.laneLimits;
2623
+ if (!limits || typeof limits !== 'object') return null;
2624
+ const value = limits[lane];
2625
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
2626
+ }
2627
+
2628
+ /** @param {number} count @param {number|null} limit @returns {'none'|'within'|'limit'|'exceeded'} */
2629
+ function wipState(count, limit) {
2630
+ if (limit == null) return 'none';
2631
+ return count > limit ? 'exceeded' : count === limit ? 'limit' : 'within';
1171
2632
  }
1172
2633
 
1173
2634
  /** @param {unknown} value @returns {string[]} */
@@ -1175,6 +2636,14 @@ function normalizeOrder(value) {
1175
2636
  return Array.isArray(value) ? [...new Set(value.map(String))] : [];
1176
2637
  }
1177
2638
 
2639
+ /** @param {unknown} value @returns {string} */
2640
+ function stringifyValue(value) {
2641
+ if (value == null) return '';
2642
+ if (typeof value === 'object') return 'nodeType' in value
2643
+ ? String(/** @type {Node} */ (value).textContent ?? '') : '';
2644
+ return String(value);
2645
+ }
2646
+
1178
2647
  /** @template {{id:string}} T @param {T[]} descriptors @param {string[]} order @returns {T[]} */
1179
2648
  function orderDescriptors(descriptors, order) {
1180
2649
  const byId = new Map(descriptors.map((descriptor) => [descriptor.id, descriptor]));
@@ -1192,6 +2661,28 @@ function moveOrderedId(order, id, targetId, position) {
1192
2661
  return next;
1193
2662
  }
1194
2663
 
2664
+ /**
2665
+ * Replaces only the currently resolved axis positions inside a retained preference. Temporarily
2666
+ * absent derived ids keep their relative slots until later data makes them available again.
2667
+ * @param {string[]} preference Full retained preference.
2668
+ * @param {{id:string}[]} descriptors Currently available descriptors.
2669
+ * @param {string[]} resolvedOrder Next order for available descriptors.
2670
+ * @param {boolean} preserveUnknown Whether unavailable ids should be retained.
2671
+ * @returns {string[]}
2672
+ */
2673
+ function mergeKanbanResolvedOrder(preference, descriptors, resolvedOrder, preserveUnknown) {
2674
+ if (!preserveUnknown) return [...resolvedOrder];
2675
+ const available = new Set(descriptors.map((descriptor) => descriptor.id));
2676
+ const replacements = [...resolvedOrder];
2677
+ let cursor = 0;
2678
+ const merged = normalizeOrder(preference).flatMap((id) => {
2679
+ if (!available.has(id)) return [id];
2680
+ return cursor < replacements.length ? [replacements[cursor++]] : [];
2681
+ });
2682
+ merged.push(...replacements.slice(cursor));
2683
+ return normalizeOrder(merged);
2684
+ }
2685
+
1195
2686
  /** @param {KanbanMovePoint} left @param {KanbanMovePoint} right @returns {boolean} */
1196
2687
  function sameMovePoint(left, right) {
1197
2688
  return left.column === right.column && left.lane === right.lane && left.index === right.index;
@@ -1202,6 +2693,12 @@ function sameArray(left, right) {
1202
2693
  return left.length === right.length && left.every((value, index) => Object.is(value, right[index]));
1203
2694
  }
1204
2695
 
2696
+ /** @param {Set<unknown>|unknown[]} haystack @param {unknown} needle @returns {boolean} */
2697
+ function hasId(haystack, needle) {
2698
+ for (const candidate of haystack) if (Object.is(candidate, needle)) return true;
2699
+ return false;
2700
+ }
2701
+
1205
2702
  /** @param {{target?:EventTarget|null}|Event} event @returns {Element|null} */
1206
2703
  function elementTarget(event) {
1207
2704
  const target = event.target;
@@ -1216,9 +2713,16 @@ function hasTextSelection() {
1216
2713
  }
1217
2714
 
1218
2715
  /**
1219
- * Record movement event emitted before a local commit or external request.
2716
+ * Record movement event emitted before a local commit or external request. `record`, `id`, and
2717
+ * `from` describe the primary record; `records`, `ids`, and `moves` describe the whole atomic move.
1220
2718
  * @event KanbanView#recordmove
1221
- * @type {CustomEvent<{record:KanbanRecord,id:unknown,from:KanbanMovePoint,to:KanbanMovePoint,column:KanbanColumn,swimlane:KanbanSwimlane|null,limitExceeded:boolean}>}
2719
+ * @type {CustomEvent<{record:KanbanRecord,id:unknown,records:KanbanRecord[],ids:unknown[],moves:{id:unknown,from:KanbanMovePoint}[],from:KanbanMovePoint,to:KanbanMovePoint,column:KanbanColumn,swimlane:KanbanSwimlane|null,limitExceeded:boolean}>}
2720
+ */
2721
+
2722
+ /**
2723
+ * Refused movement event. Nothing was written and no `recordmove` was emitted.
2724
+ * @event KanbanView#movereject
2725
+ * @type {CustomEvent<{reason:KanbanRejectReason,ids:unknown[],records:KanbanRecord[],from:KanbanMovePoint|null,to:KanbanMovePoint|null,column:KanbanColumn|null,swimlane:KanbanSwimlane|null,limit:number|null,count:number,message:string}>}
1222
2726
  */
1223
2727
 
1224
2728
  /**
@@ -1226,3 +2730,21 @@ function hasTextSelection() {
1226
2730
  * @event KanbanView#recordaction
1227
2731
  * @type {CustomEvent<{record:KanbanRecord,id:unknown,action:unknown,actionId:string|undefined,event:Event}>}
1228
2732
  */
2733
+
2734
+ /**
2735
+ * Request to create a record in one column and lane. The board never creates records itself.
2736
+ * @event KanbanView#recordadd
2737
+ * @type {CustomEvent<{column:KanbanColumn|null,swimlane:KanbanSwimlane|null,columnId:string,laneId:string|null,event:Event}>}
2738
+ */
2739
+
2740
+ /**
2741
+ * Board search change from the built-in control.
2742
+ * @event KanbanView#searchchange
2743
+ * @type {CustomEvent<{search:string}>}
2744
+ */
2745
+
2746
+ /**
2747
+ * Undo and redo availability after a committed, reverted, or discarded local move.
2748
+ * @event KanbanView#historychange
2749
+ * @type {CustomEvent<{canUndo:boolean,canRedo:boolean,depth:{undo:number,redo:number}}>}
2750
+ */