@zeyos/zx 4.2.0 → 4.3.4

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 (69) hide show
  1. package/CHANGELOG.md +148 -1
  2. package/README.md +5 -3
  3. package/dist/types/components/calendar/calendar-model.d.ts +22 -0
  4. package/dist/types/components/calendar/calendar.d.ts +19 -2
  5. package/dist/types/components/card-view/card-view.d.ts +1 -1
  6. package/dist/types/components/card-view/record-card.d.ts +47 -5
  7. package/dist/types/components/dialog/dialog.d.ts +6 -1
  8. package/dist/types/components/dropdown/dropdown.d.ts +5 -0
  9. package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
  10. package/dist/types/components/kanban-view/kanban-view.d.ts +669 -24
  11. package/dist/types/components/launcher/launcher.d.ts +5 -0
  12. package/dist/types/components/message/message.d.ts +9 -1
  13. package/dist/types/components/modal/modal.d.ts +44 -7
  14. package/dist/types/components/sheet/sheet.d.ts +22 -12
  15. package/dist/types/core/component.d.ts +6 -1
  16. package/dist/types/core/overlay-host.d.ts +12 -0
  17. package/dist/types/index.d.ts +2 -1
  18. package/dist/zx-zeyos.esm.js +173 -43
  19. package/dist/zx-zeyos.esm.min.js +1 -1
  20. package/dist/zx.css +876 -230
  21. package/dist/zx.esm.js +2053 -338
  22. package/dist/zx.esm.min.js +3 -3
  23. package/dist/zx.global.js +2053 -338
  24. package/dist/zx.global.min.js +3 -3
  25. package/dist/zx.min.css +1 -1
  26. package/docs/api.json +250 -2
  27. package/docs/llms.md +67 -48
  28. package/docs/llms.txt +1 -3
  29. package/package.json +1 -2
  30. package/src/components/account-menu/account-menu.css +1 -1
  31. package/src/components/app-rail/app-rail.css +1 -1
  32. package/src/components/calendar/calendar-model.js +45 -0
  33. package/src/components/calendar/calendar.css +96 -34
  34. package/src/components/calendar/calendar.js +122 -29
  35. package/src/components/card-view/card-view.css +211 -9
  36. package/src/components/card-view/card-view.js +7 -7
  37. package/src/components/card-view/record-card.js +92 -19
  38. package/src/components/context-menu/context-menu.css +2 -2
  39. package/src/components/context-menu/context-menu.js +5 -1
  40. package/src/components/date-picker/date-picker.css +18 -11
  41. package/src/components/datebox/date-range-box.css +4 -2
  42. package/src/components/datebox/datebox.css +4 -2
  43. package/src/components/dialog/dialog.css +4 -3
  44. package/src/components/dialog/dialog.js +2 -1
  45. package/src/components/dropdown/dropdown.css +4 -2
  46. package/src/components/dropdown/dropdown.js +7 -2
  47. package/src/components/kanban-view/kanban-policy.js +412 -0
  48. package/src/components/kanban-view/kanban-view.css +480 -36
  49. package/src/components/kanban-view/kanban-view.js +1514 -235
  50. package/src/components/launcher/launcher.css +10 -8
  51. package/src/components/launcher/launcher.js +9 -2
  52. package/src/components/menu-button/menu-button.css +2 -2
  53. package/src/components/message/message.css +15 -25
  54. package/src/components/message/message.js +17 -11
  55. package/src/components/modal/modal.css +6 -4
  56. package/src/components/modal/modal.js +99 -25
  57. package/src/components/select/select.css +9 -7
  58. package/src/components/sheet/sheet.css +25 -7
  59. package/src/components/sheet/sheet.js +43 -23
  60. package/src/components/table/table.css +5 -3
  61. package/src/components/tag-picker/tag-picker.css +5 -3
  62. package/src/components/tooltip/tooltip.css +4 -2
  63. package/src/components/tooltip/tooltip.js +4 -1
  64. package/src/core/component.js +8 -3
  65. package/src/core/overlay-host.js +23 -0
  66. package/src/index.js +8 -3
  67. package/styles/tokens/dark.css +4 -0
  68. package/styles/tokens/semantic.css +83 -8
  69. package/DESIGN-SYSTEM.md +0 -182
@@ -1,15 +1,27 @@
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';
8
+ import { ContextMenu } from '../context-menu/context-menu.js';
6
9
  import { RecordView, readViewField, viewRecordId } from '../view/record-view.js';
10
+ import {
11
+ absoluteKanbanIndex, allowsKanbanTransition, clampKanbanIndex, createKanbanHistory,
12
+ evaluateKanbanMove, kanbanSearchTerms, locateKanbanRecord, matchesKanbanSearch, normalizeAxisId,
13
+ normalizeKanbanRules, readKanbanAccessor, reorderKanbanRecords, resolveKanbanRules
14
+ } from './kanban-policy.js';
7
15
 
8
16
  /** @typedef {Record<string, any>} KanbanRecord */
9
17
  /** @typedef {import('../view/record-view.js').RecordViewOptions} RecordViewOptions */
10
18
  /** @typedef {import('../card-view/record-card.js').RecordCardPreview} RecordCardPreview */
11
19
  /** @typedef {import('../card-view/record-card.js').RecordCardLink} RecordCardLink */
12
20
  /** @typedef {import('../card-view/record-card.js').RecordCardAction} RecordCardAction */
21
+ /** @typedef {import('../menu-button/menu-button.js').MenuItem} MenuItem */
22
+ /** @typedef {import('./kanban-policy.js').KanbanRule} KanbanRule */
23
+ /** @typedef {import('./kanban-policy.js').KanbanRejectReason} KanbanRejectReason */
24
+ /** @typedef {import('./kanban-policy.js').KanbanMoveEvaluation} KanbanMoveEvaluation */
13
25
  /** @typedef {string|((record: KanbanRecord, index: number) => unknown)|null} KanbanAccessor */
14
26
  /** @typedef {string|((record: KanbanRecord, index: number) => unknown)|null} KanbanCardField */
15
27
 
@@ -34,7 +46,11 @@ import { RecordView, readViewField, viewRecordId } from '../view/record-view.js'
34
46
  * @property {string} id Stable column id.
35
47
  * @property {string} label Visible column label.
36
48
  * @property {unknown} [value] Value written by local moves; defaults to `id`.
37
- * @property {number|null} [limit=null] Advisory work-in-progress limit.
49
+ * @property {number|null} [limit=null] Work-in-progress limit across every lane.
50
+ * @property {Record<string, number>} [laneLimits] Per-lane limits inside this column.
51
+ * @property {'warn'|'block'} [wipPolicy] Overrides the board `wipPolicy` for this column.
52
+ * @property {string[]|null} [from=null] Origin column ids allowed to move in. `null`, an absent
53
+ * value, or `['*']` admits any origin; a move that stays in the column is never restricted.
38
54
  * @property {(record: KanbanRecord, context: KanbanMoveContext) => boolean} [accept] Eligibility
39
55
  * predicate. Returning false rejects the move before the cancelable event is emitted.
40
56
  */
@@ -44,6 +60,8 @@ import { RecordView, readViewField, viewRecordId } from '../view/record-view.js'
44
60
  * @property {string} id Stable lane id.
45
61
  * @property {string} label Visible lane label.
46
62
  * @property {unknown} [value] Value written by local moves; defaults to `id`.
63
+ * @property {number|null} [limit=null] Work-in-progress limit across every column in the lane.
64
+ * @property {string[]|null} [from=null] Origin lane ids allowed to move in.
47
65
  * @property {(record: KanbanRecord, context: KanbanMoveContext) => boolean} [accept] Eligibility
48
66
  * predicate. Returning false rejects the move before the cancelable event is emitted.
49
67
  */
@@ -67,6 +85,29 @@ import { RecordView, readViewField, viewRecordId } from '../view/record-view.js'
67
85
  * @property {string[]} collapsedSwimlanes Collapsed swim-lane ids.
68
86
  */
69
87
 
88
+ /**
89
+ * @typedef {Object} KanbanCardContext
90
+ * @property {KanbanRecord} record Record being rendered.
91
+ * @property {unknown} id Record id.
92
+ * @property {number} index Display index in the current record order.
93
+ * @property {string} column Column id.
94
+ * @property {string|null} lane Lane id, or null when lanes are disabled.
95
+ * @property {KanbanRule[]} rules Rules matching this record, in configured order.
96
+ * @property {boolean} selected Whether the record is selected.
97
+ */
98
+
99
+ /**
100
+ * @typedef {Object} KanbanHeaderContext
101
+ * @property {KanbanColumn} [column] Column being rendered.
102
+ * @property {KanbanSwimlane|null} [swimlane] Lane being rendered, or the lane a column belongs to.
103
+ * @property {number} count Records rendered in this section.
104
+ * @property {number} total Records this section holds before search and filters.
105
+ * @property {number} columnTotal Records in the whole column, across lanes.
106
+ * @property {number|null} limit Applicable work-in-progress limit.
107
+ * @property {'none'|'within'|'limit'|'exceeded'} wip Capacity state.
108
+ * @property {boolean} collapsed Whether the section is collapsed.
109
+ */
110
+
70
111
  /**
71
112
  * @typedef {Object} KanbanViewOptions
72
113
  * @property {import('../view/record-view.js').ViewField[]} [fields=[]] Shared field descriptors.
@@ -91,6 +132,31 @@ import { RecordView, readViewField, viewRecordId } from '../view/record-view.js'
91
132
  * @property {RecordCardLink|((record:KanbanRecord,index:number)=>RecordCardLink)|null} [link=null] Record-card primary link descriptor or reader.
92
133
  * @property {RecordCardAction[]|((record:KanbanRecord,index:number)=>RecordCardAction[])} [actions=[]] Card actions.
93
134
  * @property {'local'|'external'} [moveMode='local'] Whether accepted moves update local data.
135
+ * @property {'card'|'handle'} [dragFrom='card'] Whether the whole card or only its handle starts a
136
+ * pointer drag. Touch drags from the card body require a long press so the board still scrolls.
137
+ * @property {boolean} [multiMove=true] Move the whole selection when a selected card is picked up.
138
+ * @property {boolean} [autoScroll=true] Scroll the board and columns while dragging near an edge.
139
+ * @property {'warn'|'block'} [wipPolicy='warn'] Whether exceeding a limit warns or refuses.
140
+ * @property {KanbanRule[]} [rules=[]] Ordered visual card rules.
141
+ * @property {string} [search=''] Board search text.
142
+ * @property {string[]|null} [searchFields=null] Field ids searched; null searches visible fields
143
+ * plus the title and subtitle sources.
144
+ * @property {((record:KanbanRecord,index:number)=>boolean)|null} [filter=null] Extra card predicate.
145
+ * @property {boolean} [searchControl=false] Render the built-in board search control.
146
+ * @property {boolean} [history=true] Record committed local moves for undo and redo.
147
+ * @property {number} [historyLimit=50] Maximum remembered move steps.
148
+ * @property {boolean} [historyControls=false] Render built-in undo and redo controls.
149
+ * @property {string|null} [columnHeight=null] CSS length turning each column into a scroll region.
150
+ * @property {boolean|((column:KanbanColumn,swimlane:KanbanSwimlane|null)=>boolean)} [allowAdd=false]
151
+ * Render a per-column add control that emits `recordadd`.
152
+ * @property {boolean|MenuItem[]|((context:KanbanCardContext)=>MenuItem[])} [contextMenu=false]
153
+ * Card context menu. `true` offers move commands; an array or function appends application items.
154
+ * @property {((context:KanbanCardContext)=>Node|null|undefined)|null} [renderCard=null] Replaces card content.
155
+ * @property {((context:KanbanHeaderContext)=>Node|null|undefined)|null} [renderColumnHeader=null] Replaces column header content.
156
+ * @property {((context:KanbanHeaderContext)=>Node|null|undefined)|null} [renderSwimlaneHeader=null] Replaces lane header content.
157
+ * @property {((context:KanbanHeaderContext)=>Node|null|undefined)|null} [renderColumnEmpty=null] Empty-column placeholder.
158
+ * @property {((context:{records:KanbanRecord[],ids:unknown[],count:number})=>Node|null|undefined)|null} [renderDragPreview=null] Replaces the floating drag preview.
159
+ * @property {Record<string,string>} [labels={}] Overrides for every visible and announced string.
94
160
  * @property {string[]} [columnOrder=[]] Preferred column order.
95
161
  * @property {string[]} [swimlaneOrder=[]] Preferred swim-lane order.
96
162
  * @property {string[]} [collapsedColumns=[]] Initially collapsed columns.
@@ -100,21 +166,97 @@ import { RecordView, readViewField, viewRecordId } from '../view/record-view.js'
100
166
  * @property {'outlined'|'raised'|'filled'} [variant='outlined'] Shared record-card treatment.
101
167
  * @property {string} [label='Kanban board'] Accessible board label.
102
168
  * @property {(event: CustomEvent<Record<string,unknown>>) => void} [onrecordmove]
169
+ * @property {(event: CustomEvent<Record<string,unknown>>) => void} [onmovereject]
103
170
  * @property {(event: CustomEvent<Record<string,unknown>>) => void} [onrecordaction]
171
+ * @property {(event: CustomEvent<Record<string,unknown>>) => void} [onrecordadd]
172
+ * @property {(event: CustomEvent<{search:string}>) => void} [onsearchchange]
173
+ * @property {(event: CustomEvent<{canUndo:boolean,canRedo:boolean,depth:{undo:number,redo:number}}>) => void} [onhistorychange]
104
174
  */
105
175
 
106
176
  /** @typedef {{record:KanbanRecord,id:unknown,index:number,column:string,lane:string|null}} KanbanCardMeta */
107
177
  /** @typedef {{column:string,lane:string|null,list:HTMLElement}} KanbanSectionMeta */
108
- /** @typedef {{id:unknown,from:KanbanMovePoint,to:KanbanMovePoint}} KanbanKeyboardMove */
178
+ /** @typedef {{ids:unknown[],from:KanbanMovePoint,to:KanbanMovePoint}} KanbanKeyboardMove */
109
179
 
110
180
  const INTERACTIVE = 'a, button, input, select, textarea, summary, [contenteditable="true"]';
111
181
 
182
+ /** Distance a pointer must travel before a press becomes a drag rather than a click. */
183
+ const DRAG_THRESHOLD = 4;
184
+
185
+ /** Delay before a touch press on a card body becomes a drag instead of a scroll. */
186
+ const TOUCH_HOLD = 350;
187
+
188
+ /** Keystrokes are coalesced for this long before the built-in search control rebuilds the board. */
189
+ const SEARCH_DEBOUNCE = 120;
190
+
191
+ /** Distance from a scroll edge at which a drag starts auto-scrolling, and the speed it uses. */
192
+ const SCROLL_EDGE = 56;
193
+ const SCROLL_SPEED = 18;
194
+
112
195
  /**
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.
196
+ * Every user-visible and announced string, so a host can translate the board without patching it.
197
+ * Values run through `printf`, so named placeholders may be reordered or dropped by a translation.
198
+ */
199
+ const DEFAULT_LABELS = Object.freeze({
200
+ fields: 'Fields',
201
+ search: 'Search board',
202
+ searchPlaceholder: 'Search cards',
203
+ clearSearch: 'Clear search',
204
+ searchSummary: '%count% of %total% cards match.',
205
+ undo: 'Undo',
206
+ redo: 'Redo',
207
+ add: 'Add card',
208
+ addTo: 'Add card to %target%',
209
+ move: 'Move %name%',
210
+ moveMultiple: 'Move %count% selected cards',
211
+ collapseColumn: 'Collapse column %label%',
212
+ expandColumn: 'Expand column %label%',
213
+ collapseLane: 'Collapse swim lane %label%',
214
+ expandLane: 'Expand swim lane %label%',
215
+ count: '%count% records',
216
+ countFiltered: '%count% of %total% records',
217
+ countLimit: '%count% records, work-in-progress limit %limit%',
218
+ countLimitInLane: '%count% records in this lane, %total% of work-in-progress limit %limit% in the column',
219
+ empty: 'No records',
220
+ columnEmpty: '',
221
+ selected: 'Selected',
222
+ notSelected: 'Not selected',
223
+ noMatches: 'No cards match the current search.',
224
+ record: 'record',
225
+ grabbed: 'Grabbed %name%. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.',
226
+ grabbedMultiple: 'Grabbed %count% cards. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.',
227
+ target: 'Target %target%, position %position% of %total%.',
228
+ moved: 'Moved %name% to %target%.%wip%',
229
+ movedMultiple: 'Moved %count% cards to %target%.%wip%',
230
+ moveRequested: 'Move requested for %name% to %target%.%wip%',
231
+ moveCanceled: 'Move canceled for %name%.',
232
+ moveCanceledPlain: 'Move canceled.',
233
+ limitReached: ' Work-in-progress limit %limit% reached.',
234
+ limitExceeded: ' Work-in-progress limit %limit% exceeded.',
235
+ rejectAccept: 'Move rejected: %target% does not accept %name%.',
236
+ rejectTransition: 'Move rejected: %target% does not accept cards from %origin%.',
237
+ rejectWip: 'Move rejected: %target% is at its work-in-progress limit of %limit%.',
238
+ rejectDestination: 'Move rejected: the destination is not available.',
239
+ rejectGrouping: 'Move rejected: callback grouping requires external move mode.',
240
+ undone: 'Move undone.',
241
+ redone: 'Move redone.',
242
+ nothingToUndo: 'Nothing to undo.',
243
+ nothingToRedo: 'Nothing to redo.',
244
+ menuMoveTo: 'Move to %target%',
245
+ menuMoveToLane: 'Move to lane %target%',
246
+ dragPreview: '%count% cards'
247
+ });
248
+
249
+ /**
250
+ * Configurable record board with semantic columns, optional swim lanes, and equivalent pointer,
251
+ * touch, keyboard, context-menu, and programmatic movement. Local moves clone records; callback
252
+ * grouping remains fully usable for display and external moves but cannot be written safely by
253
+ * local cross-axis moves.
116
254
  * @fires KanbanView#recordmove
255
+ * @fires KanbanView#movereject
117
256
  * @fires KanbanView#recordaction
257
+ * @fires KanbanView#recordadd
258
+ * @fires KanbanView#searchchange
259
+ * @fires KanbanView#historychange
118
260
  * @extends {RecordView}
119
261
  */
120
262
  export class KanbanView extends RecordView {
@@ -134,6 +276,27 @@ export class KanbanView extends RecordView {
134
276
  link: null,
135
277
  actions: [],
136
278
  moveMode: 'local',
279
+ dragFrom: 'card',
280
+ multiMove: true,
281
+ autoScroll: true,
282
+ wipPolicy: 'warn',
283
+ rules: [],
284
+ search: '',
285
+ searchFields: null,
286
+ filter: null,
287
+ searchControl: false,
288
+ history: true,
289
+ historyLimit: 50,
290
+ historyControls: false,
291
+ columnHeight: null,
292
+ allowAdd: false,
293
+ contextMenu: false,
294
+ renderCard: null,
295
+ renderColumnHeader: null,
296
+ renderSwimlaneHeader: null,
297
+ renderColumnEmpty: null,
298
+ renderDragPreview: null,
299
+ labels: {},
137
300
  columnOrder: [],
138
301
  swimlaneOrder: [],
139
302
  collapsedColumns: [],
@@ -170,16 +333,36 @@ export class KanbanView extends RecordView {
170
333
  this._kanbanSwimlaneOrder = normalizeOrder(options.swimlaneOrder);
171
334
  this._collapsedColumns = new Set(normalizeOrder(options.collapsedColumns));
172
335
  this._collapsedSwimlanes = new Set(normalizeOrder(options.collapsedSwimlanes));
336
+ this._kanbanRules = normalizeKanbanRules(options.rules);
337
+ this._kanbanSearch = options.search == null ? '' : String(options.search);
338
+ this._kanbanFilter = typeof options.filter === 'function' ? options.filter : null;
339
+ this._kanbanHistory = createKanbanHistory(options.historyLimit);
173
340
  this._keyboardMove = /** @type {KanbanKeyboardMove|null} */ (null);
174
- this._dragId = null;
341
+ this._dragIds = /** @type {unknown[]} */ ([]);
175
342
  this._dragDestination = /** @type {KanbanMovePoint|null} */ (null);
343
+ this._pointerStart = /** @type {{x:number,y:number,id:number,ids:unknown[],touch:boolean}|null} */ (null);
344
+ this._pointerHold = 0;
345
+ this._dragFrame = 0;
346
+ this._dragPreview = /** @type {HTMLElement|null} */ (null);
347
+ this._dragPointer = /** @type {number|null} */ (null);
348
+ this._dragCaptured = false;
349
+ this._searchTimer = 0;
350
+ this._dragPoint = { x: 0, y: 0 };
351
+ this._suppressClick = false;
176
352
  this._announcementToken = 0;
177
353
  this._cardMeta = new WeakMap();
178
354
  this._sectionMeta = new WeakMap();
179
355
  this._cardsById = new Map();
356
+ this._visibleIds = new Set();
357
+ this._kanbanMenu = /** @type {ContextMenu|null} */ (null);
358
+ this._searchInput = /** @type {HTMLInputElement|null} */ (null);
359
+ this._undoButton = /** @type {HTMLButtonElement|null} */ (null);
360
+ this._redoButton = /** @type {HTMLButtonElement|null} */ (null);
180
361
 
181
362
  this._toolbar = h('div', { class: 'zx-record-view__toolbar zx-kanban-view__toolbar' });
182
- if (options.fieldControls) this._toolbar.append(this._createViewFieldControls());
363
+ if (options.fieldControls) this._toolbar.append(this._createViewFieldControls(this._text('fields')));
364
+ if (options.searchControl) this._toolbar.append(this._createSearchControl());
365
+ if (options.historyControls) this._toolbar.append(this._createHistoryControls());
183
366
  this._board = h('div', {
184
367
  class: 'zx-kanban-view__board', role: 'region', ariaLabel: options.label
185
368
  });
@@ -187,16 +370,24 @@ export class KanbanView extends RecordView {
187
370
  class: 'zx-kanban-view__live', role: 'status', ariaLive: 'polite', ariaAtomic: 'true'
188
371
  });
189
372
  root.replaceChildren(this._toolbar, this._board, this._live);
373
+ if (options.columnHeight) {
374
+ root.style.setProperty('--zx-kanban-column-height', String(options.columnHeight));
375
+ root.dataset.columnScroll = 'true';
376
+ }
190
377
 
191
378
  this.listen(this._board, 'click', (event) => this._handleBoardClick(/** @type {MouseEvent} */ (event)));
192
379
  this.listen(this._board, 'dblclick', (event) => this._handleBoardDoubleClick(/** @type {MouseEvent} */ (event)));
193
380
  this.listen(this._board, 'keydown', (event) => this._handleBoardKeydown(/** @type {KeyboardEvent} */ (event)));
194
381
  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());
382
+ this.listen(this._board, 'pointerdown', (event) => this._handlePointerDown(/** @type {PointerEvent} */ (event)));
383
+ this.listen(this._board, 'pointermove', (event) => this._handlePointerMove(/** @type {PointerEvent} */ (event)));
384
+ this.listen(this._board, 'pointerup', (event) => this._handlePointerUp(/** @type {PointerEvent} */ (event)));
385
+ this.listen(this._board, 'pointercancel', () => this._clearDrag());
386
+ // Native drag would fight the pointer path and cannot serve touch at all; suppress the
387
+ // browser's own text and image dragging so only one movement model is ever in flight.
388
+ this.listen(this._board, 'dragstart', (event) => event.preventDefault());
199
389
  this._refreshView('init');
390
+ if (options.contextMenu) this._createContextMenu();
200
391
  return root;
201
392
  } catch (error) {
202
393
  if (this._kanbanSnapshot) restoreTarget(root, this._kanbanSnapshot);
@@ -216,7 +407,13 @@ export class KanbanView extends RecordView {
216
407
  */
217
408
  setColumns(columns) {
218
409
  this._kanbanColumns = columns === null ? null : normalizeKanbanDescriptors(columns, 'column');
219
- this._kanbanColumnOrder = reconcileKanbanOrder(this._rawColumns(), this._kanbanColumnOrder);
410
+ const descriptors = this._rawColumns();
411
+ this._kanbanColumnOrder = reconcileKanbanPreference(
412
+ descriptors, this._kanbanColumnOrder, this._kanbanColumns === null);
413
+ if (this._kanbanColumns !== null) {
414
+ const ids = new Set(descriptors.map((column) => column.id));
415
+ this._collapsedColumns = new Set([...this._collapsedColumns].filter((id) => ids.has(id)));
416
+ }
220
417
  this._refreshView('columns');
221
418
  this._emitKanbanState('columns');
222
419
  return this;
@@ -234,7 +431,14 @@ export class KanbanView extends RecordView {
234
431
  */
235
432
  setSwimlanes(lanes) {
236
433
  this._kanbanSwimlanes = lanes === null ? null : normalizeKanbanDescriptors(lanes, 'swimlane');
237
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(this._rawSwimlanes(), this._kanbanSwimlaneOrder);
434
+ const descriptors = this._rawSwimlanes();
435
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
436
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(
437
+ descriptors, this._kanbanSwimlaneOrder, preserveUnknown);
438
+ if (!preserveUnknown) {
439
+ const ids = new Set(descriptors.map((lane) => lane.id));
440
+ this._collapsedSwimlanes = new Set([...this._collapsedSwimlanes].filter((id) => ids.has(id)));
441
+ }
238
442
  this._refreshView('swimlanes');
239
443
  this._emitKanbanState('swimlanes');
240
444
  return this;
@@ -252,9 +456,12 @@ export class KanbanView extends RecordView {
252
456
  * @returns {this}
253
457
  */
254
458
  setColumnOrder(order, options = {}) {
255
- const next = reconcileKanbanOrder(this._rawColumns(), order);
256
- if (sameArray(next, this.getColumnOrder())) return this;
257
- this._kanbanColumnOrder = next;
459
+ const descriptors = this._rawColumns();
460
+ const current = this.getColumnOrder();
461
+ const next = reconcileKanbanOrder(descriptors, order);
462
+ if (sameArray(next, current)) return this;
463
+ this._kanbanColumnOrder = mergeKanbanResolvedOrder(
464
+ this._kanbanColumnOrder, descriptors, next, this._kanbanColumns === null);
258
465
  this._refreshView('columnorder');
259
466
  if (!options.silent) this._emitKanbanState('columnorder');
260
467
  return this;
@@ -283,9 +490,13 @@ export class KanbanView extends RecordView {
283
490
  * @returns {this}
284
491
  */
285
492
  setSwimlaneOrder(order, options = {}) {
286
- const next = reconcileKanbanOrder(this._rawSwimlanes(), order);
287
- if (sameArray(next, this.getSwimlaneOrder())) return this;
288
- this._kanbanSwimlaneOrder = next;
493
+ const descriptors = this._rawSwimlanes();
494
+ const current = this.getSwimlaneOrder();
495
+ const next = reconcileKanbanOrder(descriptors, order);
496
+ if (sameArray(next, current)) return this;
497
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
498
+ this._kanbanSwimlaneOrder = mergeKanbanResolvedOrder(
499
+ this._kanbanSwimlaneOrder, descriptors, next, preserveUnknown);
289
500
  this._refreshView('swimlaneorder');
290
501
  if (!options.silent) this._emitKanbanState('swimlaneorder');
291
502
  return this;
@@ -361,19 +572,38 @@ export class KanbanView extends RecordView {
361
572
  }
362
573
 
363
574
  /**
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.
575
+ * Proposes and, in local mode, commits a record move. Equivalent to `moveRecords([id], …)`.
366
576
  * @param {unknown} id Record id.
367
577
  * @param {KanbanDestination} destination Destination.
368
578
  * @returns {this}
369
579
  * @fires KanbanView#recordmove
580
+ * @fires KanbanView#movereject
370
581
  * @fires RecordView#datachange
371
582
  */
372
583
  moveRecord(id, destination) {
584
+ return this.moveRecords([id], destination);
585
+ }
586
+
587
+ /**
588
+ * Proposes and, in local mode, commits an atomic move of one or more records. Destination
589
+ * eligibility, transition rules, and work-in-progress policy are all checked before a single
590
+ * cancelable `recordmove` event; the move commits completely or not at all, and the moving
591
+ * records keep their relative order at the destination.
592
+ * @param {unknown[]} ids Record ids, in any order.
593
+ * @param {KanbanDestination} destination Destination.
594
+ * @returns {this}
595
+ * @fires KanbanView#recordmove
596
+ * @fires KanbanView#movereject
597
+ * @fires RecordView#datachange
598
+ */
599
+ moveRecords(ids, destination) {
373
600
  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;
601
+ if (!Array.isArray(ids) || !destination || typeof destination !== 'object') return this;
602
+ const moving = this._orderedIds(ids);
603
+ if (!moving.length) return this;
604
+ const records = moving.map((id) => /** @type {KanbanRecord} */ (this.getRecord(id)));
605
+ const origins = moving.map((id) => /** @type {KanbanMovePoint} */ (this._recordLocation(id)));
606
+ const from = origins[0];
377
607
  const columns = this._resolvedColumns();
378
608
  const lanes = this._resolvedSwimlanes();
379
609
  const columnId = destination.column == null ? from.column : String(destination.column);
@@ -381,35 +611,42 @@ export class KanbanView extends RecordView {
381
611
  ? null : destination.lane === undefined ? from.lane : normalizeAxisId(destination.lane);
382
612
  const column = columns.find((candidate) => candidate.id === columnId);
383
613
  const lane = options.swimlaneBy == null
384
- ? null : lanes.find((candidate) => candidate.id === laneId);
614
+ ? null : lanes.find((candidate) => candidate.id === laneId) ?? null;
385
615
  if (!column || options.swimlaneBy != null && !lane) {
386
- this._announce('Move rejected: the destination is not available.');
616
+ this._rejectMove('destination', { ids: moving, records, from, to: null, column: null, swimlane: null });
387
617
  return this;
388
618
  }
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);
619
+
620
+ const excluded = new Set(moving);
621
+ const cellCount = this._recordsIn(columnId, laneId)
622
+ .filter((entry) => !excluded.has(this._viewRecordId(entry.record))).length;
623
+ const index = clampKanbanIndex(destination.index, cellCount);
395
624
  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)}.`);
625
+ if (moving.length === 1 && sameMovePoint(from, to)) return this;
626
+ const context = {
627
+ id: moving[0], from: { ...from }, to: { ...to },
628
+ column: { ...column }, swimlane: lane ? { ...lane } : null
629
+ };
630
+ const evaluation = this._evaluateMove(records, origins, column, lane, to, context, excluded);
631
+ if (!evaluation.allowed) {
632
+ this._rejectMove(/** @type {KanbanRejectReason} */ (evaluation.reason), {
633
+ ids: moving, records, from, to, column, swimlane: lane, evaluation
634
+ });
401
635
  return this;
402
636
  }
403
637
 
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.');
638
+ const entries = [];
639
+ for (let position = 0; position < moving.length; position += 1) {
640
+ const record = records[position];
641
+ const moved = options.moveMode === 'local'
642
+ ? this._localMovedRecord(record, origins[position], to, column, lane) : record;
643
+ if (!moved) {
644
+ this._rejectMove('grouping', { ids: moving, records, from, to, column, swimlane: lane });
409
645
  return this;
410
646
  }
647
+ entries.push({ id: moving[position], record: moved });
411
648
  }
412
- const next = reorderKanbanData(this._viewData, id, movedRecord, {
649
+ const next = reorderKanbanRecords(this._viewData, entries, {
413
650
  recordId: options.recordId,
414
651
  columnBy: (candidate, candidateIndex) => this._readAxis(options.columnBy, candidate, candidateIndex),
415
652
  swimlaneBy: options.swimlaneBy == null ? null
@@ -417,39 +654,161 @@ export class KanbanView extends RecordView {
417
654
  destination: to
418
655
  });
419
656
  if (!next) {
420
- this._announce('Move rejected: the destination could not be resolved.');
657
+ this._rejectMove('destination', { ids: moving, records, from, to, column, swimlane: lane });
421
658
  return this;
422
659
  }
423
- const finalCount = destinationColumnCount + 1;
424
- const limitExceeded = column.limit != null && finalCount > column.limit;
660
+
661
+ const wip = this._wipAnnouncement(evaluation);
425
662
  const moveEvent = this.emit('recordmove', {
426
- record, id, from: { ...from }, to: { ...next.to }, column: { ...column },
427
- swimlane: lane ? { ...lane } : null, limitExceeded
428
- });
663
+ record: records[0], id: moving[0], records: [...records], ids: [...moving],
664
+ moves: next.moves.map((move) => ({ id: move.id, from: { ...move.from } })),
665
+ from: { ...from }, to: { ...next.to }, column: { ...column },
666
+ swimlane: lane ? { ...lane } : null, limitExceeded: evaluation.limitExceeded
667
+ }, { honorDomCancellation: true });
429
668
  if (moveEvent.defaultPrevented) {
430
- this._announce(`Move canceled for ${this._recordName(record, from.index)}.`);
669
+ this._announce(this._text('moveCanceled', { name: this._movingName(records) }));
431
670
  return this;
432
671
  }
433
672
  if (options.moveMode === 'external') {
434
- this._announce(`Move requested for ${this._recordName(record, from.index)} to ${this._targetName(next.to)}.`);
673
+ this._announce(this._text('moveRequested', {
674
+ name: this._movingName(records), target: this._targetName(next.to), wip
675
+ }));
435
676
  return this;
436
677
  }
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());
678
+ this._commitLocalRecords(next.records);
679
+ this._announce(moving.length > 1
680
+ ? this._text('movedMultiple', { count: moving.length, target: this._targetName(next.to), wip })
681
+ : this._text('moved', {
682
+ name: this._recordName(entries[0].record, next.to.index), target: this._targetName(next.to), wip
683
+ }));
684
+ queueMicrotask(() => this._moveHandle(moving[0])?.focus());
685
+ return this;
686
+ }
687
+
688
+ /** Whether a committed local move can be reverted. @returns {boolean} */
689
+ canUndo() {
690
+ return this._kanbanOptions().history !== false && this._kanbanHistory.canUndo();
691
+ }
692
+
693
+ /** Whether a reverted local move can be reapplied. @returns {boolean} */
694
+ canRedo() {
695
+ return this._kanbanOptions().history !== false && this._kanbanHistory.canRedo();
696
+ }
697
+
698
+ /**
699
+ * Reverts the last committed local move, restoring the exact record order and selection.
700
+ * @returns {this}
701
+ * @fires RecordView#datachange
702
+ * @fires KanbanView#historychange
703
+ */
704
+ undo() {
705
+ return this._travelHistory('undo');
706
+ }
707
+
708
+ /**
709
+ * Reapplies the last reverted local move.
710
+ * @returns {this}
711
+ * @fires RecordView#datachange
712
+ * @fires KanbanView#historychange
713
+ */
714
+ redo() {
715
+ return this._travelHistory('redo');
716
+ }
717
+
718
+ /** Empties both history stacks. @returns {this} @fires KanbanView#historychange */
719
+ clearHistory() {
720
+ this._kanbanHistory.clear();
721
+ this._syncHistoryControls();
722
+ this._emitHistory();
723
+ return this;
724
+ }
725
+
726
+ /** Returns the current board search text. @returns {string} */
727
+ getSearch() {
728
+ return this._kanbanSearch;
729
+ }
730
+
731
+ /**
732
+ * Narrows visible cards to those matching a search. Hidden records keep their position in the
733
+ * data, so a card dropped between two visible cards still lands between them.
734
+ * @param {string} search Search text.
735
+ * @param {{silent?:boolean}} [options={}] Event behavior.
736
+ * @returns {this}
737
+ * @fires KanbanView#searchchange
738
+ */
739
+ setSearch(search, options = {}) {
740
+ const next = search == null ? '' : String(search);
741
+ if (next === this._kanbanSearch) return this;
742
+ this._kanbanSearch = next;
743
+ if (this._searchInput && this._searchInput.value !== next) this._searchInput.value = next;
744
+ this._refreshView('search');
745
+ if (!options.silent) this.emit('searchchange', { search: next });
746
+ return this;
747
+ }
748
+
749
+ /**
750
+ * Replaces the extra card predicate. Pass null to clear it.
751
+ * @param {((record:KanbanRecord,index:number)=>boolean)|null} filter Predicate.
752
+ * @returns {this}
753
+ */
754
+ setFilter(filter) {
755
+ if (filter != null && typeof filter !== 'function') {
756
+ throw new TypeError('Kanban filter must be a function or null');
757
+ }
758
+ this._kanbanFilter = filter ?? null;
759
+ this._refreshView('filter');
442
760
  return this;
443
761
  }
444
762
 
763
+ /** Returns the records currently rendered, in board order. @returns {KanbanRecord[]} */
764
+ getVisibleRecords() {
765
+ return this._viewData.filter((record) => this._visibleIds.has(this._viewRecordId(record)));
766
+ }
767
+
768
+ /**
769
+ * Replaces every record. History is dropped because its snapshots describe records the host has
770
+ * just replaced, and reinstating them would resurrect data the application no longer owns.
771
+ * @param {KanbanRecord[]} records New records.
772
+ * @returns {this}
773
+ */
774
+ setData(records) {
775
+ this._resetHistory();
776
+ return super.setData(records);
777
+ }
778
+
779
+ /** @param {KanbanRecord[]} records Records to append. @returns {this} */
780
+ addData(records) {
781
+ this._resetHistory();
782
+ return super.addData(records);
783
+ }
784
+
785
+ /** @param {unknown} id Existing record id. @param {KanbanRecord} record Replacement. @returns {this} */
786
+ updateRecord(id, record) {
787
+ this._resetHistory();
788
+ return super.updateRecord(id, record);
789
+ }
790
+
791
+ /** @param {unknown} id Record id. @returns {this} */
792
+ removeRecord(id) {
793
+ this._resetHistory();
794
+ return super.removeRecord(id);
795
+ }
796
+
445
797
  /** Returns common plus board-specific serializable configuration. @returns {KanbanViewState} */
446
798
  getViewState() {
799
+ // Resolve once so newly derived axes join the preference without discarding saved axes that are
800
+ // temporarily absent from an empty or partial result set.
801
+ this.getColumnOrder();
802
+ this.getSwimlaneOrder();
803
+ const derivedColumns = this._kanbanColumns === null;
804
+ const derivedSwimlanes = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
447
805
  return {
448
806
  ...super.getViewState(),
449
- columnOrder: this.getColumnOrder(),
450
- swimlaneOrder: this.getSwimlaneOrder(),
451
- collapsedColumns: this.getCollapsedColumns(),
452
- collapsedSwimlanes: this.getCollapsedSwimlanes()
807
+ columnOrder: derivedColumns ? [...this._kanbanColumnOrder] : this.getColumnOrder(),
808
+ swimlaneOrder: derivedSwimlanes ? [...this._kanbanSwimlaneOrder] : this.getSwimlaneOrder(),
809
+ collapsedColumns: derivedColumns ? [...this._collapsedColumns] : this.getCollapsedColumns(),
810
+ collapsedSwimlanes: derivedSwimlanes
811
+ ? [...this._collapsedSwimlanes] : this.getCollapsedSwimlanes()
453
812
  };
454
813
  }
455
814
 
@@ -462,17 +821,25 @@ export class KanbanView extends RecordView {
462
821
  setViewState(state, options = {}) {
463
822
  const source = state && typeof state === 'object' ? state : {};
464
823
  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));
824
+ const columns = this._rawColumns();
825
+ const lanes = this._rawSwimlanes();
826
+ const derivedColumns = this._kanbanColumns === null;
827
+ const derivedSwimlanes = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
828
+ this._kanbanColumnOrder = reconcileKanbanPreference(columns,
829
+ Array.isArray(source.columnOrder) ? source.columnOrder : this._kanbanColumnOrder,
830
+ derivedColumns);
831
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(lanes,
832
+ Array.isArray(source.swimlaneOrder) ? source.swimlaneOrder : this._kanbanSwimlaneOrder,
833
+ derivedSwimlanes);
834
+ const columnIds = new Set(columns.map((column) => column.id));
835
+ const laneIds = new Set(lanes.map((lane) => lane.id));
471
836
  if (Array.isArray(source.collapsedColumns)) {
472
- this._collapsedColumns = new Set(source.collapsedColumns.map(String).filter((id) => columnIds.has(id)));
837
+ const collapsed = normalizeOrder(source.collapsedColumns);
838
+ this._collapsedColumns = new Set(derivedColumns ? collapsed : collapsed.filter((id) => columnIds.has(id)));
473
839
  }
474
840
  if (Array.isArray(source.collapsedSwimlanes)) {
475
- this._collapsedSwimlanes = new Set(source.collapsedSwimlanes.map(String).filter((id) => laneIds.has(id)));
841
+ const collapsed = normalizeOrder(source.collapsedSwimlanes);
842
+ this._collapsedSwimlanes = new Set(derivedSwimlanes ? collapsed : collapsed.filter((id) => laneIds.has(id)));
476
843
  }
477
844
  this._refreshView('state');
478
845
  if (!options.silent) this._emitKanbanState('restore');
@@ -483,6 +850,11 @@ export class KanbanView extends RecordView {
483
850
  destroy() {
484
851
  if (this._kanbanDestroyed) return;
485
852
  this._kanbanDestroyed = true;
853
+ this._clearDrag();
854
+ clearTimeout(this._pointerHold);
855
+ clearTimeout(this._searchTimer);
856
+ this._kanbanMenu?.destroy();
857
+ this._kanbanMenu = null;
486
858
  const root = this.el;
487
859
  const snapshot = this._kanbanSnapshot;
488
860
  super.destroy();
@@ -498,25 +870,52 @@ export class KanbanView extends RecordView {
498
870
  }
499
871
  if (_reason === 'loading') return;
500
872
  const options = this._kanbanOptions();
873
+ const activeElement = document.activeElement;
501
874
  const activeMeta = this._metaForTarget(document.activeElement);
502
875
  const restoreHandle = Boolean(document.activeElement?.classList?.contains('zx-kanban-view__move-handle'));
876
+ const activeCollapse = activeElement instanceof Element
877
+ ? activeElement.closest('[data-kanban-collapse]') : null;
878
+ const activeSection = activeCollapse?.closest('.zx-kanban-view__column');
879
+ const activeCollapseFocus = activeCollapse && this._board.contains(activeCollapse) ? {
880
+ axis: /** @type {'column'|'swimlane'} */ (/** @type {HTMLElement} */ (activeCollapse).dataset.kanbanCollapse),
881
+ id: String(/** @type {HTMLElement} */ (activeCollapse).dataset.kanbanId),
882
+ lane: activeSection ? this._sectionMeta.get(activeSection)?.lane ?? null : null
883
+ } : null;
503
884
  const columns = this._resolvedColumns();
504
885
  const lanes = this._resolvedSwimlanes();
505
886
  this._cardMeta = new WeakMap();
506
887
  this._sectionMeta = new WeakMap();
507
888
  this._cardsById = new Map();
889
+ this._resolveVisibility();
508
890
  const content = [];
509
891
  if (options.swimlaneBy == null) {
510
892
  content.push(this._renderColumns(columns, null, false));
511
893
  } else {
512
894
  lanes.forEach((lane) => content.push(this._renderLane(lane, columns)));
513
895
  }
514
- const shownCards = this._cardsById.size;
515
- if (shownCards === 0 && !this._viewData.length) content.push(this._emptyContent());
896
+ if (!this._viewData.length) content.push(this._emptyContent(this._text('empty')));
897
+ else if (!this._visibleIds.size) content.push(this._emptyContent(this._text('noMatches')));
516
898
  this._board.replaceChildren(...content);
517
- if (this._keyboardMove && !this.getRecord(this._keyboardMove.id)) this._keyboardMove = null;
899
+ if (this._keyboardMove) {
900
+ this._keyboardMove.ids = this._keyboardMove.ids.filter((id) => this.getRecord(id));
901
+ if (!this._keyboardMove.ids.length) this._keyboardMove = null;
902
+ }
518
903
  this._syncKeyboardTarget();
519
- if (activeMeta) queueMicrotask(() => {
904
+ if (activeCollapseFocus || activeMeta) queueMicrotask(() => {
905
+ if (activeCollapseFocus) {
906
+ this._collapseControl(activeCollapseFocus.axis, activeCollapseFocus.id,
907
+ activeCollapseFocus.lane)?.focus();
908
+ return;
909
+ }
910
+ if (!activeMeta) return;
911
+ if (activeMeta.lane != null && this._collapsedSwimlanes.has(activeMeta.lane)) {
912
+ this._collapseControl('swimlane', activeMeta.lane)?.focus();
913
+ return;
914
+ }
915
+ if (this._collapsedColumns.has(activeMeta.column)) {
916
+ this._collapseControl('column', activeMeta.column, activeMeta.lane)?.focus();
917
+ return;
918
+ }
520
919
  const card = this._cardsById.get(activeMeta.id);
521
920
  const target = restoreHandle ? card?.querySelector('.zx-kanban-view__move-handle') : card;
522
921
  if (target?.isConnected) /** @type {HTMLElement} */ (target).focus();
@@ -529,21 +928,29 @@ export class KanbanView extends RecordView {
529
928
  const collapsed = this._collapsedSwimlanes.has(lane.id);
530
929
  const headingId = uid('zx-kanban-lane');
531
930
  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;
931
+ const entries = this._viewData.filter((record, index) =>
932
+ normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === lane.id);
933
+ const total = entries.length;
934
+ const count = entries.filter((record) => this._visibleIds.has(this._viewRecordId(record))).length;
534
935
  const toggle = h('button', {
535
936
  class: 'zx-kanban-view__collapse', type: 'button', ariaExpanded: String(!collapsed),
536
- ariaControls: bodyId, ariaLabel: `${collapsed ? 'Expand' : 'Collapse'} swim lane ${lane.label}`,
937
+ ariaControls: bodyId, ariaLabel: this._text(collapsed ? 'expandLane' : 'collapseLane', { label: lane.label }),
537
938
  dataset: { kanbanCollapse: 'swimlane', kanbanId: lane.id }
538
939
  }, 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);
940
+ const limit = lane.limit ?? null;
941
+ const wip = wipState(total, limit);
942
+ const custom = this._renderHook('renderSwimlaneHeader', {
943
+ swimlane: { ...lane }, count, total, columnTotal: total, limit, wip, collapsed
944
+ });
945
+ const heading = h('h2', { class: 'zx-kanban-view__lane-heading', id: headingId }, toggle,
946
+ custom ?? [h('span', {}, lane.label),
947
+ options.showCounts || limit != null ? this._countIndicator(count, total, limit) : null]);
542
948
  const body = this._renderColumns(columns, lane, true);
543
949
  body.id = bodyId;
544
950
  body.hidden = collapsed;
545
951
  return h('section', {
546
- class: 'zx-kanban-view__lane', ariaLabelledby: headingId, dataset: { collapsed: String(collapsed) }
952
+ class: 'zx-kanban-view__lane', ariaLabelledby: headingId,
953
+ dataset: { collapsed: String(collapsed), wip }
547
954
  }, heading, body);
548
955
  }
549
956
 
@@ -571,34 +978,51 @@ export class KanbanView extends RecordView {
571
978
  const collapsed = this._collapsedColumns.has(column.id);
572
979
  const headingId = uid('zx-kanban-column');
573
980
  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';
981
+ const columnEntries = this._viewData.filter((record, index) =>
982
+ normalizeAxisId(this._readAxis(options.columnBy, record, index)) === column.id);
983
+ const columnTotal = columnEntries.length;
984
+ const visible = entries.filter((entry) => this._visibleIds.has(this._viewRecordId(entry.record)));
985
+ const laneLimit = lane ? readColumnLaneLimit(column, lane.id) : null;
986
+ const limit = laneLimit ?? column.limit ?? null;
987
+ const wip = wipState(laneLimit == null ? columnTotal : entries.length, limit);
578
988
  const toggle = h('button', {
579
989
  class: 'zx-kanban-view__collapse', type: 'button', ariaExpanded: String(!collapsed),
580
- ariaControls: listId, ariaLabel: `${collapsed ? 'Expand' : 'Collapse'} column ${column.label}`,
990
+ ariaControls: listId, ariaLabel: this._text(collapsed ? 'expandColumn' : 'collapseColumn', { label: column.label }),
581
991
  dataset: { kanbanCollapse: 'column', kanbanId: column.id }
582
992
  }, 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;
993
+ const custom = this._renderHook('renderColumnHeader', {
994
+ column: { ...column }, swimlane: lane ? { ...lane } : null,
995
+ count: visible.length, total: entries.length, columnTotal, limit, wip, collapsed
996
+ });
997
+ const indicator = options.showCounts || limit != null
998
+ ? this._columnIndicator(visible.length, entries.length, columnTotal, limit, hasLanes && laneLimit == null)
999
+ : null;
590
1000
  const heading = h(hasLanes ? 'h3' : 'h2', {
591
1001
  class: 'zx-kanban-view__column-heading', id: headingId
592
- }, toggle, h('span', { class: 'zx-kanban-view__column-label' }, column.label), indicator);
1002
+ }, toggle, custom ?? [h('span', { class: 'zx-kanban-view__column-label' }, column.label), indicator]);
593
1003
  const list = h('ul', {
594
1004
  class: 'zx-kanban-view__cards', id: listId, ariaLabel: `${column.label}${lane ? `, ${lane.label}` : ''}`
595
1005
  });
596
- entries.forEach((entry) => list.append(this._renderCard(entry.record, entry.index, column.id, lane?.id ?? null, hasLanes)));
1006
+ visible.forEach((entry) =>
1007
+ list.append(this._renderCard(entry.record, entry.index, column.id, lane?.id ?? null, hasLanes)));
1008
+ if (!visible.length) {
1009
+ const placeholder = this._renderHook('renderColumnEmpty', {
1010
+ column: { ...column }, swimlane: lane ? { ...lane } : null,
1011
+ count: 0, total: entries.length, columnTotal, limit, wip, collapsed
1012
+ }) ?? this._placeholderText();
1013
+ if (placeholder) list.append(h('li', { class: 'zx-kanban-view__column-empty', role: 'presentation' }, placeholder));
1014
+ }
597
1015
  list.hidden = collapsed;
1016
+ const add = this._allowsAdd(column, lane) ? h('button', {
1017
+ class: 'zx-kanban-view__add', type: 'button',
1018
+ ariaLabel: this._text('addTo', { target: `${column.label}${lane ? `, ${lane.label}` : ''}` }),
1019
+ dataset: { kanbanAdd: column.id, kanbanLane: lane?.id ?? '' }
1020
+ }, icon('plus', { size: 13 }), h('span', {}, this._text('add'))) : null;
1021
+ if (add) add.hidden = collapsed;
598
1022
  const section = h('section', {
599
1023
  class: 'zx-kanban-view__column', ariaLabelledby: headingId,
600
1024
  dataset: { column: column.id, collapsed: String(collapsed), wip }
601
- }, heading, list);
1025
+ }, heading, list, add);
602
1026
  this._sectionMeta.set(section, { column: column.id, lane: lane?.id ?? null, list });
603
1027
  return section;
604
1028
  }
@@ -607,10 +1031,15 @@ export class KanbanView extends RecordView {
607
1031
  _renderCard(record, index, column, lane, hasLanes) {
608
1032
  const options = this._kanbanOptions();
609
1033
  const id = this._viewRecordId(record);
610
- const grabbed = Boolean(this._keyboardMove && Object.is(this._keyboardMove.id, id));
1034
+ const grabbed = Boolean(this._keyboardMove?.ids.some((candidate) => Object.is(candidate, id)));
1035
+ const rules = resolveKanbanRules(this._kanbanRules, record, { index, column, lane });
1036
+ const multiple = grabbed && this._keyboardMove ? this._keyboardMove.ids.length : 1;
611
1037
  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)}`
1038
+ class: 'zx-kanban-view__move-handle', type: 'button',
1039
+ ariaPressed: String(grabbed),
1040
+ ariaLabel: multiple > 1
1041
+ ? this._text('moveMultiple', { count: multiple })
1042
+ : this._text('move', { name: this._recordName(record, index) })
614
1043
  }, icon('drag', { size: 13 }));
615
1044
  const card = createRecordCard(record, index, {
616
1045
  fields: this.getFields(),
@@ -629,20 +1058,130 @@ export class KanbanView extends RecordView {
629
1058
  });
630
1059
  card.tabIndex = 0;
631
1060
  if (grabbed) card.dataset.grabbed = 'true';
1061
+ this._applyCardContent(card, { record, id, index, column, lane, rules, selected: this._isViewSelected(id) });
1062
+ this._applyCardRules(card, rules);
1063
+ this._applyCardDescription(card, id);
632
1064
  const meta = { record, id, index, column, lane };
633
1065
  this._cardMeta.set(card, meta);
634
1066
  this._cardsById.set(id, card);
635
1067
  return card;
636
1068
  }
637
1069
 
638
- /** @returns {HTMLElement} */
639
- _emptyContent() {
1070
+ /**
1071
+ * Hands card content to `renderCard` while keeping the managed shell: the list item, its
1072
+ * selection control, the action group, and the move handle are never given away, because they
1073
+ * carry the drop, focus, and movement semantics the board is responsible for.
1074
+ * @param {HTMLElement} card Card built from the shared anatomy.
1075
+ * @param {KanbanCardContext} context Card context.
1076
+ * @returns {void}
1077
+ */
1078
+ _applyCardContent(card, context) {
1079
+ const custom = this._renderHook('renderCard', context);
1080
+ if (!custom) return;
1081
+ const body = card.querySelector('.zx-record-card__body');
1082
+ const titles = card.querySelector('.zx-record-card__titles');
1083
+ if (!body || !titles) return;
1084
+ titles.replaceChildren(custom);
1085
+ body.querySelector('.zx-record-card__eyebrow')?.remove();
1086
+ body.querySelector('.zx-record-card__metadata')?.remove();
1087
+ // The generated heading carried the accessible name; a replaced body must supply its own.
1088
+ card.removeAttribute('aria-labelledby');
1089
+ card.setAttribute('aria-label', this._recordName(context.record, context.index));
1090
+ card.dataset.customCard = 'true';
1091
+ }
1092
+
1093
+ /**
1094
+ * Applies ordered card rules. The first match owns the marker tone; every match with a label adds
1095
+ * a badge, and rule descriptions join the accessible description so the marker is never the only
1096
+ * carrier of the meaning.
1097
+ * @param {HTMLElement} card Card element.
1098
+ * @param {KanbanRule[]} rules Matching rules.
1099
+ * @returns {void}
1100
+ */
1101
+ _applyCardRules(card, rules) {
1102
+ if (!rules.length) return;
1103
+ card.dataset.rule = rules.map((rule) => rule.id).join(' ');
1104
+ card.dataset.ruleTone = rules[0].tone;
1105
+ const badges = rules.filter((rule) => rule.label);
1106
+ if (badges.length) {
1107
+ const list = h('p', { class: 'zx-kanban-view__rules' }, badges.map((rule) => badge({
1108
+ label: rule.label, icon: rule.icon ?? undefined, kind: rule.tone, size: 'sm', variant: 'soft'
1109
+ })));
1110
+ card.querySelector('.zx-record-card__body')?.append(list);
1111
+ }
1112
+ const descriptions = rules.map((rule) => rule.description).filter(Boolean).join('. ');
1113
+ if (descriptions) card.dataset.ruleDescription = descriptions;
1114
+ }
1115
+
1116
+ /**
1117
+ * Composes one card's accessible description from its selection state and its rules, so that
1118
+ * refreshing selection cannot quietly drop what a rule contributed.
1119
+ * @param {HTMLElement} card Card element.
1120
+ * @param {unknown} id Record id.
1121
+ * @returns {void}
1122
+ */
1123
+ _applyCardDescription(card, id) {
1124
+ const parts = [];
1125
+ if (this.options.selectable) {
1126
+ parts.push(this._text(this._isViewSelected(id) ? 'selected' : 'notSelected'));
1127
+ }
1128
+ if (card.dataset.ruleDescription) parts.push(card.dataset.ruleDescription);
1129
+ if (parts.length) card.setAttribute('aria-description', parts.join('. '));
1130
+ else card.removeAttribute('aria-description');
1131
+ }
1132
+
1133
+ /** @param {string} text Board-level empty message. @returns {HTMLElement} */
1134
+ _emptyContent(text) {
640
1135
  const supplied = typeof this.options.emptyText === 'function' ? this.options.emptyText() : this.options.emptyText;
641
- const content = supplied ?? 'No records';
1136
+ const content = supplied ?? text;
642
1137
  return h('div', { class: 'zx-kanban-view__empty', role: 'status' },
643
1138
  content && typeof content === 'object' && 'nodeType' in content ? /** @type {Node} */ (content) : String(content));
644
1139
  }
645
1140
 
1141
+ /** @returns {string} Per-column placeholder text, or an empty string for none. */
1142
+ _placeholderText() {
1143
+ return this._text('columnEmpty');
1144
+ }
1145
+
1146
+ /**
1147
+ * @param {number} count Visible records.
1148
+ * @param {number} total Records before search and filters.
1149
+ * @param {number|null} limit Applicable limit.
1150
+ * @returns {HTMLElement}
1151
+ */
1152
+ _countIndicator(count, total, limit) {
1153
+ const filtered = count !== total;
1154
+ return h('span', {
1155
+ class: 'zx-kanban-view__count',
1156
+ ariaLabel: limit != null ? this._text('countLimit', { count: total, limit })
1157
+ : filtered ? this._text('countFiltered', { count, total }) : this._text('count', { count })
1158
+ }, limit != null ? `${total} / ${limit}`
1159
+ : filtered ? `${count} / ${total}` : String(count));
1160
+ }
1161
+
1162
+ /**
1163
+ * @param {number} count Visible records in this column and lane.
1164
+ * @param {number} total Records in this column and lane before search and filters.
1165
+ * @param {number} columnTotal Records across the whole column.
1166
+ * @param {number|null} limit Applicable limit.
1167
+ * @param {boolean} columnScoped Whether the limit counts the column rather than this lane.
1168
+ * @returns {HTMLElement}
1169
+ */
1170
+ _columnIndicator(count, total, columnTotal, limit, columnScoped) {
1171
+ const filtered = count !== total;
1172
+ const label = limit == null
1173
+ ? filtered ? this._text('countFiltered', { count, total }) : this._text('count', { count })
1174
+ : columnScoped
1175
+ ? this._text('countLimitInLane', { count: total, total: columnTotal, limit })
1176
+ : this._text('countLimit', { count: total, limit });
1177
+ // A limit measures real capacity, so it is always shown against the unfiltered count; only the
1178
+ // limitless indicator reports how much of the column a search is currently hiding.
1179
+ const text = limit == null
1180
+ ? filtered ? `${count} / ${total}` : String(count)
1181
+ : columnScoped ? `${total} · ${columnTotal} / ${limit}` : `${total} / ${limit}`;
1182
+ return h('span', { class: 'zx-kanban-view__count', ariaLabel: label }, text);
1183
+ }
1184
+
646
1185
  /** @returns {KanbanColumn[]} */
647
1186
  _rawColumns() {
648
1187
  const options = this._kanbanOptions();
@@ -654,7 +1193,8 @@ export class KanbanView extends RecordView {
654
1193
  /** @returns {KanbanColumn[]} */
655
1194
  _resolvedColumns() {
656
1195
  const columns = this._rawColumns();
657
- this._kanbanColumnOrder = reconcileKanbanOrder(columns, this._kanbanColumnOrder);
1196
+ this._kanbanColumnOrder = reconcileKanbanPreference(
1197
+ columns, this._kanbanColumnOrder, this._kanbanColumns === null);
658
1198
  return orderDescriptors(columns, this._kanbanColumnOrder);
659
1199
  }
660
1200
 
@@ -670,7 +1210,9 @@ export class KanbanView extends RecordView {
670
1210
  /** @returns {KanbanSwimlane[]} */
671
1211
  _resolvedSwimlanes() {
672
1212
  const lanes = this._rawSwimlanes();
673
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(lanes, this._kanbanSwimlaneOrder);
1213
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
1214
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(
1215
+ lanes, this._kanbanSwimlaneOrder, preserveUnknown);
674
1216
  return orderDescriptors(lanes, this._kanbanSwimlaneOrder);
675
1217
  }
676
1218
 
@@ -700,13 +1242,88 @@ export class KanbanView extends RecordView {
700
1242
  const options = this._kanbanOptions();
701
1243
  const index = this._viewData.findIndex((record) => Object.is(this._viewRecordId(record), id));
702
1244
  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 };
1245
+ return locateKanbanRecord(this._viewData, index, {
1246
+ recordId: options.recordId,
1247
+ columnBy: (record, recordIndex) => this._readAxis(options.columnBy, record, recordIndex),
1248
+ swimlaneBy: options.swimlaneBy == null ? null
1249
+ : (record, recordIndex) => this._readAxis(options.swimlaneBy, record, recordIndex)
1250
+ });
1251
+ }
1252
+
1253
+ /**
1254
+ * Recomputes which records survive the search and the filter predicate.
1255
+ * @returns {void}
1256
+ */
1257
+ _resolveVisibility() {
1258
+ const terms = kanbanSearchTerms(this._kanbanSearch);
1259
+ const filter = this._kanbanFilter;
1260
+ // Field descriptors are cloned on every read, so they are resolved once for the whole pass
1261
+ // rather than per record — a search runs on every keystroke.
1262
+ const sources = terms.length ? this._searchSources() : null;
1263
+ this._visibleIds = new Set();
1264
+ this._viewData.forEach((record, index) => {
1265
+ if (filter && filter(record, index) !== true) return;
1266
+ if (sources && !matchesKanbanSearch(this._searchableText(record, index, sources), terms)) return;
1267
+ this._visibleIds.add(this._viewRecordId(record));
1268
+ });
1269
+ }
1270
+
1271
+ /**
1272
+ * Resolves what a search looks at, once. `searchFields` names them explicitly; otherwise the
1273
+ * visible fields are searched, plus the title and subtitle sources — a card is findable by the
1274
+ * name it shows even when that field is hidden from the metadata list.
1275
+ * @returns {{fields:import('../view/record-view.js').ViewField[],readers:((record:KanbanRecord,index:number)=>unknown)[]}}
1276
+ */
1277
+ _searchSources() {
1278
+ const options = this._kanbanOptions();
1279
+ const configured = Array.isArray(options.searchFields) ? options.searchFields.map(String) : null;
1280
+ const all = this.getFields();
1281
+ if (configured) return { fields: all.filter((field) => configured.includes(field.id)), readers: [] };
1282
+ const fields = this.getVisibleFields();
1283
+ const known = new Set(fields.map((field) => field.id));
1284
+ const readers = [];
1285
+ for (const source of [options.titleField, options.subtitleField]) {
1286
+ if (source == null) continue;
1287
+ if (typeof source === 'function') {
1288
+ readers.push(source);
1289
+ continue;
1290
+ }
1291
+ if (known.has(source)) continue;
1292
+ const field = all.find((candidate) => candidate.id === source);
1293
+ if (field) fields.push(field);
1294
+ else readers.push((record) => record?.[source]);
1295
+ }
1296
+ return { fields, readers };
1297
+ }
1298
+
1299
+ /**
1300
+ * Builds the text one record is searched through. Field `render` output is deliberately ignored:
1301
+ * it may be a DOM node, and matching what a formatter produced rather than what the record holds
1302
+ * would make the same query behave differently in two views over the same data.
1303
+ * @param {KanbanRecord} record Record.
1304
+ * @param {number} index Display index.
1305
+ * @param {{fields:import('../view/record-view.js').ViewField[],readers:((record:KanbanRecord,index:number)=>unknown)[]}} sources Resolved sources.
1306
+ * @returns {string} Searchable text.
1307
+ */
1308
+ _searchableText(record, index, sources) {
1309
+ const parts = sources.fields.map((field) => stringifyValue(readViewField(field, record, index)));
1310
+ for (const reader of sources.readers) parts.push(stringifyValue(reader(record, index)));
1311
+ return parts.filter(Boolean).join(' ');
1312
+ }
1313
+
1314
+ /**
1315
+ * Translates a position among rendered cards into a position among all records in one bucket.
1316
+ * @param {string} column Column id.
1317
+ * @param {string|null} lane Lane id.
1318
+ * @param {number} visibleIndex Position among visible cards.
1319
+ * @param {Set<unknown>} excluded Ids being moved, which the visible list already omits.
1320
+ * @returns {number} Absolute position.
1321
+ */
1322
+ _absoluteIndex(column, lane, visibleIndex, excluded) {
1323
+ const entries = this._recordsIn(column, lane)
1324
+ .filter((entry) => !excluded.has(this._viewRecordId(entry.record)));
1325
+ return absoluteKanbanIndex(
1326
+ entries.map((entry) => this._visibleIds.has(this._viewRecordId(entry.record))), visibleIndex);
710
1327
  }
711
1328
 
712
1329
  /**
@@ -731,8 +1348,301 @@ export class KanbanView extends RecordView {
731
1348
  return clone;
732
1349
  }
733
1350
 
1351
+ /**
1352
+ * Runs transition, eligibility, and capacity policy for a whole move. Every distinct origin is
1353
+ * checked, so a multi-card move drawn from two columns cannot slip past a transition rule that
1354
+ * only the second origin violates.
1355
+ * @param {KanbanRecord[]} records Moving records.
1356
+ * @param {KanbanMovePoint[]} origins Their origins.
1357
+ * @param {KanbanColumn} column Destination column.
1358
+ * @param {KanbanSwimlane|null} lane Destination lane.
1359
+ * @param {KanbanMovePoint} to Destination point.
1360
+ * @param {KanbanMoveContext} context Predicate context.
1361
+ * @param {Set<unknown>} excluded Moving ids, excluded from destination counts.
1362
+ * @returns {KanbanMoveEvaluation} Decision.
1363
+ */
1364
+ _evaluateMove(records, origins, column, lane, to, context, excluded) {
1365
+ const options = this._kanbanOptions();
1366
+ for (const origin of origins) {
1367
+ const blocked = allowsKanbanTransition(column, lane, origin);
1368
+ if (blocked) {
1369
+ return {
1370
+ allowed: false, reason: blocked, limit: null, count: 0,
1371
+ limitExceeded: false, limitReached: false,
1372
+ policy: column.wipPolicy ?? (options.wipPolicy === 'block' ? 'block' : 'warn')
1373
+ };
1374
+ }
1375
+ }
1376
+ const excludes = (record) => !excluded.has(this._viewRecordId(record));
1377
+ const columnCount = this._viewData.filter((record, index) =>
1378
+ normalizeAxisId(this._readAxis(options.columnBy, record, index)) === to.column && excludes(record)).length;
1379
+ const laneCount = options.swimlaneBy == null ? 0 : this._viewData.filter((record, index) =>
1380
+ normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === to.lane && excludes(record)).length;
1381
+ const cellCount = this._recordsIn(to.column, to.lane).filter((entry) => excludes(entry.record)).length;
1382
+ return evaluateKanbanMove({
1383
+ records, column, swimlane: lane, context, from: origins[0],
1384
+ columnCount, laneCount, cellCount, policy: options.wipPolicy === 'block' ? 'block' : 'warn'
1385
+ });
1386
+ }
1387
+
1388
+ /**
1389
+ * Announces and reports a refused move. Nothing is written and no `recordmove` is emitted, so a
1390
+ * listener can neither observe nor undo a move that never happened.
1391
+ * @param {KanbanRejectReason} reason Refusal reason.
1392
+ * @param {{ids:unknown[],records:KanbanRecord[],from:KanbanMovePoint|null,to:KanbanMovePoint|null,column:KanbanColumn|null,swimlane:KanbanSwimlane|null,evaluation?:KanbanMoveEvaluation}} detail Context.
1393
+ * @returns {void}
1394
+ * @fires KanbanView#movereject
1395
+ */
1396
+ _rejectMove(reason, detail) {
1397
+ const target = detail.to ? this._targetName(detail.to) : '';
1398
+ const name = this._movingName(detail.records);
1399
+ const message = reason === 'destination' ? this._text('rejectDestination')
1400
+ : reason === 'grouping' ? this._text('rejectGrouping')
1401
+ : reason === 'transition' || reason === 'lane-transition'
1402
+ ? this._text('rejectTransition', { target, origin: detail.from ? this._targetName(detail.from) : '' })
1403
+ : reason === 'wip' || reason === 'lane-wip'
1404
+ ? this._text('rejectWip', { target, limit: detail.evaluation?.limit ?? '' })
1405
+ : this._text('rejectAccept', { target, name });
1406
+ this._announce(message);
1407
+ this.emit('movereject', {
1408
+ reason, ids: [...detail.ids], records: [...detail.records],
1409
+ record: detail.records[0] ?? null, id: detail.ids[0],
1410
+ from: detail.from ? { ...detail.from } : null, to: detail.to ? { ...detail.to } : null,
1411
+ column: detail.column ? { ...detail.column } : null,
1412
+ swimlane: detail.swimlane ? { ...detail.swimlane } : null,
1413
+ limit: detail.evaluation?.limit ?? null, count: detail.evaluation?.count ?? 0, message
1414
+ });
1415
+ }
1416
+
1417
+ /**
1418
+ * Installs a committed local record order, remembering the previous one for undo.
1419
+ * @param {KanbanRecord[]} records Next record order.
1420
+ * @returns {void}
1421
+ */
1422
+ _commitLocalRecords(records) {
1423
+ const options = this._kanbanOptions();
1424
+ if (options.history !== false) {
1425
+ this._kanbanHistory.push({ records: [...this._viewData], selection: this.getSelectionIds() });
1426
+ this._syncHistoryControls();
1427
+ }
1428
+ this._viewData = records;
1429
+ // A manual bucket position and a local sort cannot both be authoritative. Clear the local sort
1430
+ // on an accepted local move so the visible order and serialized state remain truthful.
1431
+ if (options.sortMode === 'local' && this.getSort()) this.setSort(null);
1432
+ else this._refreshView('move');
1433
+ this.emit('datachange', { records: this.getData() });
1434
+ if (options.history !== false) this._emitHistory();
1435
+ }
1436
+
1437
+ /**
1438
+ * @param {'undo'|'redo'} direction Travel direction.
1439
+ * @returns {this}
1440
+ */
1441
+ _travelHistory(direction) {
1442
+ if (this._kanbanOptions().history === false) return this;
1443
+ const current = { records: [...this._viewData], selection: this.getSelectionIds() };
1444
+ const entry = direction === 'undo'
1445
+ ? this._kanbanHistory.undo(current) : this._kanbanHistory.redo(current);
1446
+ if (!entry) {
1447
+ this._announce(this._text(direction === 'undo' ? 'nothingToUndo' : 'nothingToRedo'));
1448
+ return this;
1449
+ }
1450
+ this._viewData = [...entry.records];
1451
+ this._refreshView(direction);
1452
+ this.setSelection(entry.selection);
1453
+ this._syncHistoryControls();
1454
+ this.emit('datachange', { records: this.getData() });
1455
+ this._emitHistory();
1456
+ this._announce(this._text(direction === 'undo' ? 'undone' : 'redone'));
1457
+ return this;
1458
+ }
1459
+
1460
+ /** Drops both stacks without announcing, for host-driven data replacement. @returns {void} */
1461
+ _resetHistory() {
1462
+ if (!this._kanbanHistory) return;
1463
+ const had = this._kanbanHistory.canUndo() || this._kanbanHistory.canRedo();
1464
+ this._kanbanHistory.clear();
1465
+ this._syncHistoryControls();
1466
+ if (had) this._emitHistory();
1467
+ }
1468
+
1469
+ /** @returns {void} */
1470
+ _emitHistory() {
1471
+ this.emit('historychange', {
1472
+ canUndo: this.canUndo(), canRedo: this.canRedo(), depth: this._kanbanHistory.depth()
1473
+ });
1474
+ }
1475
+
1476
+ /** @returns {void} */
1477
+ _syncHistoryControls() {
1478
+ if (this._undoButton) this._undoButton.disabled = !this.canUndo();
1479
+ if (this._redoButton) this._redoButton.disabled = !this.canRedo();
1480
+ }
1481
+
1482
+ /** @returns {HTMLElement} Built-in search control. */
1483
+ _createSearchControl() {
1484
+ const input = /** @type {HTMLInputElement} */ (h('input', {
1485
+ class: 'zx-kanban-view__search-input', type: 'search',
1486
+ value: this._kanbanSearch, ariaLabel: this._text('search'),
1487
+ placeholder: this._text('searchPlaceholder')
1488
+ }));
1489
+ this._searchInput = input;
1490
+ // Every keystroke would otherwise rebuild every card. The public setSearch stays immediate.
1491
+ this.listen(input, 'input', () => {
1492
+ clearTimeout(this._searchTimer);
1493
+ this._searchTimer = setTimeout(() => {
1494
+ this._searchTimer = 0;
1495
+ if (!this._kanbanDestroyed) this.setSearch(input.value);
1496
+ }, SEARCH_DEBOUNCE);
1497
+ });
1498
+ this.listen(input, 'keydown', (event) => {
1499
+ if (/** @type {KeyboardEvent} */ (event).key !== 'Escape' || !input.value) return;
1500
+ event.stopPropagation();
1501
+ clearTimeout(this._searchTimer);
1502
+ this._searchTimer = 0;
1503
+ this.setSearch('');
1504
+ });
1505
+ return h('div', { class: 'zx-kanban-view__search' }, icon('search', { size: 13 }), input);
1506
+ }
1507
+
1508
+ /** @returns {HTMLElement} Built-in history controls. */
1509
+ _createHistoryControls() {
1510
+ this._undoButton = /** @type {HTMLButtonElement} */ (h('button', {
1511
+ class: 'zx-button zx-button--sm', type: 'button', disabled: true,
1512
+ dataset: { kanbanHistory: 'undo' }
1513
+ }, this._text('undo')));
1514
+ this._redoButton = /** @type {HTMLButtonElement} */ (h('button', {
1515
+ class: 'zx-button zx-button--sm', type: 'button', disabled: true,
1516
+ dataset: { kanbanHistory: 'redo' }
1517
+ }, this._text('redo')));
1518
+ const group = h('div', { class: 'zx-kanban-view__history', role: 'group' },
1519
+ this._undoButton, this._redoButton);
1520
+ this.listen(group, 'click', (event) => {
1521
+ const control = /** @type {Element|null} */ (event.target)?.closest?.('[data-kanban-history]');
1522
+ if (!control) return;
1523
+ if (/** @type {HTMLElement} */ (control).dataset.kanbanHistory === 'undo') this.undo();
1524
+ else this.redo();
1525
+ });
1526
+ return group;
1527
+ }
1528
+
1529
+ /** Attaches the card context menu. @returns {void} */
1530
+ _createContextMenu() {
1531
+ this._kanbanMenu = new ContextMenu(this._board, {
1532
+ selector: '.zx-record-card',
1533
+ items: (context) => this._menuItems(context),
1534
+ onselect: ({ detail }) => this._handleMenuSelect(detail)
1535
+ });
1536
+ }
1537
+
1538
+ /**
1539
+ * Builds the menu for one card: every destination the same policy path would accept, plus any
1540
+ * application items. Destinations the board would refuse are shown disabled rather than hidden,
1541
+ * so a rule is discoverable instead of silently removing a command.
1542
+ * @param {Element|null} context Card element the menu opened on.
1543
+ * @returns {(MenuItem|'-')[]} Menu entries.
1544
+ */
1545
+ _menuItems(context) {
1546
+ const meta = context ? this._cardMeta.get(context) : null;
1547
+ if (!meta) return [];
1548
+ const options = this._kanbanOptions();
1549
+ const ids = this._movingIds(meta.id);
1550
+ /** @type {(MenuItem|'-')[]} */
1551
+ const items = [];
1552
+ for (const column of this._resolvedColumns()) {
1553
+ if (column.id === meta.column) continue;
1554
+ items.push({
1555
+ label: this._text('menuMoveTo', { target: column.label }),
1556
+ value: { kind: 'column', id: column.id, ids },
1557
+ disabled: !this._canMove(ids, { column: column.id })
1558
+ });
1559
+ }
1560
+ if (options.swimlaneBy != null) {
1561
+ for (const lane of this._resolvedSwimlanes()) {
1562
+ if (lane.id === meta.lane) continue;
1563
+ items.push({
1564
+ label: this._text('menuMoveToLane', { target: lane.label }),
1565
+ value: { kind: 'lane', id: lane.id, ids },
1566
+ disabled: !this._canMove(ids, { lane: lane.id })
1567
+ });
1568
+ }
1569
+ }
1570
+ const extra = typeof options.contextMenu === 'function'
1571
+ ? options.contextMenu({
1572
+ record: meta.record, id: meta.id, index: meta.index, column: meta.column, lane: meta.lane,
1573
+ rules: resolveKanbanRules(this._kanbanRules, meta.record, meta), selected: this._isViewSelected(meta.id)
1574
+ })
1575
+ : Array.isArray(options.contextMenu) ? options.contextMenu : [];
1576
+ if (Array.isArray(extra) && extra.length) items.push('-', ...extra);
1577
+ return items;
1578
+ }
1579
+
1580
+ /**
1581
+ * @param {{value:unknown,item:MenuItem,context:Element|null}} detail Selection detail.
1582
+ * @returns {void}
1583
+ */
1584
+ _handleMenuSelect(detail) {
1585
+ const value = /** @type {{kind?:string,id?:string,ids?:unknown[]}|null} */ (detail.value);
1586
+ if (!value || typeof value !== 'object' || !value.kind || !Array.isArray(value.ids)) return;
1587
+ this.moveRecords(value.ids, value.kind === 'lane' ? { lane: value.id } : { column: value.id });
1588
+ }
1589
+
1590
+ /**
1591
+ * Dry-runs the policy path for a destination without emitting or committing anything.
1592
+ * @param {unknown[]} ids Moving ids.
1593
+ * @param {KanbanDestination} destination Destination.
1594
+ * @returns {boolean} Whether the move would be allowed.
1595
+ */
1596
+ _canMove(ids, destination) {
1597
+ const options = this._kanbanOptions();
1598
+ const moving = this._orderedIds(ids);
1599
+ if (!moving.length) return false;
1600
+ const records = moving.map((id) => /** @type {KanbanRecord} */ (this.getRecord(id)));
1601
+ const origins = moving.map((id) => /** @type {KanbanMovePoint} */ (this._recordLocation(id)));
1602
+ const from = origins[0];
1603
+ const columnId = destination.column == null ? from.column : String(destination.column);
1604
+ const laneId = options.swimlaneBy == null
1605
+ ? null : destination.lane === undefined ? from.lane : normalizeAxisId(destination.lane);
1606
+ const column = this._resolvedColumns().find((candidate) => candidate.id === columnId);
1607
+ const lane = options.swimlaneBy == null
1608
+ ? null : this._resolvedSwimlanes().find((candidate) => candidate.id === laneId) ?? null;
1609
+ if (!column || options.swimlaneBy != null && !lane) return false;
1610
+ const excluded = new Set(moving);
1611
+ const to = { column: columnId, lane: laneId, index: 0 };
1612
+ const context = {
1613
+ id: moving[0], from: { ...from }, to: { ...to },
1614
+ column: { ...column }, swimlane: lane ? { ...lane } : null
1615
+ };
1616
+ return this._evaluateMove(records, origins, column, lane, to, context, excluded).allowed;
1617
+ }
1618
+
1619
+ /**
1620
+ * Resolves the ids one gesture moves: the whole selection when the grabbed card belongs to it,
1621
+ * otherwise just that card. Picking up an unselected card never drags the selection along.
1622
+ * @param {unknown} id Grabbed record id.
1623
+ * @returns {unknown[]} Ids to move, in board order.
1624
+ */
1625
+ _movingIds(id) {
1626
+ const options = this._kanbanOptions();
1627
+ if (options.multiMove === false || options.selectable !== 'multi' || !this._isViewSelected(id)) return [id];
1628
+ const selection = this.getSelectionIds();
1629
+ return selection.length > 1 ? selection : [id];
1630
+ }
1631
+
1632
+ /** @param {unknown[]} ids Requested ids. @returns {unknown[]} Existing ids in board order. */
1633
+ _orderedIds(ids) {
1634
+ const requested = new Set(ids);
1635
+ return this._viewData
1636
+ .map((record) => this._viewRecordId(record))
1637
+ .filter((id) => requested.has(id));
1638
+ }
1639
+
734
1640
  /** @param {MouseEvent} event @returns {void} */
735
1641
  _handleBoardClick(event) {
1642
+ if (this._suppressClick) {
1643
+ this._suppressClick = false;
1644
+ return;
1645
+ }
736
1646
  const target = elementTarget(event);
737
1647
  const collapse = target?.closest('[data-kanban-collapse]');
738
1648
  if (collapse) {
@@ -741,6 +1651,19 @@ export class KanbanView extends RecordView {
741
1651
  else this.toggleSwimlane(String(element.dataset.kanbanId));
742
1652
  return;
743
1653
  }
1654
+ const add = target?.closest('[data-kanban-add]');
1655
+ if (add) {
1656
+ const element = /** @type {HTMLElement} */ (add);
1657
+ const columnId = String(element.dataset.kanbanAdd);
1658
+ const laneId = element.dataset.kanbanLane || null;
1659
+ this.emit('recordadd', {
1660
+ column: this._resolvedColumns().find((candidate) => candidate.id === columnId) ?? null,
1661
+ swimlane: laneId == null ? null
1662
+ : this._resolvedSwimlanes().find((candidate) => candidate.id === laneId) ?? null,
1663
+ columnId, laneId, event
1664
+ });
1665
+ return;
1666
+ }
744
1667
  const meta = this._metaForTarget(target);
745
1668
  if (!meta) return;
746
1669
  const selection = /** @type {HTMLInputElement|null} */ (target?.closest('input[data-record-selection]'));
@@ -782,7 +1705,14 @@ export class KanbanView extends RecordView {
782
1705
 
783
1706
  /** @param {KeyboardEvent} event @returns {void} */
784
1707
  _handleBoardKeydown(event) {
1708
+ if (this._dragIds.length && event.key === 'Escape') {
1709
+ event.preventDefault();
1710
+ this._clearDrag();
1711
+ this._announce(this._text('moveCanceledPlain'));
1712
+ return;
1713
+ }
785
1714
  const target = elementTarget(event);
1715
+ if (this._handleHistoryKeydown(event, target)) return;
786
1716
  const handle = target?.closest('.zx-kanban-view__move-handle');
787
1717
  const meta = this._metaForTarget(handle);
788
1718
  if (handle && meta) {
@@ -802,42 +1732,73 @@ export class KanbanView extends RecordView {
802
1732
  }
803
1733
  }
804
1734
 
1735
+ /**
1736
+ * @param {KeyboardEvent} event Keyboard event.
1737
+ * @param {Element|null} target Event target.
1738
+ * @returns {boolean} Whether the event was handled as a history command.
1739
+ */
1740
+ _handleHistoryKeydown(event, target) {
1741
+ if (this._kanbanOptions().history === false || !(event.ctrlKey || event.metaKey)) return false;
1742
+ // Text entry owns its own undo stack; never take Ctrl+Z away from a field on the board.
1743
+ if (target?.closest('input, textarea, [contenteditable="true"]')) return false;
1744
+ // A key the board cannot act on belongs to the browser or the application, not to an
1745
+ // announcement saying there was nothing to do.
1746
+ const key = event.key.toLowerCase();
1747
+ if (key === 'z' && !event.shiftKey && this.canUndo()) {
1748
+ event.preventDefault();
1749
+ this.undo();
1750
+ return true;
1751
+ }
1752
+ if ((key === 'y' || key === 'z' && event.shiftKey) && this.canRedo()) {
1753
+ event.preventDefault();
1754
+ this.redo();
1755
+ return true;
1756
+ }
1757
+ return false;
1758
+ }
1759
+
805
1760
  /** @param {KeyboardEvent} event @param {HTMLElement} handle @param {KanbanCardMeta} meta @returns {void} */
806
1761
  _handleMoveKeydown(event, handle, meta) {
807
1762
  if (event.key === ' ' || event.key === 'Enter') {
808
1763
  event.preventDefault();
809
- if (this._keyboardMove && Object.is(this._keyboardMove.id, meta.id)) {
810
- const destination = { ...this._keyboardMove.to };
1764
+ if (this._keyboardMove?.ids.some((candidate) => Object.is(candidate, meta.id))) {
1765
+ const move = this._keyboardMove;
811
1766
  this._keyboardMove = null;
812
1767
  handle.setAttribute('aria-pressed', 'false');
813
1768
  const card = /** @type {HTMLElement|null} */ (handle.closest('.zx-record-card'));
814
1769
  if (card) delete card.dataset.grabbed;
815
1770
  this._syncKeyboardTarget();
816
- this.moveRecord(meta.id, destination);
1771
+ this._commitInteractionMove(move.ids, move.to);
817
1772
  } else {
818
1773
  const from = this._recordLocation(meta.id);
819
1774
  if (!from) return;
820
- this._keyboardMove = { id: meta.id, from: { ...from }, to: { ...from } };
1775
+ const ids = this._movingIds(meta.id);
1776
+ const start = { ...from, index: this._visibleIndex(meta.id) };
1777
+ this._keyboardMove = { ids, from: { ...from }, to: start };
821
1778
  handle.setAttribute('aria-pressed', 'true');
822
1779
  const card = handle.closest('.zx-record-card');
823
1780
  if (card) /** @type {HTMLElement} */ (card).dataset.grabbed = 'true';
824
1781
  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.`);
1782
+ this._announce(ids.length > 1
1783
+ ? this._text('grabbedMultiple', { count: ids.length })
1784
+ : this._text('grabbed', { name: this._recordName(meta.record, meta.index) }));
826
1785
  }
827
1786
  return;
828
1787
  }
829
1788
  if (event.key === 'Escape' && this._keyboardMove) {
830
1789
  event.preventDefault();
831
- const record = this.getRecord(this._keyboardMove.id);
1790
+ const records = this._keyboardMove.ids.map((id) => this.getRecord(id)).filter(Boolean);
832
1791
  this._keyboardMove = null;
833
1792
  handle.setAttribute('aria-pressed', 'false');
834
1793
  const card = /** @type {HTMLElement|null} */ (handle.closest('.zx-record-card'));
835
1794
  if (card) delete card.dataset.grabbed;
836
1795
  this._syncKeyboardTarget();
837
- this._announce(`Move canceled${record ? ` for ${this._recordName(record, meta.index)}` : ''}.`);
1796
+ this._announce(records.length
1797
+ ? this._text('moveCanceled', { name: this._movingName(/** @type {KanbanRecord[]} */ (records)) })
1798
+ : this._text('moveCanceledPlain'));
838
1799
  return;
839
1800
  }
840
- if (!this._keyboardMove || !Object.is(this._keyboardMove.id, meta.id)
1801
+ if (!this._keyboardMove || !this._keyboardMove.ids.some((candidate) => Object.is(candidate, meta.id))
841
1802
  || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
842
1803
  event.preventDefault();
843
1804
  const move = this._keyboardMove;
@@ -856,11 +1817,12 @@ export class KanbanView extends RecordView {
856
1817
  } else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
857
1818
  move.to.index += event.key === 'ArrowUp' ? -1 : 1;
858
1819
  }
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);
1820
+ const targetCount = this._visibleCount(move.to.column, move.to.lane, new Set(move.ids));
1821
+ move.to.index = clampKanbanIndex(move.to.index, targetCount);
862
1822
  this._syncKeyboardTarget();
863
- this._announce(`Target ${this._targetName(move.to)}, position ${move.to.index + 1} of ${targetCount + 1}.`);
1823
+ this._announce(this._text('target', {
1824
+ target: this._targetName(move.to), position: move.to.index + 1, total: targetCount + 1
1825
+ }));
864
1826
  }
865
1827
 
866
1828
  /** @param {Event} event @returns {void} */
@@ -872,78 +1834,229 @@ export class KanbanView extends RecordView {
872
1834
  image.hidden = true;
873
1835
  }
874
1836
 
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;
1837
+ /**
1838
+ * Records a candidate drag. Nothing moves yet: a press that never travels stays a click, and a
1839
+ * touch on the card body stays a scroll until the long press elapses.
1840
+ * @param {PointerEvent} event Pointer event.
1841
+ * @returns {void}
1842
+ */
1843
+ _handlePointerDown(event) {
1844
+ this._suppressClick = false;
1845
+ if (event.button != null && event.button !== 0) return;
1846
+ const target = elementTarget(event);
1847
+ const handle = target?.closest('.zx-kanban-view__move-handle');
1848
+ const meta = this._metaForTarget(handle ?? target);
1849
+ if (!meta) return;
1850
+ if (!handle) {
1851
+ if (this._kanbanOptions().dragFrom !== 'card') return;
1852
+ if (target?.closest(INTERACTIVE) || target?.closest('.zx-record-card__selection')) return;
882
1853
  }
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';
1854
+ const ids = this._movingIds(meta.id);
1855
+ this._pointerStart = {
1856
+ x: event.clientX, y: event.clientY, id: event.pointerId, ids,
1857
+ touch: event.pointerType === 'touch'
1858
+ };
1859
+ this._dragPoint = { x: event.clientX, y: event.clientY };
1860
+ clearTimeout(this._pointerHold);
1861
+ if (event.pointerType !== 'touch' || handle) return;
1862
+ // A finger resting on a card must be allowed to scroll the board; only a deliberate hold that
1863
+ // never travels — so the browser has not begun panning — turns into a drag.
1864
+ this._pointerHold = setTimeout(() => this._beginDrag(this._dragPoint.x, this._dragPoint.y), TOUCH_HOLD);
888
1865
  }
889
1866
 
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';
1867
+ /** @param {PointerEvent} event @returns {void} */
1868
+ _handlePointerMove(event) {
1869
+ const start = this._pointerStart;
1870
+ if (!this._dragIds.length) {
1871
+ if (!start || event.pointerId !== start.id) return;
1872
+ const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y);
1873
+ this._dragPoint = { x: event.clientX, y: event.clientY };
1874
+ if (distance <= DRAG_THRESHOLD) return;
1875
+ if (start.touch && this._pointerHold) {
1876
+ // The finger travelled before the hold elapsed: this is a scroll, not a drag.
1877
+ clearTimeout(this._pointerHold);
1878
+ this._pointerHold = 0;
1879
+ this._pointerStart = null;
1880
+ return;
1881
+ }
1882
+ this._beginDrag(event.clientX, event.clientY);
1883
+ if (!this._dragIds.length) return;
908
1884
  }
909
- if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
1885
+ // A second finger landing on the board must not steer a drag it did not start.
1886
+ if (this._dragPointer != null && event.pointerId !== this._dragPointer) return;
1887
+ event.preventDefault();
1888
+ this._dragPoint = { x: event.clientX, y: event.clientY };
1889
+ this._positionDragPreview();
1890
+ this._updateDropTarget(event.clientX, event.clientY);
910
1891
  }
911
1892
 
912
- /** @param {DragEvent} event @returns {void} */
913
- _handleDrop(event) {
914
- if (this._dragId == null || !this._dragDestination) return;
1893
+ /** @param {PointerEvent} event @returns {void} */
1894
+ _handlePointerUp(event) {
1895
+ if (this._dragIds.length && this._dragPointer != null && event.pointerId !== this._dragPointer) return;
1896
+ clearTimeout(this._pointerHold);
1897
+ this._pointerHold = 0;
1898
+ this._pointerStart = null;
1899
+ if (!this._dragIds.length) return;
915
1900
  event.preventDefault();
916
- const id = this._dragId;
917
- const destination = { ...this._dragDestination };
1901
+ const ids = [...this._dragIds];
1902
+ const destination = this._dragDestination ? { ...this._dragDestination } : null;
918
1903
  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 };
1904
+ this._suppressClick = true;
1905
+ if (destination) this._commitInteractionMove(ids, destination);
1906
+ }
1907
+
1908
+ /**
1909
+ * Starts a pointer drag: the floating preview appears, the board stops selecting text, and the
1910
+ * auto-scroll loop begins.
1911
+ * @param {number} x Pointer client x.
1912
+ * @param {number} y Pointer client y.
1913
+ * @returns {void}
1914
+ */
1915
+ _beginDrag(x, y) {
1916
+ const start = this._pointerStart;
1917
+ if (!start || this._dragIds.length) return;
1918
+ this._pointerHold = 0;
1919
+ this._dragIds = start.ids.filter((id) => this.getRecord(id));
1920
+ if (!this._dragIds.length) return;
1921
+ this._dragPoint = { x, y };
1922
+ this._board.dataset.dragging = 'true';
1923
+ for (const id of this._dragIds) {
1924
+ const card = this._cardsById.get(id);
1925
+ if (card) card.dataset.dragging = 'true';
931
1926
  }
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
- };
1927
+ this._dragPointer = start.id;
1928
+ try {
1929
+ this._board.setPointerCapture(start.id);
1930
+ this._dragCaptured = true;
1931
+ } catch {
1932
+ // Capture is an enhancement; the drag still tracks through board-level pointer events.
1933
+ }
1934
+ globalThis.getSelection?.()?.removeAllRanges();
1935
+ this._dragPreview = this._createDragPreview();
1936
+ this.el.append(this._dragPreview);
1937
+ this._positionDragPreview();
1938
+ this._updateDropTarget(x, y);
1939
+ if (this._kanbanOptions().autoScroll !== false) this._scheduleAutoScroll();
1940
+ }
1941
+
1942
+ /** @returns {HTMLElement} Floating drag preview. */
1943
+ _createDragPreview() {
1944
+ const records = this._dragIds.map((id) => /** @type {KanbanRecord} */ (this.getRecord(id)));
1945
+ const custom = this._renderHook('renderDragPreview', {
1946
+ records, ids: [...this._dragIds], count: this._dragIds.length
1947
+ });
1948
+ const content = custom ?? (this._dragIds.length > 1
1949
+ ? this._text('dragPreview', { count: this._dragIds.length })
1950
+ : this._movingName(records));
1951
+ return h('div', {
1952
+ class: 'zx-kanban-view__drag-preview', ariaHidden: 'true',
1953
+ dataset: { count: String(this._dragIds.length) }
1954
+ }, content);
939
1955
  }
940
1956
 
941
1957
  /** @returns {void} */
942
- _clearDrag() {
1958
+ _positionDragPreview() {
1959
+ if (!this._dragPreview) return;
1960
+ this._dragPreview.style.transform =
1961
+ `translate3d(${Math.round(this._dragPoint.x)}px, ${Math.round(this._dragPoint.y)}px, 0)`;
1962
+ }
1963
+
1964
+ /**
1965
+ * Resolves and marks the drop destination under the pointer. Pointer capture retargets events to
1966
+ * the board, so the element under the pointer is looked up rather than read off the event.
1967
+ * @param {number} x Pointer client x.
1968
+ * @param {number} y Pointer client y.
1969
+ * @returns {void}
1970
+ */
1971
+ _updateDropTarget(x, y) {
1972
+ const element = document.elementFromPoint(x, y);
1973
+ const section = element?.closest('.zx-kanban-view__column');
1974
+ const sectionMeta = section && this._board.contains(section) ? this._sectionMeta.get(section) : null;
943
1975
  this._clearDropMarkers();
944
- for (const card of this._board.querySelectorAll('[data-dragging]')) delete /** @type {HTMLElement} */ (card).dataset.dragging;
945
- this._dragId = null;
1976
+ if (!sectionMeta || this._collapsedColumns.has(sectionMeta.column)) {
1977
+ this._dragDestination = null;
1978
+ return;
1979
+ }
1980
+ // The insertion point is derived from card geometry rather than from whatever element the
1981
+ // pointer happens to be over, so hovering a card already in flight, a gap, or the empty-column
1982
+ // placeholder all resolve to the same well-defined slot.
1983
+ const cards = this._draggableCards(sectionMeta.list);
1984
+ const index = cards.findIndex((card) => {
1985
+ const rectangle = card.getBoundingClientRect();
1986
+ return y < rectangle.top + rectangle.height / 2;
1987
+ });
1988
+ const position = index < 0 ? cards.length : index;
1989
+ if (cards[position]) cards[position].dataset.drop = 'before';
1990
+ else if (cards.length) cards[cards.length - 1].dataset.drop = 'after';
1991
+ else sectionMeta.list.dataset.drop = 'append';
1992
+ this._dragDestination = { column: sectionMeta.column, lane: sectionMeta.lane, index: position };
1993
+ }
1994
+
1995
+ /** @returns {void} */
1996
+ _scheduleAutoScroll() {
1997
+ if (this._dragFrame) return;
1998
+ this._dragFrame = requestAnimationFrame(() => {
1999
+ this._dragFrame = 0;
2000
+ if (!this._dragIds.length) return;
2001
+ this._autoScroll();
2002
+ this._scheduleAutoScroll();
2003
+ });
2004
+ }
2005
+
2006
+ /** Scrolls the board horizontally and the hovered column vertically near their edges. @returns {void} */
2007
+ _autoScroll() {
2008
+ const { x, y } = this._dragPoint;
2009
+ const board = this._board.getBoundingClientRect();
2010
+ if (x < board.left + SCROLL_EDGE) this._board.scrollLeft -= SCROLL_SPEED;
2011
+ else if (x > board.right - SCROLL_EDGE) this._board.scrollLeft += SCROLL_SPEED;
2012
+ const list = document.elementFromPoint(x, y)?.closest('.zx-kanban-view__cards');
2013
+ if (!list || !this._board.contains(list) || list.scrollHeight <= list.clientHeight) return;
2014
+ const bounds = list.getBoundingClientRect();
2015
+ if (y < bounds.top + SCROLL_EDGE) list.scrollTop -= SCROLL_SPEED;
2016
+ else if (y > bounds.bottom - SCROLL_EDGE) list.scrollTop += SCROLL_SPEED;
2017
+ }
2018
+
2019
+ /**
2020
+ * Commits a move whose index came from rendered cards. Search and filters hide records without
2021
+ * removing them, so the visible position is translated back before anything is proposed.
2022
+ * @param {unknown[]} ids Moving ids.
2023
+ * @param {KanbanMovePoint} point Destination in visible-card space.
2024
+ * @returns {void}
2025
+ */
2026
+ _commitInteractionMove(ids, point) {
2027
+ const excluded = new Set(ids);
2028
+ this.moveRecords(ids, {
2029
+ column: point.column,
2030
+ lane: point.lane,
2031
+ index: this._absoluteIndex(point.column, point.lane, point.index, excluded)
2032
+ });
2033
+ }
2034
+
2035
+ /** @returns {void} */
2036
+ _clearDrag() {
2037
+ if (this._dragFrame) cancelAnimationFrame(this._dragFrame);
2038
+ this._dragFrame = 0;
2039
+ clearTimeout(this._pointerHold);
2040
+ this._pointerHold = 0;
2041
+ this._pointerStart = null;
2042
+ this._dragPreview?.remove();
2043
+ this._dragPreview = null;
2044
+ this._dragIds = [];
946
2045
  this._dragDestination = null;
2046
+ if (this._board) {
2047
+ if (this._dragCaptured && this._dragPointer != null) {
2048
+ try {
2049
+ this._board.releasePointerCapture(this._dragPointer);
2050
+ } catch {
2051
+ // The capture is already gone, which is exactly the state this wanted.
2052
+ }
2053
+ }
2054
+ this._clearDropMarkers();
2055
+ delete this._board.dataset.dragging;
2056
+ for (const card of this._board.querySelectorAll('[data-dragging]')) delete /** @type {HTMLElement} */ (card).dataset.dragging;
2057
+ }
2058
+ this._dragPointer = null;
2059
+ this._dragCaptured = false;
947
2060
  }
948
2061
 
949
2062
  /** @returns {void} */
@@ -951,6 +2064,31 @@ export class KanbanView extends RecordView {
951
2064
  for (const element of this._board.querySelectorAll('[data-drop]')) delete /** @type {HTMLElement} */ (element).dataset.drop;
952
2065
  }
953
2066
 
2067
+ /** @param {HTMLElement} list Card list. @returns {HTMLElement[]} Cards excluding the ones in flight. */
2068
+ _draggableCards(list) {
2069
+ const moving = new Set(this._dragIds.length ? this._dragIds : this._keyboardMove?.ids ?? []);
2070
+ return /** @type {HTMLElement[]} */ ([...list.querySelectorAll(':scope > .zx-record-card')]
2071
+ .filter((card) => !hasId(moving, this._cardMeta.get(card)?.id)));
2072
+ }
2073
+
2074
+ /** @param {string} column @param {string|null} lane @param {Set<unknown>} excluded @returns {number} */
2075
+ _visibleCount(column, lane, excluded) {
2076
+ return this._recordsIn(column, lane).filter((entry) => {
2077
+ const id = this._viewRecordId(entry.record);
2078
+ return !hasId(excluded, id) && this._visibleIds.has(id);
2079
+ }).length;
2080
+ }
2081
+
2082
+ /** @param {unknown} id Record id. @returns {number} Position among visible cards in its bucket. */
2083
+ _visibleIndex(id) {
2084
+ const location = this._recordLocation(id);
2085
+ if (!location) return 0;
2086
+ const visible = this._recordsIn(location.column, location.lane)
2087
+ .filter((entry) => this._visibleIds.has(this._viewRecordId(entry.record)));
2088
+ const position = visible.findIndex((entry) => Object.is(this._viewRecordId(entry.record), id));
2089
+ return position < 0 ? visible.length : position;
2090
+ }
2091
+
954
2092
  /** @returns {void} */
955
2093
  _syncKeyboardTarget() {
956
2094
  for (const element of this._board.querySelectorAll('[data-keyboard-target]')) {
@@ -965,10 +2103,9 @@ export class KanbanView extends RecordView {
965
2103
  });
966
2104
  const sectionMeta = section ? this._sectionMeta.get(section) : null;
967
2105
  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));
2106
+ const cards = this._draggableCards(sectionMeta.list);
970
2107
  const target = cards[move.to.index];
971
- if (target) /** @type {HTMLElement} */ (target).dataset.keyboardTarget = 'before';
2108
+ if (target) target.dataset.keyboardTarget = 'before';
972
2109
  else sectionMeta.list.dataset.keyboardTarget = 'append';
973
2110
  }
974
2111
 
@@ -977,7 +2114,7 @@ export class KanbanView extends RecordView {
977
2114
  for (const [id, card] of this._cardsById) {
978
2115
  const selected = this._isViewSelected(id);
979
2116
  card.dataset.selected = String(selected);
980
- if (this.options.selectable) card.setAttribute('aria-selected', String(selected));
2117
+ this._applyCardDescription(card, id);
981
2118
  const input = /** @type {HTMLInputElement|null} */ (card.querySelector('[data-record-selection]'));
982
2119
  if (input) input.checked = selected;
983
2120
  }
@@ -994,15 +2131,61 @@ export class KanbanView extends RecordView {
994
2131
  return /** @type {HTMLElement|null} */ (this._cardsById.get(id)?.querySelector('.zx-kanban-view__move-handle') ?? null);
995
2132
  }
996
2133
 
2134
+ /**
2135
+ * Resolves a collapse control after a board refresh, retaining the same lane copy for columns.
2136
+ * @param {'column'|'swimlane'} axis Collapse axis.
2137
+ * @param {string} id Descriptor id.
2138
+ * @param {string|null} [lane=null] Column copy's lane.
2139
+ * @returns {HTMLElement|null}
2140
+ */
2141
+ _collapseControl(axis, id, lane = null) {
2142
+ return /** @type {HTMLElement|null} */ ([...this._board.querySelectorAll('[data-kanban-collapse]')]
2143
+ .find((candidate) => {
2144
+ const control = /** @type {HTMLElement} */ (candidate);
2145
+ if (control.dataset.kanbanCollapse !== axis || control.dataset.kanbanId !== id) return false;
2146
+ if (axis !== 'column') return true;
2147
+ const section = control.closest('.zx-kanban-view__column');
2148
+ return section ? this._sectionMeta.get(section)?.lane === lane : false;
2149
+ }) ?? null);
2150
+ }
2151
+
2152
+ /**
2153
+ * Runs one presentation hook, ignoring anything that is not a node so a hook cannot smuggle
2154
+ * markup in as a string.
2155
+ * @param {'renderCard'|'renderColumnHeader'|'renderSwimlaneHeader'|'renderColumnEmpty'|'renderDragPreview'} name Hook name.
2156
+ * @param {any} context Hook context.
2157
+ * @returns {Node|null} Node to adopt, or null for the default presentation.
2158
+ */
2159
+ _renderHook(name, context) {
2160
+ const hook = this._kanbanOptions()[name];
2161
+ if (typeof hook !== 'function') return null;
2162
+ const result = hook(context);
2163
+ return result && typeof result === 'object' && 'nodeType' in result ? /** @type {Node} */ (result) : null;
2164
+ }
2165
+
2166
+ /** @param {KanbanColumn} column @param {KanbanSwimlane|null} lane @returns {boolean} */
2167
+ _allowsAdd(column, lane) {
2168
+ const allow = this._kanbanOptions().allowAdd;
2169
+ if (typeof allow === 'function') return allow(column, lane) === true;
2170
+ return allow === true;
2171
+ }
2172
+
997
2173
  /** @param {KanbanRecord} record @param {number} index @returns {string} */
998
2174
  _recordName(record, index) {
999
2175
  const source = this._kanbanOptions().titleField;
1000
2176
  const value = typeof source === 'function' ? source(record, index) : typeof source === 'string'
1001
2177
  ? this._readAxis(source, record, index) : this._viewRecordId(record);
1002
2178
  if (value && typeof value === 'object' && 'nodeType' in value) {
1003
- return /** @type {Node} */ (value).textContent?.trim() || 'record';
2179
+ return /** @type {Node} */ (value).textContent?.trim() || this._text('record');
1004
2180
  }
1005
- return String(value ?? 'record');
2181
+ return String(value ?? this._text('record'));
2182
+ }
2183
+
2184
+ /** @param {KanbanRecord[]} records Moving records. @returns {string} */
2185
+ _movingName(records) {
2186
+ if (!records.length) return this._text('record');
2187
+ if (records.length === 1) return this._recordName(records[0], this._viewData.indexOf(records[0]));
2188
+ return this._text('dragPreview', { count: records.length });
1006
2189
  }
1007
2190
 
1008
2191
  /** @param {KanbanMovePoint} point @returns {string} */
@@ -1013,6 +2196,26 @@ export class KanbanView extends RecordView {
1013
2196
  return `${column?.label ?? point.column}${lane ? `, ${lane.label}` : ''}`;
1014
2197
  }
1015
2198
 
2199
+ /** @param {KanbanMoveEvaluation} evaluation Capacity report. @returns {string} */
2200
+ _wipAnnouncement(evaluation) {
2201
+ if (evaluation.limit == null) return '';
2202
+ if (evaluation.limitExceeded) return this._text('limitExceeded', { limit: evaluation.limit });
2203
+ return evaluation.limitReached ? this._text('limitReached', { limit: evaluation.limit }) : '';
2204
+ }
2205
+
2206
+ /**
2207
+ * Resolves one localizable string.
2208
+ * @param {keyof typeof DEFAULT_LABELS|string} key Label key.
2209
+ * @param {Record<string, unknown>} [args] Placeholder values.
2210
+ * @returns {string} Resolved text.
2211
+ */
2212
+ _text(key, args) {
2213
+ const overrides = this._kanbanOptions().labels;
2214
+ const source = overrides && typeof overrides === 'object' && key in overrides
2215
+ ? overrides[key] : /** @type {Record<string,string>} */ (DEFAULT_LABELS)[key];
2216
+ return printf(source == null ? String(key) : String(source), args);
2217
+ }
2218
+
1016
2219
  /** @param {string} message @returns {void} */
1017
2220
  _announce(message) {
1018
2221
  const token = ++this._announcementToken;
@@ -1053,15 +2256,28 @@ export function normalizeKanbanDescriptors(descriptors, axis = 'column') {
1053
2256
  if (source.accept != null && typeof source.accept !== 'function') {
1054
2257
  throw new TypeError(`Kanban ${axis} ${id} accept must be a function`);
1055
2258
  }
2259
+ if (source.from != null && !Array.isArray(source.from)) {
2260
+ throw new TypeError(`Kanban ${axis} ${id} from must be an array or null`);
2261
+ }
1056
2262
  seen.add(id);
1057
2263
  const normalized = /** @type {Record<string, any>} */ ({
1058
2264
  ...source,
1059
2265
  id,
1060
2266
  label: String(source.label ?? axisLabel(source.id)),
2267
+ limit: normalizeLimit(source.limit),
2268
+ from: Array.isArray(source.from) ? source.from.map(String) : null,
1061
2269
  ...(Object.prototype.hasOwnProperty.call(source, 'value') ? {} : { value: source.id })
1062
2270
  });
1063
- if (axis === 'column') normalized.limit = normalizeLimit(source.limit);
1064
- else delete normalized.limit;
2271
+ if (axis === 'column') {
2272
+ const laneLimits = normalizeLaneLimits(source.laneLimits);
2273
+ if (laneLimits) normalized.laneLimits = laneLimits;
2274
+ if (source.wipPolicy === 'block' || source.wipPolicy === 'warn') {
2275
+ normalized.wipPolicy = source.wipPolicy;
2276
+ }
2277
+ } else {
2278
+ delete normalized.laneLimits;
2279
+ delete normalized.wipPolicy;
2280
+ }
1065
2281
  return normalized;
1066
2282
  }));
1067
2283
  }
@@ -1099,8 +2315,24 @@ export function reconcileKanbanOrder(descriptors, order) {
1099
2315
  }
1100
2316
 
1101
2317
  /**
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.
2318
+ * Reconciles the visible descriptor order while optionally retaining preferences for derived axes
2319
+ * that are absent from the current result set. This lets saved state be applied before an async
2320
+ * query supplies the records from which columns or lanes are derived.
2321
+ * @param {{id:string}[]} descriptors Available descriptors.
2322
+ * @param {unknown} order Preferred order.
2323
+ * @param {boolean} [preserveUnknown=false] Retain temporarily unavailable ids.
2324
+ * @returns {string[]}
2325
+ */
2326
+ export function reconcileKanbanPreference(descriptors, order, preserveUnknown = false) {
2327
+ if (!preserveUnknown) return reconcileKanbanOrder(descriptors, order);
2328
+ const preferred = normalizeOrder(order);
2329
+ const available = descriptors.map((descriptor) => descriptor.id);
2330
+ return [...preferred, ...available.filter((id) => !preferred.includes(id))];
2331
+ }
2332
+
2333
+ /**
2334
+ * Reorders records around one already-cloned moved record. Retained as the single-record form of
2335
+ * `reorderKanbanRecords`; the input array and every input record remain untouched.
1104
2336
  * @param {KanbanRecord[]} records Records.
1105
2337
  * @param {unknown} id Moving record id.
1106
2338
  * @param {KanbanRecord} movedRecord Cloned record carrying its destination axis values.
@@ -1108,46 +2340,9 @@ export function reconcileKanbanOrder(descriptors, order) {
1108
2340
  * @returns {{records:KanbanRecord[],from:KanbanMovePoint,to:KanbanMovePoint}|null}
1109
2341
  */
1110
2342
  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);
2343
+ if (!movedRecord || typeof movedRecord !== 'object') return null;
2344
+ const result = reorderKanbanRecords(records, [{ id, record: movedRecord }], options);
2345
+ return result ? { records: result.records, from: result.moves[0].from, to: result.to } : null;
1151
2346
  }
1152
2347
 
1153
2348
  /** @param {unknown} value @returns {string} */
@@ -1164,10 +2359,33 @@ function normalizeLimit(value) {
1164
2359
  return Math.floor(number);
1165
2360
  }
1166
2361
 
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))));
2362
+ /** @param {unknown} value @returns {Record<string, number>|undefined} */
2363
+ function normalizeLaneLimits(value) {
2364
+ if (value == null) return undefined;
2365
+ if (typeof value !== 'object' || Array.isArray(value)) {
2366
+ throw new TypeError('Kanban column laneLimits must be an object');
2367
+ }
2368
+ /** @type {Record<string, number>} */
2369
+ const result = {};
2370
+ for (const [lane, limit] of Object.entries(/** @type {Record<string, unknown>} */ (value))) {
2371
+ const normalized = normalizeLimit(limit);
2372
+ if (normalized != null) result[lane] = normalized;
2373
+ }
2374
+ return result;
2375
+ }
2376
+
2377
+ /** @param {KanbanColumn} column @param {string} lane @returns {number|null} */
2378
+ function readColumnLaneLimit(column, lane) {
2379
+ const limits = column.laneLimits;
2380
+ if (!limits || typeof limits !== 'object') return null;
2381
+ const value = limits[lane];
2382
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
2383
+ }
2384
+
2385
+ /** @param {number} count @param {number|null} limit @returns {'none'|'within'|'limit'|'exceeded'} */
2386
+ function wipState(count, limit) {
2387
+ if (limit == null) return 'none';
2388
+ return count > limit ? 'exceeded' : count === limit ? 'limit' : 'within';
1171
2389
  }
1172
2390
 
1173
2391
  /** @param {unknown} value @returns {string[]} */
@@ -1175,6 +2393,14 @@ function normalizeOrder(value) {
1175
2393
  return Array.isArray(value) ? [...new Set(value.map(String))] : [];
1176
2394
  }
1177
2395
 
2396
+ /** @param {unknown} value @returns {string} */
2397
+ function stringifyValue(value) {
2398
+ if (value == null) return '';
2399
+ if (typeof value === 'object') return 'nodeType' in value
2400
+ ? String(/** @type {Node} */ (value).textContent ?? '') : '';
2401
+ return String(value);
2402
+ }
2403
+
1178
2404
  /** @template {{id:string}} T @param {T[]} descriptors @param {string[]} order @returns {T[]} */
1179
2405
  function orderDescriptors(descriptors, order) {
1180
2406
  const byId = new Map(descriptors.map((descriptor) => [descriptor.id, descriptor]));
@@ -1192,6 +2418,28 @@ function moveOrderedId(order, id, targetId, position) {
1192
2418
  return next;
1193
2419
  }
1194
2420
 
2421
+ /**
2422
+ * Replaces only the currently resolved axis positions inside a retained preference. Temporarily
2423
+ * absent derived ids keep their relative slots until later data makes them available again.
2424
+ * @param {string[]} preference Full retained preference.
2425
+ * @param {{id:string}[]} descriptors Currently available descriptors.
2426
+ * @param {string[]} resolvedOrder Next order for available descriptors.
2427
+ * @param {boolean} preserveUnknown Whether unavailable ids should be retained.
2428
+ * @returns {string[]}
2429
+ */
2430
+ function mergeKanbanResolvedOrder(preference, descriptors, resolvedOrder, preserveUnknown) {
2431
+ if (!preserveUnknown) return [...resolvedOrder];
2432
+ const available = new Set(descriptors.map((descriptor) => descriptor.id));
2433
+ const replacements = [...resolvedOrder];
2434
+ let cursor = 0;
2435
+ const merged = normalizeOrder(preference).flatMap((id) => {
2436
+ if (!available.has(id)) return [id];
2437
+ return cursor < replacements.length ? [replacements[cursor++]] : [];
2438
+ });
2439
+ merged.push(...replacements.slice(cursor));
2440
+ return normalizeOrder(merged);
2441
+ }
2442
+
1195
2443
  /** @param {KanbanMovePoint} left @param {KanbanMovePoint} right @returns {boolean} */
1196
2444
  function sameMovePoint(left, right) {
1197
2445
  return left.column === right.column && left.lane === right.lane && left.index === right.index;
@@ -1202,6 +2450,12 @@ function sameArray(left, right) {
1202
2450
  return left.length === right.length && left.every((value, index) => Object.is(value, right[index]));
1203
2451
  }
1204
2452
 
2453
+ /** @param {Set<unknown>|unknown[]} haystack @param {unknown} needle @returns {boolean} */
2454
+ function hasId(haystack, needle) {
2455
+ for (const candidate of haystack) if (Object.is(candidate, needle)) return true;
2456
+ return false;
2457
+ }
2458
+
1205
2459
  /** @param {{target?:EventTarget|null}|Event} event @returns {Element|null} */
1206
2460
  function elementTarget(event) {
1207
2461
  const target = event.target;
@@ -1216,9 +2470,16 @@ function hasTextSelection() {
1216
2470
  }
1217
2471
 
1218
2472
  /**
1219
- * Record movement event emitted before a local commit or external request.
2473
+ * Record movement event emitted before a local commit or external request. `record`, `id`, and
2474
+ * `from` describe the primary record; `records`, `ids`, and `moves` describe the whole atomic move.
1220
2475
  * @event KanbanView#recordmove
1221
- * @type {CustomEvent<{record:KanbanRecord,id:unknown,from:KanbanMovePoint,to:KanbanMovePoint,column:KanbanColumn,swimlane:KanbanSwimlane|null,limitExceeded:boolean}>}
2476
+ * @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}>}
2477
+ */
2478
+
2479
+ /**
2480
+ * Refused movement event. Nothing was written and no `recordmove` was emitted.
2481
+ * @event KanbanView#movereject
2482
+ * @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
2483
  */
1223
2484
 
1224
2485
  /**
@@ -1226,3 +2487,21 @@ function hasTextSelection() {
1226
2487
  * @event KanbanView#recordaction
1227
2488
  * @type {CustomEvent<{record:KanbanRecord,id:unknown,action:unknown,actionId:string|undefined,event:Event}>}
1228
2489
  */
2490
+
2491
+ /**
2492
+ * Request to create a record in one column and lane. The board never creates records itself.
2493
+ * @event KanbanView#recordadd
2494
+ * @type {CustomEvent<{column:KanbanColumn|null,swimlane:KanbanSwimlane|null,columnId:string,laneId:string|null,event:Event}>}
2495
+ */
2496
+
2497
+ /**
2498
+ * Board search change from the built-in control.
2499
+ * @event KanbanView#searchchange
2500
+ * @type {CustomEvent<{search:string}>}
2501
+ */
2502
+
2503
+ /**
2504
+ * Undo and redo availability after a committed, reverted, or discarded local move.
2505
+ * @event KanbanView#historychange
2506
+ * @type {CustomEvent<{canUndo:boolean,canRedo:boolean,depth:{undo:number,redo:number}}>}
2507
+ */