@zeyos/zx 4.1.1 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -0
- package/DESIGN-SYSTEM.md +33 -3
- package/README.md +22 -5
- package/dist/types/components/app-icon/app-icon.d.ts +8 -1
- package/dist/types/components/aurora/aurora.d.ts +72 -0
- package/dist/types/components/calendar/calendar-model.d.ts +202 -0
- package/dist/types/components/calendar/calendar.d.ts +517 -0
- package/dist/types/components/card-view/card-view.d.ts +303 -0
- package/dist/types/components/card-view/record-card.d.ts +242 -0
- package/dist/types/components/dialog/dialog.d.ts +6 -1
- package/dist/types/components/dropdown/dropdown.d.ts +5 -0
- package/dist/types/components/kanban-view/kanban-view.d.ts +572 -0
- package/dist/types/components/launcher/launcher.d.ts +5 -0
- package/dist/types/components/message/message.d.ts +9 -1
- package/dist/types/components/modal/modal.d.ts +44 -7
- package/dist/types/components/sheet/sheet.d.ts +22 -12
- package/dist/types/components/table/table.d.ts +120 -31
- package/dist/types/components/table-view/table-view.d.ts +192 -0
- package/dist/types/components/view/record-view.d.ts +464 -0
- package/dist/types/core/overlay-host.d.ts +12 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/zeyos/calendar.d.ts +91 -0
- package/dist/types/zeyos/icons.d.ts +18 -2
- package/dist/types/zeyos/index.d.ts +4 -0
- package/dist/types/zeyos/legacy-saved-views.d.ts +66 -0
- package/dist/types/zeyos/saved-views.d.ts +331 -0
- package/dist/types/zeyos/view.d.ts +305 -0
- package/dist/zx-zeyos.esm.js +4742 -1470
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +2536 -471
- package/dist/zx.esm.js +5193 -223
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +5193 -223
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +1729 -182
- package/docs/llms.md +236 -32
- package/docs/llms.txt +16 -6
- package/package.json +1 -1
- package/src/components/account-menu/account-menu.css +1 -1
- package/src/components/app-icon/app-icon.css +77 -51
- package/src/components/app-icon/app-icon.js +28 -4
- package/src/components/app-rail/app-rail.css +12 -3
- package/src/components/app-sidebar/app-sidebar.css +11 -2
- package/src/components/aurora/aurora.css +143 -0
- package/src/components/aurora/aurora.js +146 -0
- package/src/components/avatar/avatar.css +1 -1
- package/src/components/button/button.css +2 -2
- package/src/components/calendar/calendar-model.js +402 -0
- package/src/components/calendar/calendar.css +609 -0
- package/src/components/calendar/calendar.js +1619 -0
- package/src/components/card/card.css +2 -2
- package/src/components/card-view/card-view.css +389 -0
- package/src/components/card-view/card-view.js +527 -0
- package/src/components/card-view/record-card.js +392 -0
- package/src/components/context-menu/context-menu.css +4 -4
- package/src/components/context-menu/context-menu.js +5 -1
- package/src/components/date-picker/date-picker.css +31 -12
- package/src/components/datebox/date-range-box.css +17 -2
- package/src/components/datebox/datebox.css +18 -3
- package/src/components/dialog/dialog.css +4 -3
- package/src/components/dialog/dialog.js +2 -1
- package/src/components/dropdown/dropdown.css +17 -3
- package/src/components/dropdown/dropdown.js +7 -2
- package/src/components/kanban-view/kanban-view.css +216 -0
- package/src/components/kanban-view/kanban-view.js +1228 -0
- package/src/components/launcher/launcher.css +24 -9
- package/src/components/launcher/launcher.js +9 -2
- package/src/components/master-panel/master-panel.css +14 -3
- package/src/components/menu-button/menu-button.css +4 -4
- package/src/components/message/message.css +13 -21
- package/src/components/message/message.js +17 -11
- package/src/components/modal/modal.css +19 -5
- package/src/components/modal/modal.js +99 -25
- package/src/components/navigation-bar/navigation-bar.css +17 -5
- package/src/components/panel/panel.css +10 -2
- package/src/components/select/select.css +26 -8
- package/src/components/sheet/sheet.css +28 -7
- package/src/components/sheet/sheet.js +43 -23
- package/src/components/table/table.css +83 -5
- package/src/components/table/table.js +322 -37
- package/src/components/table-view/table-view.css +18 -0
- package/src/components/table-view/table-view.js +347 -0
- package/src/components/tag-picker/tag-picker.css +18 -4
- package/src/components/tooltip/tooltip.css +17 -3
- package/src/components/tooltip/tooltip.js +4 -1
- package/src/components/view/record-view.css +114 -0
- package/src/components/view/record-view.js +770 -0
- package/src/core/overlay-host.js +23 -0
- package/src/index.js +15 -0
- package/src/zeyos/calendar.js +115 -0
- package/src/zeyos/icons.js +11 -4
- package/src/zeyos/index.js +13 -0
- package/src/zeyos/launcher.js +2 -2
- package/src/zeyos/legacy-saved-views.js +155 -0
- package/src/zeyos/saved-views.js +815 -0
- package/src/zeyos/table.js +3 -1
- package/src/zeyos/view.js +543 -0
- package/styles/icons.css +3 -10
- package/styles/tokens/dark.css +4 -0
- package/styles/tokens/semantic.css +142 -3
- package/styles/zx.css +6 -0
|
@@ -0,0 +1,1228 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
|
|
3
|
+
import { icon } from '../../core/icons.js';
|
|
4
|
+
import { uid } from '../../core/util.js';
|
|
5
|
+
import { createRecordCard, resolveRecordActions } from '../card-view/record-card.js';
|
|
6
|
+
import { RecordView, readViewField, viewRecordId } from '../view/record-view.js';
|
|
7
|
+
|
|
8
|
+
/** @typedef {Record<string, any>} KanbanRecord */
|
|
9
|
+
/** @typedef {import('../view/record-view.js').RecordViewOptions} RecordViewOptions */
|
|
10
|
+
/** @typedef {import('../card-view/record-card.js').RecordCardPreview} RecordCardPreview */
|
|
11
|
+
/** @typedef {import('../card-view/record-card.js').RecordCardLink} RecordCardLink */
|
|
12
|
+
/** @typedef {import('../card-view/record-card.js').RecordCardAction} RecordCardAction */
|
|
13
|
+
/** @typedef {string|((record: KanbanRecord, index: number) => unknown)|null} KanbanAccessor */
|
|
14
|
+
/** @typedef {string|((record: KanbanRecord, index: number) => unknown)|null} KanbanCardField */
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} KanbanMovePoint
|
|
18
|
+
* @property {string} column Column id.
|
|
19
|
+
* @property {string|null} lane Swim-lane id, or null when lanes are disabled.
|
|
20
|
+
* @property {number} index Zero-based position within the destination column and lane.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} KanbanMoveContext
|
|
25
|
+
* @property {unknown} id Record id.
|
|
26
|
+
* @property {KanbanMovePoint} from Original position.
|
|
27
|
+
* @property {KanbanMovePoint} to Proposed position.
|
|
28
|
+
* @property {KanbanColumn} column Destination column.
|
|
29
|
+
* @property {KanbanSwimlane|null} swimlane Destination lane.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} KanbanColumn
|
|
34
|
+
* @property {string} id Stable column id.
|
|
35
|
+
* @property {string} label Visible column label.
|
|
36
|
+
* @property {unknown} [value] Value written by local moves; defaults to `id`.
|
|
37
|
+
* @property {number|null} [limit=null] Advisory work-in-progress limit.
|
|
38
|
+
* @property {(record: KanbanRecord, context: KanbanMoveContext) => boolean} [accept] Eligibility
|
|
39
|
+
* predicate. Returning false rejects the move before the cancelable event is emitted.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} KanbanSwimlane
|
|
44
|
+
* @property {string} id Stable lane id.
|
|
45
|
+
* @property {string} label Visible lane label.
|
|
46
|
+
* @property {unknown} [value] Value written by local moves; defaults to `id`.
|
|
47
|
+
* @property {(record: KanbanRecord, context: KanbanMoveContext) => boolean} [accept] Eligibility
|
|
48
|
+
* predicate. Returning false rejects the move before the cancelable event is emitted.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {Object} KanbanDestination
|
|
53
|
+
* @property {string} [column] Destination column; omitted keeps the current column.
|
|
54
|
+
* @property {string|null} [lane] Destination lane; omitted keeps the current lane.
|
|
55
|
+
* @property {number} [index] Zero-based destination position; omitted appends.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {Object} KanbanViewState
|
|
60
|
+
* @property {1} version State schema version.
|
|
61
|
+
* @property {string[]} fieldOrder Shared field order.
|
|
62
|
+
* @property {string[]} hiddenFields Shared hidden fields.
|
|
63
|
+
* @property {{id:string,dir:'asc'|'desc'}|null} sort Shared sort.
|
|
64
|
+
* @property {string[]} columnOrder Column order.
|
|
65
|
+
* @property {string[]} swimlaneOrder Swim-lane order.
|
|
66
|
+
* @property {string[]} collapsedColumns Collapsed column ids.
|
|
67
|
+
* @property {string[]} collapsedSwimlanes Collapsed swim-lane ids.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {Object} KanbanViewOptions
|
|
72
|
+
* @property {import('../view/record-view.js').ViewField[]} [fields=[]] Shared field descriptors.
|
|
73
|
+
* @property {KanbanRecord[]} [data=[]] Records.
|
|
74
|
+
* @property {string|((record:KanbanRecord)=>unknown)} [recordId='ID'] Stable record id accessor.
|
|
75
|
+
* @property {{id:string,dir:'asc'|'desc'}|null} [sort=null] Shared initial sort.
|
|
76
|
+
* @property {'local'|'server'} [sortMode='local'] Shared sort mode.
|
|
77
|
+
* @property {false|'single'|'multi'} [selectable=false] Shared selection mode.
|
|
78
|
+
* @property {unknown[]} [selection=[]] Initial record ids.
|
|
79
|
+
* @property {string[]} [fieldOrder=[]] Shared field order.
|
|
80
|
+
* @property {string[]} [hiddenFields=[]] Shared hidden fields.
|
|
81
|
+
* @property {boolean} [fieldControls=true] Show shared field controls.
|
|
82
|
+
* @property {string|Node|(() => string|Node)|null} [emptyText=null] Empty-board content.
|
|
83
|
+
* @property {KanbanAccessor} [columnBy='status'] Column accessor.
|
|
84
|
+
* @property {KanbanColumn[]|null} [columns=null] Explicit columns, or null to derive them.
|
|
85
|
+
* @property {KanbanAccessor} [swimlaneBy=null] Optional swim-lane accessor.
|
|
86
|
+
* @property {KanbanSwimlane[]|null} [swimlanes=null] Explicit lanes, or null to derive them.
|
|
87
|
+
* @property {KanbanCardField} [titleField=null] Card title field or reader.
|
|
88
|
+
* @property {KanbanCardField} [subtitleField=null] Card subtitle field or reader.
|
|
89
|
+
* @property {string|((record:KanbanRecord,index:number)=>RecordCardPreview)|null} [preview=null] Preview field or reader.
|
|
90
|
+
* @property {KanbanCardField} [previewAlt=null] Preview alternative-text field or reader.
|
|
91
|
+
* @property {RecordCardLink|((record:KanbanRecord,index:number)=>RecordCardLink)|null} [link=null] Record-card primary link descriptor or reader.
|
|
92
|
+
* @property {RecordCardAction[]|((record:KanbanRecord,index:number)=>RecordCardAction[])} [actions=[]] Card actions.
|
|
93
|
+
* @property {'local'|'external'} [moveMode='local'] Whether accepted moves update local data.
|
|
94
|
+
* @property {string[]} [columnOrder=[]] Preferred column order.
|
|
95
|
+
* @property {string[]} [swimlaneOrder=[]] Preferred swim-lane order.
|
|
96
|
+
* @property {string[]} [collapsedColumns=[]] Initially collapsed columns.
|
|
97
|
+
* @property {string[]} [collapsedSwimlanes=[]] Initially collapsed lanes.
|
|
98
|
+
* @property {boolean} [showCounts=true] Show record counts and WIP indicators.
|
|
99
|
+
* @property {boolean} [showEmptyColumns=true] Keep configured empty columns visible.
|
|
100
|
+
* @property {'outlined'|'raised'|'filled'} [variant='outlined'] Shared record-card treatment.
|
|
101
|
+
* @property {string} [label='Kanban board'] Accessible board label.
|
|
102
|
+
* @property {(event: CustomEvent<Record<string,unknown>>) => void} [onrecordmove]
|
|
103
|
+
* @property {(event: CustomEvent<Record<string,unknown>>) => void} [onrecordaction]
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/** @typedef {{record:KanbanRecord,id:unknown,index:number,column:string,lane:string|null}} KanbanCardMeta */
|
|
107
|
+
/** @typedef {{column:string,lane:string|null,list:HTMLElement}} KanbanSectionMeta */
|
|
108
|
+
/** @typedef {{id:unknown,from:KanbanMovePoint,to:KanbanMovePoint}} KanbanKeyboardMove */
|
|
109
|
+
|
|
110
|
+
const INTERACTIVE = 'a, button, input, select, textarea, summary, [contenteditable="true"]';
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Configurable record board with semantic columns, optional swim lanes, and equivalent pointer and
|
|
114
|
+
* keyboard movement. Local moves clone records; callback grouping remains fully usable for display
|
|
115
|
+
* and external moves but cannot be written safely by local cross-axis moves.
|
|
116
|
+
* @fires KanbanView#recordmove
|
|
117
|
+
* @fires KanbanView#recordaction
|
|
118
|
+
* @extends {RecordView}
|
|
119
|
+
*/
|
|
120
|
+
export class KanbanView extends RecordView {
|
|
121
|
+
static cssName = 'kanban-view';
|
|
122
|
+
|
|
123
|
+
/** @type {Readonly<KanbanViewOptions & RecordViewOptions>} */
|
|
124
|
+
static defaults = {
|
|
125
|
+
...RecordView.defaults,
|
|
126
|
+
columnBy: 'status',
|
|
127
|
+
columns: null,
|
|
128
|
+
swimlaneBy: null,
|
|
129
|
+
swimlanes: null,
|
|
130
|
+
titleField: null,
|
|
131
|
+
subtitleField: null,
|
|
132
|
+
preview: null,
|
|
133
|
+
previewAlt: null,
|
|
134
|
+
link: null,
|
|
135
|
+
actions: [],
|
|
136
|
+
moveMode: 'local',
|
|
137
|
+
columnOrder: [],
|
|
138
|
+
swimlaneOrder: [],
|
|
139
|
+
collapsedColumns: [],
|
|
140
|
+
collapsedSwimlanes: [],
|
|
141
|
+
showCounts: true,
|
|
142
|
+
showEmptyColumns: true,
|
|
143
|
+
variant: 'outlined',
|
|
144
|
+
label: 'Kanban board'
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Creates a Kanban record view.
|
|
149
|
+
* @param {Element|string|null} [target=null] Existing root, selector, or null.
|
|
150
|
+
* @param {KanbanViewOptions & RecordViewOptions} [options={}] Kanban and shared record-view options.
|
|
151
|
+
*/
|
|
152
|
+
constructor(target = null, options = {}) {
|
|
153
|
+
super(target, options);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @returns {HTMLElement} Board root. */
|
|
157
|
+
render() {
|
|
158
|
+
const existing = Boolean(this.el);
|
|
159
|
+
const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
|
|
160
|
+
const options = this._kanbanOptions();
|
|
161
|
+
this._kanbanSnapshot = existing ? snapshotTarget(root) : null;
|
|
162
|
+
this._kanbanDestroyed = false;
|
|
163
|
+
try {
|
|
164
|
+
this._initRecordView(root);
|
|
165
|
+
this._kanbanColumns = options.columns === null
|
|
166
|
+
? null : normalizeKanbanDescriptors(options.columns, 'column');
|
|
167
|
+
this._kanbanSwimlanes = options.swimlanes === null
|
|
168
|
+
? null : normalizeKanbanDescriptors(options.swimlanes, 'swimlane');
|
|
169
|
+
this._kanbanColumnOrder = normalizeOrder(options.columnOrder);
|
|
170
|
+
this._kanbanSwimlaneOrder = normalizeOrder(options.swimlaneOrder);
|
|
171
|
+
this._collapsedColumns = new Set(normalizeOrder(options.collapsedColumns));
|
|
172
|
+
this._collapsedSwimlanes = new Set(normalizeOrder(options.collapsedSwimlanes));
|
|
173
|
+
this._keyboardMove = /** @type {KanbanKeyboardMove|null} */ (null);
|
|
174
|
+
this._dragId = null;
|
|
175
|
+
this._dragDestination = /** @type {KanbanMovePoint|null} */ (null);
|
|
176
|
+
this._announcementToken = 0;
|
|
177
|
+
this._cardMeta = new WeakMap();
|
|
178
|
+
this._sectionMeta = new WeakMap();
|
|
179
|
+
this._cardsById = new Map();
|
|
180
|
+
|
|
181
|
+
this._toolbar = h('div', { class: 'zx-record-view__toolbar zx-kanban-view__toolbar' });
|
|
182
|
+
if (options.fieldControls) this._toolbar.append(this._createViewFieldControls());
|
|
183
|
+
this._board = h('div', {
|
|
184
|
+
class: 'zx-kanban-view__board', role: 'region', ariaLabel: options.label
|
|
185
|
+
});
|
|
186
|
+
this._live = h('div', {
|
|
187
|
+
class: 'zx-kanban-view__live', role: 'status', ariaLive: 'polite', ariaAtomic: 'true'
|
|
188
|
+
});
|
|
189
|
+
root.replaceChildren(this._toolbar, this._board, this._live);
|
|
190
|
+
|
|
191
|
+
this.listen(this._board, 'click', (event) => this._handleBoardClick(/** @type {MouseEvent} */ (event)));
|
|
192
|
+
this.listen(this._board, 'dblclick', (event) => this._handleBoardDoubleClick(/** @type {MouseEvent} */ (event)));
|
|
193
|
+
this.listen(this._board, 'keydown', (event) => this._handleBoardKeydown(/** @type {KeyboardEvent} */ (event)));
|
|
194
|
+
this.listen(this._board, 'error', (event) => this._handlePreviewError(event), { capture: true });
|
|
195
|
+
this.listen(this._board, 'dragstart', (event) => this._handleDragStart(/** @type {DragEvent} */ (event)));
|
|
196
|
+
this.listen(this._board, 'dragover', (event) => this._handleDragOver(/** @type {DragEvent} */ (event)));
|
|
197
|
+
this.listen(this._board, 'drop', (event) => this._handleDrop(/** @type {DragEvent} */ (event)));
|
|
198
|
+
this.listen(this._board, 'dragend', () => this._clearDrag());
|
|
199
|
+
this._refreshView('init');
|
|
200
|
+
return root;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (this._kanbanSnapshot) restoreTarget(root, this._kanbanSnapshot);
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Returns resolved columns in visual order. @returns {KanbanColumn[]} */
|
|
208
|
+
getColumns() {
|
|
209
|
+
return this._resolvedColumns().map((column) => ({ ...column }));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Replaces configured columns. Pass null to derive them from current data.
|
|
214
|
+
* @param {KanbanColumn[]|null} columns Columns.
|
|
215
|
+
* @returns {this}
|
|
216
|
+
*/
|
|
217
|
+
setColumns(columns) {
|
|
218
|
+
this._kanbanColumns = columns === null ? null : normalizeKanbanDescriptors(columns, 'column');
|
|
219
|
+
this._kanbanColumnOrder = reconcileKanbanOrder(this._rawColumns(), this._kanbanColumnOrder);
|
|
220
|
+
this._refreshView('columns');
|
|
221
|
+
this._emitKanbanState('columns');
|
|
222
|
+
return this;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Returns resolved lane descriptors in visual order. @returns {KanbanSwimlane[]} */
|
|
226
|
+
getSwimlanes() {
|
|
227
|
+
return this._resolvedSwimlanes().map((lane) => ({ ...lane }));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Replaces configured lanes. Pass null to derive them from current data.
|
|
232
|
+
* @param {KanbanSwimlane[]|null} lanes Lanes.
|
|
233
|
+
* @returns {this}
|
|
234
|
+
*/
|
|
235
|
+
setSwimlanes(lanes) {
|
|
236
|
+
this._kanbanSwimlanes = lanes === null ? null : normalizeKanbanDescriptors(lanes, 'swimlane');
|
|
237
|
+
this._kanbanSwimlaneOrder = reconcileKanbanOrder(this._rawSwimlanes(), this._kanbanSwimlaneOrder);
|
|
238
|
+
this._refreshView('swimlanes');
|
|
239
|
+
this._emitKanbanState('swimlanes');
|
|
240
|
+
return this;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Returns the current resolved column order. @returns {string[]} */
|
|
244
|
+
getColumnOrder() {
|
|
245
|
+
return this._resolvedColumns().map((column) => column.id);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Reconciles and applies a preferred column order.
|
|
250
|
+
* @param {string[]} order Preferred ids.
|
|
251
|
+
* @param {{silent?:boolean}} [options={}] Event behavior.
|
|
252
|
+
* @returns {this}
|
|
253
|
+
*/
|
|
254
|
+
setColumnOrder(order, options = {}) {
|
|
255
|
+
const next = reconcileKanbanOrder(this._rawColumns(), order);
|
|
256
|
+
if (sameArray(next, this.getColumnOrder())) return this;
|
|
257
|
+
this._kanbanColumnOrder = next;
|
|
258
|
+
this._refreshView('columnorder');
|
|
259
|
+
if (!options.silent) this._emitKanbanState('columnorder');
|
|
260
|
+
return this;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Moves a column relative to another.
|
|
265
|
+
* @param {string} id Moving id.
|
|
266
|
+
* @param {string} targetId Target id.
|
|
267
|
+
* @param {'before'|'after'} [position='before'] Target edge.
|
|
268
|
+
* @returns {this}
|
|
269
|
+
*/
|
|
270
|
+
moveColumn(id, targetId, position = 'before') {
|
|
271
|
+
return this.setColumnOrder(moveOrderedId(this.getColumnOrder(), id, targetId, position));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Returns the current resolved lane order. @returns {string[]} */
|
|
275
|
+
getSwimlaneOrder() {
|
|
276
|
+
return this._resolvedSwimlanes().map((lane) => lane.id);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Reconciles and applies a preferred lane order.
|
|
281
|
+
* @param {string[]} order Preferred ids.
|
|
282
|
+
* @param {{silent?:boolean}} [options={}] Event behavior.
|
|
283
|
+
* @returns {this}
|
|
284
|
+
*/
|
|
285
|
+
setSwimlaneOrder(order, options = {}) {
|
|
286
|
+
const next = reconcileKanbanOrder(this._rawSwimlanes(), order);
|
|
287
|
+
if (sameArray(next, this.getSwimlaneOrder())) return this;
|
|
288
|
+
this._kanbanSwimlaneOrder = next;
|
|
289
|
+
this._refreshView('swimlaneorder');
|
|
290
|
+
if (!options.silent) this._emitKanbanState('swimlaneorder');
|
|
291
|
+
return this;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Moves a lane relative to another.
|
|
296
|
+
* @param {string} id Moving id.
|
|
297
|
+
* @param {string} targetId Target id.
|
|
298
|
+
* @param {'before'|'after'} [position='before'] Target edge.
|
|
299
|
+
* @returns {this}
|
|
300
|
+
*/
|
|
301
|
+
moveSwimlane(id, targetId, position = 'before') {
|
|
302
|
+
return this.setSwimlaneOrder(moveOrderedId(this.getSwimlaneOrder(), id, targetId, position));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Returns collapsed column ids. @returns {string[]} */
|
|
306
|
+
getCollapsedColumns() {
|
|
307
|
+
return this.getColumnOrder().filter((id) => this._collapsedColumns.has(id));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Expands or collapses one column across every lane.
|
|
312
|
+
* @param {string} id Column id.
|
|
313
|
+
* @param {boolean} [collapsed=true] Desired state.
|
|
314
|
+
* @param {{silent?:boolean}} [options={}] Event behavior.
|
|
315
|
+
* @returns {this}
|
|
316
|
+
*/
|
|
317
|
+
setColumnCollapsed(id, collapsed = true, options = {}) {
|
|
318
|
+
const columnId = String(id);
|
|
319
|
+
if (!this.getColumnOrder().includes(columnId)) throw new RangeError(`Unknown Kanban column: ${columnId}`);
|
|
320
|
+
const changed = collapsed ? !this._collapsedColumns.has(columnId) : this._collapsedColumns.has(columnId);
|
|
321
|
+
if (!changed) return this;
|
|
322
|
+
if (collapsed) this._collapsedColumns.add(columnId);
|
|
323
|
+
else this._collapsedColumns.delete(columnId);
|
|
324
|
+
this._refreshView('columncollapse');
|
|
325
|
+
if (!options.silent) this._emitKanbanState('columncollapse');
|
|
326
|
+
return this;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Toggles a column. @param {string} id Column id. @returns {this} */
|
|
330
|
+
toggleColumn(id) {
|
|
331
|
+
return this.setColumnCollapsed(id, !this._collapsedColumns.has(String(id)));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Returns collapsed lane ids. @returns {string[]} */
|
|
335
|
+
getCollapsedSwimlanes() {
|
|
336
|
+
return this.getSwimlaneOrder().filter((id) => this._collapsedSwimlanes.has(id));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Expands or collapses one lane.
|
|
341
|
+
* @param {string} id Lane id.
|
|
342
|
+
* @param {boolean} [collapsed=true] Desired state.
|
|
343
|
+
* @param {{silent?:boolean}} [options={}] Event behavior.
|
|
344
|
+
* @returns {this}
|
|
345
|
+
*/
|
|
346
|
+
setSwimlaneCollapsed(id, collapsed = true, options = {}) {
|
|
347
|
+
const laneId = String(id);
|
|
348
|
+
if (!this.getSwimlaneOrder().includes(laneId)) throw new RangeError(`Unknown Kanban lane: ${laneId}`);
|
|
349
|
+
const changed = collapsed ? !this._collapsedSwimlanes.has(laneId) : this._collapsedSwimlanes.has(laneId);
|
|
350
|
+
if (!changed) return this;
|
|
351
|
+
if (collapsed) this._collapsedSwimlanes.add(laneId);
|
|
352
|
+
else this._collapsedSwimlanes.delete(laneId);
|
|
353
|
+
this._refreshView('swimlanecollapse');
|
|
354
|
+
if (!options.silent) this._emitKanbanState('swimlanecollapse');
|
|
355
|
+
return this;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Toggles a lane. @param {string} id Lane id. @returns {this} */
|
|
359
|
+
toggleSwimlane(id) {
|
|
360
|
+
return this.setSwimlaneCollapsed(id, !this._collapsedSwimlanes.has(String(id)));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Proposes and, in local mode, commits a record move. Destination acceptance is checked before
|
|
365
|
+
* a cancelable `recordmove` event. WIP limits are reported but remain advisory.
|
|
366
|
+
* @param {unknown} id Record id.
|
|
367
|
+
* @param {KanbanDestination} destination Destination.
|
|
368
|
+
* @returns {this}
|
|
369
|
+
* @fires KanbanView#recordmove
|
|
370
|
+
* @fires RecordView#datachange
|
|
371
|
+
*/
|
|
372
|
+
moveRecord(id, destination) {
|
|
373
|
+
const options = this._kanbanOptions();
|
|
374
|
+
const record = this.getRecord(id);
|
|
375
|
+
const from = this._recordLocation(id);
|
|
376
|
+
if (!record || !from || !destination || typeof destination !== 'object') return this;
|
|
377
|
+
const columns = this._resolvedColumns();
|
|
378
|
+
const lanes = this._resolvedSwimlanes();
|
|
379
|
+
const columnId = destination.column == null ? from.column : String(destination.column);
|
|
380
|
+
const laneId = options.swimlaneBy == null
|
|
381
|
+
? null : destination.lane === undefined ? from.lane : normalizeAxisId(destination.lane);
|
|
382
|
+
const column = columns.find((candidate) => candidate.id === columnId);
|
|
383
|
+
const lane = options.swimlaneBy == null
|
|
384
|
+
? null : lanes.find((candidate) => candidate.id === laneId);
|
|
385
|
+
if (!column || options.swimlaneBy != null && !lane) {
|
|
386
|
+
this._announce('Move rejected: the destination is not available.');
|
|
387
|
+
return this;
|
|
388
|
+
}
|
|
389
|
+
const destinationCount = this._recordsIn(columnId, laneId)
|
|
390
|
+
.filter((candidate) => !Object.is(this._viewRecordId(candidate.record), id)).length;
|
|
391
|
+
const destinationColumnCount = this._viewData.filter((candidate, candidateIndex) =>
|
|
392
|
+
normalizeAxisId(this._readAxis(options.columnBy, candidate, candidateIndex)) === columnId
|
|
393
|
+
&& !Object.is(this._viewRecordId(candidate), id)).length;
|
|
394
|
+
const index = normalizeMoveIndex(destination.index, destinationCount);
|
|
395
|
+
const to = { column: columnId, lane: laneId, index };
|
|
396
|
+
if (sameMovePoint(from, to)) return this;
|
|
397
|
+
const context = { id, from: { ...from }, to: { ...to }, column: { ...column }, swimlane: lane ? { ...lane } : null };
|
|
398
|
+
if (column.accept && column.accept(record, context) === false
|
|
399
|
+
|| lane?.accept && lane.accept(record, context) === false) {
|
|
400
|
+
this._announce(`Move rejected for ${this._recordName(record, from.index)}.`);
|
|
401
|
+
return this;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let movedRecord = record;
|
|
405
|
+
if (options.moveMode === 'local') {
|
|
406
|
+
movedRecord = this._localMovedRecord(record, from, to, column, lane);
|
|
407
|
+
if (!movedRecord) {
|
|
408
|
+
this._announce('Move rejected: callback grouping requires external move mode.');
|
|
409
|
+
return this;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const next = reorderKanbanData(this._viewData, id, movedRecord, {
|
|
413
|
+
recordId: options.recordId,
|
|
414
|
+
columnBy: (candidate, candidateIndex) => this._readAxis(options.columnBy, candidate, candidateIndex),
|
|
415
|
+
swimlaneBy: options.swimlaneBy == null ? null
|
|
416
|
+
: (candidate, candidateIndex) => this._readAxis(options.swimlaneBy, candidate, candidateIndex),
|
|
417
|
+
destination: to
|
|
418
|
+
});
|
|
419
|
+
if (!next) {
|
|
420
|
+
this._announce('Move rejected: the destination could not be resolved.');
|
|
421
|
+
return this;
|
|
422
|
+
}
|
|
423
|
+
const finalCount = destinationColumnCount + 1;
|
|
424
|
+
const limitExceeded = column.limit != null && finalCount > column.limit;
|
|
425
|
+
const moveEvent = this.emit('recordmove', {
|
|
426
|
+
record, id, from: { ...from }, to: { ...next.to }, column: { ...column },
|
|
427
|
+
swimlane: lane ? { ...lane } : null, limitExceeded
|
|
428
|
+
});
|
|
429
|
+
if (moveEvent.defaultPrevented) {
|
|
430
|
+
this._announce(`Move canceled for ${this._recordName(record, from.index)}.`);
|
|
431
|
+
return this;
|
|
432
|
+
}
|
|
433
|
+
if (options.moveMode === 'external') {
|
|
434
|
+
this._announce(`Move requested for ${this._recordName(record, from.index)} to ${this._targetName(next.to)}.`);
|
|
435
|
+
return this;
|
|
436
|
+
}
|
|
437
|
+
this._viewData = next.records;
|
|
438
|
+
this._refreshView('move');
|
|
439
|
+
this.emit('datachange', { records: this.getData() });
|
|
440
|
+
this._announce(`Moved ${this._recordName(movedRecord, next.to.index)} to ${this._targetName(next.to)}.`);
|
|
441
|
+
queueMicrotask(() => this._moveHandle(id)?.focus());
|
|
442
|
+
return this;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Returns common plus board-specific serializable configuration. @returns {KanbanViewState} */
|
|
446
|
+
getViewState() {
|
|
447
|
+
return {
|
|
448
|
+
...super.getViewState(),
|
|
449
|
+
columnOrder: this.getColumnOrder(),
|
|
450
|
+
swimlaneOrder: this.getSwimlaneOrder(),
|
|
451
|
+
collapsedColumns: this.getCollapsedColumns(),
|
|
452
|
+
collapsedSwimlanes: this.getCollapsedSwimlanes()
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Restores common and Kanban configuration. State from another record view is accepted safely.
|
|
458
|
+
* @param {Partial<KanbanViewState>|null} state Saved state.
|
|
459
|
+
* @param {{silent?:boolean}} [options={}] Event behavior.
|
|
460
|
+
* @returns {this}
|
|
461
|
+
*/
|
|
462
|
+
setViewState(state, options = {}) {
|
|
463
|
+
const source = state && typeof state === 'object' ? state : {};
|
|
464
|
+
super.setViewState(source, { silent: true });
|
|
465
|
+
this._kanbanColumnOrder = reconcileKanbanOrder(
|
|
466
|
+
this._rawColumns(), Array.isArray(source.columnOrder) ? source.columnOrder : this._kanbanColumnOrder);
|
|
467
|
+
this._kanbanSwimlaneOrder = reconcileKanbanOrder(
|
|
468
|
+
this._rawSwimlanes(), Array.isArray(source.swimlaneOrder) ? source.swimlaneOrder : this._kanbanSwimlaneOrder);
|
|
469
|
+
const columnIds = new Set(this._rawColumns().map((column) => column.id));
|
|
470
|
+
const laneIds = new Set(this._rawSwimlanes().map((lane) => lane.id));
|
|
471
|
+
if (Array.isArray(source.collapsedColumns)) {
|
|
472
|
+
this._collapsedColumns = new Set(source.collapsedColumns.map(String).filter((id) => columnIds.has(id)));
|
|
473
|
+
}
|
|
474
|
+
if (Array.isArray(source.collapsedSwimlanes)) {
|
|
475
|
+
this._collapsedSwimlanes = new Set(source.collapsedSwimlanes.map(String).filter((id) => laneIds.has(id)));
|
|
476
|
+
}
|
|
477
|
+
this._refreshView('state');
|
|
478
|
+
if (!options.silent) this._emitKanbanState('restore');
|
|
479
|
+
return this;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Restores enhanced targets after base lifecycle cleanup. @returns {void} */
|
|
483
|
+
destroy() {
|
|
484
|
+
if (this._kanbanDestroyed) return;
|
|
485
|
+
this._kanbanDestroyed = true;
|
|
486
|
+
const root = this.el;
|
|
487
|
+
const snapshot = this._kanbanSnapshot;
|
|
488
|
+
super.destroy();
|
|
489
|
+
if (snapshot && root) restoreTarget(root, snapshot);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** @protected @param {string} _reason Refresh reason. @returns {void} */
|
|
493
|
+
_refreshView(_reason) {
|
|
494
|
+
if (!this._board) return;
|
|
495
|
+
if (_reason === 'selection') {
|
|
496
|
+
this._syncCardSelection();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (_reason === 'loading') return;
|
|
500
|
+
const options = this._kanbanOptions();
|
|
501
|
+
const activeMeta = this._metaForTarget(document.activeElement);
|
|
502
|
+
const restoreHandle = Boolean(document.activeElement?.classList?.contains('zx-kanban-view__move-handle'));
|
|
503
|
+
const columns = this._resolvedColumns();
|
|
504
|
+
const lanes = this._resolvedSwimlanes();
|
|
505
|
+
this._cardMeta = new WeakMap();
|
|
506
|
+
this._sectionMeta = new WeakMap();
|
|
507
|
+
this._cardsById = new Map();
|
|
508
|
+
const content = [];
|
|
509
|
+
if (options.swimlaneBy == null) {
|
|
510
|
+
content.push(this._renderColumns(columns, null, false));
|
|
511
|
+
} else {
|
|
512
|
+
lanes.forEach((lane) => content.push(this._renderLane(lane, columns)));
|
|
513
|
+
}
|
|
514
|
+
const shownCards = this._cardsById.size;
|
|
515
|
+
if (shownCards === 0 && !this._viewData.length) content.push(this._emptyContent());
|
|
516
|
+
this._board.replaceChildren(...content);
|
|
517
|
+
if (this._keyboardMove && !this.getRecord(this._keyboardMove.id)) this._keyboardMove = null;
|
|
518
|
+
this._syncKeyboardTarget();
|
|
519
|
+
if (activeMeta) queueMicrotask(() => {
|
|
520
|
+
const card = this._cardsById.get(activeMeta.id);
|
|
521
|
+
const target = restoreHandle ? card?.querySelector('.zx-kanban-view__move-handle') : card;
|
|
522
|
+
if (target?.isConnected) /** @type {HTMLElement} */ (target).focus();
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** @param {KanbanSwimlane} lane @param {KanbanColumn[]} columns @returns {HTMLElement} */
|
|
527
|
+
_renderLane(lane, columns) {
|
|
528
|
+
const options = this._kanbanOptions();
|
|
529
|
+
const collapsed = this._collapsedSwimlanes.has(lane.id);
|
|
530
|
+
const headingId = uid('zx-kanban-lane');
|
|
531
|
+
const bodyId = uid('zx-kanban-lane-body');
|
|
532
|
+
const count = this._viewData.filter((record, index) =>
|
|
533
|
+
normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === lane.id).length;
|
|
534
|
+
const toggle = h('button', {
|
|
535
|
+
class: 'zx-kanban-view__collapse', type: 'button', ariaExpanded: String(!collapsed),
|
|
536
|
+
ariaControls: bodyId, ariaLabel: `${collapsed ? 'Expand' : 'Collapse'} swim lane ${lane.label}`,
|
|
537
|
+
dataset: { kanbanCollapse: 'swimlane', kanbanId: lane.id }
|
|
538
|
+
}, icon(collapsed ? 'chevron-right' : 'chevron-down', { size: 13 }));
|
|
539
|
+
const heading = h('h2', { class: 'zx-kanban-view__lane-heading', id: headingId },
|
|
540
|
+
toggle, h('span', {}, lane.label), options.showCounts
|
|
541
|
+
? h('span', { class: 'zx-kanban-view__count', ariaLabel: `${count} records` }, String(count)) : null);
|
|
542
|
+
const body = this._renderColumns(columns, lane, true);
|
|
543
|
+
body.id = bodyId;
|
|
544
|
+
body.hidden = collapsed;
|
|
545
|
+
return h('section', {
|
|
546
|
+
class: 'zx-kanban-view__lane', ariaLabelledby: headingId, dataset: { collapsed: String(collapsed) }
|
|
547
|
+
}, heading, body);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** @param {KanbanColumn[]} columns @param {KanbanSwimlane|null} lane @param {boolean} hasLanes @returns {HTMLElement} */
|
|
551
|
+
_renderColumns(columns, lane, hasLanes) {
|
|
552
|
+
const options = this._kanbanOptions();
|
|
553
|
+
const host = h('div', { class: 'zx-kanban-view__columns' });
|
|
554
|
+
for (const column of columns) {
|
|
555
|
+
const entries = this._recordsIn(column.id, lane?.id ?? null);
|
|
556
|
+
if (!options.showEmptyColumns && entries.length === 0) continue;
|
|
557
|
+
host.append(this._renderColumn(column, lane, entries, hasLanes));
|
|
558
|
+
}
|
|
559
|
+
return host;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* @param {KanbanColumn} column
|
|
564
|
+
* @param {KanbanSwimlane|null} lane
|
|
565
|
+
* @param {{record:KanbanRecord,index:number}[]} entries
|
|
566
|
+
* @param {boolean} hasLanes
|
|
567
|
+
* @returns {HTMLElement}
|
|
568
|
+
*/
|
|
569
|
+
_renderColumn(column, lane, entries, hasLanes) {
|
|
570
|
+
const options = this._kanbanOptions();
|
|
571
|
+
const collapsed = this._collapsedColumns.has(column.id);
|
|
572
|
+
const headingId = uid('zx-kanban-column');
|
|
573
|
+
const listId = uid('zx-kanban-column-list');
|
|
574
|
+
const columnCount = this._viewData.filter((record, index) =>
|
|
575
|
+
normalizeAxisId(this._readAxis(options.columnBy, record, index)) === column.id).length;
|
|
576
|
+
const wip = column.limit == null ? 'none'
|
|
577
|
+
: columnCount > column.limit ? 'exceeded' : columnCount === column.limit ? 'limit' : 'within';
|
|
578
|
+
const toggle = h('button', {
|
|
579
|
+
class: 'zx-kanban-view__collapse', type: 'button', ariaExpanded: String(!collapsed),
|
|
580
|
+
ariaControls: listId, ariaLabel: `${collapsed ? 'Expand' : 'Collapse'} column ${column.label}`,
|
|
581
|
+
dataset: { kanbanCollapse: 'column', kanbanId: column.id }
|
|
582
|
+
}, icon(collapsed ? 'chevron-right' : 'chevron-down', { size: 13 }));
|
|
583
|
+
const indicator = options.showCounts || column.limit != null ? h('span', {
|
|
584
|
+
class: 'zx-kanban-view__count',
|
|
585
|
+
ariaLabel: column.limit == null ? `${entries.length} records`
|
|
586
|
+
: hasLanes ? `${entries.length} records in this lane, ${columnCount} of work-in-progress limit ${column.limit} in the column`
|
|
587
|
+
: `${columnCount} records, work-in-progress limit ${column.limit}`
|
|
588
|
+
}, column.limit == null ? String(entries.length)
|
|
589
|
+
: hasLanes ? `${entries.length} · ${columnCount} / ${column.limit}` : `${columnCount} / ${column.limit}`) : null;
|
|
590
|
+
const heading = h(hasLanes ? 'h3' : 'h2', {
|
|
591
|
+
class: 'zx-kanban-view__column-heading', id: headingId
|
|
592
|
+
}, toggle, h('span', { class: 'zx-kanban-view__column-label' }, column.label), indicator);
|
|
593
|
+
const list = h('ul', {
|
|
594
|
+
class: 'zx-kanban-view__cards', id: listId, ariaLabel: `${column.label}${lane ? `, ${lane.label}` : ''}`
|
|
595
|
+
});
|
|
596
|
+
entries.forEach((entry) => list.append(this._renderCard(entry.record, entry.index, column.id, lane?.id ?? null, hasLanes)));
|
|
597
|
+
list.hidden = collapsed;
|
|
598
|
+
const section = h('section', {
|
|
599
|
+
class: 'zx-kanban-view__column', ariaLabelledby: headingId,
|
|
600
|
+
dataset: { column: column.id, collapsed: String(collapsed), wip }
|
|
601
|
+
}, heading, list);
|
|
602
|
+
this._sectionMeta.set(section, { column: column.id, lane: lane?.id ?? null, list });
|
|
603
|
+
return section;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** @param {KanbanRecord} record @param {number} index @param {string} column @param {string|null} lane @param {boolean} hasLanes @returns {HTMLLIElement} */
|
|
607
|
+
_renderCard(record, index, column, lane, hasLanes) {
|
|
608
|
+
const options = this._kanbanOptions();
|
|
609
|
+
const id = this._viewRecordId(record);
|
|
610
|
+
const grabbed = Boolean(this._keyboardMove && Object.is(this._keyboardMove.id, id));
|
|
611
|
+
const handle = h('button', {
|
|
612
|
+
class: 'zx-kanban-view__move-handle', type: 'button', draggable: true,
|
|
613
|
+
ariaPressed: String(grabbed), ariaLabel: `Move ${this._recordName(record, index)}`
|
|
614
|
+
}, icon('drag', { size: 13 }));
|
|
615
|
+
const card = createRecordCard(record, index, {
|
|
616
|
+
fields: this.getFields(),
|
|
617
|
+
visibleFields: this.getVisibleFields(),
|
|
618
|
+
titleField: options.titleField,
|
|
619
|
+
subtitleField: options.subtitleField,
|
|
620
|
+
preview: options.preview ?? undefined,
|
|
621
|
+
previewAlt: options.previewAlt,
|
|
622
|
+
link: options.link ?? undefined,
|
|
623
|
+
actions: options.actions,
|
|
624
|
+
selectable: options.selectable,
|
|
625
|
+
selected: this._isViewSelected(id),
|
|
626
|
+
variant: options.variant,
|
|
627
|
+
headingLevel: hasLanes ? 4 : 3,
|
|
628
|
+
controls: [handle]
|
|
629
|
+
});
|
|
630
|
+
card.tabIndex = 0;
|
|
631
|
+
if (grabbed) card.dataset.grabbed = 'true';
|
|
632
|
+
const meta = { record, id, index, column, lane };
|
|
633
|
+
this._cardMeta.set(card, meta);
|
|
634
|
+
this._cardsById.set(id, card);
|
|
635
|
+
return card;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** @returns {HTMLElement} */
|
|
639
|
+
_emptyContent() {
|
|
640
|
+
const supplied = typeof this.options.emptyText === 'function' ? this.options.emptyText() : this.options.emptyText;
|
|
641
|
+
const content = supplied ?? 'No records';
|
|
642
|
+
return h('div', { class: 'zx-kanban-view__empty', role: 'status' },
|
|
643
|
+
content && typeof content === 'object' && 'nodeType' in content ? /** @type {Node} */ (content) : String(content));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** @returns {KanbanColumn[]} */
|
|
647
|
+
_rawColumns() {
|
|
648
|
+
const options = this._kanbanOptions();
|
|
649
|
+
return this._kanbanColumns === null
|
|
650
|
+
? deriveKanbanDescriptors(this._viewData, (record, index) => this._readAxis(options.columnBy, record, index))
|
|
651
|
+
: this._kanbanColumns.map((column) => ({ ...column }));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** @returns {KanbanColumn[]} */
|
|
655
|
+
_resolvedColumns() {
|
|
656
|
+
const columns = this._rawColumns();
|
|
657
|
+
this._kanbanColumnOrder = reconcileKanbanOrder(columns, this._kanbanColumnOrder);
|
|
658
|
+
return orderDescriptors(columns, this._kanbanColumnOrder);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** @returns {KanbanSwimlane[]} */
|
|
662
|
+
_rawSwimlanes() {
|
|
663
|
+
const options = this._kanbanOptions();
|
|
664
|
+
if (options.swimlaneBy == null) return [];
|
|
665
|
+
return this._kanbanSwimlanes === null
|
|
666
|
+
? deriveKanbanDescriptors(this._viewData, (record, index) => this._readAxis(options.swimlaneBy, record, index))
|
|
667
|
+
: this._kanbanSwimlanes.map((lane) => ({ ...lane }));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** @returns {KanbanSwimlane[]} */
|
|
671
|
+
_resolvedSwimlanes() {
|
|
672
|
+
const lanes = this._rawSwimlanes();
|
|
673
|
+
this._kanbanSwimlaneOrder = reconcileKanbanOrder(lanes, this._kanbanSwimlaneOrder);
|
|
674
|
+
return orderDescriptors(lanes, this._kanbanSwimlaneOrder);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** @param {KanbanAccessor} accessor @param {KanbanRecord} record @param {number} index @returns {unknown} */
|
|
678
|
+
_readAxis(accessor, record, index) {
|
|
679
|
+
if (typeof accessor === 'function') return accessor(record, index);
|
|
680
|
+
if (accessor == null) return null;
|
|
681
|
+
const field = this.getFields().find((candidate) => candidate.id === accessor);
|
|
682
|
+
return field ? readViewField(field, record, index) : record?.[accessor];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** @param {string} column @param {string|null} lane @returns {{record:KanbanRecord,index:number}[]} */
|
|
686
|
+
_recordsIn(column, lane) {
|
|
687
|
+
const options = this._kanbanOptions();
|
|
688
|
+
const result = [];
|
|
689
|
+
this._viewData.forEach((record, index) => {
|
|
690
|
+
if (normalizeAxisId(this._readAxis(options.columnBy, record, index)) !== column) return;
|
|
691
|
+
if (options.swimlaneBy != null
|
|
692
|
+
&& normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) !== lane) return;
|
|
693
|
+
result.push({ record, index });
|
|
694
|
+
});
|
|
695
|
+
return result;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** @param {unknown} id @returns {KanbanMovePoint|null} */
|
|
699
|
+
_recordLocation(id) {
|
|
700
|
+
const options = this._kanbanOptions();
|
|
701
|
+
const index = this._viewData.findIndex((record) => Object.is(this._viewRecordId(record), id));
|
|
702
|
+
if (index < 0) return null;
|
|
703
|
+
const record = this._viewData[index];
|
|
704
|
+
const column = normalizeAxisId(this._readAxis(options.columnBy, record, index));
|
|
705
|
+
const lane = options.swimlaneBy == null
|
|
706
|
+
? null : normalizeAxisId(this._readAxis(options.swimlaneBy, record, index));
|
|
707
|
+
const within = this._recordsIn(column, lane)
|
|
708
|
+
.findIndex((entry) => Object.is(this._viewRecordId(entry.record), id));
|
|
709
|
+
return { column, lane, index: within };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* @param {KanbanRecord} record @param {KanbanMovePoint} from @param {KanbanMovePoint} to
|
|
714
|
+
* @param {KanbanColumn} column @param {KanbanSwimlane|null} lane @returns {KanbanRecord|null}
|
|
715
|
+
*/
|
|
716
|
+
_localMovedRecord(record, from, to, column, lane) {
|
|
717
|
+
const options = this._kanbanOptions();
|
|
718
|
+
const clone = { ...record };
|
|
719
|
+
if (from.column !== to.column) {
|
|
720
|
+
if (typeof options.columnBy !== 'string') return null;
|
|
721
|
+
clone[options.columnBy] = Object.prototype.hasOwnProperty.call(column, 'value') ? column.value : column.id;
|
|
722
|
+
}
|
|
723
|
+
if (from.lane !== to.lane) {
|
|
724
|
+
if (typeof options.swimlaneBy !== 'string' || !lane) return null;
|
|
725
|
+
clone[options.swimlaneBy] = Object.prototype.hasOwnProperty.call(lane, 'value') ? lane.value : lane.id;
|
|
726
|
+
}
|
|
727
|
+
const currentIndex = this._viewData.indexOf(record);
|
|
728
|
+
if (normalizeAxisId(this._readAxis(options.columnBy, clone, currentIndex)) !== to.column) return null;
|
|
729
|
+
if (options.swimlaneBy != null
|
|
730
|
+
&& normalizeAxisId(this._readAxis(options.swimlaneBy, clone, currentIndex)) !== to.lane) return null;
|
|
731
|
+
return clone;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** @param {MouseEvent} event @returns {void} */
|
|
735
|
+
_handleBoardClick(event) {
|
|
736
|
+
const target = elementTarget(event);
|
|
737
|
+
const collapse = target?.closest('[data-kanban-collapse]');
|
|
738
|
+
if (collapse) {
|
|
739
|
+
const element = /** @type {HTMLElement} */ (collapse);
|
|
740
|
+
if (element.dataset.kanbanCollapse === 'column') this.toggleColumn(String(element.dataset.kanbanId));
|
|
741
|
+
else this.toggleSwimlane(String(element.dataset.kanbanId));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const meta = this._metaForTarget(target);
|
|
745
|
+
if (!meta) return;
|
|
746
|
+
const selection = /** @type {HTMLInputElement|null} */ (target?.closest('input[data-record-selection]'));
|
|
747
|
+
if (selection) {
|
|
748
|
+
this.toggleSelection(meta.id, { selected: selection.checked, range: event.shiftKey });
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const actionControl = target?.closest('[data-record-action]');
|
|
752
|
+
if (actionControl) {
|
|
753
|
+
const actionId = /** @type {HTMLElement} */ (actionControl).dataset.recordAction;
|
|
754
|
+
const action = resolveRecordActions(meta.record, meta.index, this._kanbanOptions().actions)
|
|
755
|
+
.find((candidate) => candidate.id === actionId);
|
|
756
|
+
if (!action || action.disabled) {
|
|
757
|
+
event.preventDefault();
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (typeof action.onclick === 'function') action.onclick(meta.record, meta.index, event);
|
|
761
|
+
const emitted = this.emit('recordaction', {
|
|
762
|
+
record: meta.record, id: meta.id, index: meta.index, action: { ...action }, event
|
|
763
|
+
});
|
|
764
|
+
if (emitted.defaultPrevented) event.preventDefault();
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (target?.closest('.zx-kanban-view__move-handle, .zx-record-card__selection, [data-record-select]')) return;
|
|
768
|
+
if (target?.closest(INTERACTIVE) || hasTextSelection()) return;
|
|
769
|
+
this.emit('recordclick', { ...meta, event });
|
|
770
|
+
if (this.options.selectable === 'single' && !this._isViewSelected(meta.id)) {
|
|
771
|
+
this.setSelection([meta.id]);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** @param {MouseEvent} event @returns {void} */
|
|
776
|
+
_handleBoardDoubleClick(event) {
|
|
777
|
+
const target = elementTarget(event);
|
|
778
|
+
const meta = this._metaForTarget(target);
|
|
779
|
+
if (!meta || target?.closest(INTERACTIVE) || hasTextSelection()) return;
|
|
780
|
+
this.emit('recorddblclick', { ...meta, event });
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** @param {KeyboardEvent} event @returns {void} */
|
|
784
|
+
_handleBoardKeydown(event) {
|
|
785
|
+
const target = elementTarget(event);
|
|
786
|
+
const handle = target?.closest('.zx-kanban-view__move-handle');
|
|
787
|
+
const meta = this._metaForTarget(handle);
|
|
788
|
+
if (handle && meta) {
|
|
789
|
+
this._handleMoveKeydown(event, /** @type {HTMLElement} */ (handle), meta);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const card = target?.closest('.zx-record-card');
|
|
793
|
+
const cardMeta = card ? this._cardMeta.get(card) : null;
|
|
794
|
+
if (!cardMeta || target !== card) return;
|
|
795
|
+
if (event.key === 'Enter') {
|
|
796
|
+
event.preventDefault();
|
|
797
|
+
this.emit('recordclick', { ...cardMeta, event });
|
|
798
|
+
if (this.options.selectable === 'single') this.setSelection([cardMeta.id]);
|
|
799
|
+
} else if (event.key === ' ' && this.options.selectable !== false) {
|
|
800
|
+
event.preventDefault();
|
|
801
|
+
this.toggleSelection(cardMeta.id);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** @param {KeyboardEvent} event @param {HTMLElement} handle @param {KanbanCardMeta} meta @returns {void} */
|
|
806
|
+
_handleMoveKeydown(event, handle, meta) {
|
|
807
|
+
if (event.key === ' ' || event.key === 'Enter') {
|
|
808
|
+
event.preventDefault();
|
|
809
|
+
if (this._keyboardMove && Object.is(this._keyboardMove.id, meta.id)) {
|
|
810
|
+
const destination = { ...this._keyboardMove.to };
|
|
811
|
+
this._keyboardMove = null;
|
|
812
|
+
handle.setAttribute('aria-pressed', 'false');
|
|
813
|
+
const card = /** @type {HTMLElement|null} */ (handle.closest('.zx-record-card'));
|
|
814
|
+
if (card) delete card.dataset.grabbed;
|
|
815
|
+
this._syncKeyboardTarget();
|
|
816
|
+
this.moveRecord(meta.id, destination);
|
|
817
|
+
} else {
|
|
818
|
+
const from = this._recordLocation(meta.id);
|
|
819
|
+
if (!from) return;
|
|
820
|
+
this._keyboardMove = { id: meta.id, from: { ...from }, to: { ...from } };
|
|
821
|
+
handle.setAttribute('aria-pressed', 'true');
|
|
822
|
+
const card = handle.closest('.zx-record-card');
|
|
823
|
+
if (card) /** @type {HTMLElement} */ (card).dataset.grabbed = 'true';
|
|
824
|
+
this._syncKeyboardTarget();
|
|
825
|
+
this._announce(`Grabbed ${this._recordName(meta.record, meta.index)}. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.`);
|
|
826
|
+
}
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (event.key === 'Escape' && this._keyboardMove) {
|
|
830
|
+
event.preventDefault();
|
|
831
|
+
const record = this.getRecord(this._keyboardMove.id);
|
|
832
|
+
this._keyboardMove = null;
|
|
833
|
+
handle.setAttribute('aria-pressed', 'false');
|
|
834
|
+
const card = /** @type {HTMLElement|null} */ (handle.closest('.zx-record-card'));
|
|
835
|
+
if (card) delete card.dataset.grabbed;
|
|
836
|
+
this._syncKeyboardTarget();
|
|
837
|
+
this._announce(`Move canceled${record ? ` for ${this._recordName(record, meta.index)}` : ''}.`);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (!this._keyboardMove || !Object.is(this._keyboardMove.id, meta.id)
|
|
841
|
+
|| !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
|
|
842
|
+
event.preventDefault();
|
|
843
|
+
const move = this._keyboardMove;
|
|
844
|
+
const laneModifier = event.altKey || event.ctrlKey || event.metaKey;
|
|
845
|
+
if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight') && !laneModifier) {
|
|
846
|
+
const columns = this.getColumnOrder();
|
|
847
|
+
const current = columns.indexOf(move.to.column);
|
|
848
|
+
const next = columns[current + (event.key === 'ArrowLeft' ? -1 : 1)];
|
|
849
|
+
if (next != null) move.to.column = next;
|
|
850
|
+
} else if ((event.key === 'ArrowUp' || event.key === 'ArrowDown')
|
|
851
|
+
&& laneModifier && this._kanbanOptions().swimlaneBy != null) {
|
|
852
|
+
const lanes = this.getSwimlaneOrder();
|
|
853
|
+
const current = lanes.indexOf(String(move.to.lane));
|
|
854
|
+
const next = lanes[current + (event.key === 'ArrowUp' ? -1 : 1)];
|
|
855
|
+
if (next != null) move.to.lane = next;
|
|
856
|
+
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
|
857
|
+
move.to.index += event.key === 'ArrowUp' ? -1 : 1;
|
|
858
|
+
}
|
|
859
|
+
const targetCount = this._recordsIn(move.to.column, move.to.lane)
|
|
860
|
+
.filter((entry) => !Object.is(this._viewRecordId(entry.record), move.id)).length;
|
|
861
|
+
move.to.index = normalizeMoveIndex(move.to.index, targetCount);
|
|
862
|
+
this._syncKeyboardTarget();
|
|
863
|
+
this._announce(`Target ${this._targetName(move.to)}, position ${move.to.index + 1} of ${targetCount + 1}.`);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** @param {Event} event @returns {void} */
|
|
867
|
+
_handlePreviewError(event) {
|
|
868
|
+
const image = /** @type {HTMLImageElement|null} */ (elementTarget(event)?.closest('img[data-record-preview]'));
|
|
869
|
+
if (!image) return;
|
|
870
|
+
const preview = /** @type {HTMLElement|null} */ (image.closest('.zx-record-card__preview'));
|
|
871
|
+
if (preview) preview.dataset.failed = 'true';
|
|
872
|
+
image.hidden = true;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** @param {DragEvent} event @returns {void} */
|
|
876
|
+
_handleDragStart(event) {
|
|
877
|
+
const handle = elementTarget(event)?.closest('.zx-kanban-view__move-handle');
|
|
878
|
+
const meta = this._metaForTarget(handle);
|
|
879
|
+
if (!handle || !meta) {
|
|
880
|
+
event.preventDefault();
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
this._dragId = meta.id;
|
|
884
|
+
const card = handle.closest('.zx-record-card');
|
|
885
|
+
if (card) /** @type {HTMLElement} */ (card).dataset.dragging = 'true';
|
|
886
|
+
event.dataTransfer?.setData('text/plain', 'zx-kanban-record');
|
|
887
|
+
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** @param {DragEvent} event @returns {void} */
|
|
891
|
+
_handleDragOver(event) {
|
|
892
|
+
if (this._dragId == null) return;
|
|
893
|
+
const section = elementTarget(event)?.closest('.zx-kanban-view__column');
|
|
894
|
+
const sectionMeta = section ? this._sectionMeta.get(section) : null;
|
|
895
|
+
if (!sectionMeta) return;
|
|
896
|
+
event.preventDefault();
|
|
897
|
+
const destination = this._pointerDestination(event, sectionMeta);
|
|
898
|
+
this._dragDestination = destination;
|
|
899
|
+
this._clearDropMarkers();
|
|
900
|
+
const card = elementTarget(event)?.closest('.zx-record-card');
|
|
901
|
+
const cardMeta = card ? this._cardMeta.get(card) : null;
|
|
902
|
+
if (card && cardMeta && !Object.is(cardMeta.id, this._dragId)) {
|
|
903
|
+
const rectangle = card.getBoundingClientRect();
|
|
904
|
+
/** @type {HTMLElement} */ (card).dataset.drop = event.clientY >= rectangle.top + rectangle.height / 2
|
|
905
|
+
? 'after' : 'before';
|
|
906
|
+
} else {
|
|
907
|
+
sectionMeta.list.dataset.drop = 'append';
|
|
908
|
+
}
|
|
909
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** @param {DragEvent} event @returns {void} */
|
|
913
|
+
_handleDrop(event) {
|
|
914
|
+
if (this._dragId == null || !this._dragDestination) return;
|
|
915
|
+
event.preventDefault();
|
|
916
|
+
const id = this._dragId;
|
|
917
|
+
const destination = { ...this._dragDestination };
|
|
918
|
+
this._clearDrag();
|
|
919
|
+
this.moveRecord(id, destination);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** @param {DragEvent} event @param {KanbanSectionMeta} section @returns {KanbanMovePoint} */
|
|
923
|
+
_pointerDestination(event, section) {
|
|
924
|
+
const cards = [...section.list.querySelectorAll(':scope > .zx-record-card')]
|
|
925
|
+
.filter((card) => !Object.is(this._cardMeta.get(card)?.id, this._dragId));
|
|
926
|
+
const target = elementTarget(event)?.closest('.zx-record-card');
|
|
927
|
+
const targetMeta = target ? this._cardMeta.get(target) : null;
|
|
928
|
+
if (target && targetMeta && Object.is(targetMeta.id, this._dragId)) {
|
|
929
|
+
const from = this._recordLocation(this._dragId);
|
|
930
|
+
return { column: section.column, lane: section.lane, index: from?.index ?? cards.length };
|
|
931
|
+
}
|
|
932
|
+
const targetIndex = target ? cards.indexOf(target) : -1;
|
|
933
|
+
if (targetIndex < 0) return { column: section.column, lane: section.lane, index: cards.length };
|
|
934
|
+
const rectangle = target.getBoundingClientRect();
|
|
935
|
+
return {
|
|
936
|
+
column: section.column, lane: section.lane,
|
|
937
|
+
index: targetIndex + (event.clientY >= rectangle.top + rectangle.height / 2 ? 1 : 0)
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** @returns {void} */
|
|
942
|
+
_clearDrag() {
|
|
943
|
+
this._clearDropMarkers();
|
|
944
|
+
for (const card of this._board.querySelectorAll('[data-dragging]')) delete /** @type {HTMLElement} */ (card).dataset.dragging;
|
|
945
|
+
this._dragId = null;
|
|
946
|
+
this._dragDestination = null;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** @returns {void} */
|
|
950
|
+
_clearDropMarkers() {
|
|
951
|
+
for (const element of this._board.querySelectorAll('[data-drop]')) delete /** @type {HTMLElement} */ (element).dataset.drop;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** @returns {void} */
|
|
955
|
+
_syncKeyboardTarget() {
|
|
956
|
+
for (const element of this._board.querySelectorAll('[data-keyboard-target]')) {
|
|
957
|
+
delete /** @type {HTMLElement} */ (element).dataset.keyboardTarget;
|
|
958
|
+
}
|
|
959
|
+
const move = this._keyboardMove;
|
|
960
|
+
if (!move) return;
|
|
961
|
+
const section = [...this._board.querySelectorAll('.zx-kanban-view__column')]
|
|
962
|
+
.find((candidate) => {
|
|
963
|
+
const meta = this._sectionMeta.get(candidate);
|
|
964
|
+
return meta?.column === move.to.column && meta?.lane === move.to.lane;
|
|
965
|
+
});
|
|
966
|
+
const sectionMeta = section ? this._sectionMeta.get(section) : null;
|
|
967
|
+
if (!sectionMeta) return;
|
|
968
|
+
const cards = [...sectionMeta.list.querySelectorAll(':scope > .zx-record-card')]
|
|
969
|
+
.filter((card) => !Object.is(this._cardMeta.get(card)?.id, move.id));
|
|
970
|
+
const target = cards[move.to.index];
|
|
971
|
+
if (target) /** @type {HTMLElement} */ (target).dataset.keyboardTarget = 'before';
|
|
972
|
+
else sectionMeta.list.dataset.keyboardTarget = 'append';
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** @returns {void} */
|
|
976
|
+
_syncCardSelection() {
|
|
977
|
+
for (const [id, card] of this._cardsById) {
|
|
978
|
+
const selected = this._isViewSelected(id);
|
|
979
|
+
card.dataset.selected = String(selected);
|
|
980
|
+
if (this.options.selectable) card.setAttribute('aria-selected', String(selected));
|
|
981
|
+
const input = /** @type {HTMLInputElement|null} */ (card.querySelector('[data-record-selection]'));
|
|
982
|
+
if (input) input.checked = selected;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** @param {EventTarget|null} target @returns {KanbanCardMeta|null} */
|
|
987
|
+
_metaForTarget(target) {
|
|
988
|
+
const card = elementTarget({ target })?.closest('.zx-record-card');
|
|
989
|
+
return card ? this._cardMeta.get(card) ?? null : null;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** @param {unknown} id @returns {HTMLElement|null} */
|
|
993
|
+
_moveHandle(id) {
|
|
994
|
+
return /** @type {HTMLElement|null} */ (this._cardsById.get(id)?.querySelector('.zx-kanban-view__move-handle') ?? null);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** @param {KanbanRecord} record @param {number} index @returns {string} */
|
|
998
|
+
_recordName(record, index) {
|
|
999
|
+
const source = this._kanbanOptions().titleField;
|
|
1000
|
+
const value = typeof source === 'function' ? source(record, index) : typeof source === 'string'
|
|
1001
|
+
? this._readAxis(source, record, index) : this._viewRecordId(record);
|
|
1002
|
+
if (value && typeof value === 'object' && 'nodeType' in value) {
|
|
1003
|
+
return /** @type {Node} */ (value).textContent?.trim() || 'record';
|
|
1004
|
+
}
|
|
1005
|
+
return String(value ?? 'record');
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** @param {KanbanMovePoint} point @returns {string} */
|
|
1009
|
+
_targetName(point) {
|
|
1010
|
+
const column = this._resolvedColumns().find((candidate) => candidate.id === point.column);
|
|
1011
|
+
const lane = point.lane == null ? null
|
|
1012
|
+
: this._resolvedSwimlanes().find((candidate) => candidate.id === point.lane);
|
|
1013
|
+
return `${column?.label ?? point.column}${lane ? `, ${lane.label}` : ''}`;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** @param {string} message @returns {void} */
|
|
1017
|
+
_announce(message) {
|
|
1018
|
+
const token = ++this._announcementToken;
|
|
1019
|
+
this._live.textContent = '';
|
|
1020
|
+
queueMicrotask(() => {
|
|
1021
|
+
if (token === this._announcementToken && this._live?.isConnected) this._live.textContent = message;
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** @param {string} reason @returns {void} */
|
|
1026
|
+
_emitKanbanState(reason) {
|
|
1027
|
+
this.emit('statechange', { state: this.getViewState(), reason });
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** @returns {Readonly<KanbanViewOptions & RecordViewOptions>} */
|
|
1031
|
+
_kanbanOptions() {
|
|
1032
|
+
return /** @type {Readonly<KanbanViewOptions & RecordViewOptions>} */ (this.options);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Validates and clones configured column or lane descriptors.
|
|
1038
|
+
* @param {unknown} descriptors Descriptors.
|
|
1039
|
+
* @param {'column'|'swimlane'} [axis='column'] Descriptor kind.
|
|
1040
|
+
* @returns {KanbanColumn[]}
|
|
1041
|
+
*/
|
|
1042
|
+
export function normalizeKanbanDescriptors(descriptors, axis = 'column') {
|
|
1043
|
+
if (!Array.isArray(descriptors)) throw new TypeError(`Kanban ${axis}s must be an array or null`);
|
|
1044
|
+
const seen = new Set();
|
|
1045
|
+
return /** @type {KanbanColumn[]} */ (descriptors.map((descriptor, index) => {
|
|
1046
|
+
if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor)) {
|
|
1047
|
+
throw new TypeError(`Kanban ${axis} at index ${index} must be an object`);
|
|
1048
|
+
}
|
|
1049
|
+
const source = /** @type {Record<string, any>} */ (descriptor);
|
|
1050
|
+
const id = String(source.id ?? '').trim();
|
|
1051
|
+
if (!id && source.id !== '') throw new TypeError(`Kanban ${axis} at index ${index} requires an id`);
|
|
1052
|
+
if (seen.has(id)) throw new TypeError(`Duplicate Kanban ${axis}: ${id}`);
|
|
1053
|
+
if (source.accept != null && typeof source.accept !== 'function') {
|
|
1054
|
+
throw new TypeError(`Kanban ${axis} ${id} accept must be a function`);
|
|
1055
|
+
}
|
|
1056
|
+
seen.add(id);
|
|
1057
|
+
const normalized = /** @type {Record<string, any>} */ ({
|
|
1058
|
+
...source,
|
|
1059
|
+
id,
|
|
1060
|
+
label: String(source.label ?? axisLabel(source.id)),
|
|
1061
|
+
...(Object.prototype.hasOwnProperty.call(source, 'value') ? {} : { value: source.id })
|
|
1062
|
+
});
|
|
1063
|
+
if (axis === 'column') normalized.limit = normalizeLimit(source.limit);
|
|
1064
|
+
else delete normalized.limit;
|
|
1065
|
+
return normalized;
|
|
1066
|
+
}));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Derives stable descriptors from distinct accessor values in first-seen order.
|
|
1071
|
+
* @param {KanbanRecord[]} records Records.
|
|
1072
|
+
* @param {KanbanAccessor} accessor Grouping accessor.
|
|
1073
|
+
* @returns {KanbanColumn[]}
|
|
1074
|
+
*/
|
|
1075
|
+
export function deriveKanbanDescriptors(records, accessor) {
|
|
1076
|
+
const result = [];
|
|
1077
|
+
const seen = new Set();
|
|
1078
|
+
records.forEach((record, index) => {
|
|
1079
|
+
const value = readKanbanAccessor(record, index, accessor);
|
|
1080
|
+
const id = normalizeAxisId(value);
|
|
1081
|
+
if (seen.has(id)) return;
|
|
1082
|
+
seen.add(id);
|
|
1083
|
+
result.push({ id, label: axisLabel(value), value, limit: null });
|
|
1084
|
+
});
|
|
1085
|
+
return result;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Reconciles a stale or partial order with available descriptors.
|
|
1090
|
+
* @param {{id:string}[]} descriptors Available descriptors.
|
|
1091
|
+
* @param {unknown} order Preferred order.
|
|
1092
|
+
* @returns {string[]}
|
|
1093
|
+
*/
|
|
1094
|
+
export function reconcileKanbanOrder(descriptors, order) {
|
|
1095
|
+
const available = descriptors.map((descriptor) => descriptor.id);
|
|
1096
|
+
const allowed = new Set(available);
|
|
1097
|
+
const preferred = normalizeOrder(order).filter((id) => allowed.has(id));
|
|
1098
|
+
return [...new Set(preferred), ...available.filter((id) => !preferred.includes(id))];
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Reorders records around an already-cloned moved record. The input array and every input record
|
|
1103
|
+
* remain untouched; destination index is relative to records in that one column/lane.
|
|
1104
|
+
* @param {KanbanRecord[]} records Records.
|
|
1105
|
+
* @param {unknown} id Moving record id.
|
|
1106
|
+
* @param {KanbanRecord} movedRecord Cloned record carrying its destination axis values.
|
|
1107
|
+
* @param {{recordId:string|((record:KanbanRecord)=>unknown),columnBy:KanbanAccessor,swimlaneBy:KanbanAccessor,destination:KanbanMovePoint}} options Accessors and destination.
|
|
1108
|
+
* @returns {{records:KanbanRecord[],from:KanbanMovePoint,to:KanbanMovePoint}|null}
|
|
1109
|
+
*/
|
|
1110
|
+
export function reorderKanbanData(records, id, movedRecord, options) {
|
|
1111
|
+
if (!Array.isArray(records) || !movedRecord || typeof movedRecord !== 'object') return null;
|
|
1112
|
+
const sourceIndex = records.findIndex((record) => Object.is(viewRecordId(record, options.recordId), id));
|
|
1113
|
+
if (sourceIndex < 0) return null;
|
|
1114
|
+
const source = records[sourceIndex];
|
|
1115
|
+
const sourceColumn = normalizeAxisId(readKanbanAccessor(source, sourceIndex, options.columnBy));
|
|
1116
|
+
const sourceLane = options.swimlaneBy == null ? null
|
|
1117
|
+
: normalizeAxisId(readKanbanAccessor(source, sourceIndex, options.swimlaneBy));
|
|
1118
|
+
const sourceGroup = records.filter((record, index) =>
|
|
1119
|
+
normalizeAxisId(readKanbanAccessor(record, index, options.columnBy)) === sourceColumn
|
|
1120
|
+
&& (options.swimlaneBy == null
|
|
1121
|
+
|| normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy)) === sourceLane));
|
|
1122
|
+
const from = {
|
|
1123
|
+
column: sourceColumn, lane: sourceLane,
|
|
1124
|
+
index: sourceGroup.findIndex((record) => Object.is(viewRecordId(record, options.recordId), id))
|
|
1125
|
+
};
|
|
1126
|
+
const next = records.filter((_, index) => index !== sourceIndex);
|
|
1127
|
+
const destinationRecords = next.filter((record, index) =>
|
|
1128
|
+
normalizeAxisId(readKanbanAccessor(record, index, options.columnBy)) === options.destination.column
|
|
1129
|
+
&& (options.swimlaneBy == null
|
|
1130
|
+
|| normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy)) === options.destination.lane));
|
|
1131
|
+
const targetIndex = normalizeMoveIndex(options.destination.index, destinationRecords.length);
|
|
1132
|
+
let insertion = next.length;
|
|
1133
|
+
if (destinationRecords[targetIndex]) insertion = next.indexOf(destinationRecords[targetIndex]);
|
|
1134
|
+
else if (destinationRecords.length) insertion = next.indexOf(destinationRecords.at(-1)) + 1;
|
|
1135
|
+
next.splice(insertion, 0, movedRecord);
|
|
1136
|
+
return {
|
|
1137
|
+
records: next,
|
|
1138
|
+
from,
|
|
1139
|
+
to: { column: options.destination.column, lane: options.swimlaneBy == null ? null : options.destination.lane, index: targetIndex }
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/** @param {KanbanRecord} record @param {number} index @param {KanbanAccessor} accessor @returns {unknown} */
|
|
1144
|
+
function readKanbanAccessor(record, index, accessor) {
|
|
1145
|
+
return typeof accessor === 'function' ? accessor(record, index) : accessor == null ? null : record?.[accessor];
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** @param {unknown} value @returns {string} */
|
|
1149
|
+
function normalizeAxisId(value) {
|
|
1150
|
+
return value == null ? '' : String(value);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** @param {unknown} value @returns {string} */
|
|
1154
|
+
function axisLabel(value) {
|
|
1155
|
+
if (value == null || String(value).trim() === '') return 'Unassigned';
|
|
1156
|
+
return String(value);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** @param {unknown} value @returns {number|null} */
|
|
1160
|
+
function normalizeLimit(value) {
|
|
1161
|
+
if (value == null || value === '') return null;
|
|
1162
|
+
const number = Number(value);
|
|
1163
|
+
if (!Number.isFinite(number) || number < 0) throw new TypeError('Kanban column limit must be a non-negative number');
|
|
1164
|
+
return Math.floor(number);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** @param {unknown} value @param {number} maximum @returns {number} */
|
|
1168
|
+
function normalizeMoveIndex(value, maximum) {
|
|
1169
|
+
if (value == null || !Number.isFinite(Number(value))) return maximum;
|
|
1170
|
+
return Math.max(0, Math.min(maximum, Math.trunc(Number(value))));
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** @param {unknown} value @returns {string[]} */
|
|
1174
|
+
function normalizeOrder(value) {
|
|
1175
|
+
return Array.isArray(value) ? [...new Set(value.map(String))] : [];
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** @template {{id:string}} T @param {T[]} descriptors @param {string[]} order @returns {T[]} */
|
|
1179
|
+
function orderDescriptors(descriptors, order) {
|
|
1180
|
+
const byId = new Map(descriptors.map((descriptor) => [descriptor.id, descriptor]));
|
|
1181
|
+
return order.map((id) => byId.get(id)).filter(Boolean);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** @param {string[]} order @param {string} id @param {string} targetId @param {'before'|'after'} position @returns {string[]} */
|
|
1185
|
+
function moveOrderedId(order, id, targetId, position) {
|
|
1186
|
+
const next = [...order];
|
|
1187
|
+
const from = next.indexOf(String(id));
|
|
1188
|
+
if (from < 0 || !next.includes(String(targetId)) || String(id) === String(targetId)) return next;
|
|
1189
|
+
next.splice(from, 1);
|
|
1190
|
+
const target = next.indexOf(String(targetId));
|
|
1191
|
+
next.splice(target + (position === 'after' ? 1 : 0), 0, String(id));
|
|
1192
|
+
return next;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** @param {KanbanMovePoint} left @param {KanbanMovePoint} right @returns {boolean} */
|
|
1196
|
+
function sameMovePoint(left, right) {
|
|
1197
|
+
return left.column === right.column && left.lane === right.lane && left.index === right.index;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** @param {unknown[]} left @param {unknown[]} right @returns {boolean} */
|
|
1201
|
+
function sameArray(left, right) {
|
|
1202
|
+
return left.length === right.length && left.every((value, index) => Object.is(value, right[index]));
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** @param {{target?:EventTarget|null}|Event} event @returns {Element|null} */
|
|
1206
|
+
function elementTarget(event) {
|
|
1207
|
+
const target = event.target;
|
|
1208
|
+
if (!target) return null;
|
|
1209
|
+
return target instanceof Element ? target : /** @type {Node} */ (target).parentElement;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** @returns {boolean} */
|
|
1213
|
+
function hasTextSelection() {
|
|
1214
|
+
const selection = globalThis.getSelection?.();
|
|
1215
|
+
return Boolean(selection && !selection.isCollapsed && String(selection).trim());
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Record movement event emitted before a local commit or external request.
|
|
1220
|
+
* @event KanbanView#recordmove
|
|
1221
|
+
* @type {CustomEvent<{record:KanbanRecord,id:unknown,from:KanbanMovePoint,to:KanbanMovePoint,column:KanbanColumn,swimlane:KanbanSwimlane|null,limitExceeded:boolean}>}
|
|
1222
|
+
*/
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Secondary card action event.
|
|
1226
|
+
* @event KanbanView#recordaction
|
|
1227
|
+
* @type {CustomEvent<{record:KanbanRecord,id:unknown,action:unknown,actionId:string|undefined,event:Event}>}
|
|
1228
|
+
*/
|