@flux-ui/components 3.0.0-next.57 → 3.0.0-next.58

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.
@@ -1,20 +1,149 @@
1
- import type { FluxKanbanMoveEvent } from '@flux-ui/types';
2
- import { ref } from 'vue';
3
- import type { FluxKanbanDragState, FluxKanbanInjection } from '$flux/data/di';
1
+ import type { FluxKanbanMoveColumnEvent, FluxKanbanMoveEvent } from '@flux-ui/types';
2
+ import type { Ref } from 'vue';
3
+ import { computed, ref, unref } from 'vue';
4
+ import type { FluxKanbanColumnDragState, FluxKanbanDragState, FluxKanbanInjection, FluxKanbanKeyboardDirection } from '$flux/data/di';
5
+
6
+ export type UseKanbanOptions = {
7
+ readonly disabled: Ref<boolean>;
8
+ readonly reorderableColumns: Ref<boolean>;
9
+ readonly canMove?: Ref<((event: FluxKanbanMoveEvent) => boolean) | undefined>;
10
+ readonly onMove: (event: FluxKanbanMoveEvent) => void;
11
+ readonly onMoveColumn: (event: FluxKanbanMoveColumnEvent) => void;
12
+ readonly onAnnounce: (message: string) => void;
13
+ };
14
+
15
+ const AUTOSCROLL_ZONE = 40;
16
+ const AUTOSCROLL_MAX_SPEED = 12;
17
+ const DRAG_LEAVE_GRACE_MS = 50;
4
18
 
5
19
  /**
6
20
  * Internal composable for managing kanban drag-and-drop state.
7
- * Provides card registration, drag tracking, and drop target management.
21
+ * Provides card registration, drag tracking, drop target management,
22
+ * keyboard drag-and-drop, column reordering, drop validation and auto-scroll.
8
23
  */
9
- export function useKanban(onMove: (event: FluxKanbanMoveEvent) => void): FluxKanbanInjection {
24
+ export function useKanban(options: UseKanbanOptions): FluxKanbanInjection {
10
25
  const dragState = ref<FluxKanbanDragState | null>(null);
26
+ const columnDragState = ref<FluxKanbanColumnDragState | null>(null);
27
+
11
28
  const cardRegistry = new WeakMap<Element, { readonly cardId: string | number }>();
29
+ const cardElementsById = new Map<string | number, Element>();
30
+ const columnRegistry = new WeakMap<Element, { readonly columnId: string | number }>();
31
+ const columnElementsById = new Map<string | number, Element>();
32
+ const columnBodyById = new Map<string | number, Element>();
33
+
34
+ let boardElement: Element | null = null;
35
+ let clearTimer: ReturnType<typeof setTimeout> | null = null;
36
+ let autoScrollFrame: number | null = null;
37
+ let autoScrollX = 0;
38
+ let autoScrollY = 0;
39
+ let autoScrollVerticalTarget: Element | null = null;
40
+
41
+ const isDropAllowed = computed(() => {
42
+ const state = unref(dragState);
43
+
44
+ if (!state || state.dropColumnId === null) {
45
+ return true;
46
+ }
47
+
48
+ const validate = unref(options.canMove);
49
+
50
+ if (!validate) {
51
+ return true;
52
+ }
53
+
54
+ return validate(buildMoveEvent(state));
55
+ });
56
+
57
+ function buildMoveEvent(state: FluxKanbanDragState): FluxKanbanMoveEvent {
58
+ return {
59
+ cardId: state.cardId,
60
+ fromColumnId: state.fromColumnId,
61
+ toColumnId: state.dropColumnId as string | number,
62
+ beforeCardId: state.beforeCardId ?? undefined
63
+ };
64
+ }
65
+
66
+ function clearTimerIfAny(): void {
67
+ if (clearTimer !== null) {
68
+ clearTimeout(clearTimer);
69
+ clearTimer = null;
70
+ }
71
+ }
72
+
73
+ function isSelfDrop(state: FluxKanbanDragState): boolean {
74
+ if (state.dropColumnId !== state.fromColumnId) {
75
+ return false;
76
+ }
77
+
78
+ if (state.beforeCardId === state.cardId) {
79
+ return true;
80
+ }
81
+
82
+ const fromBody = columnBodyById.get(state.fromColumnId);
83
+
84
+ if (!fromBody) {
85
+ return false;
86
+ }
87
+
88
+ const cards = Array.from(fromBody.children).filter(child => cardRegistry.has(child));
89
+ const draggedIndex = cards.findIndex(elm => cardRegistry.get(elm)?.cardId === state.cardId);
90
+
91
+ if (draggedIndex === -1) {
92
+ return false;
93
+ }
94
+
95
+ if (state.beforeCardId === null) {
96
+ return draggedIndex === cards.length - 1;
97
+ }
98
+
99
+ const beforeIndex = cards.findIndex(elm => cardRegistry.get(elm)?.cardId === state.beforeCardId);
100
+ return beforeIndex !== -1 && beforeIndex === draggedIndex + 1;
101
+ }
102
+
103
+ function getColumnIndex(columnId: string | number): number {
104
+ if (!boardElement) {
105
+ return -1;
106
+ }
107
+
108
+ const columns = Array.from(boardElement.children).filter(child => columnRegistry.has(child));
109
+ return columns.findIndex(elm => columnRegistry.get(elm)?.columnId === columnId);
110
+ }
111
+
112
+ function getColumnByIndex(index: number): { readonly columnId: string | number } | null {
113
+ if (!boardElement) {
114
+ return null;
115
+ }
116
+
117
+ const columns = Array.from(boardElement.children).filter(child => columnRegistry.has(child));
118
+ const elm = columns[index];
119
+
120
+ return elm ? columnRegistry.get(elm)! : null;
121
+ }
122
+
123
+ function getCardsInColumn(columnId: string | number): (string | number)[] {
124
+ const body = columnBodyById.get(columnId);
125
+
126
+ if (!body) {
127
+ return [];
128
+ }
129
+
130
+ return Array.from(body.children)
131
+ .map(child => cardRegistry.get(child)?.cardId)
132
+ .filter((id): id is string | number => id !== undefined);
133
+ }
12
134
 
13
135
  function registerCard(element: Element, cardId: string | number): void {
14
136
  cardRegistry.set(element, {cardId});
137
+ cardElementsById.set(cardId, element);
15
138
  }
16
139
 
17
140
  function unregisterCard(element: Element): void {
141
+ const info = cardRegistry.get(element);
142
+
143
+ if (info) {
144
+ cardElementsById.delete(info.cardId);
145
+ }
146
+
18
147
  cardRegistry.delete(element);
19
148
  }
20
149
 
@@ -22,11 +151,50 @@ export function useKanban(onMove: (event: FluxKanbanMoveEvent) => void): FluxKan
22
151
  return cardRegistry.get(element);
23
152
  }
24
153
 
154
+ function registerColumn(element: Element, columnId: string | number): void {
155
+ columnRegistry.set(element, {columnId});
156
+ columnElementsById.set(columnId, element);
157
+ }
158
+
159
+ function unregisterColumn(element: Element): void {
160
+ const info = columnRegistry.get(element);
161
+
162
+ if (info) {
163
+ columnElementsById.delete(info.columnId);
164
+ columnBodyById.delete(info.columnId);
165
+ }
166
+
167
+ columnRegistry.delete(element);
168
+ }
169
+
170
+ function getColumnInfo(element: Element): { readonly columnId: string | number } | undefined {
171
+ return columnRegistry.get(element);
172
+ }
173
+
174
+ function setBoardElement(element: Element | null): void {
175
+ boardElement = element;
176
+ }
177
+
178
+ function setColumnBodyElement(columnId: string | number, element: Element | null): void {
179
+ if (element) {
180
+ columnBodyById.set(columnId, element);
181
+ } else {
182
+ columnBodyById.delete(columnId);
183
+ }
184
+ }
185
+
25
186
  function startDrag(cardId: string | number, fromColumnId: string | number): void {
26
- dragState.value = {cardId, fromColumnId, dropColumnId: null, beforeCardId: null};
187
+ if (unref(options.disabled)) {
188
+ return;
189
+ }
190
+
191
+ clearTimerIfAny();
192
+ dragState.value = {mode: 'pointer', cardId, fromColumnId, dropColumnId: null, beforeCardId: null};
27
193
  }
28
194
 
29
195
  function endDrag(): void {
196
+ clearTimerIfAny();
197
+ stopAutoScroll();
30
198
  dragState.value = null;
31
199
  }
32
200
 
@@ -35,6 +203,7 @@ export function useKanban(onMove: (event: FluxKanbanMoveEvent) => void): FluxKan
35
203
  return;
36
204
  }
37
205
 
206
+ clearTimerIfAny();
38
207
  dragState.value = {...dragState.value, dropColumnId: columnId, beforeCardId};
39
208
  }
40
209
 
@@ -43,26 +212,437 @@ export function useKanban(onMove: (event: FluxKanbanMoveEvent) => void): FluxKan
43
212
  return;
44
213
  }
45
214
 
46
- dragState.value = {...dragState.value, dropColumnId: null, beforeCardId: null};
215
+ clearTimerIfAny();
216
+ clearTimer = setTimeout(() => {
217
+ clearTimer = null;
218
+
219
+ if (!dragState.value) {
220
+ return;
221
+ }
222
+
223
+ dragState.value = {...dragState.value, dropColumnId: null, beforeCardId: null};
224
+ }, DRAG_LEAVE_GRACE_MS);
47
225
  }
48
226
 
49
227
  function commitDrop(): void {
50
228
  const state = dragState.value;
229
+ clearTimerIfAny();
230
+ stopAutoScroll();
51
231
 
52
232
  if (!state || state.dropColumnId === null) {
53
233
  dragState.value = null;
54
234
  return;
55
235
  }
56
236
 
57
- onMove({
58
- cardId: state.cardId,
59
- fromColumnId: state.fromColumnId,
60
- toColumnId: state.dropColumnId,
61
- beforeCardId: state.beforeCardId ?? undefined
237
+ if (isSelfDrop(state)) {
238
+ dragState.value = null;
239
+ return;
240
+ }
241
+
242
+ const event = buildMoveEvent(state);
243
+ const validate = unref(options.canMove);
244
+
245
+ if (validate && !validate(event)) {
246
+ options.onAnnounce('Drop not allowed.');
247
+ dragState.value = null;
248
+ return;
249
+ }
250
+
251
+ options.onMove(event);
252
+ options.onAnnounce(`Card moved to ${String(state.dropColumnId)}.`);
253
+ dragState.value = null;
254
+ }
255
+
256
+ /* region keyboard */
257
+
258
+ function findCurrentColumnId(cardId: string | number): string | number | null {
259
+ for (const columnId of columnElementsById.keys()) {
260
+ if (getCardsInColumn(columnId).includes(cardId)) {
261
+ return columnId;
262
+ }
263
+ }
264
+
265
+ return null;
266
+ }
267
+
268
+ function findCurrentBeforeCardId(cardId: string | number, columnId: string | number): string | number | null {
269
+ const cards = getCardsInColumn(columnId);
270
+ const idx = cards.indexOf(cardId);
271
+
272
+ if (idx === -1) {
273
+ return null;
274
+ }
275
+
276
+ return cards[idx + 1] ?? null;
277
+ }
278
+
279
+ function grabCard(cardId: string | number, fromColumnId: string | number): void {
280
+ if (unref(options.disabled)) {
281
+ return;
282
+ }
283
+
284
+ clearTimerIfAny();
285
+
286
+ dragState.value = {
287
+ mode: 'keyboard',
288
+ cardId,
289
+ fromColumnId,
290
+ dropColumnId: null,
291
+ beforeCardId: null,
292
+ originBeforeCardId: findCurrentBeforeCardId(cardId, fromColumnId)
293
+ };
294
+ options.onAnnounce('Card grabbed. Use arrow keys to move, Enter to drop, Escape to cancel.');
295
+ }
296
+
297
+ function moveKeyboard(direction: FluxKanbanKeyboardDirection): void {
298
+ const state = dragState.value;
299
+
300
+ if (!state || state.mode !== 'keyboard') {
301
+ return;
302
+ }
303
+
304
+ const cardId = state.cardId;
305
+ const currentColumnId = findCurrentColumnId(cardId);
306
+
307
+ if (currentColumnId === null) {
308
+ return;
309
+ }
310
+
311
+ const target = direction === 'up' || direction === 'down'
312
+ ? computeWithinColumnTarget(cardId, currentColumnId, direction)
313
+ : computeAcrossColumnTarget(currentColumnId, direction);
314
+
315
+ if (!target) {
316
+ return;
317
+ }
318
+
319
+ const event: FluxKanbanMoveEvent = {
320
+ cardId,
321
+ fromColumnId: currentColumnId,
322
+ toColumnId: target.columnId,
323
+ beforeCardId: target.beforeCardId ?? undefined
324
+ };
325
+
326
+ const validate = unref(options.canMove);
327
+
328
+ if (validate && !validate(event)) {
329
+ options.onAnnounce('Move not allowed.');
330
+ return;
331
+ }
332
+
333
+ options.onMove(event);
334
+ options.onAnnounce(target.announcement);
335
+ restoreCardFocus(cardId);
336
+ }
337
+
338
+ function restoreCardFocus(cardId: string | number): void {
339
+ requestAnimationFrame(() => {
340
+ const elm = cardElementsById.get(cardId);
341
+
342
+ if (elm instanceof HTMLElement) {
343
+ elm.focus();
344
+ }
62
345
  });
346
+ }
347
+
348
+ function computeWithinColumnTarget(
349
+ cardId: string | number,
350
+ columnId: string | number,
351
+ direction: 'up' | 'down'
352
+ ): { columnId: string | number; beforeCardId: string | number | null; announcement: string } | null {
353
+ const cards = getCardsInColumn(columnId);
354
+ const idx = cards.indexOf(cardId);
355
+
356
+ if (idx === -1) {
357
+ return null;
358
+ }
359
+
360
+ if (direction === 'up') {
361
+ if (idx === 0) {
362
+ return null;
363
+ }
364
+
365
+ return {
366
+ columnId,
367
+ beforeCardId: cards[idx - 1],
368
+ announcement: `Position ${idx} of ${cards.length}.`
369
+ };
370
+ }
371
+
372
+ if (idx === cards.length - 1) {
373
+ return null;
374
+ }
375
+
376
+ return {
377
+ columnId,
378
+ beforeCardId: cards[idx + 2] ?? null,
379
+ announcement: `Position ${idx + 2} of ${cards.length}.`
380
+ };
381
+ }
382
+
383
+ function computeAcrossColumnTarget(
384
+ currentColumnId: string | number,
385
+ direction: 'left' | 'right'
386
+ ): { columnId: string | number; beforeCardId: string | number | null; announcement: string } | null {
387
+ const currentIdx = getColumnIndex(currentColumnId);
388
+
389
+ if (currentIdx === -1) {
390
+ return null;
391
+ }
392
+
393
+ const nextIdx = direction === 'left' ? currentIdx - 1 : currentIdx + 1;
394
+ const nextColumn = getColumnByIndex(nextIdx);
395
+
396
+ if (!nextColumn) {
397
+ return null;
398
+ }
399
+
400
+ const targetCards = getCardsInColumn(nextColumn.columnId);
401
+
402
+ return {
403
+ columnId: nextColumn.columnId,
404
+ beforeCardId: targetCards[0] ?? null,
405
+ announcement: `Moved to column ${String(nextColumn.columnId)}.`
406
+ };
407
+ }
408
+
409
+ function commitKeyboardDrop(): void {
410
+ const state = dragState.value;
411
+
412
+ if (!state || state.mode !== 'keyboard') {
413
+ return;
414
+ }
63
415
 
416
+ const currentColumnId = findCurrentColumnId(state.cardId);
64
417
  dragState.value = null;
418
+
419
+ if (currentColumnId !== null) {
420
+ options.onAnnounce(`Card dropped in ${String(currentColumnId)}.`);
421
+ }
65
422
  }
66
423
 
67
- return {dragState, registerCard, unregisterCard, getCardInfo, startDrag, endDrag, updateDropTarget, clearDropTarget, commitDrop};
424
+ function cancelKeyboardDrop(): void {
425
+ const state = dragState.value;
426
+
427
+ if (!state || state.mode !== 'keyboard') {
428
+ return;
429
+ }
430
+
431
+ const cardId = state.cardId;
432
+ const currentColumnId = findCurrentColumnId(cardId);
433
+ const currentBeforeCardId = currentColumnId !== null
434
+ ? findCurrentBeforeCardId(cardId, currentColumnId)
435
+ : null;
436
+
437
+ const isAtOrigin = currentColumnId === state.fromColumnId
438
+ && currentBeforeCardId === (state.originBeforeCardId ?? null);
439
+
440
+ if (!isAtOrigin && currentColumnId !== null) {
441
+ options.onMove({
442
+ cardId,
443
+ fromColumnId: currentColumnId,
444
+ toColumnId: state.fromColumnId,
445
+ beforeCardId: state.originBeforeCardId ?? undefined
446
+ });
447
+ }
448
+
449
+ dragState.value = null;
450
+ options.onAnnounce('Drop cancelled.');
451
+ }
452
+
453
+ function isCardGrabbed(cardId: string | number): boolean {
454
+ const state = unref(dragState);
455
+ return state !== null && state.mode === 'keyboard' && state.cardId === cardId;
456
+ }
457
+
458
+ /* endregion */
459
+
460
+ /* region columns */
461
+
462
+ function startColumnDrag(columnId: string | number): void {
463
+ if (!unref(options.reorderableColumns) || unref(options.disabled)) {
464
+ return;
465
+ }
466
+
467
+ columnDragState.value = {columnId, dropBeforeColumnId: null};
468
+ }
469
+
470
+ function endColumnDrag(): void {
471
+ stopAutoScroll();
472
+ columnDragState.value = null;
473
+ }
474
+
475
+ function updateColumnDropTarget(beforeColumnId: string | number | null): void {
476
+ if (!columnDragState.value) {
477
+ return;
478
+ }
479
+
480
+ columnDragState.value = {...columnDragState.value, dropBeforeColumnId: beforeColumnId};
481
+ }
482
+
483
+ function commitColumnDrop(): void {
484
+ const state = columnDragState.value;
485
+ stopAutoScroll();
486
+
487
+ if (!state) {
488
+ return;
489
+ }
490
+
491
+ if (state.dropBeforeColumnId === state.columnId) {
492
+ columnDragState.value = null;
493
+ return;
494
+ }
495
+
496
+ const fromIdx = getColumnIndex(state.columnId);
497
+ const beforeIdx = state.dropBeforeColumnId === null
498
+ ? -1
499
+ : getColumnIndex(state.dropBeforeColumnId);
500
+
501
+ if (fromIdx !== -1 && beforeIdx === fromIdx + 1) {
502
+ columnDragState.value = null;
503
+ return;
504
+ }
505
+
506
+ options.onMoveColumn({
507
+ columnId: state.columnId,
508
+ beforeColumnId: state.dropBeforeColumnId ?? undefined
509
+ });
510
+ columnDragState.value = null;
511
+ }
512
+
513
+ /* endregion */
514
+
515
+ /* region auto-scroll */
516
+
517
+ function onPointerMove(clientX: number, clientY: number): void {
518
+ if (!unref(dragState) && !unref(columnDragState)) {
519
+ return;
520
+ }
521
+
522
+ autoScrollX = computeHorizontalScrollDelta(clientX);
523
+ autoScrollY = 0;
524
+ autoScrollVerticalTarget = null;
525
+
526
+ const state = unref(dragState);
527
+
528
+ if (state && state.dropColumnId !== null) {
529
+ const body = columnBodyById.get(state.dropColumnId);
530
+
531
+ if (body) {
532
+ autoScrollY = computeVerticalScrollDelta(body, clientY);
533
+ autoScrollVerticalTarget = body;
534
+ }
535
+ }
536
+
537
+ if (autoScrollX !== 0 || autoScrollY !== 0) {
538
+ startAutoScroll();
539
+ } else {
540
+ stopAutoScroll();
541
+ }
542
+ }
543
+
544
+ function computeHorizontalScrollDelta(clientX: number): number {
545
+ if (!boardElement) {
546
+ return 0;
547
+ }
548
+
549
+ const rect = boardElement.getBoundingClientRect();
550
+
551
+ if (clientX < rect.left + AUTOSCROLL_ZONE) {
552
+ const distance = Math.max(0, clientX - rect.left);
553
+ return -Math.round(((AUTOSCROLL_ZONE - distance) / AUTOSCROLL_ZONE) * AUTOSCROLL_MAX_SPEED);
554
+ }
555
+
556
+ if (clientX > rect.right - AUTOSCROLL_ZONE) {
557
+ const distance = Math.max(0, rect.right - clientX);
558
+ return Math.round(((AUTOSCROLL_ZONE - distance) / AUTOSCROLL_ZONE) * AUTOSCROLL_MAX_SPEED);
559
+ }
560
+
561
+ return 0;
562
+ }
563
+
564
+ function computeVerticalScrollDelta(target: Element, clientY: number): number {
565
+ const rect = target.getBoundingClientRect();
566
+
567
+ if (clientY < rect.top + AUTOSCROLL_ZONE) {
568
+ const distance = Math.max(0, clientY - rect.top);
569
+ return -Math.round(((AUTOSCROLL_ZONE - distance) / AUTOSCROLL_ZONE) * AUTOSCROLL_MAX_SPEED);
570
+ }
571
+
572
+ if (clientY > rect.bottom - AUTOSCROLL_ZONE) {
573
+ const distance = Math.max(0, rect.bottom - clientY);
574
+ return Math.round(((AUTOSCROLL_ZONE - distance) / AUTOSCROLL_ZONE) * AUTOSCROLL_MAX_SPEED);
575
+ }
576
+
577
+ return 0;
578
+ }
579
+
580
+ function startAutoScroll(): void {
581
+ if (autoScrollFrame !== null) {
582
+ return;
583
+ }
584
+
585
+ const tick = () => {
586
+ if (autoScrollX !== 0 && boardElement) {
587
+ boardElement.scrollLeft += autoScrollX;
588
+ }
589
+
590
+ if (autoScrollY !== 0 && autoScrollVerticalTarget) {
591
+ autoScrollVerticalTarget.scrollTop += autoScrollY;
592
+ }
593
+
594
+ if (autoScrollX === 0 && autoScrollY === 0) {
595
+ autoScrollFrame = null;
596
+ return;
597
+ }
598
+
599
+ autoScrollFrame = requestAnimationFrame(tick);
600
+ };
601
+
602
+ autoScrollFrame = requestAnimationFrame(tick);
603
+ }
604
+
605
+ function stopAutoScroll(): void {
606
+ if (autoScrollFrame !== null) {
607
+ cancelAnimationFrame(autoScrollFrame);
608
+ autoScrollFrame = null;
609
+ }
610
+
611
+ autoScrollX = 0;
612
+ autoScrollY = 0;
613
+ autoScrollVerticalTarget = null;
614
+ }
615
+
616
+ /* endregion */
617
+
618
+ return {
619
+ disabled: options.disabled,
620
+ reorderableColumns: options.reorderableColumns,
621
+ dragState,
622
+ columnDragState,
623
+ isDropAllowed,
624
+ registerCard,
625
+ unregisterCard,
626
+ getCardInfo,
627
+ registerColumn,
628
+ unregisterColumn,
629
+ getColumnInfo,
630
+ setBoardElement,
631
+ setColumnBodyElement,
632
+ startDrag,
633
+ endDrag,
634
+ updateDropTarget,
635
+ clearDropTarget,
636
+ commitDrop,
637
+ grabCard,
638
+ moveKeyboard,
639
+ commitKeyboardDrop,
640
+ cancelKeyboardDrop,
641
+ isCardGrabbed,
642
+ startColumnDrag,
643
+ endColumnDrag,
644
+ updateColumnDropTarget,
645
+ commitColumnDrop,
646
+ onPointerMove
647
+ };
68
648
  }