@tuturuuu/ui 0.13.0 → 0.14.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.14.0](https://github.com/tutur3u/platform/compare/ui-v0.13.0...ui-v0.14.0) (2026-07-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * **tasks:** add quick-create targeting and edge autoscroll ([f03e932](https://github.com/tutur3u/platform/commit/f03e9324b0cce18e9f9974cc8fe251bb58b686bd))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **tasks:** scroll kanban board at drag edges ([1053f6c](https://github.com/tutur3u/platform/commit/1053f6cc6db9788b7e184ed8bc7febdb2adacf97))
14
+
3
15
  ## [0.13.0](https://github.com/tutur3u/platform/compare/ui-v0.12.0...ui-v0.13.0) (2026-07-05)
4
16
 
5
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -85,10 +85,10 @@
85
85
  "@tiptap/react": "3.27.1",
86
86
  "@tiptap/starter-kit": "3.27.1",
87
87
  "@tuturuuu/ai": "0.2.2",
88
- "@tuturuuu/apis": "0.7.1",
88
+ "@tuturuuu/apis": "0.7.2",
89
89
  "@tuturuuu/hooks": "0.0.2",
90
90
  "@tuturuuu/icons": "0.0.6",
91
- "@tuturuuu/internal-api": "0.14.1",
91
+ "@tuturuuu/internal-api": "0.15.0",
92
92
  "@tuturuuu/supabase": "0.4.0",
93
93
  "@tuturuuu/utils": "0.13.0",
94
94
  "@types/debug": "^4.1.13",
@@ -7,6 +7,7 @@ import {
7
7
  TASK_NAVIGATION_LEADERBOARDS_CONFIG_ID,
8
8
  TASK_NAVIGATION_PROGRESS_CONFIG_ID,
9
9
  TASK_NAVIGATION_STATS_CONFIG_ID,
10
+ TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID,
10
11
  } from '@tuturuuu/internal-api/users';
11
12
  import type { Workspace } from '@tuturuuu/types';
12
13
  import { SettingItemTab } from '@tuturuuu/ui/custom/settings-item-tab';
@@ -34,6 +35,11 @@ import {
34
35
  type TaskDialogPresentation,
35
36
  } from '../../tu-do/shared/task-dialog-presentation';
36
37
  import { TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID } from '../../tu-do/shared/task-due-date-visibility';
38
+ import {
39
+ DEFAULT_TASK_QUICK_CREATE_TARGET_LIST,
40
+ normalizeTaskQuickCreateTargetList,
41
+ type TaskQuickCreateTargetList,
42
+ } from '../../tu-do/shared/task-quick-create-target-list';
37
43
  import {
38
44
  clampTaskSoundEffectsVolume,
39
45
  DEFAULT_TASK_SOUND_EFFECTS_VOLUME,
@@ -142,6 +148,14 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
142
148
  const { data: dialogPresentationRaw, isLoading: dialogPresentationLoading } =
143
149
  useUserConfig(TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID, 'compact');
144
150
  const updateDialogPresentation = useUpdateUserConfig();
151
+ const {
152
+ data: quickCreateTargetListRaw,
153
+ isLoading: quickCreateTargetListLoading,
154
+ } = useUserConfig(
155
+ TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID,
156
+ DEFAULT_TASK_QUICK_CREATE_TARGET_LIST
157
+ );
158
+ const updateQuickCreateTargetList = useUpdateUserConfig();
145
159
 
146
160
  const { data: settings, isLoading } = useQuery({
147
161
  queryKey: ['user-task-settings'],
@@ -211,6 +225,9 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
211
225
  const dialogPresentation = normalizeTaskDialogPresentation(
212
226
  dialogPresentationRaw
213
227
  );
228
+ const quickCreateTargetList = normalizeTaskQuickCreateTargetList(
229
+ quickCreateTargetListRaw
230
+ );
214
231
 
215
232
  const handleDialogPresentationChange = (value: string) => {
216
233
  const nextValue: TaskDialogPresentation = normalizeTaskDialogPresentation(
@@ -223,6 +240,15 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
223
240
  });
224
241
  };
225
242
 
243
+ const handleQuickCreateTargetListChange = (value: string) => {
244
+ const nextValue: TaskQuickCreateTargetList =
245
+ normalizeTaskQuickCreateTargetList(value);
246
+ updateQuickCreateTargetList.mutate({
247
+ configId: TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID,
248
+ value: nextValue,
249
+ });
250
+ };
251
+
226
252
  return (
227
253
  <div className="space-y-8">
228
254
  <div className="grid gap-6">
@@ -330,6 +356,35 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
330
356
  </Select>
331
357
  </SettingItemTab>
332
358
  <Separator />
359
+ <SettingItemTab
360
+ title={t('quick_create_target_list')}
361
+ description={t('quick_create_target_list_description')}
362
+ >
363
+ <Select
364
+ value={quickCreateTargetList}
365
+ onValueChange={handleQuickCreateTargetListChange}
366
+ disabled={
367
+ quickCreateTargetListLoading ||
368
+ updateQuickCreateTargetList.isPending
369
+ }
370
+ >
371
+ <SelectTrigger
372
+ aria-label={t('quick_create_target_list')}
373
+ className="w-44"
374
+ >
375
+ <SelectValue />
376
+ </SelectTrigger>
377
+ <SelectContent>
378
+ <SelectItem value="default_list">
379
+ {t('quick_create_target_list_default')}
380
+ </SelectItem>
381
+ <SelectItem value="hovered_list">
382
+ {t('quick_create_target_list_hovered')}
383
+ </SelectItem>
384
+ </SelectContent>
385
+ </Select>
386
+ </SettingItemTab>
387
+ <Separator />
333
388
  <SettingItemTab
334
389
  title={t('draft_mode')}
335
390
  description={t('draft_mode_description')}
@@ -182,6 +182,7 @@ interface BoardColumnProps {
182
182
  specialPinned?: boolean;
183
183
  specialStickyOffset?: string;
184
184
  onSpecialPinnedChange?: (pinned: boolean) => void;
185
+ onHoverTaskList?: (listId: string) => void;
185
186
  readOnly?: boolean;
186
187
  }
187
188
 
@@ -214,6 +215,7 @@ export function BoardColumn({
214
215
  specialPinned = false,
215
216
  specialStickyOffset,
216
217
  onSpecialPinnedChange,
218
+ onHoverTaskList,
217
219
  readOnly = false,
218
220
  }: BoardColumnProps) {
219
221
  const t = useTranslations('common');
@@ -530,6 +532,7 @@ export function BoardColumn({
530
532
  data-kanban-pinned-special={specialStickyOffset ? 'true' : undefined}
531
533
  data-kanban-column-id={column.id}
532
534
  data-kanban-real-column={isExternalStaging ? undefined : 'true'}
535
+ onPointerEnter={() => onHoverTaskList?.(column.id)}
533
536
  className={cn(
534
537
  'group flex h-full w-14 shrink-0 snap-start flex-col items-center rounded-xl border border-dashed transition-all duration-200',
535
538
  'touch-none select-none overflow-hidden hover:shadow-md',
@@ -583,6 +586,7 @@ export function BoardColumn({
583
586
  data-kanban-pinned-special={specialStickyOffset ? 'true' : undefined}
584
587
  data-kanban-column-id={column.id}
585
588
  data-kanban-real-column={isExternalStaging ? undefined : 'true'}
589
+ onPointerEnter={() => onHoverTaskList?.(column.id)}
586
590
  className={cn(
587
591
  'group flex h-full w-[var(--kanban-column-width)] shrink-0 snap-start flex-col rounded-xl transition-all duration-200 last:snap-end',
588
592
  'touch-none select-none',
@@ -0,0 +1,118 @@
1
+ import type { DragMoveEvent } from '@dnd-kit/core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ getKanbanDragAutoScrollPointerX,
5
+ getKanbanEdgeAutoScrollAmount,
6
+ } from './auto-scroll';
7
+
8
+ const rect = {
9
+ left: 100,
10
+ right: 500,
11
+ };
12
+
13
+ function createDragMoveEvent({
14
+ deltaX = 0,
15
+ initialLeft = 200,
16
+ pointerStartX,
17
+ translatedLeft = initialLeft,
18
+ width = 80,
19
+ }: {
20
+ deltaX?: number;
21
+ initialLeft?: number;
22
+ pointerStartX?: number;
23
+ translatedLeft?: number | null;
24
+ width?: number;
25
+ }) {
26
+ return {
27
+ active: {
28
+ rect: {
29
+ current: {
30
+ initial: {
31
+ left: initialLeft,
32
+ width,
33
+ },
34
+ translated:
35
+ translatedLeft === null
36
+ ? null
37
+ : {
38
+ left: translatedLeft,
39
+ width,
40
+ },
41
+ },
42
+ },
43
+ },
44
+ activatorEvent:
45
+ pointerStartX === undefined
46
+ ? ({ type: 'keydown' } as Event)
47
+ : ({ clientX: pointerStartX } as unknown as Event),
48
+ delta: {
49
+ x: deltaX,
50
+ y: 0,
51
+ },
52
+ } as unknown as DragMoveEvent;
53
+ }
54
+
55
+ describe('getKanbanEdgeAutoScrollAmount', () => {
56
+ it('scrolls left when the drag center is near the left edge', () => {
57
+ expect(
58
+ getKanbanEdgeAutoScrollAmount(125, rect, {
59
+ threshold: 100,
60
+ speed: 10,
61
+ maxSpeed: 30,
62
+ })
63
+ ).toBeLessThan(0);
64
+ });
65
+
66
+ it('scrolls right when the drag center is near the right edge', () => {
67
+ expect(
68
+ getKanbanEdgeAutoScrollAmount(475, rect, {
69
+ threshold: 100,
70
+ speed: 10,
71
+ maxSpeed: 30,
72
+ })
73
+ ).toBeGreaterThan(0);
74
+ });
75
+
76
+ it('does nothing away from horizontal edges or without a drag center', () => {
77
+ expect(getKanbanEdgeAutoScrollAmount(300, rect)).toBe(0);
78
+ expect(getKanbanEdgeAutoScrollAmount(null, rect)).toBe(0);
79
+ });
80
+
81
+ it('uses pointer position for edge auto-scroll instead of card center', () => {
82
+ const pointerX = getKanbanDragAutoScrollPointerX(
83
+ createDragMoveEvent({
84
+ deltaX: 215,
85
+ pointerStartX: 260,
86
+ translatedLeft: 190,
87
+ width: 80,
88
+ })
89
+ );
90
+
91
+ expect(pointerX).toBe(475);
92
+ expect(
93
+ getKanbanEdgeAutoScrollAmount(pointerX, rect, {
94
+ threshold: 100,
95
+ speed: 10,
96
+ maxSpeed: 30,
97
+ })
98
+ ).toBeGreaterThan(0);
99
+ expect(
100
+ getKanbanEdgeAutoScrollAmount(230, rect, {
101
+ threshold: 100,
102
+ speed: 10,
103
+ maxSpeed: 30,
104
+ })
105
+ ).toBe(0);
106
+ });
107
+
108
+ it('falls back to the active card center without pointer coordinates', () => {
109
+ expect(
110
+ getKanbanDragAutoScrollPointerX(
111
+ createDragMoveEvent({
112
+ translatedLeft: 370,
113
+ width: 100,
114
+ })
115
+ )
116
+ ).toBe(420);
117
+ });
118
+ });
@@ -1,86 +1,160 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef } from 'react';
3
+ import type {
4
+ DragMoveEvent,
5
+ DragOverEvent,
6
+ DragStartEvent,
7
+ } from '@dnd-kit/core';
8
+ import { useCallback, useEffect, useRef } from 'react';
9
+
10
+ export const KANBAN_EDGE_AUTO_SCROLL_THRESHOLD = 100;
11
+ const KANBAN_EDGE_AUTO_SCROLL_SPEED = 10;
12
+ const KANBAN_EDGE_AUTO_SCROLL_MAX_SPEED = 30;
13
+
14
+ type KanbanDragAutoScrollEvent = DragMoveEvent | DragOverEvent | DragStartEvent;
15
+
16
+ interface HorizontalRect {
17
+ left: number;
18
+ right: number;
19
+ }
20
+
21
+ function getTouchListClientX(touches: unknown) {
22
+ if (!touches || typeof touches !== 'object') return null;
23
+
24
+ const list = touches as ArrayLike<{ clientX?: unknown }> & {
25
+ item?: (index: number) => { clientX?: unknown } | null;
26
+ };
27
+ const touch = list.item?.(0) ?? list[0];
28
+
29
+ return typeof touch?.clientX === 'number' ? touch.clientX : null;
30
+ }
31
+
32
+ function getPointerEventClientX(event: Event) {
33
+ if ('clientX' in event && typeof event.clientX === 'number') {
34
+ return event.clientX;
35
+ }
36
+
37
+ if ('touches' in event) {
38
+ const touchX = getTouchListClientX(event.touches);
39
+ if (touchX !== null) return touchX;
40
+ }
41
+
42
+ if ('changedTouches' in event) {
43
+ return getTouchListClientX(event.changedTouches);
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ function getDragEventDeltaX(event: KanbanDragAutoScrollEvent) {
50
+ return 'delta' in event && typeof event.delta?.x === 'number'
51
+ ? event.delta.x
52
+ : 0;
53
+ }
54
+
55
+ function getDragActiveCenterX(event: KanbanDragAutoScrollEvent) {
56
+ const activeRect =
57
+ event.active.rect.current.translated ?? event.active.rect.current.initial;
58
+
59
+ return activeRect ? activeRect.left + activeRect.width / 2 : null;
60
+ }
61
+
62
+ export function getKanbanDragAutoScrollPointerX(
63
+ event: KanbanDragAutoScrollEvent
64
+ ) {
65
+ const pointerStartX = getPointerEventClientX(event.activatorEvent);
66
+
67
+ if (pointerStartX !== null) {
68
+ return pointerStartX + getDragEventDeltaX(event);
69
+ }
70
+
71
+ return getDragActiveCenterX(event);
72
+ }
73
+
74
+ export function getKanbanEdgeAutoScrollAmount(
75
+ pointerX: number | null,
76
+ rect: HorizontalRect,
77
+ options: {
78
+ maxSpeed?: number;
79
+ speed?: number;
80
+ threshold?: number;
81
+ } = {}
82
+ ) {
83
+ if (pointerX === null) return 0;
84
+
85
+ const threshold = options.threshold ?? KANBAN_EDGE_AUTO_SCROLL_THRESHOLD;
86
+ const speed = options.speed ?? KANBAN_EDGE_AUTO_SCROLL_SPEED;
87
+ const maxSpeed = options.maxSpeed ?? KANBAN_EDGE_AUTO_SCROLL_MAX_SPEED;
88
+
89
+ if (pointerX <= rect.left + threshold) {
90
+ const distanceFromLeft = Math.max(0, pointerX - rect.left);
91
+ const intensity = 1 - distanceFromLeft / threshold;
92
+ return -Math.min(speed + intensity * (maxSpeed - speed), maxSpeed);
93
+ }
94
+
95
+ if (pointerX >= rect.right - threshold) {
96
+ const distanceFromRight = Math.max(0, rect.right - pointerX);
97
+ const intensity = 1 - distanceFromRight / threshold;
98
+ return Math.min(speed + intensity * (maxSpeed - speed), maxSpeed);
99
+ }
100
+
101
+ return 0;
102
+ }
4
103
 
5
104
  export function useAutoScroll(
6
- isDraggingRef: React.MutableRefObject<boolean>,
7
105
  scrollContainerRef: React.RefObject<HTMLDivElement | null>
8
106
  ) {
9
107
  const autoScrollRafRef = useRef<number | null>(null);
108
+ const isAutoScrollActiveRef = useRef(false);
109
+ const pointerXRef = useRef<number | null>(null);
10
110
 
11
- useEffect(() => {
12
- const EDGE_THRESHOLD = 100; // Distance from edge to trigger scroll (px)
13
- const SCROLL_SPEED = 10; // Base scroll speed (px per frame)
14
- const MAX_SCROLL_SPEED = 30; // Maximum scroll speed (px per frame)
111
+ const stopAutoScroll = useCallback(() => {
112
+ isAutoScrollActiveRef.current = false;
113
+ pointerXRef.current = null;
15
114
 
16
- let currentPointerX = 0;
115
+ if (autoScrollRafRef.current !== null) {
116
+ cancelAnimationFrame(autoScrollRafRef.current);
117
+ autoScrollRafRef.current = null;
118
+ }
119
+ }, []);
120
+
121
+ const autoScroll = useCallback(() => {
122
+ if (!isAutoScrollActiveRef.current || !scrollContainerRef.current) {
123
+ autoScrollRafRef.current = null;
124
+ return;
125
+ }
126
+
127
+ const container = scrollContainerRef.current;
128
+ const scrollAmount = getKanbanEdgeAutoScrollAmount(
129
+ pointerXRef.current,
130
+ container.getBoundingClientRect()
131
+ );
17
132
 
18
- function handlePointerMove(event: PointerEvent) {
19
- currentPointerX = event.clientX;
133
+ if (scrollAmount !== 0) {
134
+ container.scrollLeft += scrollAmount;
20
135
  }
21
136
 
22
- function autoScroll() {
23
- if (!isDraggingRef.current || !scrollContainerRef.current) {
24
- autoScrollRafRef.current = null;
25
- return;
26
- }
27
-
28
- const container = scrollContainerRef.current;
29
- const rect = container.getBoundingClientRect();
30
-
31
- // Calculate distance from edges
32
- const distanceFromLeft = currentPointerX - rect.left;
33
- const distanceFromRight = rect.right - currentPointerX;
34
-
35
- let scrollAmount = 0;
36
-
37
- // Scroll left when near left edge
38
- if (distanceFromLeft < EDGE_THRESHOLD && distanceFromLeft > 0) {
39
- const intensity = 1 - distanceFromLeft / EDGE_THRESHOLD;
40
- scrollAmount = -Math.min(
41
- SCROLL_SPEED + intensity * (MAX_SCROLL_SPEED - SCROLL_SPEED),
42
- MAX_SCROLL_SPEED
43
- );
44
- }
45
- // Scroll right when near right edge
46
- else if (distanceFromRight < EDGE_THRESHOLD && distanceFromRight > 0) {
47
- const intensity = 1 - distanceFromRight / EDGE_THRESHOLD;
48
- scrollAmount = Math.min(
49
- SCROLL_SPEED + intensity * (MAX_SCROLL_SPEED - SCROLL_SPEED),
50
- MAX_SCROLL_SPEED
51
- );
52
- }
53
-
54
- // Apply scroll if needed
55
- if (scrollAmount !== 0) {
56
- container.scrollLeft += scrollAmount;
57
- }
58
-
59
- // Continue the animation loop
137
+ autoScrollRafRef.current = requestAnimationFrame(autoScroll);
138
+ }, [scrollContainerRef]);
139
+
140
+ const startAutoScroll = useCallback(() => {
141
+ isAutoScrollActiveRef.current = true;
142
+
143
+ if (autoScrollRafRef.current === null) {
60
144
  autoScrollRafRef.current = requestAnimationFrame(autoScroll);
61
145
  }
146
+ }, [autoScroll]);
147
+
148
+ const updateAutoScrollPointerX = useCallback((pointerX: number | null) => {
149
+ pointerXRef.current = pointerX;
150
+ }, []);
151
+
152
+ useEffect(() => stopAutoScroll, [stopAutoScroll]);
62
153
 
63
- // Start auto-scroll loop when dragging starts (triggered by isDraggingRef)
64
- window.addEventListener('pointermove', handlePointerMove);
65
-
66
- // Watch for drag start to initialize auto-scroll loop
67
- const startAutoScrollLoop = () => {
68
- if (isDraggingRef.current && !autoScrollRafRef.current) {
69
- autoScrollRafRef.current = requestAnimationFrame(autoScroll);
70
- }
71
- };
72
-
73
- // Poll for drag start (alternative: trigger from onDragStart)
74
- const pollInterval = setInterval(startAutoScrollLoop, 100);
75
-
76
- return () => {
77
- window.removeEventListener('pointermove', handlePointerMove);
78
- clearInterval(pollInterval);
79
- if (autoScrollRafRef.current) {
80
- cancelAnimationFrame(autoScrollRafRef.current);
81
- }
82
- };
83
- }, [isDraggingRef, scrollContainerRef]);
84
-
85
- return { autoScrollRafRef };
154
+ return {
155
+ autoScrollRafRef,
156
+ startAutoScroll,
157
+ stopAutoScroll,
158
+ updateAutoScrollPointerX,
159
+ };
86
160
  }
@@ -25,7 +25,7 @@ import { isPersonalExternalOverlayTask } from '../../../../../../../lib/task-per
25
25
  import { useBoardBroadcast } from '../../../../shared/board-broadcast-context';
26
26
  import { invalidateKanbanDeadlineTasks } from '../data/kanban-deadline-query';
27
27
  import { MAX_SAFE_INTEGER_SORT } from '../kanban-constants';
28
- import { useAutoScroll } from './auto-scroll';
28
+ import { getKanbanDragAutoScrollPointerX, useAutoScroll } from './auto-scroll';
29
29
  import { getColumnReorderUpdates } from './column-reorder';
30
30
  import { calculateSortKeyWithRetry as createCalculateSortKeyWithRetry } from './kanban-sort-helpers';
31
31
  import {
@@ -566,8 +566,15 @@ export function useKanbanDnd({
566
566
  [wsId]
567
567
  );
568
568
 
569
- // Initialize auto-scroll
570
- useAutoScroll(isDraggingRef, scrollContainerRef);
569
+ const { startAutoScroll, stopAutoScroll, updateAutoScrollPointerX } =
570
+ useAutoScroll(scrollContainerRef);
571
+
572
+ const updateAutoScrollFromDragEvent = useCallback(
573
+ (event: DragMoveEvent | DragOverEvent | DragStartEvent) => {
574
+ updateAutoScrollPointerX(getKanbanDragAutoScrollPointerX(event));
575
+ },
576
+ [updateAutoScrollPointerX]
577
+ );
571
578
 
572
579
  const resetDragState = useCallback(
573
580
  (clearOptimisticUpdates = false) => {
@@ -582,12 +589,13 @@ export function useKanbanDnd({
582
589
  dragSessionMetricsRef.current = null;
583
590
  dragStartTaskIndexesByListRef.current.clear();
584
591
  isDraggingRef.current = false;
592
+ stopAutoScroll();
585
593
 
586
594
  if (clearOptimisticUpdates) {
587
595
  setOptimisticUpdateInProgress(new Set());
588
596
  }
589
597
  },
590
- [setDragPreviewPosition]
598
+ [setDragPreviewPosition, stopAutoScroll]
591
599
  );
592
600
 
593
601
  const markTaskIdsPending = useCallback((taskIds: string[]) => {
@@ -606,6 +614,8 @@ export function useKanbanDnd({
606
614
 
607
615
  const processTaskDragPreview = useCallback(
608
616
  (event: DragMoveEvent) => {
617
+ updateAutoScrollFromDragEvent(event);
618
+
609
619
  const { active, over } = event;
610
620
  const activeType = active.data?.current?.type;
611
621
  if (!activeType) return;
@@ -730,6 +740,7 @@ export function useKanbanDnd({
730
740
  getDragPreviewForList,
731
741
  getDragPreviewForListSurface,
732
742
  setDragPreviewPosition,
743
+ updateAutoScrollFromDragEvent,
733
744
  wsId,
734
745
  ]
735
746
  );
@@ -742,6 +753,8 @@ export function useKanbanDnd({
742
753
 
743
754
  // Enable auto-scroll
744
755
  isDraggingRef.current = true;
756
+ updateAutoScrollFromDragEvent(event);
757
+ startAutoScroll();
745
758
 
746
759
  const { type } = active.data.current;
747
760
  if (type === 'Column') {
@@ -750,6 +763,7 @@ export function useKanbanDnd({
750
763
  }
751
764
  if (type === 'Task') {
752
765
  if (!wsId) {
766
+ resetDragState(true);
753
767
  return;
754
768
  }
755
769
 
@@ -79,6 +79,7 @@ interface KanbanColumnsProps {
79
79
  pin: SpecialTaskListPin,
80
80
  pinned: boolean
81
81
  ) => void;
82
+ onHoveredTaskListChange?: (listId: string | null) => void;
82
83
  readOnly?: boolean;
83
84
  }
84
85
 
@@ -156,6 +157,7 @@ export function KanbanColumns({
156
157
  onDeadlineSectionCollapsedChange,
157
158
  specialTaskListPins,
158
159
  onSpecialTaskListPinnedChange,
160
+ onHoveredTaskListChange,
159
161
  readOnly = false,
160
162
  }: KanbanColumnsProps) {
161
163
  const initialScrollAnchoredBoardRef = useRef<string | null>(null);
@@ -270,6 +272,7 @@ export function KanbanColumns({
270
272
  return (
271
273
  <div
272
274
  ref={boardRef}
275
+ onPointerLeave={() => onHoveredTaskListChange?.(null)}
273
276
  className="scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent relative flex h-full w-full snap-x snap-mandatory gap-3 overflow-x-auto overscroll-x-contain scroll-smooth"
274
277
  style={
275
278
  {
@@ -416,6 +419,9 @@ export function KanbanColumns({
416
419
  onSpecialTaskListPinnedChange?.('closed_tasks', pinned);
417
420
  }
418
421
  }}
422
+ onHoverTaskList={
423
+ list.is_external_staging ? undefined : onHoveredTaskListChange
424
+ }
419
425
  readOnly={readOnly}
420
426
  />
421
427
  );
@@ -104,6 +104,7 @@ interface Props {
104
104
  onBulkSelectionActiveChange?: (active: boolean) => void;
105
105
  canUseBoardAssignees?: boolean;
106
106
  assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
107
+ onHoveredTaskListChange?: (listId: string | null) => void;
107
108
  readOnly?: boolean;
108
109
  }
109
110
 
@@ -129,6 +130,7 @@ export function KanbanBoard({
129
130
  onBulkSelectionActiveChange,
130
131
  canUseBoardAssignees,
131
132
  assigneeMemberSource,
133
+ onHoveredTaskListChange,
132
134
  readOnly = false,
133
135
  }: Props) {
134
136
  const tCommon = useTranslations('common');
@@ -150,7 +152,6 @@ export function KanbanBoard({
150
152
  const [assigneeSearchQuery, setAssigneeSearchQuery] = useState('');
151
153
 
152
154
  // Refs
153
- const scrollContainerRef = useRef<HTMLDivElement>(null);
154
155
  const taskHeightsRef = useRef<Map<string, number>>(new Map());
155
156
  const boardRef = useRef<HTMLDivElement>(null);
156
157
 
@@ -412,7 +413,7 @@ export function KanbanBoard({
412
413
  ),
413
414
  reorderTaskMutation,
414
415
  taskHeightsRef,
415
- scrollContainerRef,
416
+ scrollContainerRef: boardRef,
416
417
  });
417
418
 
418
419
  const sensors = useSensors(
@@ -565,6 +566,7 @@ export function KanbanBoard({
565
566
  onTaskListCollapsedChange={onTaskListCollapsedChange}
566
567
  specialTaskListPins={specialTaskListPins}
567
568
  onSpecialTaskListPinnedChange={onSpecialTaskListPinnedChange}
569
+ onHoveredTaskListChange={onHoveredTaskListChange}
568
570
  readOnly={readOnly}
569
571
  />
570
572
 
@@ -17,7 +17,9 @@ import { getBoardConfigKey } from '../board-config-storage';
17
17
  import { BoardViews } from '../board-views';
18
18
 
19
19
  const listWorkspaceTasksMock = vi.hoisted(() => vi.fn());
20
+ const getUserConfigMock = vi.hoisted(() => vi.fn());
20
21
  const getUserWorkspaceConfigMock = vi.hoisted(() => vi.fn());
22
+ const updateUserConfigMock = vi.hoisted(() => vi.fn());
21
23
  const updateUserWorkspaceConfigMock = vi.hoisted(() => vi.fn());
22
24
  const createTaskMock = vi.fn();
23
25
  const loadListPageMock = vi.fn();
@@ -64,8 +66,11 @@ vi.mock('@tuturuuu/internal-api/tasks', () => ({
64
66
  vi.mock('@tuturuuu/internal-api/users', () => ({
65
67
  TASK_BOARD_PINNED_SPECIAL_LISTS_CONFIG_ID: 'TASK_BOARD_PINNED_SPECIAL_LISTS',
66
68
  TASK_LAST_BOARD_VIEW_CONFIG_ID: 'TASK_LAST_BOARD_VIEW',
69
+ TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID: 'TASK_QUICK_CREATE_TARGET_LIST',
70
+ getUserConfig: (...args: unknown[]) => getUserConfigMock(...args),
67
71
  getUserWorkspaceConfig: (...args: unknown[]) =>
68
72
  getUserWorkspaceConfigMock(...args),
73
+ updateUserConfig: (...args: unknown[]) => updateUserConfigMock(...args),
69
74
  updateUserWorkspaceConfig: (...args: unknown[]) =>
70
75
  updateUserWorkspaceConfigMock(...args),
71
76
  }));
@@ -260,8 +265,12 @@ describe('BoardViews', () => {
260
265
  progressivePagination = {};
261
266
  listWorkspaceTasksMock.mockReset();
262
267
  listWorkspaceTasksMock.mockResolvedValue({ tasks: mockTasks });
268
+ getUserConfigMock.mockReset();
269
+ getUserConfigMock.mockResolvedValue({ value: null });
263
270
  getUserWorkspaceConfigMock.mockReset();
264
271
  getUserWorkspaceConfigMock.mockResolvedValue({ value: null });
272
+ updateUserConfigMock.mockReset();
273
+ updateUserConfigMock.mockResolvedValue({ message: 'ok' });
265
274
  updateUserWorkspaceConfigMock.mockReset();
266
275
  updateUserWorkspaceConfigMock.mockResolvedValue({ message: 'ok' });
267
276
  window.localStorage.clear();
@@ -312,6 +321,7 @@ describe('BoardViews', () => {
312
321
  expect(boardHeaderProps?.titlePrefix).toBeDefined();
313
322
  expect(kanbanBoardProps?.readOnly).toBe(true);
314
323
  expect(listWorkspaceTasksMock).not.toHaveBeenCalled();
324
+ expect(getUserConfigMock).not.toHaveBeenCalled();
315
325
  });
316
326
 
317
327
  it('enables assignees for personal boards that have guest access', async () => {
@@ -544,6 +554,66 @@ describe('BoardViews', () => {
544
554
  );
545
555
  });
546
556
 
557
+ it('creates a task in the hovered list when the quick-create target prefers hovered lists', async () => {
558
+ getUserConfigMock.mockResolvedValue({ value: 'hovered_list' });
559
+ const { queryClient } = renderBoardViews({
560
+ board: { ...mockBoard, default_list_id: 'list-1' },
561
+ });
562
+
563
+ await waitFor(() => {
564
+ expect(
565
+ queryClient.getQueryData([
566
+ 'user-config',
567
+ 'TASK_QUICK_CREATE_TARGET_LIST',
568
+ ])
569
+ ).toBe('hovered_list');
570
+ });
571
+
572
+ await act(async () => {
573
+ kanbanBoardProps?.onHoveredTaskListChange?.('list-2');
574
+ });
575
+
576
+ fireEvent.keyDown(document, { key: 'c' });
577
+
578
+ expect(createTaskMock).toHaveBeenCalledTimes(1);
579
+ expect(createTaskMock).toHaveBeenCalledWith(
580
+ 'board-1',
581
+ 'list-2',
582
+ mockLists,
583
+ expect.objectContaining({ labels: [] })
584
+ );
585
+ });
586
+
587
+ it('falls back to the board default list in hovered-list mode when no hovered list is selectable', async () => {
588
+ getUserConfigMock.mockResolvedValue({ value: 'hovered_list' });
589
+ const { queryClient } = renderBoardViews({
590
+ board: { ...mockBoard, default_list_id: 'list-2' },
591
+ });
592
+
593
+ await waitFor(() => {
594
+ expect(
595
+ queryClient.getQueryData([
596
+ 'user-config',
597
+ 'TASK_QUICK_CREATE_TARGET_LIST',
598
+ ])
599
+ ).toBe('hovered_list');
600
+ });
601
+
602
+ await act(async () => {
603
+ kanbanBoardProps?.onHoveredTaskListChange?.('external-list');
604
+ });
605
+
606
+ fireEvent.keyDown(document, { key: 'c' });
607
+
608
+ expect(createTaskMock).toHaveBeenCalledTimes(1);
609
+ expect(createTaskMock).toHaveBeenCalledWith(
610
+ 'board-1',
611
+ 'list-2',
612
+ mockLists,
613
+ expect.objectContaining({ labels: [] })
614
+ );
615
+ });
616
+
547
617
  it('falls back to the first list when the default list is unavailable', () => {
548
618
  renderBoardViews({
549
619
  board: { ...mockBoard, default_list_id: 'list-does-not-exist' },
@@ -13,6 +13,7 @@ import {
13
13
  import {
14
14
  TASK_BOARD_PINNED_SPECIAL_LISTS_CONFIG_ID,
15
15
  TASK_LAST_BOARD_VIEW_CONFIG_ID,
16
+ TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID,
16
17
  } from '@tuturuuu/internal-api/users';
17
18
  import type {
18
19
  Workspace,
@@ -35,6 +36,7 @@ import {
35
36
  useMemo,
36
37
  useState,
37
38
  } from 'react';
39
+ import { useUserConfig } from '../../../../hooks/use-user-config';
38
40
  import {
39
41
  useUpdateUserWorkspaceConfig,
40
42
  useUserWorkspaceConfig,
@@ -58,6 +60,10 @@ import {
58
60
  type SpecialTaskListPin,
59
61
  serializeSpecialTaskListPins,
60
62
  } from './special-task-list-pins';
63
+ import {
64
+ DEFAULT_TASK_QUICK_CREATE_TARGET_LIST,
65
+ normalizeTaskQuickCreateTargetList,
66
+ } from './task-quick-create-target-list';
61
67
 
62
68
  export type ViewType =
63
69
  | 'kanban'
@@ -290,8 +296,19 @@ export function BoardViews({
290
296
  const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
291
297
  const [kanbanBulkSelectionActive, setKanbanBulkSelectionActive] =
292
298
  useState(false);
299
+ const [hoveredTaskListId, setHoveredTaskListId] = useState<string | null>(
300
+ null
301
+ );
293
302
  const { createTask } = useTaskDialog();
294
303
  const localTaskState = readOnly || publicView;
304
+ const { data: quickCreateTargetListRaw } = useUserConfig(
305
+ TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID,
306
+ DEFAULT_TASK_QUICK_CREATE_TARGET_LIST,
307
+ { enabled: !localTaskState }
308
+ );
309
+ const quickCreateTargetList = normalizeTaskQuickCreateTargetList(
310
+ quickCreateTargetListRaw
311
+ );
295
312
  const boardAssigneesEnabled =
296
313
  !workspace.personal ||
297
314
  board.access_type === 'guest' ||
@@ -820,6 +837,15 @@ export function BoardViews({
820
837
  statusFilteredLists,
821
838
  ]);
822
839
 
840
+ useEffect(() => {
841
+ if (!hoveredTaskListId) return;
842
+
843
+ const stillSelectable = filteredLists.some(
844
+ (list) => list.id === hoveredTaskListId && !list.is_external_staging
845
+ );
846
+ if (!stillSelectable) setHoveredTaskListId(null);
847
+ }, [filteredLists, hoveredTaskListId]);
848
+
823
849
  const sourceTasks = useMemo(() => {
824
850
  if (localTaskState) return locallyFilteredTasks;
825
851
 
@@ -902,11 +928,14 @@ export function BoardViews({
902
928
  const selectableLists = filteredLists.filter(
903
929
  (list) => !list.is_external_staging
904
930
  );
905
- // Prefer the board's configured default list for new tasks, falling back
906
- // to the first selectable list when unset or the list is unavailable.
907
- const targetList =
931
+ const defaultTargetList =
908
932
  selectableLists.find((list) => list.id === board.default_list_id) ??
909
933
  selectableLists[0];
934
+ const hoveredTargetList =
935
+ quickCreateTargetList === 'hovered_list'
936
+ ? selectableLists.find((list) => list.id === hoveredTaskListId)
937
+ : undefined;
938
+ const targetList = hoveredTargetList ?? defaultTargetList;
910
939
  if (!targetList) return;
911
940
  createTask(board.id, targetList.id, selectableLists, filters);
912
941
  },
@@ -1022,6 +1051,7 @@ export function BoardViews({
1022
1051
  onBulkSelectionActiveChange={setKanbanBulkSelectionActive}
1023
1052
  canUseBoardAssignees={boardAssigneesEnabled}
1024
1053
  assigneeMemberSource={assigneeMemberSource}
1054
+ onHoveredTaskListChange={setHoveredTaskListId}
1025
1055
  readOnly={readOnly}
1026
1056
  />
1027
1057
  );
@@ -0,0 +1,20 @@
1
+ export const DEFAULT_TASK_QUICK_CREATE_TARGET_LIST = 'default_list';
2
+
3
+ export const TASK_QUICK_CREATE_TARGET_LIST_VALUES = [
4
+ DEFAULT_TASK_QUICK_CREATE_TARGET_LIST,
5
+ 'hovered_list',
6
+ ] as const;
7
+
8
+ export type TaskQuickCreateTargetList =
9
+ (typeof TASK_QUICK_CREATE_TARGET_LIST_VALUES)[number];
10
+
11
+ export function normalizeTaskQuickCreateTargetList(
12
+ value: string | null | undefined,
13
+ fallback: TaskQuickCreateTargetList = DEFAULT_TASK_QUICK_CREATE_TARGET_LIST
14
+ ): TaskQuickCreateTargetList {
15
+ return TASK_QUICK_CREATE_TARGET_LIST_VALUES.includes(
16
+ value as TaskQuickCreateTargetList
17
+ )
18
+ ? (value as TaskQuickCreateTargetList)
19
+ : fallback;
20
+ }
@@ -11,14 +11,22 @@ import { getUserConfig, updateUserConfig } from '@tuturuuu/internal-api/users';
11
11
  * @param defaultValue - Default value if config doesn't exist
12
12
  * @returns Query result with the config value
13
13
  */
14
- export function useUserConfig(configId: string, defaultValue: string = '') {
14
+ export function useUserConfig(
15
+ configId: string,
16
+ defaultValue: string = '',
17
+ options?: {
18
+ enabled?: boolean;
19
+ staleTime?: number;
20
+ }
21
+ ) {
15
22
  return useQuery({
16
23
  queryKey: ['user-config', configId],
17
24
  queryFn: async () => {
18
25
  const data = await getUserConfig(configId);
19
26
  return (data.value as string) ?? defaultValue;
20
27
  },
21
- staleTime: 5 * 60 * 1000, // 5 minutes
28
+ enabled: options?.enabled !== false && Boolean(configId),
29
+ staleTime: options?.staleTime ?? 5 * 60 * 1000, // 5 minutes
22
30
  });
23
31
  }
24
32