@lotics/ui 1.9.0 → 1.11.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.
@@ -0,0 +1,312 @@
1
+ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Platform, View } from "react-native";
3
+ import { KanbanRenderCardProps, KanbanRenderInsertCardButtonProps } from "./types";
4
+ import { CardPlaceholder } from "./placeholders";
5
+ import { DRAG_THRESHOLD, TOUCH_DRAG_DELAY, TOUCH_TOLERANCE } from "./constants";
6
+ import { InsertCardZone } from "./insert_card_zone";
7
+
8
+ interface KanbanCardProps<T> {
9
+ id: string;
10
+ columnKey: string;
11
+ index: number;
12
+ item: T;
13
+ renderCard: (props: KanbanRenderCardProps<T>) => React.ReactNode;
14
+ isDragging: boolean;
15
+ isDropTarget: boolean;
16
+ /** True when any card is being dragged (disables insert zones) */
17
+ isDragInProgress: boolean;
18
+ onCardPress?: (cardId: string, item: T, columnKey: string) => void;
19
+ onAddCardAtIndex?: (columnKey: string, index: number) => void;
20
+ renderInsertCardButton?: (props: KanbanRenderInsertCardButtonProps) => React.ReactNode;
21
+ startCardDrag: (
22
+ cardId: string,
23
+ columnKey: string,
24
+ index: number,
25
+ startX: number,
26
+ startY: number,
27
+ rect: { x: number; y: number; width: number; height: number },
28
+ ) => void;
29
+ /** Height of this card (used when this card is dragging and also drop target) */
30
+ placeholderHeight: number;
31
+ /** Height of the card being dragged (used for drop target placeholder) */
32
+ draggedCardHeight: number | null;
33
+ /** Gap between items for consistent spacing */
34
+ itemGap: number;
35
+ }
36
+
37
+ type PointerType = "mouse" | "touch" | "pen";
38
+
39
+ function KanbanCardInner<T>({
40
+ id,
41
+ columnKey,
42
+ index,
43
+ item,
44
+ renderCard,
45
+ isDragging,
46
+ isDropTarget,
47
+ isDragInProgress,
48
+ onCardPress,
49
+ onAddCardAtIndex,
50
+ renderInsertCardButton,
51
+ startCardDrag,
52
+ placeholderHeight,
53
+ draggedCardHeight,
54
+ itemGap,
55
+ }: KanbanCardProps<T>) {
56
+ const containerRef = useRef<View>(null);
57
+ const [isHovered, setIsHovered] = useState(false);
58
+ const [isPressing, setIsPressing] = useState(false);
59
+ // Visual feedback when touch delay is active (preparing to drag)
60
+ const [isDragPending, setIsDragPending] = useState(false);
61
+
62
+ const pressStartRef = useRef<{ x: number; y: number } | null>(null);
63
+ const hasDraggedRef = useRef(false);
64
+ const wasCancelledRef = useRef(false);
65
+ const elementRectRef = useRef<{
66
+ x: number;
67
+ y: number;
68
+ width: number;
69
+ height: number;
70
+ } | null>(null);
71
+ const pointerTypeRef = useRef<PointerType>("mouse");
72
+ const delayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
73
+ // True when delay has elapsed and drag can start (always true for mouse)
74
+ const canDragRef = useRef(false);
75
+
76
+ // Clear hover when this card starts dragging or any drag is in progress
77
+ useEffect(() => {
78
+ if (isDragging || isDragInProgress) setIsHovered(false);
79
+ }, [isDragging, isDragInProgress]);
80
+
81
+ // Reset internal state when drag ends (drag is handled at board level)
82
+ useEffect(() => {
83
+ if (!isDragging && hasDraggedRef.current) {
84
+ // Drag just ended - reset all internal state
85
+ hasDraggedRef.current = false;
86
+ wasCancelledRef.current = false;
87
+ canDragRef.current = false;
88
+ setIsPressing(false);
89
+ setIsDragPending(false);
90
+ pressStartRef.current = null;
91
+ elementRectRef.current = null;
92
+ if (delayTimerRef.current) {
93
+ clearTimeout(delayTimerRef.current);
94
+ delayTimerRef.current = null;
95
+ }
96
+ }
97
+ }, [isDragging]);
98
+
99
+ // Cleanup delay timer on unmount
100
+ useEffect(() => {
101
+ return () => {
102
+ if (delayTimerRef.current) {
103
+ clearTimeout(delayTimerRef.current);
104
+ }
105
+ };
106
+ }, []);
107
+
108
+ // Handle pointer down - attached via useEffect on web
109
+ const handlePointerDown = useCallback((e: PointerEvent) => {
110
+ // Determine pointer type
111
+ const pointerType = e.pointerType as PointerType;
112
+ pointerTypeRef.current = pointerType;
113
+
114
+ pressStartRef.current = { x: e.clientX, y: e.clientY };
115
+ hasDraggedRef.current = false;
116
+ wasCancelledRef.current = false;
117
+
118
+ // For touch/pen: require a delay before drag can start
119
+ // For mouse: drag can start immediately
120
+ const isTouch = pointerType === "touch" || pointerType === "pen";
121
+
122
+ if (isTouch) {
123
+ canDragRef.current = false;
124
+ setIsDragPending(true);
125
+
126
+ delayTimerRef.current = setTimeout(() => {
127
+ canDragRef.current = true;
128
+ delayTimerRef.current = null;
129
+ }, TOUCH_DRAG_DELAY);
130
+ } else {
131
+ canDragRef.current = true;
132
+ }
133
+
134
+ // Measure element position
135
+ containerRef.current?.measureInWindow((x, y, width, height) => {
136
+ elementRectRef.current = { x, y, width, height };
137
+ setIsPressing(true);
138
+ });
139
+ }, []);
140
+
141
+ // Attach pointer down listener to DOM element on web
142
+ // Must depend on isDragging because when isDragging=true, the element returns null
143
+ // and is unmounted. When isDragging becomes false, a NEW element is created
144
+ // and we need to re-attach the listener.
145
+ useEffect(() => {
146
+ if (Platform.OS !== "web") return;
147
+ if (isDragging) return; // Element doesn't exist when dragging
148
+
149
+ // Get the DOM node from the React Native View ref
150
+ const node = containerRef.current as unknown as HTMLElement | null;
151
+ if (!node) return;
152
+
153
+ node.addEventListener("pointerdown", handlePointerDown);
154
+
155
+ return () => {
156
+ node.removeEventListener("pointerdown", handlePointerDown);
157
+ };
158
+ }, [handlePointerDown, isDragging]);
159
+
160
+ useEffect(() => {
161
+ if (!isPressing) return;
162
+
163
+ const handlePointerMove = (e: PointerEvent) => {
164
+ if (!pressStartRef.current || hasDraggedRef.current || wasCancelledRef.current) return;
165
+
166
+ const dx = Math.abs(e.clientX - pressStartRef.current.x);
167
+ const dy = Math.abs(e.clientY - pressStartRef.current.y);
168
+ const distance = Math.max(dx, dy);
169
+
170
+ // If we're still in the delay period (touch only)
171
+ if (!canDragRef.current) {
172
+ // Too much movement during delay - cancel drag possibility
173
+ if (distance > TOUCH_TOLERANCE) {
174
+ wasCancelledRef.current = true;
175
+ setIsDragPending(false);
176
+ if (delayTimerRef.current) {
177
+ clearTimeout(delayTimerRef.current);
178
+ delayTimerRef.current = null;
179
+ }
180
+ }
181
+ return;
182
+ }
183
+
184
+ // Past delay (or mouse) - check drag threshold
185
+ if (distance > DRAG_THRESHOLD) {
186
+ hasDraggedRef.current = true;
187
+ setIsDragPending(false);
188
+ if (elementRectRef.current) {
189
+ startCardDrag(id, columnKey, index, e.clientX, e.clientY, elementRectRef.current);
190
+ }
191
+ }
192
+ };
193
+
194
+ const handlePointerUp = () => {
195
+ // Cleanup delay timer
196
+ if (delayTimerRef.current) {
197
+ clearTimeout(delayTimerRef.current);
198
+ delayTimerRef.current = null;
199
+ }
200
+ setIsDragPending(false);
201
+
202
+ // Trigger click if no drag occurred and wasn't cancelled
203
+ if (!hasDraggedRef.current && !wasCancelledRef.current && onCardPress) {
204
+ onCardPress(id, item, columnKey);
205
+ }
206
+
207
+ setIsPressing(false);
208
+ pressStartRef.current = null;
209
+ elementRectRef.current = null;
210
+ };
211
+
212
+ const handlePointerCancel = () => {
213
+ if (delayTimerRef.current) {
214
+ clearTimeout(delayTimerRef.current);
215
+ delayTimerRef.current = null;
216
+ }
217
+ setIsDragPending(false);
218
+ setIsPressing(false);
219
+ pressStartRef.current = null;
220
+ elementRectRef.current = null;
221
+ };
222
+
223
+ // Use pointer events for unified mouse/touch handling
224
+ window.addEventListener("pointermove", handlePointerMove);
225
+ window.addEventListener("pointerup", handlePointerUp);
226
+ window.addEventListener("pointercancel", handlePointerCancel);
227
+
228
+ return () => {
229
+ window.removeEventListener("pointermove", handlePointerMove);
230
+ window.removeEventListener("pointerup", handlePointerUp);
231
+ window.removeEventListener("pointercancel", handlePointerCancel);
232
+ };
233
+ }, [isPressing, id, columnKey, index, item, startCardDrag, onCardPress]);
234
+
235
+ const handleHoverIn = useCallback(() => {
236
+ // Don't show hover when any drag is in progress
237
+ if (!isDragInProgress) {
238
+ setIsHovered(true);
239
+ }
240
+ }, [isDragInProgress]);
241
+
242
+ const handleHoverOut = useCallback(() => {
243
+ setIsHovered(false);
244
+ }, []);
245
+
246
+ // Stable style for gap between placeholder and card - MUST be before early returns
247
+ const gapStyle = useMemo(() => ({ height: itemGap }), [itemGap]);
248
+
249
+ const cardContent = renderCard({
250
+ item,
251
+ columnKey,
252
+ id,
253
+ hovered: isHovered,
254
+ isDragging,
255
+ });
256
+
257
+ if (isDragging) {
258
+ // This card is being dragged - show placeholder at its own position if it's also drop target
259
+ if (isDropTarget) {
260
+ return (
261
+ <View style={{ overflow: "visible" }}>
262
+ {/* Maintain gap above placeholder when insert zones are enabled */}
263
+ {onAddCardAtIndex && <View style={gapStyle} />}
264
+ <CardPlaceholder height={placeholderHeight} />
265
+ </View>
266
+ );
267
+ }
268
+ return null;
269
+ }
270
+
271
+ // Placeholder should match the dragged card's height, not this card's height
272
+ const dropPlaceholderHeight = draggedCardHeight ?? placeholderHeight;
273
+
274
+ // Visual feedback style when drag is pending (touch hold)
275
+ const pendingStyle = isDragPending
276
+ ? { opacity: 0.8, transform: [{ scale: 1.02 }] as const }
277
+ : undefined;
278
+
279
+ return (
280
+ <View style={{ overflow: "visible" }}>
281
+ {/* Insert zone above this card - allows inserting at this card's position */}
282
+ {onAddCardAtIndex && (
283
+ <View style={{ height: itemGap, overflow: "visible", zIndex: 1 }}>
284
+ <InsertCardZone
285
+ height={itemGap}
286
+ columnKey={columnKey}
287
+ index={index}
288
+ onAddCardAtIndex={onAddCardAtIndex}
289
+ renderInsertCardButton={renderInsertCardButton}
290
+ isDragInProgress={isDragInProgress}
291
+ />
292
+ </View>
293
+ )}
294
+ {isDropTarget && (
295
+ <>
296
+ <CardPlaceholder height={dropPlaceholderHeight} />
297
+ <View style={gapStyle} />
298
+ </>
299
+ )}
300
+ <View
301
+ ref={containerRef}
302
+ style={pendingStyle}
303
+ onPointerEnter={handleHoverIn}
304
+ onPointerLeave={handleHoverOut}
305
+ >
306
+ {cardContent}
307
+ </View>
308
+ </View>
309
+ );
310
+ }
311
+
312
+ export const KanbanCard = memo(KanbanCardInner) as typeof KanbanCardInner;