@zeyos/zx 4.3.0 → 4.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +111 -1
  2. package/README.md +0 -3
  3. package/dist/types/components/calendar/calendar-model.d.ts +22 -0
  4. package/dist/types/components/card-view/card-view.d.ts +1 -1
  5. package/dist/types/components/card-view/record-card.d.ts +47 -5
  6. package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
  7. package/dist/types/components/kanban-view/kanban-view.d.ts +669 -24
  8. package/dist/types/core/component.d.ts +6 -1
  9. package/dist/types/index.d.ts +2 -1
  10. package/dist/zx-zeyos.esm.js +35 -11
  11. package/dist/zx-zeyos.esm.min.js +1 -1
  12. package/dist/zx.css +597 -78
  13. package/dist/zx.esm.js +1758 -238
  14. package/dist/zx.esm.min.js +3 -3
  15. package/dist/zx.global.js +1758 -238
  16. package/dist/zx.global.min.js +3 -3
  17. package/dist/zx.min.css +1 -1
  18. package/docs/api.json +214 -2
  19. package/docs/llms.md +30 -30
  20. package/docs/llms.txt +0 -2
  21. package/package.json +1 -2
  22. package/src/components/calendar/calendar-model.js +45 -0
  23. package/src/components/calendar/calendar.css +30 -10
  24. package/src/components/calendar/calendar.js +8 -10
  25. package/src/components/card-view/card-view.css +211 -9
  26. package/src/components/card-view/card-view.js +7 -7
  27. package/src/components/card-view/record-card.js +92 -19
  28. package/src/components/kanban-view/kanban-policy.js +412 -0
  29. package/src/components/kanban-view/kanban-view.css +480 -36
  30. package/src/components/kanban-view/kanban-view.js +1514 -235
  31. package/src/components/message/message.css +9 -10
  32. package/src/core/component.js +8 -3
  33. package/src/index.js +8 -3
  34. package/DESIGN-SYSTEM.md +0 -182
@@ -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
+ }