@zeyos/zx 4.3.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.
- package/CHANGELOG.md +111 -1
- package/README.md +0 -3
- package/dist/types/components/calendar/calendar-model.d.ts +22 -0
- package/dist/types/components/card-view/card-view.d.ts +1 -1
- package/dist/types/components/card-view/record-card.d.ts +47 -5
- package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
- package/dist/types/components/kanban-view/kanban-view.d.ts +669 -24
- package/dist/types/core/component.d.ts +6 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/zx-zeyos.esm.js +35 -11
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +597 -78
- package/dist/zx.esm.js +1758 -238
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +1758 -238
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +214 -2
- package/docs/llms.md +30 -30
- package/docs/llms.txt +0 -2
- package/package.json +1 -2
- package/src/components/calendar/calendar-model.js +45 -0
- package/src/components/calendar/calendar.css +30 -10
- package/src/components/calendar/calendar.js +8 -10
- package/src/components/card-view/card-view.css +211 -9
- package/src/components/card-view/card-view.js +7 -7
- package/src/components/card-view/record-card.js +92 -19
- package/src/components/kanban-view/kanban-policy.js +412 -0
- package/src/components/kanban-view/kanban-view.css +480 -36
- package/src/components/kanban-view/kanban-view.js +1514 -235
- package/src/components/message/message.css +9 -10
- package/src/core/component.js +8 -3
- package/src/index.js +8 -3
- 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]
|
|
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 {{
|
|
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
|
-
*
|
|
114
|
-
*
|
|
115
|
-
|
|
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.
|
|
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, '
|
|
196
|
-
this.listen(this._board, '
|
|
197
|
-
this.listen(this._board, '
|
|
198
|
-
this.listen(this._board, '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
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.
|
|
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
|
-
|
|
375
|
-
const
|
|
376
|
-
if (!
|
|
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.
|
|
616
|
+
this._rejectMove('destination', { ids: moving, records, from, to: null, column: null, swimlane: null });
|
|
387
617
|
return this;
|
|
388
618
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
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 = {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
this.
|
|
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 =
|
|
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.
|
|
657
|
+
this._rejectMove('destination', { ids: moving, records, from, to, column, swimlane: lane });
|
|
421
658
|
return this;
|
|
422
659
|
}
|
|
423
|
-
|
|
424
|
-
const
|
|
660
|
+
|
|
661
|
+
const wip = this._wipAnnouncement(evaluation);
|
|
425
662
|
const moveEvent = this.emit('recordmove', {
|
|
426
|
-
record, id
|
|
427
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
438
|
-
this.
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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:
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
this.
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
515
|
-
if (
|
|
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
|
|
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
|
|
533
|
-
normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === lane.id)
|
|
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:
|
|
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
|
|
540
|
-
|
|
541
|
-
|
|
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,
|
|
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
|
|
575
|
-
normalizeAxisId(this._readAxis(options.columnBy, record, index)) === column.id)
|
|
576
|
-
const
|
|
577
|
-
|
|
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:
|
|
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
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
:
|
|
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
|
-
|
|
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
|
|
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',
|
|
613
|
-
ariaPressed: String(grabbed),
|
|
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
|
-
/**
|
|
639
|
-
|
|
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 ??
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
810
|
-
const
|
|
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.
|
|
1771
|
+
this._commitInteractionMove(move.ids, move.to);
|
|
817
1772
|
} else {
|
|
818
1773
|
const from = this._recordLocation(meta.id);
|
|
819
1774
|
if (!from) return;
|
|
820
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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.
|
|
860
|
-
|
|
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(
|
|
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
|
-
/**
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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 {
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
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 {
|
|
913
|
-
|
|
914
|
-
if (this.
|
|
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
|
|
917
|
-
const destination = { ...this._dragDestination };
|
|
1901
|
+
const ids = [...this._dragIds];
|
|
1902
|
+
const destination = this._dragDestination ? { ...this._dragDestination } : null;
|
|
918
1903
|
this._clearDrag();
|
|
919
|
-
this.
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
-
|
|
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
|
-
|
|
945
|
-
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
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')
|
|
1064
|
-
|
|
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
|
-
*
|
|
1103
|
-
*
|
|
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 (!
|
|
1112
|
-
const
|
|
1113
|
-
|
|
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 @
|
|
1168
|
-
function
|
|
1169
|
-
if (value == null
|
|
1170
|
-
|
|
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
|
+
*/
|