@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,487 @@
1
+ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ FlatList,
4
+ GestureResponderEvent,
5
+ LayoutChangeEvent,
6
+ ListRenderItem,
7
+ NativeScrollEvent,
8
+ NativeSyntheticEvent,
9
+ Pressable,
10
+ StyleSheet,
11
+ View,
12
+ } from "react-native";
13
+ import { colors } from "../colors";
14
+ import {
15
+ KanbanItem,
16
+ KanbanRenderAddCardPlaceholderProps,
17
+ KanbanRenderInsertCardButtonProps,
18
+ KanbanRenderCardProps,
19
+ KanbanRenderCollapsedColumnProps,
20
+ KanbanRenderColumnHeaderProps,
21
+ } from "./types";
22
+ import { KanbanCard } from "./kanban_card";
23
+ import { CardPlaceholder, ColumnPlaceholder } from "./placeholders";
24
+ import { COLUMN_CONTENT_PADDING, DRAG_THRESHOLD } from "./constants";
25
+ import type { ColumnRegistration } from "./kanban_board";
26
+
27
+ // Memoized separator component to prevent re-renders
28
+ const ItemSeparator = memo(function ItemSeparator({ height }: { height: number }) {
29
+ const style = useMemo(() => ({ height }), [height]);
30
+ return <View style={style} />;
31
+ });
32
+
33
+ // Memoized footer placeholder component
34
+ const FooterPlaceholder = memo(function FooterPlaceholder({ height }: { height: number }) {
35
+ return <CardPlaceholder height={height} />;
36
+ });
37
+
38
+ // Stable key extractor (defined outside component to avoid recreation)
39
+ const keyExtractor = <T,>(item: KanbanItem<T>) => item.id;
40
+
41
+ interface KanbanColumnProps<T> {
42
+ columnKey: string;
43
+ title: string;
44
+ index: number;
45
+ items: KanbanItem<T>[];
46
+ renderCard: (props: KanbanRenderCardProps<T>) => React.ReactNode;
47
+ renderColumnHeader: (props: KanbanRenderColumnHeaderProps) => React.ReactNode;
48
+ renderAddCardPlaceholder: (props: KanbanRenderAddCardPlaceholderProps) => React.ReactNode;
49
+ renderInsertCardButton?: (props: KanbanRenderInsertCardButtonProps) => React.ReactNode;
50
+ renderCollapsedColumn: (props: KanbanRenderCollapsedColumnProps) => React.ReactNode;
51
+ isDragging: boolean;
52
+ isDropTarget: boolean;
53
+ /** True when any card is being dragged (disables insert zones) */
54
+ isDragInProgress: boolean;
55
+ cardDragId: string | null;
56
+ cardDropTargetIndex: number | null;
57
+ /** Height of the card being dragged (for placeholder sizing) */
58
+ draggedCardHeight: number | null;
59
+ /** Show full-column highlight when a card is being dropped (column-only mode) */
60
+ showCardDropHighlight: boolean;
61
+ columnWidth: number;
62
+ /** Width to use for drop target placeholder (matches dragged column's width) */
63
+ placeholderWidth: number;
64
+ /** Height to use for drop target placeholder (matches dragged column's height) */
65
+ placeholderHeight?: number;
66
+ itemHeight: number | ((item: T, index: number) => number);
67
+ itemGap: number;
68
+ onCardPress?: (cardId: string, item: T, columnKey: string) => void;
69
+ onAddCard?: (columnKey: string) => void;
70
+ onAddCardAtIndex?: (columnKey: string, index: number) => void;
71
+ startCardDrag: (
72
+ cardId: string,
73
+ columnKey: string,
74
+ index: number,
75
+ startX: number,
76
+ startY: number,
77
+ rect: { x: number; y: number; width: number; height: number },
78
+ ) => void;
79
+ startColumnDrag: (
80
+ columnKey: string,
81
+ index: number,
82
+ startX: number,
83
+ startY: number,
84
+ rect: { x: number; y: number; width: number; height: number },
85
+ ) => void;
86
+ registerColumn: (columnKey: string, data: ColumnRegistration | null) => void;
87
+ updateColumnScroll: (columnKey: string, scrollY: number) => void;
88
+ onPointerEnter: (columnKey: string) => void;
89
+ isMinimized: boolean;
90
+ onMinimizeToggle: (columnKey: string) => void;
91
+ }
92
+
93
+ function KanbanColumnInner<T>({
94
+ columnKey,
95
+ title,
96
+ index,
97
+ items,
98
+ renderCard,
99
+ renderColumnHeader,
100
+ renderAddCardPlaceholder,
101
+ renderInsertCardButton,
102
+ renderCollapsedColumn,
103
+ isDragging,
104
+ isDropTarget,
105
+ isDragInProgress,
106
+ cardDragId,
107
+ cardDropTargetIndex,
108
+ draggedCardHeight,
109
+ showCardDropHighlight,
110
+ columnWidth,
111
+ placeholderWidth,
112
+ placeholderHeight,
113
+ itemHeight,
114
+ itemGap,
115
+ onCardPress,
116
+ onAddCard,
117
+ onAddCardAtIndex,
118
+ startCardDrag,
119
+ startColumnDrag,
120
+ registerColumn,
121
+ updateColumnScroll,
122
+ onPointerEnter,
123
+ isMinimized,
124
+ onMinimizeToggle,
125
+ }: KanbanColumnProps<T>) {
126
+ const containerRef = useRef<View>(null);
127
+ const listRef = useRef<FlatList>(null);
128
+ const headerHeightRef = useRef(0);
129
+
130
+ const cardDragIdRef = useRef(cardDragId);
131
+ const cardDropTargetIndexRef = useRef(cardDropTargetIndex);
132
+ const itemsLengthRef = useRef(items.length);
133
+ const draggedCardHeightRef = useRef(draggedCardHeight);
134
+
135
+ cardDragIdRef.current = cardDragId;
136
+ cardDropTargetIndexRef.current = cardDropTargetIndex;
137
+ itemsLengthRef.current = items.length;
138
+ draggedCardHeightRef.current = draggedCardHeight;
139
+
140
+ // Column drag threshold state (similar to card dragging)
141
+ const [isHeaderPressing, setIsHeaderPressing] = useState(false);
142
+ const headerPressStartRef = useRef<{ x: number; y: number } | null>(null);
143
+ const hasColumnDraggedRef = useRef(false);
144
+ const columnRectRef = useRef<{
145
+ x: number;
146
+ y: number;
147
+ width: number;
148
+ height: number;
149
+ } | null>(null);
150
+
151
+ const getHeight = useCallback(
152
+ (item: T, idx: number): number => {
153
+ return typeof itemHeight === "function" ? itemHeight(item, idx) : itemHeight;
154
+ },
155
+ [itemHeight],
156
+ );
157
+
158
+ useEffect(() => {
159
+ const element = containerRef.current as unknown as HTMLElement | null;
160
+ if (element && (listRef.current || isMinimized)) {
161
+ registerColumn(columnKey, {
162
+ element,
163
+ headerHeight: isMinimized ? 0 : headerHeightRef.current,
164
+ listRef: listRef.current, // null when minimized, FlatList when expanded
165
+ });
166
+ }
167
+ return () => registerColumn(columnKey, null);
168
+ }, [columnKey, registerColumn, isMinimized]);
169
+
170
+ const handleHeaderLayout = useCallback(
171
+ (event: LayoutChangeEvent) => {
172
+ headerHeightRef.current = event.nativeEvent.layout.height;
173
+ const element = containerRef.current as unknown as HTMLElement | null;
174
+ if (element && listRef.current) {
175
+ registerColumn(columnKey, {
176
+ element,
177
+ headerHeight: headerHeightRef.current,
178
+ listRef: listRef.current,
179
+ });
180
+ }
181
+ },
182
+ [columnKey, registerColumn],
183
+ );
184
+
185
+ // Handle column drag with threshold (same as cards)
186
+ useEffect(() => {
187
+ if (!isHeaderPressing) return;
188
+
189
+ const handleMouseMove = (e: MouseEvent) => {
190
+ if (!headerPressStartRef.current || hasColumnDraggedRef.current) return;
191
+
192
+ const dx = Math.abs(e.clientX - headerPressStartRef.current.x);
193
+ const dy = Math.abs(e.clientY - headerPressStartRef.current.y);
194
+
195
+ if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) {
196
+ hasColumnDraggedRef.current = true;
197
+ if (columnRectRef.current) {
198
+ startColumnDrag(columnKey, index, e.clientX, e.clientY, columnRectRef.current);
199
+ }
200
+ }
201
+ };
202
+
203
+ const handleMouseUp = () => {
204
+ setIsHeaderPressing(false);
205
+ headerPressStartRef.current = null;
206
+ columnRectRef.current = null;
207
+ };
208
+
209
+ window.addEventListener("mousemove", handleMouseMove);
210
+ window.addEventListener("mouseup", handleMouseUp);
211
+
212
+ return () => {
213
+ window.removeEventListener("mousemove", handleMouseMove);
214
+ window.removeEventListener("mouseup", handleMouseUp);
215
+ };
216
+ }, [isHeaderPressing, columnKey, index, startColumnDrag]);
217
+
218
+ const handleHeaderPressIn = useCallback((event: GestureResponderEvent) => {
219
+ // Use clientX/clientY (viewport-relative) for consistency with mousemove events
220
+ const clientX = event.nativeEvent.pageX - window.scrollX;
221
+ const clientY = event.nativeEvent.pageY - window.scrollY;
222
+ headerPressStartRef.current = { x: clientX, y: clientY };
223
+ hasColumnDraggedRef.current = false;
224
+
225
+ const element = containerRef.current as unknown as HTMLElement | null;
226
+ if (element) {
227
+ const rect = element.getBoundingClientRect();
228
+ columnRectRef.current = rect;
229
+ setIsHeaderPressing(true);
230
+ }
231
+ }, []);
232
+
233
+ const handleScroll = useCallback(
234
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
235
+ updateColumnScroll(columnKey, event.nativeEvent.contentOffset.y);
236
+ },
237
+ [columnKey, updateColumnScroll],
238
+ );
239
+
240
+ const handlePointerEnter = useCallback(() => {
241
+ onPointerEnter(columnKey);
242
+ }, [columnKey, onPointerEnter]);
243
+
244
+ const handleMinimizeToggle = useCallback(() => {
245
+ onMinimizeToggle(columnKey);
246
+ }, [columnKey, onMinimizeToggle]);
247
+
248
+ const handleMinimizedPress = useCallback(() => {
249
+ onMinimizeToggle(columnKey);
250
+ }, [columnKey, onMinimizeToggle]);
251
+
252
+ const flatListGetItemLayout = useCallback(
253
+ (_: ArrayLike<KanbanItem<T>> | null | undefined, itemIndex: number) => {
254
+ if (typeof itemHeight === "number") {
255
+ return {
256
+ length: itemHeight,
257
+ offset: itemIndex * (itemHeight + itemGap),
258
+ index: itemIndex,
259
+ };
260
+ }
261
+ let offset = 0;
262
+ for (let i = 0; i < itemIndex; i++) {
263
+ offset += getHeight(items[i].data, i) + itemGap;
264
+ }
265
+ return {
266
+ length: getHeight(items[itemIndex]?.data, itemIndex),
267
+ offset,
268
+ index: itemIndex,
269
+ };
270
+ },
271
+ [items, itemHeight, itemGap, getHeight],
272
+ );
273
+
274
+ // O(1) lookup map for item id -> index
275
+ const itemIndexMap = useMemo(() => {
276
+ const map = new Map<string, number>();
277
+ items.forEach((item, idx) => map.set(item.id, idx));
278
+ return map;
279
+ }, [items]);
280
+
281
+ // Separator that hides when after dragged card that renders null
282
+ // When onAddCardAtIndex is provided, cards render their own gaps with insert zones
283
+ const renderSeparator = useCallback(
284
+ (props: { leadingItem: KanbanItem<T> | null }) => {
285
+ // When insert zones are enabled, cards render their own gaps
286
+ if (onAddCardAtIndex) {
287
+ return null;
288
+ }
289
+
290
+ if (!props.leadingItem) {
291
+ return <ItemSeparator height={itemGap} />;
292
+ }
293
+
294
+ const isLeadingDragging = cardDragIdRef.current === props.leadingItem.id;
295
+ const leadingIndex = itemIndexMap.get(props.leadingItem.id) ?? -1;
296
+
297
+ if (isLeadingDragging) {
298
+ // Dragged card renders null unless it's also the drop target
299
+ const isLeadingDropTarget = cardDropTargetIndexRef.current === leadingIndex;
300
+
301
+ // Skip separator after dragged card that renders null
302
+ if (!isLeadingDropTarget) {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ return <ItemSeparator height={itemGap} />;
308
+ },
309
+ [itemGap, itemIndexMap, onAddCardAtIndex],
310
+ );
311
+
312
+ const renderItem: ListRenderItem<KanbanItem<T>> = useCallback(
313
+ ({ item, index: itemIndex }) => {
314
+ const isCardDragging = cardDragIdRef.current === item.id;
315
+ const isCardDropTarget = cardDropTargetIndexRef.current === itemIndex;
316
+ const height = getHeight(item.data, itemIndex);
317
+
318
+ return (
319
+ <KanbanCard
320
+ id={item.id}
321
+ columnKey={columnKey}
322
+ index={itemIndex}
323
+ item={item.data}
324
+ renderCard={renderCard}
325
+ isDragging={isCardDragging}
326
+ isDropTarget={isCardDropTarget}
327
+ isDragInProgress={isDragInProgress}
328
+ onCardPress={onCardPress}
329
+ onAddCardAtIndex={onAddCardAtIndex}
330
+ renderInsertCardButton={renderInsertCardButton}
331
+ startCardDrag={startCardDrag}
332
+ placeholderHeight={height}
333
+ // Only pass draggedCardHeight to drop target - prevents all cards from re-rendering
334
+ draggedCardHeight={isCardDropTarget ? draggedCardHeightRef.current : null}
335
+ itemGap={itemGap}
336
+ />
337
+ );
338
+ },
339
+ [
340
+ columnKey,
341
+ renderCard,
342
+ onCardPress,
343
+ onAddCardAtIndex,
344
+ renderInsertCardButton,
345
+ startCardDrag,
346
+ getHeight,
347
+ itemGap,
348
+ isDragInProgress,
349
+ ],
350
+ );
351
+
352
+ const handleAddCard = useCallback(() => {
353
+ onAddCard?.(columnKey);
354
+ }, [onAddCard, columnKey]);
355
+
356
+ // Stable footer renderer using memoized component
357
+ const renderFooter = useCallback(() => {
358
+ if (cardDropTargetIndexRef.current === null) return null;
359
+ if (cardDropTargetIndexRef.current !== itemsLengthRef.current) return null;
360
+
361
+ const height = draggedCardHeightRef.current ?? 60;
362
+ return <FooterPlaceholder height={height} />;
363
+ }, []);
364
+
365
+ // Tells FlatList when to re-render items (refs don't trigger re-renders)
366
+ const listExtraData = useMemo(
367
+ () => [cardDragId, cardDropTargetIndex, draggedCardHeight],
368
+ [cardDragId, cardDropTargetIndex, draggedCardHeight],
369
+ );
370
+
371
+ // Stable container styles
372
+ const containerStyle = useMemo(() => [styles.container, { width: columnWidth }], [columnWidth]);
373
+
374
+ // List content style - remove top padding when insert zones are enabled
375
+ // since first card renders its own gap
376
+ const listContentStyle = useMemo(
377
+ () => (onAddCardAtIndex ? [styles.listContent, { paddingTop: 0 }] : styles.listContent),
378
+ [onAddCardAtIndex],
379
+ );
380
+ const minimizedContainerStyle = useMemo(
381
+ () => ({ width: columnWidth, height: "100%" as const }),
382
+ [columnWidth],
383
+ );
384
+
385
+ if (isDragging) {
386
+ return isDropTarget ? (
387
+ <ColumnPlaceholder width={placeholderWidth} height={placeholderHeight} />
388
+ ) : null;
389
+ }
390
+
391
+ if (isMinimized) {
392
+ const showDropIndicator = showCardDropHighlight || cardDropTargetIndex !== null;
393
+
394
+ return (
395
+ <>
396
+ {isDropTarget && <ColumnPlaceholder width={placeholderWidth} height={placeholderHeight} />}
397
+ <View
398
+ ref={containerRef}
399
+ style={minimizedContainerStyle}
400
+ onPointerEnter={handlePointerEnter}
401
+ >
402
+ {renderCollapsedColumn({
403
+ columnKey,
404
+ title,
405
+ itemCount: items.length,
406
+ showDropIndicator,
407
+ onExpand: handleMinimizedPress,
408
+ })}
409
+ </View>
410
+ </>
411
+ );
412
+ }
413
+
414
+ return (
415
+ <>
416
+ {isDropTarget && <ColumnPlaceholder width={placeholderWidth} height={placeholderHeight} />}
417
+ <View ref={containerRef} style={containerStyle} onPointerEnter={handlePointerEnter}>
418
+ <Pressable onLayout={handleHeaderLayout} onPressIn={handleHeaderPressIn}>
419
+ {renderColumnHeader({
420
+ columnKey,
421
+ title,
422
+ itemCount: items.length,
423
+ isMinimized,
424
+ onMinimizeToggle: handleMinimizeToggle,
425
+ })}
426
+ </Pressable>
427
+ <FlatList
428
+ ref={listRef}
429
+ data={items}
430
+ extraData={listExtraData}
431
+ keyExtractor={keyExtractor}
432
+ renderItem={renderItem}
433
+ contentContainerStyle={listContentStyle}
434
+ ItemSeparatorComponent={renderSeparator}
435
+ ListFooterComponent={renderFooter}
436
+ showsVerticalScrollIndicator={true}
437
+ onScroll={handleScroll}
438
+ scrollEventThrottle={16}
439
+ getItemLayout={flatListGetItemLayout}
440
+ // Performance optimizations
441
+ removeClippedSubviews={false} // Keep false for smooth drag animations
442
+ maxToRenderPerBatch={10}
443
+ updateCellsBatchingPeriod={50}
444
+ windowSize={5} // Render 5 screens worth (enough for most columns)
445
+ initialNumToRender={15}
446
+ />
447
+ {onAddCard && (
448
+ <View style={styles.addCardFooter}>
449
+ {renderAddCardPlaceholder({
450
+ columnKey,
451
+ onPress: handleAddCard,
452
+ })}
453
+ </View>
454
+ )}
455
+ {showCardDropHighlight && <View style={styles.cardDropHighlight} />}
456
+ </View>
457
+ </>
458
+ );
459
+ }
460
+
461
+ export const KanbanColumn = memo(KanbanColumnInner) as typeof KanbanColumnInner;
462
+
463
+ const styles = StyleSheet.create({
464
+ container: {
465
+ backgroundColor: colors.zinc[50],
466
+ borderRadius: 16,
467
+ maxHeight: "100%",
468
+ },
469
+ listContent: {
470
+ padding: COLUMN_CONTENT_PADDING,
471
+ },
472
+ addCardFooter: {
473
+ padding: COLUMN_CONTENT_PADDING,
474
+ paddingTop: 0,
475
+ },
476
+ cardDropHighlight: {
477
+ position: "absolute",
478
+ top: 0,
479
+ left: 0,
480
+ right: 0,
481
+ bottom: 0,
482
+ backgroundColor: colors.blue[100],
483
+ opacity: 0.5,
484
+ borderRadius: 8,
485
+ pointerEvents: "none",
486
+ },
487
+ });
@@ -0,0 +1,54 @@
1
+ import React, { memo } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors } from "../colors";
4
+
5
+ interface CardPlaceholderProps {
6
+ height: number;
7
+ }
8
+
9
+ /**
10
+ * Placeholder shown when dragging a card to a new position
11
+ */
12
+ export const CardPlaceholder = memo(function CardPlaceholder({ height }: CardPlaceholderProps) {
13
+ return <View style={[styles.cardPlaceholder, { height }]} />;
14
+ });
15
+
16
+ interface ColumnPlaceholderProps {
17
+ width: number;
18
+ height?: number;
19
+ }
20
+
21
+ /**
22
+ * Placeholder shown when dragging a column to a new position
23
+ */
24
+ export const ColumnPlaceholder = memo(function ColumnPlaceholder({
25
+ width,
26
+ height,
27
+ }: ColumnPlaceholderProps) {
28
+ return (
29
+ <View style={[styles.columnPlaceholder, { width, height }]}>
30
+ <View style={styles.columnPlaceholderContent} />
31
+ </View>
32
+ );
33
+ });
34
+
35
+ const styles = StyleSheet.create({
36
+ cardPlaceholder: {
37
+ backgroundColor: colors.blue[50],
38
+ borderRadius: 8,
39
+ borderWidth: 2,
40
+ borderColor: colors.blue[300],
41
+ boxSizing: "border-box",
42
+ },
43
+ columnPlaceholder: {
44
+ minHeight: 200,
45
+ backgroundColor: colors.blue[50],
46
+ borderRadius: 8,
47
+ borderWidth: 2,
48
+ borderColor: colors.blue[300],
49
+ },
50
+ columnPlaceholderContent: {
51
+ flex: 1,
52
+ minHeight: 100,
53
+ },
54
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Represents a single item/card in the Kanban board
3
+ */
4
+ export interface KanbanItem<T = unknown> {
5
+ id: string;
6
+ data: T;
7
+ }
8
+
9
+ /**
10
+ * Represents a column in the Kanban board
11
+ */
12
+ export interface KanbanColumn<T = unknown> {
13
+ key: string;
14
+ title: string;
15
+ items: KanbanItem<T>[];
16
+ }
17
+
18
+ /**
19
+ * Props passed to the renderCard function
20
+ */
21
+ export interface KanbanRenderCardProps<T = unknown> {
22
+ item: T;
23
+ columnKey: string;
24
+ id: string;
25
+ hovered: boolean;
26
+ isDragging: boolean;
27
+ }
28
+
29
+ /**
30
+ * Props passed to the renderColumnHeader function
31
+ */
32
+ export interface KanbanRenderColumnHeaderProps {
33
+ columnKey: string;
34
+ title: string;
35
+ itemCount: number;
36
+ isMinimized: boolean;
37
+ onMinimizeToggle: () => void;
38
+ }
39
+
40
+ /**
41
+ * Information about the current drag operation
42
+ */
43
+ export interface DragInfo {
44
+ type: "card" | "column";
45
+ id: string;
46
+ sourceColumnKey: string;
47
+ sourceIndex: number;
48
+ }
49
+
50
+ /**
51
+ * Information about the current drop target
52
+ */
53
+ export interface DropTarget {
54
+ type: "card" | "column";
55
+ columnKey: string;
56
+ index: number;
57
+ }
58
+
59
+ /**
60
+ * Drag preview dimensions and offset
61
+ */
62
+ export interface DragPreviewInfo {
63
+ width: number;
64
+ height: number;
65
+ offsetX: number;
66
+ offsetY: number;
67
+ }
68
+
69
+ /**
70
+ * Result of a card move operation
71
+ */
72
+ export interface CardMoveResult<T = unknown> {
73
+ cardId: string;
74
+ cardData: T;
75
+ sourceColumnKey: string;
76
+ sourceIndex: number;
77
+ targetColumnKey: string;
78
+ targetIndex: number;
79
+ }
80
+
81
+ /**
82
+ * Result of a column move operation
83
+ */
84
+ export interface ColumnMoveResult {
85
+ columnKey: string;
86
+ sourceIndex: number;
87
+ targetIndex: number;
88
+ }
89
+
90
+ /**
91
+ * Props passed to the renderAddCardPlaceholder function
92
+ */
93
+ export interface KanbanRenderAddCardPlaceholderProps {
94
+ columnKey: string;
95
+ onPress: () => void;
96
+ }
97
+
98
+ /**
99
+ * Props passed to the renderInsertCardButton function
100
+ */
101
+ export interface KanbanRenderInsertCardButtonProps {
102
+ columnKey: string;
103
+ index: number;
104
+ onPress: () => void;
105
+ }
106
+
107
+ /**
108
+ * Props passed to the renderCollapsedColumn function
109
+ */
110
+ export interface KanbanRenderCollapsedColumnProps {
111
+ columnKey: string;
112
+ title: string;
113
+ itemCount: number;
114
+ showDropIndicator: boolean;
115
+ onExpand: () => void;
116
+ }