@lotics/ui 1.10.0 → 1.11.1
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/package.json +24 -7
- package/src/alert.tsx +35 -5
- package/src/avatar.tsx +28 -3
- package/src/back_button.tsx +4 -2
- package/src/button.tsx +35 -5
- package/src/calendar/calendar_view.tsx +127 -0
- package/src/calendar/dates.ts +102 -0
- package/src/calendar/index.ts +20 -0
- package/src/calendar/layout.test.ts +103 -0
- package/src/calendar/layout.ts +142 -0
- package/src/calendar/month_view.tsx +159 -0
- package/src/calendar/time_grid_view.tsx +263 -0
- package/src/calendar/types.ts +67 -0
- package/src/checkbox_input.tsx +9 -3
- package/src/command_menu.tsx +50 -4
- package/src/dialog.tsx +1 -1
- package/src/download.ts +14 -2
- package/src/form_field.tsx +77 -25
- package/src/form_switch.tsx +22 -3
- package/src/gantt/gantt_view.tsx +145 -0
- package/src/gantt/index.ts +5 -0
- package/src/gantt/scale.test.ts +47 -0
- package/src/gantt/scale.ts +92 -0
- package/src/gantt/types.ts +51 -0
- package/src/grid/select_header_cell.tsx +1 -0
- package/src/icon.tsx +14 -8
- package/src/icon_button.tsx +10 -4
- package/src/index.css +11 -0
- package/src/kanban/constants.ts +18 -0
- package/src/kanban/default_renderers.tsx +160 -0
- package/src/kanban/drag_preview.tsx +157 -0
- package/src/kanban/index.ts +13 -0
- package/src/kanban/insert_card_zone.tsx +135 -0
- package/src/kanban/kanban_board.tsx +616 -0
- package/src/kanban/kanban_card.tsx +312 -0
- package/src/kanban/kanban_column.tsx +487 -0
- package/src/kanban/placeholders.tsx +54 -0
- package/src/kanban/types.ts +116 -0
- package/src/landmark.tsx +34 -0
- package/src/menu_button.tsx +21 -0
- package/src/menu_list_item.tsx +3 -0
- package/src/number_input.tsx +10 -1
- package/src/pill_button.tsx +1 -0
- package/src/popover.tsx +47 -2
- package/src/popover_header.tsx +4 -2
- package/src/pressable_highlight.tsx +24 -0
- package/src/radio_picker.tsx +63 -5
- package/src/section_heading.tsx +5 -3
- package/src/skip_link.tsx +46 -0
- package/src/switch.tsx +9 -1
- package/src/switch_button.tsx +3 -0
- package/src/tabs.tsx +81 -19
- package/src/text.tsx +33 -0
- package/src/text_input_field.tsx +31 -0
- package/src/tooltip.tsx +43 -6
package/src/icon.tsx
CHANGED
|
@@ -392,12 +392,18 @@ interface IconProps {
|
|
|
392
392
|
|
|
393
393
|
export function Icon({ name, size = 24, color = colors.zinc["900"], testID }: IconProps) {
|
|
394
394
|
const IconComponent = iconComponents[name] ?? iconComponents["box"];
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
395
|
+
// Icons are always decorative: the enclosing control (button, menu item,
|
|
396
|
+
// status badge) carries the accessible name. Exposing the SVG to assistive
|
|
397
|
+
// tech doubles announcements ("check check-mark"). Callers that need a
|
|
398
|
+
// standalone meaningful icon must wrap it in a View with their own label.
|
|
399
|
+
return (
|
|
400
|
+
<View
|
|
401
|
+
testID={testID}
|
|
402
|
+
accessibilityElementsHidden
|
|
403
|
+
importantForAccessibility="no-hide-descendants"
|
|
404
|
+
aria-hidden
|
|
405
|
+
>
|
|
406
|
+
<IconComponent size={size} color={color} />
|
|
407
|
+
</View>
|
|
408
|
+
);
|
|
403
409
|
}
|
package/src/icon_button.tsx
CHANGED
|
@@ -5,21 +5,27 @@ import { PressableHighlight } from "./pressable_highlight";
|
|
|
5
5
|
import { Ref, useCallback } from "react";
|
|
6
6
|
import { TooltipSide } from "./tooltip";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
interface IconButtonBase {
|
|
9
9
|
ref?: Ref<View>;
|
|
10
10
|
testID?: string;
|
|
11
11
|
icon: IconName;
|
|
12
12
|
color?: "none" | "secondary" | "white";
|
|
13
13
|
iconColor?: string;
|
|
14
|
-
tooltip?: string;
|
|
15
14
|
tooltipSide?: TooltipSide;
|
|
16
|
-
/** Accessible name for the button. Falls back to `tooltip` when omitted. */
|
|
17
|
-
accessibilityLabel?: string;
|
|
18
15
|
onPress?: (event: GestureResponderEvent) => void;
|
|
19
16
|
style?: StyleProp<ViewStyle>;
|
|
20
17
|
disabled?: boolean;
|
|
21
18
|
}
|
|
22
19
|
|
|
20
|
+
/**
|
|
21
|
+
* An icon-only button has no visible text, so it must carry an accessible
|
|
22
|
+
* name. The compiler enforces at least one of `tooltip` (also shown visually
|
|
23
|
+
* on hover) or `accessibilityLabel` (a11y-only). When both are present,
|
|
24
|
+
* `accessibilityLabel` is the announced name.
|
|
25
|
+
*/
|
|
26
|
+
export type IconButtonProps = IconButtonBase &
|
|
27
|
+
({ tooltip: string; accessibilityLabel?: string } | { tooltip?: string; accessibilityLabel: string });
|
|
28
|
+
|
|
23
29
|
export function IconButton(props: IconButtonProps) {
|
|
24
30
|
const {
|
|
25
31
|
ref,
|
package/src/index.css
CHANGED
|
@@ -336,6 +336,17 @@ html {
|
|
|
336
336
|
color: var(--color-zinc-900);
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
/* Keyboard focus ring. `:focus-visible` only matches keyboard-driven focus,
|
|
340
|
+
so pointer/touch interactions stay visually unchanged. */
|
|
341
|
+
:focus-visible {
|
|
342
|
+
outline: 2px solid var(--color-zinc-900);
|
|
343
|
+
outline-offset: 2px;
|
|
344
|
+
border-radius: 4px;
|
|
345
|
+
}
|
|
346
|
+
:focus:not(:focus-visible) {
|
|
347
|
+
outline: none;
|
|
348
|
+
}
|
|
349
|
+
|
|
339
350
|
/* @font-face declarations are NOT included here — each app provides its own
|
|
340
351
|
font loading because paths differ per platform:
|
|
341
352
|
- Frontend: /fonts/Inter_*.woff2 (served from public/)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Column layout
|
|
2
|
+
export const DEFAULT_COLUMN_WIDTH = 280;
|
|
3
|
+
export const MINIMIZED_COLUMN_WIDTH = 44;
|
|
4
|
+
export const DEFAULT_COLUMN_GAP = 16;
|
|
5
|
+
export const DEFAULT_ITEM_GAP = 8;
|
|
6
|
+
export const COLUMN_CONTENT_PADDING = 12;
|
|
7
|
+
|
|
8
|
+
// Auto-scroll behavior
|
|
9
|
+
export const AUTO_SCROLL_THRESHOLD = 60;
|
|
10
|
+
export const AUTO_SCROLL_SPEED = 8;
|
|
11
|
+
|
|
12
|
+
// Drag detection
|
|
13
|
+
export const DRAG_THRESHOLD = 5;
|
|
14
|
+
|
|
15
|
+
// Touch-specific drag activation (like dnd-kit's delay activation)
|
|
16
|
+
// On touch devices, require a hold before drag can start to distinguish from tap
|
|
17
|
+
export const TOUCH_DRAG_DELAY = 200; // ms - hold time before drag activates on touch
|
|
18
|
+
export const TOUCH_TOLERANCE = 10; // px - max movement allowed during delay period
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
2
|
+
|
|
3
|
+
import { Icon } from "../icon";
|
|
4
|
+
import { colors } from "../colors";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
KanbanRenderAddCardPlaceholderProps,
|
|
8
|
+
KanbanRenderCollapsedColumnProps,
|
|
9
|
+
KanbanRenderColumnHeaderProps,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
export function defaultRenderColumnHeader({
|
|
13
|
+
title,
|
|
14
|
+
itemCount,
|
|
15
|
+
isMinimized,
|
|
16
|
+
onMinimizeToggle,
|
|
17
|
+
}: KanbanRenderColumnHeaderProps): React.ReactNode {
|
|
18
|
+
if (isMinimized) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<View style={styles.header}>
|
|
24
|
+
<View style={styles.headerContent}>
|
|
25
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
26
|
+
<Text style={styles.headerTitleText}>{title}</Text>
|
|
27
|
+
<View style={styles.headerCount}>
|
|
28
|
+
<Text style={styles.headerCountText}>{itemCount}</Text>
|
|
29
|
+
</View>
|
|
30
|
+
</View>
|
|
31
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
32
|
+
<Pressable onPressIn={(e) => e.stopPropagation()} onPress={onMinimizeToggle} hitSlop={8}>
|
|
33
|
+
<Icon name="minimize-2" size={16} color={colors.zinc[400]} />
|
|
34
|
+
</Pressable>
|
|
35
|
+
</View>
|
|
36
|
+
</View>
|
|
37
|
+
</View>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function defaultRenderAddCardPlaceholder({
|
|
42
|
+
onPress,
|
|
43
|
+
}: KanbanRenderAddCardPlaceholderProps): React.ReactNode {
|
|
44
|
+
return (
|
|
45
|
+
<Pressable style={styles.addCard} onPress={onPress}>
|
|
46
|
+
<Icon name="plus" size={16} color={colors.zinc[400]} />
|
|
47
|
+
<Text style={styles.addCardText}>Add card</Text>
|
|
48
|
+
</Pressable>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function defaultRenderCollapsedColumn({
|
|
53
|
+
title,
|
|
54
|
+
itemCount,
|
|
55
|
+
showDropIndicator,
|
|
56
|
+
onExpand,
|
|
57
|
+
}: KanbanRenderCollapsedColumnProps): React.ReactNode {
|
|
58
|
+
return (
|
|
59
|
+
<Pressable style={styles.collapsedColumn} onPress={onExpand}>
|
|
60
|
+
<View style={styles.collapsedContent}>
|
|
61
|
+
<View style={styles.collapsedCount}>
|
|
62
|
+
<Text style={styles.collapsedCountText}>{itemCount}</Text>
|
|
63
|
+
</View>
|
|
64
|
+
<Text style={styles.collapsedTitle} numberOfLines={1}>
|
|
65
|
+
{title}
|
|
66
|
+
</Text>
|
|
67
|
+
</View>
|
|
68
|
+
{showDropIndicator && <View style={styles.collapsedDropIndicator} />}
|
|
69
|
+
</Pressable>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const styles = StyleSheet.create({
|
|
74
|
+
header: {
|
|
75
|
+
padding: 12,
|
|
76
|
+
borderBottomWidth: 1,
|
|
77
|
+
borderBottomColor: colors.zinc[200],
|
|
78
|
+
},
|
|
79
|
+
headerContent: {
|
|
80
|
+
flexDirection: "row",
|
|
81
|
+
alignItems: "center",
|
|
82
|
+
justifyContent: "space-between",
|
|
83
|
+
},
|
|
84
|
+
headerTitleText: {
|
|
85
|
+
fontWeight: "600",
|
|
86
|
+
color: colors.zinc[700],
|
|
87
|
+
},
|
|
88
|
+
headerCount: {
|
|
89
|
+
backgroundColor: colors.zinc[200],
|
|
90
|
+
paddingHorizontal: 8,
|
|
91
|
+
paddingVertical: 2,
|
|
92
|
+
borderRadius: 10,
|
|
93
|
+
},
|
|
94
|
+
headerCountText: {
|
|
95
|
+
fontSize: 12,
|
|
96
|
+
color: colors.zinc[500],
|
|
97
|
+
},
|
|
98
|
+
addCard: {
|
|
99
|
+
flexDirection: "row",
|
|
100
|
+
alignItems: "center",
|
|
101
|
+
justifyContent: "center",
|
|
102
|
+
gap: 6,
|
|
103
|
+
paddingVertical: 8,
|
|
104
|
+
borderRadius: 6,
|
|
105
|
+
borderWidth: 1,
|
|
106
|
+
borderColor: colors.zinc[200],
|
|
107
|
+
borderStyle: "dashed",
|
|
108
|
+
backgroundColor: colors.white,
|
|
109
|
+
},
|
|
110
|
+
addCardText: {
|
|
111
|
+
fontSize: 13,
|
|
112
|
+
color: colors.zinc[400],
|
|
113
|
+
},
|
|
114
|
+
collapsedColumn: {
|
|
115
|
+
backgroundColor: colors.zinc[50],
|
|
116
|
+
borderRadius: 8,
|
|
117
|
+
borderWidth: 1,
|
|
118
|
+
borderColor: colors.zinc[200],
|
|
119
|
+
alignItems: "center",
|
|
120
|
+
flex: 1,
|
|
121
|
+
paddingVertical: 12,
|
|
122
|
+
paddingHorizontal: 4,
|
|
123
|
+
alignSelf: "stretch",
|
|
124
|
+
},
|
|
125
|
+
collapsedContent: {
|
|
126
|
+
flex: 1,
|
|
127
|
+
alignItems: "center",
|
|
128
|
+
gap: 8,
|
|
129
|
+
},
|
|
130
|
+
collapsedCount: {
|
|
131
|
+
backgroundColor: colors.zinc[200],
|
|
132
|
+
paddingHorizontal: 8,
|
|
133
|
+
paddingVertical: 2,
|
|
134
|
+
borderRadius: 10,
|
|
135
|
+
},
|
|
136
|
+
collapsedCountText: {
|
|
137
|
+
fontSize: 12,
|
|
138
|
+
fontWeight: "500",
|
|
139
|
+
color: colors.zinc[600],
|
|
140
|
+
},
|
|
141
|
+
collapsedTitle: {
|
|
142
|
+
writingDirection: "ltr",
|
|
143
|
+
transform: [{ rotate: "180deg" }],
|
|
144
|
+
// @ts-expect-error web-only property for vertical text
|
|
145
|
+
writingMode: "vertical-rl",
|
|
146
|
+
fontSize: 14,
|
|
147
|
+
fontWeight: "600",
|
|
148
|
+
color: colors.zinc[700],
|
|
149
|
+
textAlign: "center",
|
|
150
|
+
},
|
|
151
|
+
collapsedDropIndicator: {
|
|
152
|
+
position: "absolute",
|
|
153
|
+
top: 8,
|
|
154
|
+
left: 4,
|
|
155
|
+
right: 4,
|
|
156
|
+
height: 3,
|
|
157
|
+
backgroundColor: colors.blue[500],
|
|
158
|
+
borderRadius: 2,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import React, { memo, useEffect, useState } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors } from "../colors";
|
|
4
|
+
import {
|
|
5
|
+
DragInfo,
|
|
6
|
+
DragPreviewInfo,
|
|
7
|
+
KanbanColumn,
|
|
8
|
+
KanbanRenderCardProps,
|
|
9
|
+
KanbanRenderColumnHeaderProps,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
interface DragPreviewProps<T> {
|
|
13
|
+
columns: KanbanColumn<T>[];
|
|
14
|
+
dragInfo: DragInfo | null;
|
|
15
|
+
dragPreview: DragPreviewInfo | null;
|
|
16
|
+
renderCard: (props: KanbanRenderCardProps<T>) => React.ReactNode;
|
|
17
|
+
renderColumnHeader: (props: KanbanRenderColumnHeaderProps) => React.ReactNode;
|
|
18
|
+
onPositionUpdate: (callback: (x: number, y: number) => void) => void;
|
|
19
|
+
initialPosition: { x: number; y: number } | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function DragPreviewInner<T>({
|
|
23
|
+
columns,
|
|
24
|
+
dragInfo,
|
|
25
|
+
dragPreview,
|
|
26
|
+
renderCard,
|
|
27
|
+
renderColumnHeader,
|
|
28
|
+
onPositionUpdate,
|
|
29
|
+
initialPosition,
|
|
30
|
+
}: DragPreviewProps<T>) {
|
|
31
|
+
const [position, setPosition] = useState(initialPosition);
|
|
32
|
+
|
|
33
|
+
// Register position update callback
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
onPositionUpdate((x, y) => setPosition({ x, y }));
|
|
36
|
+
return () => onPositionUpdate(() => {});
|
|
37
|
+
}, [onPositionUpdate]);
|
|
38
|
+
|
|
39
|
+
// Sync with initial position
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (initialPosition) setPosition(initialPosition);
|
|
42
|
+
}, [initialPosition]);
|
|
43
|
+
|
|
44
|
+
// Clear position when drag ends
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!dragInfo) setPosition(null);
|
|
47
|
+
}, [dragInfo]);
|
|
48
|
+
|
|
49
|
+
if (!dragInfo || !position || !dragPreview) return null;
|
|
50
|
+
|
|
51
|
+
const left = position.x - dragPreview.offsetX;
|
|
52
|
+
const top = position.y - dragPreview.offsetY;
|
|
53
|
+
|
|
54
|
+
if (dragInfo.type === "card") {
|
|
55
|
+
const column = columns.find((col) => col.key === dragInfo.sourceColumnKey);
|
|
56
|
+
const item = column?.items.find((i) => i.id === dragInfo.id);
|
|
57
|
+
if (!item) return null;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div style={{ ...previewStyle, left, top, width: dragPreview.width }}>
|
|
61
|
+
{renderCard({
|
|
62
|
+
item: item.data,
|
|
63
|
+
columnKey: dragInfo.sourceColumnKey,
|
|
64
|
+
id: dragInfo.id,
|
|
65
|
+
hovered: false,
|
|
66
|
+
isDragging: true,
|
|
67
|
+
})}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (dragInfo.type === "column") {
|
|
73
|
+
const column = columns.find((col) => col.key === dragInfo.id);
|
|
74
|
+
if (!column) return null;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
style={{
|
|
79
|
+
...previewStyle,
|
|
80
|
+
left,
|
|
81
|
+
top,
|
|
82
|
+
width: dragPreview.width,
|
|
83
|
+
height: dragPreview.height,
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<View style={styles.columnPreview}>
|
|
87
|
+
<View style={styles.columnPreviewHeader}>
|
|
88
|
+
{renderColumnHeader({
|
|
89
|
+
columnKey: column.key,
|
|
90
|
+
title: column.title,
|
|
91
|
+
itemCount: column.items.length,
|
|
92
|
+
isMinimized: false,
|
|
93
|
+
onMinimizeToggle: () => {},
|
|
94
|
+
})}
|
|
95
|
+
</View>
|
|
96
|
+
<View style={styles.columnPreviewContent}>
|
|
97
|
+
{column.items.slice(0, 3).map((item) => (
|
|
98
|
+
<View key={item.id} style={styles.columnPreviewCard}>
|
|
99
|
+
{renderCard({
|
|
100
|
+
item: item.data,
|
|
101
|
+
columnKey: column.key,
|
|
102
|
+
id: item.id,
|
|
103
|
+
hovered: false,
|
|
104
|
+
isDragging: true,
|
|
105
|
+
})}
|
|
106
|
+
</View>
|
|
107
|
+
))}
|
|
108
|
+
{column.items.length > 3 && (
|
|
109
|
+
<View style={styles.columnPreviewMore}>
|
|
110
|
+
<span style={{ color: colors.zinc[500], fontSize: 12 }}>
|
|
111
|
+
+{column.items.length - 3} more
|
|
112
|
+
</span>
|
|
113
|
+
</View>
|
|
114
|
+
)}
|
|
115
|
+
</View>
|
|
116
|
+
</View>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const DragPreview = memo(DragPreviewInner) as typeof DragPreviewInner;
|
|
125
|
+
|
|
126
|
+
const previewStyle: React.CSSProperties = {
|
|
127
|
+
position: "fixed",
|
|
128
|
+
zIndex: 9999,
|
|
129
|
+
pointerEvents: "none",
|
|
130
|
+
opacity: 0.85,
|
|
131
|
+
transform: "rotate(2deg)",
|
|
132
|
+
boxShadow: "0 8px 24px rgba(0,0,0,0.15)",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const styles = StyleSheet.create({
|
|
136
|
+
columnPreview: {
|
|
137
|
+
backgroundColor: colors.zinc[50],
|
|
138
|
+
borderRadius: 8,
|
|
139
|
+
borderWidth: 1,
|
|
140
|
+
borderColor: colors.zinc[200],
|
|
141
|
+
overflow: "hidden",
|
|
142
|
+
},
|
|
143
|
+
columnPreviewHeader: {
|
|
144
|
+
borderBottomWidth: 1,
|
|
145
|
+
borderBottomColor: colors.zinc[200],
|
|
146
|
+
},
|
|
147
|
+
columnPreviewContent: {
|
|
148
|
+
padding: 4,
|
|
149
|
+
},
|
|
150
|
+
columnPreviewCard: {
|
|
151
|
+
marginVertical: 2,
|
|
152
|
+
},
|
|
153
|
+
columnPreviewMore: {
|
|
154
|
+
padding: 8,
|
|
155
|
+
alignItems: "center",
|
|
156
|
+
},
|
|
157
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { KanbanBoard } from "./kanban_board";
|
|
2
|
+
export type { KanbanBoardProps } from "./kanban_board";
|
|
3
|
+
export type {
|
|
4
|
+
KanbanColumn,
|
|
5
|
+
KanbanItem,
|
|
6
|
+
KanbanRenderCardProps,
|
|
7
|
+
KanbanRenderColumnHeaderProps,
|
|
8
|
+
KanbanRenderCollapsedColumnProps,
|
|
9
|
+
KanbanRenderAddCardPlaceholderProps,
|
|
10
|
+
KanbanRenderInsertCardButtonProps,
|
|
11
|
+
CardMoveResult,
|
|
12
|
+
ColumnMoveResult,
|
|
13
|
+
} from "./types";
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { KanbanRenderInsertCardButtonProps } from "./types";
|
|
3
|
+
import { Pressable, View, StyleSheet } from "react-native";
|
|
4
|
+
import { colors } from "../colors";
|
|
5
|
+
import { Icon } from "../icon";
|
|
6
|
+
|
|
7
|
+
const HOVER_DELAY_MS = 200;
|
|
8
|
+
|
|
9
|
+
// Interactive zone that shows an insert button on hover
|
|
10
|
+
export function InsertCardZone({
|
|
11
|
+
height,
|
|
12
|
+
columnKey,
|
|
13
|
+
index,
|
|
14
|
+
onAddCardAtIndex,
|
|
15
|
+
renderInsertCardButton,
|
|
16
|
+
isDragInProgress,
|
|
17
|
+
}: {
|
|
18
|
+
height: number;
|
|
19
|
+
columnKey: string;
|
|
20
|
+
index: number;
|
|
21
|
+
onAddCardAtIndex: (columnKey: string, index: number) => void;
|
|
22
|
+
renderInsertCardButton?: (props: KanbanRenderInsertCardButtonProps) => React.ReactNode;
|
|
23
|
+
/** When true, drag is in progress and insert UI should be hidden */
|
|
24
|
+
isDragInProgress?: boolean;
|
|
25
|
+
}) {
|
|
26
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
27
|
+
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
28
|
+
|
|
29
|
+
// Cleanup timeout on unmount to prevent memory leak
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
return () => {
|
|
32
|
+
if (hoverTimeoutRef.current) {
|
|
33
|
+
clearTimeout(hoverTimeoutRef.current);
|
|
34
|
+
hoverTimeoutRef.current = null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const handlePointerEnter = useCallback(() => {
|
|
40
|
+
hoverTimeoutRef.current = setTimeout(() => {
|
|
41
|
+
setIsHovered(true);
|
|
42
|
+
}, HOVER_DELAY_MS);
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const handlePointerLeave = useCallback(() => {
|
|
46
|
+
if (hoverTimeoutRef.current) {
|
|
47
|
+
clearTimeout(hoverTimeoutRef.current);
|
|
48
|
+
hoverTimeoutRef.current = null;
|
|
49
|
+
}
|
|
50
|
+
setIsHovered(false);
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const handlePress = useCallback(() => {
|
|
54
|
+
onAddCardAtIndex(columnKey, index);
|
|
55
|
+
}, [columnKey, index, onAddCardAtIndex]);
|
|
56
|
+
|
|
57
|
+
// Don't show insert UI during drag operations
|
|
58
|
+
const showInsertUI = isHovered && !isDragInProgress;
|
|
59
|
+
|
|
60
|
+
if (renderInsertCardButton) {
|
|
61
|
+
return (
|
|
62
|
+
<View
|
|
63
|
+
style={[insertZoneStyles.container, { height }]}
|
|
64
|
+
onPointerEnter={handlePointerEnter}
|
|
65
|
+
onPointerLeave={handlePointerLeave}
|
|
66
|
+
>
|
|
67
|
+
{showInsertUI &&
|
|
68
|
+
renderInsertCardButton({
|
|
69
|
+
columnKey,
|
|
70
|
+
index,
|
|
71
|
+
onPress: handlePress,
|
|
72
|
+
})}
|
|
73
|
+
</View>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<Pressable
|
|
79
|
+
onPress={handlePress}
|
|
80
|
+
onPointerEnter={handlePointerEnter}
|
|
81
|
+
onPointerLeave={handlePointerLeave}
|
|
82
|
+
style={[insertZoneStyles.container, { height }]}
|
|
83
|
+
>
|
|
84
|
+
{showInsertUI && (
|
|
85
|
+
<View style={insertZoneStyles.buttonWrapper}>
|
|
86
|
+
<View style={insertZoneStyles.buttonContent}>
|
|
87
|
+
<View style={insertZoneStyles.line} />
|
|
88
|
+
<View style={insertZoneStyles.plusCircle}>
|
|
89
|
+
<Icon name="plus" size={16} color={colors.white} />
|
|
90
|
+
</View>
|
|
91
|
+
<View style={insertZoneStyles.line} />
|
|
92
|
+
</View>
|
|
93
|
+
</View>
|
|
94
|
+
)}
|
|
95
|
+
</Pressable>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const insertZoneStyles = StyleSheet.create({
|
|
100
|
+
container: {
|
|
101
|
+
justifyContent: "center",
|
|
102
|
+
alignItems: "center",
|
|
103
|
+
overflow: "visible",
|
|
104
|
+
},
|
|
105
|
+
buttonWrapper: {
|
|
106
|
+
position: "absolute",
|
|
107
|
+
left: 0,
|
|
108
|
+
right: 0,
|
|
109
|
+
top: -3,
|
|
110
|
+
height: 14,
|
|
111
|
+
zIndex: 10,
|
|
112
|
+
},
|
|
113
|
+
buttonContent: {
|
|
114
|
+
flexDirection: "row",
|
|
115
|
+
alignItems: "center",
|
|
116
|
+
width: "100%",
|
|
117
|
+
height: "100%",
|
|
118
|
+
paddingHorizontal: 4,
|
|
119
|
+
},
|
|
120
|
+
line: {
|
|
121
|
+
flex: 1,
|
|
122
|
+
height: 2,
|
|
123
|
+
backgroundColor: colors.zinc[400],
|
|
124
|
+
borderRadius: 1,
|
|
125
|
+
},
|
|
126
|
+
plusCircle: {
|
|
127
|
+
width: 18,
|
|
128
|
+
height: 18,
|
|
129
|
+
borderRadius: 6,
|
|
130
|
+
backgroundColor: colors.zinc[900],
|
|
131
|
+
justifyContent: "center",
|
|
132
|
+
alignItems: "center",
|
|
133
|
+
marginHorizontal: 4,
|
|
134
|
+
},
|
|
135
|
+
});
|