@zeyos/zx 4.2.0 → 4.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -1
- package/README.md +5 -3
- package/dist/types/components/calendar/calendar-model.d.ts +22 -0
- package/dist/types/components/calendar/calendar.d.ts +19 -2
- package/dist/types/components/card-view/card-view.d.ts +1 -1
- package/dist/types/components/card-view/record-card.d.ts +47 -5
- package/dist/types/components/dialog/dialog.d.ts +6 -1
- package/dist/types/components/dropdown/dropdown.d.ts +5 -0
- package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
- package/dist/types/components/kanban-view/kanban-view.d.ts +669 -24
- package/dist/types/components/launcher/launcher.d.ts +5 -0
- package/dist/types/components/message/message.d.ts +9 -1
- package/dist/types/components/modal/modal.d.ts +44 -7
- package/dist/types/components/sheet/sheet.d.ts +22 -12
- package/dist/types/core/component.d.ts +6 -1
- package/dist/types/core/overlay-host.d.ts +12 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/zx-zeyos.esm.js +173 -43
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +876 -230
- package/dist/zx.esm.js +2053 -338
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +2053 -338
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +250 -2
- package/docs/llms.md +67 -48
- package/docs/llms.txt +1 -3
- package/package.json +1 -2
- package/src/components/account-menu/account-menu.css +1 -1
- package/src/components/app-rail/app-rail.css +1 -1
- package/src/components/calendar/calendar-model.js +45 -0
- package/src/components/calendar/calendar.css +96 -34
- package/src/components/calendar/calendar.js +122 -29
- package/src/components/card-view/card-view.css +211 -9
- package/src/components/card-view/card-view.js +7 -7
- package/src/components/card-view/record-card.js +92 -19
- package/src/components/context-menu/context-menu.css +2 -2
- package/src/components/context-menu/context-menu.js +5 -1
- package/src/components/date-picker/date-picker.css +18 -11
- package/src/components/datebox/date-range-box.css +4 -2
- package/src/components/datebox/datebox.css +4 -2
- package/src/components/dialog/dialog.css +4 -3
- package/src/components/dialog/dialog.js +2 -1
- package/src/components/dropdown/dropdown.css +4 -2
- package/src/components/dropdown/dropdown.js +7 -2
- package/src/components/kanban-view/kanban-policy.js +412 -0
- package/src/components/kanban-view/kanban-view.css +480 -36
- package/src/components/kanban-view/kanban-view.js +1514 -235
- package/src/components/launcher/launcher.css +10 -8
- package/src/components/launcher/launcher.js +9 -2
- package/src/components/menu-button/menu-button.css +2 -2
- package/src/components/message/message.css +15 -25
- package/src/components/message/message.js +17 -11
- package/src/components/modal/modal.css +6 -4
- package/src/components/modal/modal.js +99 -25
- package/src/components/select/select.css +9 -7
- package/src/components/sheet/sheet.css +25 -7
- package/src/components/sheet/sheet.js +43 -23
- package/src/components/table/table.css +5 -3
- package/src/components/tag-picker/tag-picker.css +5 -3
- package/src/components/tooltip/tooltip.css +4 -2
- package/src/components/tooltip/tooltip.js +4 -1
- package/src/core/component.js +8 -3
- package/src/core/overlay-host.js +23 -0
- package/src/index.js +8 -3
- package/styles/tokens/dark.css +4 -0
- package/styles/tokens/semantic.css +83 -8
- package/DESIGN-SYSTEM.md +0 -182
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Component } from '../../core/component.js';
|
|
2
2
|
import { h, resolveElement } from '../../core/dom.js';
|
|
3
|
+
import { overlayHost } from '../../core/overlay-host.js';
|
|
3
4
|
import { position } from '../../core/position.js';
|
|
4
5
|
import { uid } from '../../core/util.js';
|
|
5
6
|
|
|
@@ -14,6 +15,7 @@ const HANDLE_ESCAPE_DISMISS = Symbol('zxDropdownEscapeDismiss');
|
|
|
14
15
|
* @property {boolean} [matchWidth=false] Whether the panel matches the anchor width.
|
|
15
16
|
* @property {'click'|'manual'} [openOn='click'] Trigger behavior.
|
|
16
17
|
* @property {boolean} [closeOnSelect=false] Whether a click within the panel closes it.
|
|
18
|
+
* @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns the panel; defaults to the anchor.
|
|
17
19
|
* @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open event listener.
|
|
18
20
|
* @property {(event: CustomEvent<Record<string, never>>) => void} [onclose] Close event listener.
|
|
19
21
|
*/
|
|
@@ -34,7 +36,8 @@ export class Dropdown extends Component {
|
|
|
34
36
|
offset: 4,
|
|
35
37
|
matchWidth: false,
|
|
36
38
|
openOn: 'click',
|
|
37
|
-
closeOnSelect: false
|
|
39
|
+
closeOnSelect: false,
|
|
40
|
+
scope: null
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
/** @type {Element} */
|
|
@@ -55,6 +58,7 @@ export class Dropdown extends Component {
|
|
|
55
58
|
if (!resolved) throw new TypeError('Dropdown anchor could not be resolved');
|
|
56
59
|
super(null, options);
|
|
57
60
|
this.#anchor = resolved;
|
|
61
|
+
overlayHost(this.options.scope ?? resolved).append(this.el);
|
|
58
62
|
this.#rememberAnchorAttribute('aria-expanded');
|
|
59
63
|
this.#rememberAnchorAttribute('aria-controls');
|
|
60
64
|
this.#anchor.setAttribute('aria-expanded', 'false');
|
|
@@ -79,7 +83,6 @@ export class Dropdown extends Component {
|
|
|
79
83
|
popover: 'manual'
|
|
80
84
|
});
|
|
81
85
|
panel.dataset.state = 'closed';
|
|
82
|
-
document.body.append(panel);
|
|
83
86
|
return panel;
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -90,6 +93,8 @@ export class Dropdown extends Component {
|
|
|
90
93
|
*/
|
|
91
94
|
open() {
|
|
92
95
|
if (this.isOpen() || this.#destroyed) return this;
|
|
96
|
+
const host = overlayHost(this.options.scope ?? this.#anchor);
|
|
97
|
+
if (this.el.parentElement !== host) host.append(this.el);
|
|
93
98
|
this.#position = position(this.#anchor, this.el, {
|
|
94
99
|
placement: this.options.placement,
|
|
95
100
|
offset: Number(this.options.offset),
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { viewRecordId } from '../view/record-view.js';
|
|
3
|
+
|
|
4
|
+
/** @typedef {Record<string, any>} KanbanRecord */
|
|
5
|
+
/** @typedef {import('./kanban-view.js').KanbanColumn} KanbanColumn */
|
|
6
|
+
/** @typedef {import('./kanban-view.js').KanbanSwimlane} KanbanSwimlane */
|
|
7
|
+
/** @typedef {import('./kanban-view.js').KanbanMovePoint} KanbanMovePoint */
|
|
8
|
+
/** @typedef {import('./kanban-view.js').KanbanAccessor} KanbanAccessor */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} KanbanRule
|
|
12
|
+
* @property {string} id Stable rule id, written to the card's `data-rule` list.
|
|
13
|
+
* @property {(record: KanbanRecord, context: {index:number,column:string,lane:string|null}) => boolean} when
|
|
14
|
+
* Predicate deciding whether the rule applies to one record.
|
|
15
|
+
* @property {'neutral'|'accent'|'success'|'warning'|'danger'|'info'} [tone='accent'] Visual intent.
|
|
16
|
+
* The first matching rule paints the card marker; later matches only contribute badges.
|
|
17
|
+
* @property {string} [label=''] Badge text. A rule without a label is marker-only.
|
|
18
|
+
* @property {string|null} [icon=null] Optional badge icon name.
|
|
19
|
+
* @property {string} [description=''] Text joined into the card's accessible description so a
|
|
20
|
+
* rule is never communicated by colour alone.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} KanbanMoveEvaluation
|
|
25
|
+
* @property {boolean} allowed Whether the move may proceed to the cancelable event.
|
|
26
|
+
* @property {KanbanRejectReason|null} reason Refusal reason, or null when allowed.
|
|
27
|
+
* @property {number|null} limit The limit the destination count was measured against.
|
|
28
|
+
* @property {number} count Resulting record count in the constrained scope.
|
|
29
|
+
* @property {boolean} limitExceeded Whether an applicable limit would be exceeded.
|
|
30
|
+
* @property {boolean} limitReached Whether an applicable limit would be met exactly.
|
|
31
|
+
* @property {'warn'|'block'} policy Effective work-in-progress policy.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {'accept'|'transition'|'wip'|'lane-accept'|'lane-transition'|'lane-wip'|'destination'|'grouping'} KanbanRejectReason
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {Object} KanbanHistoryEntry
|
|
40
|
+
* @property {KanbanRecord[]} records Record order before or after one committed move.
|
|
41
|
+
* @property {unknown[]} selection Selected record ids at that moment.
|
|
42
|
+
* @property {string} [label] Human-readable description of the step.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {Object} KanbanHistory
|
|
47
|
+
* @property {(entry: KanbanHistoryEntry) => void} push Records a step and drops the redo stack.
|
|
48
|
+
* @property {(current: KanbanHistoryEntry) => KanbanHistoryEntry|null} undo Steps back.
|
|
49
|
+
* @property {(current: KanbanHistoryEntry) => KanbanHistoryEntry|null} redo Steps forward.
|
|
50
|
+
* @property {() => boolean} canUndo Whether a step back exists.
|
|
51
|
+
* @property {() => boolean} canRedo Whether a step forward exists.
|
|
52
|
+
* @property {() => void} clear Empties both stacks.
|
|
53
|
+
* @property {() => {undo:number,redo:number}} depth Current stack depths.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** Rule tones, in the order they are documented. */
|
|
57
|
+
const RULE_TONES = Object.freeze(['neutral', 'accent', 'success', 'warning', 'danger', 'info']);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validates and clones ordered card rules. Rules are pure presentation: they never change a
|
|
61
|
+
* record, a move, or a policy decision.
|
|
62
|
+
* @param {unknown} rules Configured rules.
|
|
63
|
+
* @returns {KanbanRule[]} Normalized rules.
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeKanbanRules(rules) {
|
|
66
|
+
if (rules == null) return [];
|
|
67
|
+
if (!Array.isArray(rules)) throw new TypeError('Kanban rules must be an array or null');
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
return rules.map((rule, index) => {
|
|
70
|
+
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
|
|
71
|
+
throw new TypeError(`Kanban rule at index ${index} must be an object`);
|
|
72
|
+
}
|
|
73
|
+
const source = /** @type {Record<string, any>} */ (rule);
|
|
74
|
+
const id = String(source.id ?? '').trim();
|
|
75
|
+
if (!id) throw new TypeError(`Kanban rule at index ${index} requires an id`);
|
|
76
|
+
if (seen.has(id)) throw new TypeError(`Duplicate Kanban rule: ${id}`);
|
|
77
|
+
if (typeof source.when !== 'function') {
|
|
78
|
+
throw new TypeError(`Kanban rule ${id} requires a when predicate`);
|
|
79
|
+
}
|
|
80
|
+
seen.add(id);
|
|
81
|
+
return {
|
|
82
|
+
id,
|
|
83
|
+
when: source.when,
|
|
84
|
+
tone: RULE_TONES.includes(source.tone) ? source.tone : 'accent',
|
|
85
|
+
label: source.label == null ? '' : String(source.label),
|
|
86
|
+
icon: source.icon == null ? null : String(source.icon),
|
|
87
|
+
description: source.description == null ? '' : String(source.description)
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Returns the rules matching one record, in configured order. A predicate that throws is treated
|
|
94
|
+
* as "does not apply" so one broken rule cannot take the board down.
|
|
95
|
+
* @param {KanbanRule[]} rules Normalized rules.
|
|
96
|
+
* @param {KanbanRecord} record Record.
|
|
97
|
+
* @param {{index:number,column:string,lane:string|null}} context Position context.
|
|
98
|
+
* @returns {KanbanRule[]} Matching rules.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveKanbanRules(rules, record, context) {
|
|
101
|
+
if (!Array.isArray(rules) || !rules.length) return [];
|
|
102
|
+
return rules.filter((rule) => {
|
|
103
|
+
try {
|
|
104
|
+
return rule.when(record, context) === true;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Decides whether a proposed move may proceed, and reports the work-in-progress consequence either
|
|
113
|
+
* way. Checks run in a fixed order — transition, eligibility, then capacity — so a refusal always
|
|
114
|
+
* names the first constraint the move violates rather than an arbitrary one.
|
|
115
|
+
*
|
|
116
|
+
* Counts exclude the moving records, so a reorder inside one column is measured against the column
|
|
117
|
+
* it is already in rather than being told it doubles the load.
|
|
118
|
+
*
|
|
119
|
+
* @param {Object} input Evaluation input.
|
|
120
|
+
* @param {KanbanRecord[]} input.records Records being moved together.
|
|
121
|
+
* @param {KanbanColumn} input.column Destination column descriptor.
|
|
122
|
+
* @param {KanbanSwimlane|null} input.swimlane Destination lane descriptor, or null.
|
|
123
|
+
* @param {import('./kanban-view.js').KanbanMoveContext} input.context Context passed to `accept`.
|
|
124
|
+
* @param {KanbanMovePoint} input.from Origin of the primary record.
|
|
125
|
+
* @param {number} input.columnCount Records already in the destination column, movers excluded.
|
|
126
|
+
* @param {number} input.laneCount Records already in the destination lane, movers excluded.
|
|
127
|
+
* @param {number} input.cellCount Records already in the destination column and lane, movers excluded.
|
|
128
|
+
* @param {'warn'|'block'} [input.policy='warn'] Board work-in-progress policy.
|
|
129
|
+
* @returns {KanbanMoveEvaluation} Decision and capacity report.
|
|
130
|
+
*/
|
|
131
|
+
export function evaluateKanbanMove(input) {
|
|
132
|
+
const { records, column, swimlane, context, from } = input;
|
|
133
|
+
const moving = Math.max(1, records.length);
|
|
134
|
+
const policy = column?.wipPolicy === 'block' || column?.wipPolicy === 'warn'
|
|
135
|
+
? column.wipPolicy : input.policy === 'block' ? 'block' : 'warn';
|
|
136
|
+
/** @param {KanbanRejectReason} reason @param {Partial<KanbanMoveEvaluation>} [extra] @returns {KanbanMoveEvaluation} */
|
|
137
|
+
const refuse = (reason, extra = {}) => ({
|
|
138
|
+
allowed: false, reason, limit: null, count: 0,
|
|
139
|
+
limitExceeded: false, limitReached: false, policy, ...extra
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const blockedTransition = allowsKanbanTransition(column, swimlane, from);
|
|
143
|
+
if (blockedTransition) return refuse(blockedTransition);
|
|
144
|
+
for (const record of records) {
|
|
145
|
+
if (column.accept && column.accept(record, context) === false) return refuse('accept');
|
|
146
|
+
if (swimlane?.accept && swimlane.accept(record, context) === false) return refuse('lane-accept');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @type {{reason:KanbanRejectReason,limit:number,count:number}[]} */
|
|
150
|
+
const capacities = [];
|
|
151
|
+
if (column.limit != null) {
|
|
152
|
+
capacities.push({ reason: 'wip', limit: column.limit, count: input.columnCount + moving });
|
|
153
|
+
}
|
|
154
|
+
const cellLimit = swimlane ? readLaneLimit(column.laneLimits, swimlane.id) : null;
|
|
155
|
+
if (cellLimit != null) {
|
|
156
|
+
capacities.push({ reason: 'wip', limit: cellLimit, count: input.cellCount + moving });
|
|
157
|
+
}
|
|
158
|
+
if (swimlane?.limit != null) {
|
|
159
|
+
capacities.push({ reason: 'lane-wip', limit: swimlane.limit, count: input.laneCount + moving });
|
|
160
|
+
}
|
|
161
|
+
const violated = capacities.find((capacity) => capacity.count > capacity.limit);
|
|
162
|
+
const reported = violated ?? capacities.find((capacity) => capacity.count === capacity.limit) ?? capacities[0];
|
|
163
|
+
if (violated && policy === 'block') {
|
|
164
|
+
return refuse(violated.reason, {
|
|
165
|
+
limit: violated.limit, count: violated.count, limitExceeded: true
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
allowed: true,
|
|
170
|
+
reason: null,
|
|
171
|
+
limit: reported ? reported.limit : null,
|
|
172
|
+
count: reported ? reported.count : 0,
|
|
173
|
+
limitExceeded: Boolean(violated),
|
|
174
|
+
limitReached: Boolean(reported && !violated && reported.count === reported.limit),
|
|
175
|
+
policy
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reports which transition allow-list, if any, refuses one origin. A move that stays on an axis is
|
|
181
|
+
* never judged by that axis's rule: reordering inside a column is not a transition into it.
|
|
182
|
+
* @param {KanbanColumn} column Destination column.
|
|
183
|
+
* @param {KanbanSwimlane|null} swimlane Destination lane, or null.
|
|
184
|
+
* @param {KanbanMovePoint} from Origin.
|
|
185
|
+
* @returns {'transition'|'lane-transition'|null} Refusing rule, or null when the origin is admitted.
|
|
186
|
+
*/
|
|
187
|
+
export function allowsKanbanTransition(column, swimlane, from) {
|
|
188
|
+
if (column.id !== from.column && !allowsOrigin(column.from, from.column)) return 'transition';
|
|
189
|
+
if (swimlane && swimlane.id !== from.lane && !allowsOrigin(swimlane.from, from.lane)) {
|
|
190
|
+
return 'lane-transition';
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Splits a search string into lowercase terms. Quoted groups stay together so `"needs review"`
|
|
197
|
+
* matches a phrase rather than two independent words.
|
|
198
|
+
* @param {unknown} search Raw search string.
|
|
199
|
+
* @returns {string[]} Lowercase terms.
|
|
200
|
+
*/
|
|
201
|
+
export function kanbanSearchTerms(search) {
|
|
202
|
+
const text = search == null ? '' : String(search).trim().toLowerCase();
|
|
203
|
+
if (!text) return [];
|
|
204
|
+
return [...text.matchAll(/"([^"]*)"|(\S+)/g)]
|
|
205
|
+
.map((match) => (match[1] ?? match[2] ?? '').trim())
|
|
206
|
+
.filter(Boolean);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Reports whether searchable text contains every term.
|
|
211
|
+
* @param {string} text Concatenated searchable text.
|
|
212
|
+
* @param {string[]} terms Lowercase terms.
|
|
213
|
+
* @returns {boolean} Whether the record matches.
|
|
214
|
+
*/
|
|
215
|
+
export function matchesKanbanSearch(text, terms) {
|
|
216
|
+
if (!terms.length) return true;
|
|
217
|
+
const haystack = text.toLowerCase();
|
|
218
|
+
return terms.every((term) => haystack.includes(term));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Bounded undo/redo stack over whole record-order snapshots. Snapshots are shallow arrays of the
|
|
223
|
+
* very record objects the board already holds, so a step costs one array rather than a deep clone;
|
|
224
|
+
* this is safe precisely because a Kanban move clones the record it changes and never mutates one.
|
|
225
|
+
* @param {number} [limit=50] Maximum remembered steps.
|
|
226
|
+
* @returns {KanbanHistory} History controller.
|
|
227
|
+
*/
|
|
228
|
+
export function createKanbanHistory(limit = 50) {
|
|
229
|
+
const max = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 50;
|
|
230
|
+
/** @type {KanbanHistoryEntry[]} */
|
|
231
|
+
let undoStack = [];
|
|
232
|
+
/** @type {KanbanHistoryEntry[]} */
|
|
233
|
+
let redoStack = [];
|
|
234
|
+
return {
|
|
235
|
+
push(entry) {
|
|
236
|
+
undoStack.push(entry);
|
|
237
|
+
if (undoStack.length > max) undoStack = undoStack.slice(-max);
|
|
238
|
+
redoStack = [];
|
|
239
|
+
},
|
|
240
|
+
undo(current) {
|
|
241
|
+
const entry = undoStack.pop();
|
|
242
|
+
if (!entry) return null;
|
|
243
|
+
redoStack.push(current);
|
|
244
|
+
if (redoStack.length > max) redoStack = redoStack.slice(-max);
|
|
245
|
+
return entry;
|
|
246
|
+
},
|
|
247
|
+
redo(current) {
|
|
248
|
+
const entry = redoStack.pop();
|
|
249
|
+
if (!entry) return null;
|
|
250
|
+
undoStack.push(current);
|
|
251
|
+
if (undoStack.length > max) undoStack = undoStack.slice(-max);
|
|
252
|
+
return entry;
|
|
253
|
+
},
|
|
254
|
+
canUndo: () => undoStack.length > 0,
|
|
255
|
+
canRedo: () => redoStack.length > 0,
|
|
256
|
+
clear() {
|
|
257
|
+
undoStack = [];
|
|
258
|
+
redoStack = [];
|
|
259
|
+
},
|
|
260
|
+
depth: () => ({ undo: undoStack.length, redo: redoStack.length })
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Reorders records around one or more already-cloned moved records. The input array and every
|
|
266
|
+
* input record remain untouched. Movers keep their relative order and land contiguously, so a
|
|
267
|
+
* multi-card move reads on the board exactly as it read before it was picked up.
|
|
268
|
+
*
|
|
269
|
+
* The destination index is relative to the records remaining in the destination column and lane
|
|
270
|
+
* after the movers are lifted out — the same frame of reference a user sees while dragging.
|
|
271
|
+
*
|
|
272
|
+
* @param {KanbanRecord[]} records Current records.
|
|
273
|
+
* @param {{id:unknown,record:KanbanRecord}[]} entries Moving ids paired with their cloned records.
|
|
274
|
+
* @param {{recordId:string|((record:KanbanRecord)=>unknown),columnBy:KanbanAccessor,swimlaneBy:KanbanAccessor,destination:KanbanMovePoint}} options Accessors and destination.
|
|
275
|
+
* @returns {{records:KanbanRecord[],moves:{id:unknown,from:KanbanMovePoint}[],to:KanbanMovePoint}|null}
|
|
276
|
+
*/
|
|
277
|
+
export function reorderKanbanRecords(records, entries, options) {
|
|
278
|
+
if (!Array.isArray(records) || !Array.isArray(entries) || !entries.length) return null;
|
|
279
|
+
const sources = entries.map((entry) => ({
|
|
280
|
+
...entry,
|
|
281
|
+
index: records.findIndex((record) => Object.is(viewRecordId(record, options.recordId), entry.id))
|
|
282
|
+
}));
|
|
283
|
+
if (sources.some((source) => source.index < 0 || !source.record || typeof source.record !== 'object')) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
sources.sort((left, right) => left.index - right.index);
|
|
287
|
+
|
|
288
|
+
const moves = sources.map((source) => ({
|
|
289
|
+
id: source.id,
|
|
290
|
+
from: locateKanbanRecord(records, source.index, options)
|
|
291
|
+
}));
|
|
292
|
+
const movingIndices = new Set(sources.map((source) => source.index));
|
|
293
|
+
const next = records.filter((_, index) => !movingIndices.has(index));
|
|
294
|
+
const destinationRecords = next.filter((record, index) =>
|
|
295
|
+
normalizeAxisId(readKanbanAccessor(record, index, options.columnBy)) === options.destination.column
|
|
296
|
+
&& (options.swimlaneBy == null
|
|
297
|
+
|| normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy)) === options.destination.lane));
|
|
298
|
+
const targetIndex = clampKanbanIndex(options.destination.index, destinationRecords.length);
|
|
299
|
+
let insertion = next.length;
|
|
300
|
+
if (destinationRecords[targetIndex]) insertion = next.indexOf(destinationRecords[targetIndex]);
|
|
301
|
+
else if (destinationRecords.length) insertion = next.indexOf(destinationRecords.at(-1)) + 1;
|
|
302
|
+
next.splice(insertion, 0, ...sources.map((source) => source.record));
|
|
303
|
+
return {
|
|
304
|
+
records: next,
|
|
305
|
+
moves,
|
|
306
|
+
to: {
|
|
307
|
+
column: options.destination.column,
|
|
308
|
+
lane: options.swimlaneBy == null ? null : options.destination.lane,
|
|
309
|
+
index: targetIndex
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Resolves one record's column, lane, and position within its bucket.
|
|
316
|
+
* @param {KanbanRecord[]} records Records.
|
|
317
|
+
* @param {number} index Position in `records`.
|
|
318
|
+
* @param {{recordId:string|((record:KanbanRecord)=>unknown),columnBy:KanbanAccessor,swimlaneBy:KanbanAccessor}} options Accessors.
|
|
319
|
+
* @returns {KanbanMovePoint} Location.
|
|
320
|
+
*/
|
|
321
|
+
export function locateKanbanRecord(records, index, options) {
|
|
322
|
+
const record = records[index];
|
|
323
|
+
const column = normalizeAxisId(readKanbanAccessor(record, index, options.columnBy));
|
|
324
|
+
const lane = options.swimlaneBy == null ? null
|
|
325
|
+
: normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy));
|
|
326
|
+
const group = records.filter((candidate, candidateIndex) =>
|
|
327
|
+
normalizeAxisId(readKanbanAccessor(candidate, candidateIndex, options.columnBy)) === column
|
|
328
|
+
&& (options.swimlaneBy == null
|
|
329
|
+
|| normalizeAxisId(readKanbanAccessor(candidate, candidateIndex, options.swimlaneBy)) === lane));
|
|
330
|
+
const id = viewRecordId(record, options.recordId);
|
|
331
|
+
return {
|
|
332
|
+
column, lane,
|
|
333
|
+
index: group.findIndex((candidate) => Object.is(viewRecordId(candidate, options.recordId), id))
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Translates a position among *rendered* cards into a position among all records in the same
|
|
339
|
+
* column and lane. Search and filters hide records without removing them, and a drop between two
|
|
340
|
+
* visible cards must land between them in the underlying data rather than at the same ordinal.
|
|
341
|
+
* @param {boolean[]} visibility Per-record visibility inside the bucket, in record order.
|
|
342
|
+
* @param {number} visibleIndex Zero-based position among the visible records.
|
|
343
|
+
* @returns {number} Zero-based position among all records in the bucket.
|
|
344
|
+
*/
|
|
345
|
+
export function absoluteKanbanIndex(visibility, visibleIndex) {
|
|
346
|
+
const target = clampKanbanIndex(visibleIndex, visibility.filter(Boolean).length);
|
|
347
|
+
let seen = 0;
|
|
348
|
+
for (let index = 0; index < visibility.length; index += 1) {
|
|
349
|
+
if (!visibility[index]) continue;
|
|
350
|
+
if (seen === target) return index;
|
|
351
|
+
seen += 1;
|
|
352
|
+
}
|
|
353
|
+
return visibility.length;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Reads a grouping accessor without touching field descriptors.
|
|
358
|
+
* @param {KanbanRecord} record Record.
|
|
359
|
+
* @param {number} index Display index.
|
|
360
|
+
* @param {KanbanAccessor} accessor Accessor.
|
|
361
|
+
* @returns {unknown} Raw value.
|
|
362
|
+
*/
|
|
363
|
+
export function readKanbanAccessor(record, index, accessor) {
|
|
364
|
+
return typeof accessor === 'function' ? accessor(record, index) : accessor == null ? null : record?.[accessor];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Normalizes any grouping value to its stable string id.
|
|
369
|
+
* @param {unknown} value Raw value.
|
|
370
|
+
* @returns {string} Axis id.
|
|
371
|
+
*/
|
|
372
|
+
export function normalizeAxisId(value) {
|
|
373
|
+
return value == null ? '' : String(value);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Clamps a requested position into an insertable range, treating anything unusable as "append".
|
|
378
|
+
* @param {unknown} value Requested index.
|
|
379
|
+
* @param {number} maximum Highest insertable position.
|
|
380
|
+
* @returns {number} Clamped index.
|
|
381
|
+
*/
|
|
382
|
+
export function clampKanbanIndex(value, maximum) {
|
|
383
|
+
if (value == null || !Number.isFinite(Number(value))) return maximum;
|
|
384
|
+
return Math.max(0, Math.min(maximum, Math.trunc(Number(value))));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Reports whether a transition allow-list admits an origin.
|
|
389
|
+
* @param {unknown} allowed Allow-list, `null`/absent for any origin.
|
|
390
|
+
* @param {string|null} origin Origin id.
|
|
391
|
+
* @returns {boolean} Whether the origin is admitted.
|
|
392
|
+
*/
|
|
393
|
+
function allowsOrigin(allowed, origin) {
|
|
394
|
+
if (allowed == null) return true;
|
|
395
|
+
if (!Array.isArray(allowed)) return true;
|
|
396
|
+
if (!allowed.length) return false;
|
|
397
|
+
return allowed.some((entry) => String(entry) === '*' || String(entry) === normalizeAxisId(origin));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Reads one lane's limit inside a column.
|
|
402
|
+
* @param {unknown} laneLimits Lane limit map.
|
|
403
|
+
* @param {string} lane Lane id.
|
|
404
|
+
* @returns {number|null} Limit, or null when unconstrained.
|
|
405
|
+
*/
|
|
406
|
+
function readLaneLimit(laneLimits, lane) {
|
|
407
|
+
if (!laneLimits || typeof laneLimits !== 'object') return null;
|
|
408
|
+
const value = /** @type {Record<string, unknown>} */ (laneLimits)[lane];
|
|
409
|
+
if (value == null || value === '') return null;
|
|
410
|
+
const number = Number(value);
|
|
411
|
+
return Number.isFinite(number) && number >= 0 ? Math.floor(number) : null;
|
|
412
|
+
}
|