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