react-native-smart-grid 0.1.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.
Files changed (80) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +554 -0
  3. package/lib/module/components/DragLayer.js +71 -0
  4. package/lib/module/components/DragLayer.js.map +1 -0
  5. package/lib/module/components/DraggableTile.js +79 -0
  6. package/lib/module/components/DraggableTile.js.map +1 -0
  7. package/lib/module/components/GhostTile.js +37 -0
  8. package/lib/module/components/GhostTile.js.map +1 -0
  9. package/lib/module/components/GridTile.js +25 -0
  10. package/lib/module/components/GridTile.js.map +1 -0
  11. package/lib/module/components/ResizeHandle.js +72 -0
  12. package/lib/module/components/ResizeHandle.js.map +1 -0
  13. package/lib/module/components/SmartGrid.js +363 -0
  14. package/lib/module/components/SmartGrid.js.map +1 -0
  15. package/lib/module/context/GridDragContext.js +130 -0
  16. package/lib/module/context/GridDragContext.js.map +1 -0
  17. package/lib/module/engine/GridEngine.js +148 -0
  18. package/lib/module/engine/GridEngine.js.map +1 -0
  19. package/lib/module/engine/autoArrange.js +54 -0
  20. package/lib/module/engine/autoArrange.js.map +1 -0
  21. package/lib/module/engine/collisions.js +67 -0
  22. package/lib/module/engine/collisions.js.map +1 -0
  23. package/lib/module/hooks/useTileGesture.js +62 -0
  24. package/lib/module/hooks/useTileGesture.js.map +1 -0
  25. package/lib/module/index.js +9 -0
  26. package/lib/module/index.js.map +1 -0
  27. package/lib/module/layout/LayoutCalculator.js +29 -0
  28. package/lib/module/layout/LayoutCalculator.js.map +1 -0
  29. package/lib/module/package.json +1 -0
  30. package/lib/module/types.js +2 -0
  31. package/lib/module/types.js.map +1 -0
  32. package/lib/module/utils/pixelToGrid.js +22 -0
  33. package/lib/module/utils/pixelToGrid.js.map +1 -0
  34. package/lib/typescript/package.json +1 -0
  35. package/lib/typescript/src/components/DragLayer.d.ts +11 -0
  36. package/lib/typescript/src/components/DragLayer.d.ts.map +1 -0
  37. package/lib/typescript/src/components/DraggableTile.d.ts +14 -0
  38. package/lib/typescript/src/components/DraggableTile.d.ts.map +1 -0
  39. package/lib/typescript/src/components/GhostTile.d.ts +9 -0
  40. package/lib/typescript/src/components/GhostTile.d.ts.map +1 -0
  41. package/lib/typescript/src/components/GridTile.d.ts +9 -0
  42. package/lib/typescript/src/components/GridTile.d.ts.map +1 -0
  43. package/lib/typescript/src/components/ResizeHandle.d.ts +9 -0
  44. package/lib/typescript/src/components/ResizeHandle.d.ts.map +1 -0
  45. package/lib/typescript/src/components/SmartGrid.d.ts +214 -0
  46. package/lib/typescript/src/components/SmartGrid.d.ts.map +1 -0
  47. package/lib/typescript/src/context/GridDragContext.d.ts +44 -0
  48. package/lib/typescript/src/context/GridDragContext.d.ts.map +1 -0
  49. package/lib/typescript/src/engine/GridEngine.d.ts +35 -0
  50. package/lib/typescript/src/engine/GridEngine.d.ts.map +1 -0
  51. package/lib/typescript/src/engine/autoArrange.d.ts +4 -0
  52. package/lib/typescript/src/engine/autoArrange.d.ts.map +1 -0
  53. package/lib/typescript/src/engine/collisions.d.ts +3 -0
  54. package/lib/typescript/src/engine/collisions.d.ts.map +1 -0
  55. package/lib/typescript/src/hooks/useTileGesture.d.ts +13 -0
  56. package/lib/typescript/src/hooks/useTileGesture.d.ts.map +1 -0
  57. package/lib/typescript/src/index.d.ts +10 -0
  58. package/lib/typescript/src/index.d.ts.map +1 -0
  59. package/lib/typescript/src/layout/LayoutCalculator.d.ts +15 -0
  60. package/lib/typescript/src/layout/LayoutCalculator.d.ts.map +1 -0
  61. package/lib/typescript/src/types.d.ts +105 -0
  62. package/lib/typescript/src/types.d.ts.map +1 -0
  63. package/lib/typescript/src/utils/pixelToGrid.d.ts +9 -0
  64. package/lib/typescript/src/utils/pixelToGrid.d.ts.map +1 -0
  65. package/package.json +161 -0
  66. package/src/components/DragLayer.tsx +71 -0
  67. package/src/components/DraggableTile.tsx +88 -0
  68. package/src/components/GhostTile.tsx +42 -0
  69. package/src/components/GridTile.tsx +27 -0
  70. package/src/components/ResizeHandle.tsx +74 -0
  71. package/src/components/SmartGrid.tsx +506 -0
  72. package/src/context/GridDragContext.tsx +191 -0
  73. package/src/engine/GridEngine.ts +148 -0
  74. package/src/engine/autoArrange.ts +59 -0
  75. package/src/engine/collisions.ts +87 -0
  76. package/src/hooks/useTileGesture.ts +88 -0
  77. package/src/index.tsx +29 -0
  78. package/src/layout/LayoutCalculator.ts +50 -0
  79. package/src/types.ts +113 -0
  80. package/src/utils/pixelToGrid.ts +31 -0
@@ -0,0 +1,506 @@
1
+ import React, {
2
+ forwardRef,
3
+ useCallback,
4
+ useImperativeHandle,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
9
+ import {
10
+ ScrollView,
11
+ StyleSheet,
12
+ View,
13
+ type NativeScrollEvent,
14
+ type NativeSyntheticEvent,
15
+ } from 'react-native';
16
+ import type { Tile, PlacedTile, GridConfig, CollisionBehavior, Gravity, TilePosition, TileSize, HapticEvent, LayoutItem } from '../types';
17
+ import { GridEngine } from '../engine/GridEngine';
18
+ import { resolveCollisions } from '../engine/collisions';
19
+ import { autoArrange as autoArrangeAlgo, applyGravity } from '../engine/autoArrange';
20
+ import {
21
+ tileToPixelRect,
22
+ gridTotalHeight,
23
+ isInViewport,
24
+ } from '../layout/LayoutCalculator';
25
+ import { GridDragProvider, useGridDrag } from '../context/GridDragContext';
26
+ import { DraggableTile } from './DraggableTile';
27
+ import { GhostTile } from './GhostTile';
28
+ import { DragLayer } from './DragLayer';
29
+
30
+ /** Imperative handle exposed via `ref`. */
31
+ export type SmartGridRef = {
32
+ /** Re-packs all tiles using largest-first bin-packing. Fires `onLayoutChange`. */
33
+ autoArrange: () => void;
34
+ /** Returns a plain `LayoutItem[]` you can JSON.stringify and save anywhere. Does not include tile `data`. */
35
+ serializeLayout: () => LayoutItem[];
36
+ /** Restores a previously serialized layout. Fires `onLayoutChange`. */
37
+ restoreLayout: (layout: LayoutItem[]) => void;
38
+ /** Clears the entire selection. Equivalent to `setSelection([])`. */
39
+ clearSelection: () => void;
40
+ /** Programmatically set the selected tile IDs. Pass `[]` to clear. Fires `onSelectionChange`. */
41
+ setSelection: (ids: string[]) => void;
42
+ };
43
+
44
+ /** Argument passed to `renderTile`. */
45
+ export type RenderTileInfo<TData> = {
46
+ /** The tile being rendered, including your custom `data`. */
47
+ item: Tile<TData>;
48
+ /** `true` while this tile is actively being dragged (the placeholder left behind). */
49
+ isActive: boolean;
50
+ /**
51
+ * `true` when this tile is in the current selection.
52
+ * Selection is entered via long press — either immediately (when `draggable={false}`)
53
+ * or on release without moving (when draggable). Use to show contextual actions like a delete button.
54
+ */
55
+ isSelected: boolean;
56
+ };
57
+
58
+ /**
59
+ * Props for `SmartGrid`.
60
+ *
61
+ * `position` and `size` on each tile are optional:
62
+ * - Both provided → tile is placed exactly where specified.
63
+ * - `size` only → tile is auto-placed via bin-packing.
64
+ * - Neither → tile defaults to 1×1 and is auto-placed in order.
65
+ */
66
+ export type SmartGridProps<TData = unknown> = {
67
+ /** Array of tiles to render. `position` and `size` are optional — omit them to let the grid auto-place. */
68
+ data: Tile<TData>[];
69
+ /** Render function for each tile. Return any React Native view — SmartGrid handles absolute positioning. */
70
+ renderTile: (info: RenderTileInfo<TData>) => React.ReactNode;
71
+
72
+ /** Number of grid columns. @default 4 */
73
+ columns?: number;
74
+ /** Height of one grid row in pixels. @default 100 */
75
+ rowHeight?: number;
76
+ /** Gap between tiles in pixels. @default 8 */
77
+ gap?: number;
78
+ /** Outer padding of the grid in pixels. @default 8 */
79
+ padding?: number;
80
+
81
+ /**
82
+ * How dropped tiles interact with tiles already occupying the target space.
83
+ * - `'push'` — displaced tiles are moved to the next available slot.
84
+ * - `'swap'` — dragged tile and the tile at the drop center exchange positions.
85
+ * @default 'push'
86
+ */
87
+ collisionBehavior?: CollisionBehavior;
88
+ /**
89
+ * Compact tiles toward the origin after every drop.
90
+ * - `'none'` — tiles stay where dropped.
91
+ * - `'up'` — tiles slide upward to fill empty rows.
92
+ * - `'left'` — tiles slide left to fill empty columns.
93
+ * @default 'none'
94
+ */
95
+ gravity?: Gravity;
96
+ /** When `true`, shows a resize handle on each tile's bottom-right corner. @default false */
97
+ isEditing?: boolean;
98
+ /**
99
+ * Master switch — when `false`, no tile in the grid can be dragged regardless of individual `tile.draggable` flags.
100
+ * @default true
101
+ */
102
+ draggable?: boolean;
103
+ /**
104
+ * Master switch — when `false`, no tile in the grid can be selected regardless of individual `tile.selectable` flags.
105
+ * @default true
106
+ */
107
+ selectable?: boolean;
108
+ /**
109
+ * When `true`, long-pressing multiple tiles adds them all to the selection array.
110
+ * When `false`, selecting a tile deselects all others (single-select mode).
111
+ * @default true
112
+ */
113
+ multiSelect?: boolean;
114
+
115
+ /** Called after every drag, drop, or resize with the updated `LayoutItem[]`. Merge this back into your state to keep the grid in sync. */
116
+ onLayoutChange?: (layout: LayoutItem[]) => void;
117
+ /** Called when the user taps a tile (quick press, no drag). Use this to open detail views, modals, folders, etc. */
118
+ onTilePress?: (tile: Tile<TData>) => void;
119
+ /** Called when the user begins dragging a tile (fires after the 300ms long-press activates, before the first move). */
120
+ onTileDragStart?: (tile: Tile<TData>) => void;
121
+ /** Called when a tile is dropped at a new position. */
122
+ onTileDrop?: (tile: Tile<TData>, position: TilePosition) => void;
123
+ /** Called when a tile is resized via the resize handle. */
124
+ onTileResize?: (tile: Tile<TData>, newSize: TileSize) => void;
125
+ /**
126
+ * Called whenever the selection array changes. Receives the full array of currently selected tile IDs.
127
+ *
128
+ * Selection changes when:
129
+ * - Long press on a tile toggles it (immediately if `draggable={false}`, on release otherwise).
130
+ * - Tap on a tile while selection is active toggles it.
131
+ * - A real drag-and-drop clears the entire selection.
132
+ * - `clearSelection()` or `setSelection()` is called on the ref.
133
+ */
134
+ onSelectionChange?: (ids: string[]) => void;
135
+ /**
136
+ * Called at key interaction moments so you can trigger haptic feedback
137
+ * with whichever haptics library you prefer — no dependency added.
138
+ *
139
+ * @example
140
+ * onHaptic={(event) => {
141
+ * if (event === 'pick-up') HapticFeedback.trigger('impactMedium');
142
+ * if (event === 'snap') HapticFeedback.trigger('selection');
143
+ * if (event === 'drop') HapticFeedback.trigger('notificationSuccess');
144
+ * if (event === 'resize') HapticFeedback.trigger('impactLight');
145
+ * }}
146
+ */
147
+ onHaptic?: (event: HapticEvent) => void;
148
+ };
149
+
150
+ // ── Normalization ─────────────────────────────────────────────────────────────
151
+
152
+ function normalizeTiles<TData>(tiles: Tile<TData>[], columns: number): PlacedTile<TData>[] {
153
+ const sized = tiles.map((t) => ({ ...t, size: t.size ?? { w: 1, h: 1 } }));
154
+ const withPos = sized.filter((t): t is typeof t & { position: TilePosition } => t.position != null);
155
+ const withoutPos = sized.filter((t) => t.position == null);
156
+
157
+ if (withoutPos.length === 0) return withPos as PlacedTile<TData>[];
158
+
159
+ const engine = new GridEngine(columns);
160
+ for (const t of withPos) engine.placeAt(t.id, t.position, t.size);
161
+
162
+ const result: PlacedTile<TData>[] = [...(withPos as PlacedTile<TData>[])];
163
+ for (const t of withoutPos) {
164
+ const pos = engine.findFirstFit(t.size);
165
+ if (pos) {
166
+ engine.placeAt(t.id, pos, t.size);
167
+ result.push({ ...t, position: pos } as PlacedTile<TData>);
168
+ }
169
+ }
170
+ return result;
171
+ }
172
+
173
+ // ── Inner component (has access to GridDragContext) ───────────────────────────
174
+
175
+ type InnerProps<TData> = Omit<SmartGridProps<TData>, 'data'> & { data: PlacedTile<TData>[] };
176
+
177
+ function SmartGridInner<TData>({
178
+ data,
179
+ renderTile,
180
+ columns = 4,
181
+ rowHeight = 100,
182
+ gap = 8,
183
+ padding = 8,
184
+ }: InnerProps<TData>) {
185
+ const [containerWidth, setContainerWidth] = useState(0);
186
+ const [viewportHeight, setViewportHeight] = useState(0);
187
+ const scrollTickRef = useRef(0);
188
+ const [scrollTick, setScrollTick] = useState(0);
189
+
190
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
191
+ const containerRef = useRef<any>(null);
192
+ const drag = useGridDrag();
193
+
194
+ const config: GridConfig = useMemo(
195
+ () => ({ columns, rowHeight, gap, padding }),
196
+ [columns, rowHeight, gap, padding]
197
+ );
198
+
199
+ const totalHeight = useMemo(() => gridTotalHeight(data, config), [data, config]);
200
+
201
+ const handleLayout = useCallback(
202
+ (e: { nativeEvent: { layout: { width: number; height: number } } }) => {
203
+ setContainerWidth(e.nativeEvent.layout.width);
204
+ setViewportHeight(e.nativeEvent.layout.height);
205
+ containerRef.current?.measure(
206
+ (_x: number, _y: number, _w: number, _h: number, pageX: number, pageY: number) => {
207
+ drag.containerPageX.current = pageX;
208
+ drag.containerPageY.current = pageY;
209
+ }
210
+ );
211
+ },
212
+ [drag]
213
+ );
214
+
215
+ const handleScroll = useCallback(
216
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
217
+ drag.scrollYRef.current = e.nativeEvent.contentOffset.y;
218
+ scrollTickRef.current += 1;
219
+ setScrollTick(scrollTickRef.current);
220
+ },
221
+ [drag]
222
+ );
223
+
224
+ // Compute rect once per tile and keep it alongside the tile — avoids a second
225
+ // tileToPixelRect call in the map below.
226
+ const visibleTiles = useMemo(() => {
227
+ if (containerWidth === 0) return [];
228
+ const result: Array<{ tile: PlacedTile<TData>; rect: ReturnType<typeof tileToPixelRect> }> = [];
229
+ for (const tile of data) {
230
+ const rect = tileToPixelRect(tile.position, tile.size, config, containerWidth);
231
+ if (isInViewport(rect, drag.scrollYRef.current, viewportHeight)) {
232
+ result.push({ tile, rect });
233
+ }
234
+ }
235
+ return result;
236
+ // eslint-disable-next-line react-hooks/exhaustive-deps
237
+ }, [data, config, containerWidth, viewportHeight, scrollTick]);
238
+
239
+ // Set for O(1) isSelected lookup instead of O(n) Array.includes per rendered tile
240
+ const selectedSet = useMemo(
241
+ () => new Set(drag.selectedTileIds),
242
+ [drag.selectedTileIds]
243
+ );
244
+
245
+ return (
246
+ <View style={styles.root} ref={containerRef} onLayout={handleLayout}>
247
+ <ScrollView
248
+ onScroll={handleScroll}
249
+ scrollEventThrottle={16}
250
+ scrollEnabled={!drag.activeTile}
251
+ style={styles.scroll}
252
+ >
253
+ <View style={[styles.canvas, { height: totalHeight }]}>
254
+ {containerWidth > 0 &&
255
+ visibleTiles.map(({ tile, rect }) => {
256
+ const isActive = drag.activeTile?.id === tile.id;
257
+ const isSelected = selectedSet.has(tile.id);
258
+ return (
259
+ <DraggableTile
260
+ key={tile.id}
261
+ tile={tile}
262
+ rect={rect}
263
+ config={config}
264
+ containerWidth={containerWidth}
265
+ >
266
+ {renderTile({ item: tile, isActive, isSelected })}
267
+ </DraggableTile>
268
+ );
269
+ })}
270
+ {drag.activeTile && containerWidth > 0 && (
271
+ <GhostTile
272
+ activeTile={drag.activeTile}
273
+ config={config}
274
+ containerWidth={containerWidth}
275
+ />
276
+ )}
277
+ </View>
278
+ </ScrollView>
279
+
280
+ {drag.activeTile && drag.initialRect && (
281
+ <DragLayer tile={drag.activeTile} initialRect={drag.initialRect}>
282
+ {renderTile({ item: drag.activeTile as Tile<TData>, isActive: true, isSelected: false })}
283
+ </DragLayer>
284
+ )}
285
+ </View>
286
+ );
287
+ }
288
+
289
+ // ── Public component (provides context + ref handle) ─────────────────────────
290
+
291
+ function SmartGridWithRef<TData = unknown>(
292
+ props: SmartGridProps<TData>,
293
+ ref: React.ForwardedRef<SmartGridRef>
294
+ ) {
295
+ const columns = props.columns ?? 4;
296
+ const gravity = props.gravity ?? 'none';
297
+
298
+ const [selectedTileIds, setSelectedTileIds] = useState<string[]>([]);
299
+
300
+ const normalizedData = useMemo(
301
+ () => normalizeTiles(props.data, columns),
302
+ [props.data, columns]
303
+ );
304
+
305
+ const handleSelect = useCallback(
306
+ (ids: string[]) => {
307
+ setSelectedTileIds(ids);
308
+ props.onSelectionChange?.(ids);
309
+ },
310
+ // eslint-disable-next-line react-hooks/exhaustive-deps
311
+ [props.onSelectionChange]
312
+ );
313
+
314
+ useImperativeHandle(
315
+ ref,
316
+ () => ({
317
+ autoArrange() {
318
+ const arranged = autoArrangeAlgo(normalizedData as PlacedTile[], columns);
319
+ props.onLayoutChange?.(
320
+ arranged.map((t) => ({ id: t.id, position: t.position, size: t.size }))
321
+ );
322
+ },
323
+ serializeLayout() {
324
+ return normalizedData.map((t) => ({ id: t.id, position: t.position, size: t.size }));
325
+ },
326
+ restoreLayout(layout) {
327
+ props.onLayoutChange?.(layout);
328
+ },
329
+ clearSelection() {
330
+ handleSelect([]);
331
+ },
332
+ setSelection(ids: string[]) {
333
+ handleSelect(ids);
334
+ },
335
+ }),
336
+ // eslint-disable-next-line react-hooks/exhaustive-deps
337
+ [normalizedData, columns, props.onLayoutChange, handleSelect]
338
+ );
339
+
340
+ const handleDrop = useCallback(
341
+ (tile: PlacedTile, newPosition: TilePosition) => {
342
+ const afterCollision = resolveCollisions(
343
+ normalizedData as PlacedTile[],
344
+ tile.id,
345
+ newPosition,
346
+ props.collisionBehavior ?? 'push',
347
+ columns
348
+ );
349
+ const afterGravity = applyGravity(afterCollision, columns, gravity);
350
+ const newLayout = afterGravity.map((t) => ({ id: t.id, position: t.position, size: t.size }));
351
+
352
+ const layoutChanged = newLayout.some((item) => {
353
+ const prev = normalizedData.find((t) => t.id === item.id);
354
+ return !prev || prev.position.x !== item.position.x || prev.position.y !== item.position.y;
355
+ });
356
+ if (!layoutChanged) return;
357
+
358
+ props.onTileDrop?.(tile as Tile<TData>, newPosition);
359
+ props.onLayoutChange?.(newLayout);
360
+ },
361
+ // eslint-disable-next-line react-hooks/exhaustive-deps
362
+ [normalizedData, props.collisionBehavior, columns, gravity, props.onTileDrop, props.onLayoutChange]
363
+ );
364
+
365
+ const handleResize = useCallback(
366
+ (tile: PlacedTile, newSize: TileSize) => {
367
+ const withNewSize = normalizedData.map((t) =>
368
+ t.id === tile.id ? { ...t, size: newSize } : t
369
+ );
370
+ const afterCollision = resolveCollisions(
371
+ withNewSize as PlacedTile[],
372
+ tile.id,
373
+ (tile as PlacedTile).position,
374
+ props.collisionBehavior ?? 'push',
375
+ columns
376
+ );
377
+ const newLayout = afterCollision.map((t) => ({ id: t.id, position: t.position, size: t.size }));
378
+ props.onTileResize?.(tile as Tile<TData>, newSize);
379
+ props.onLayoutChange?.(newLayout);
380
+ },
381
+ // eslint-disable-next-line react-hooks/exhaustive-deps
382
+ [normalizedData, props.collisionBehavior, columns, props.onTileResize, props.onLayoutChange]
383
+ );
384
+
385
+ return (
386
+ <GridDragProvider
387
+ isEditing={props.isEditing ?? false}
388
+ draggable={props.draggable ?? true}
389
+ selectable={props.selectable ?? true}
390
+ multiSelect={props.multiSelect ?? true}
391
+ selectedTileIds={selectedTileIds}
392
+ onDrop={handleDrop}
393
+ onResize={handleResize}
394
+ onSelect={handleSelect}
395
+ onTilePress={props.onTilePress as ((tile: PlacedTile) => void) | undefined}
396
+ onHaptic={props.onHaptic}
397
+ >
398
+ <SmartGridInner {...props} data={normalizedData} />
399
+ </GridDragProvider>
400
+ );
401
+ }
402
+
403
+ /**
404
+ * A draggable, variable-sized tile grid for React Native.
405
+ *
406
+ * ---
407
+ *
408
+ * ### Basic usage
409
+ * ```tsx
410
+ * const gridRef = useRef<SmartGridRef>(null);
411
+ *
412
+ * <SmartGrid
413
+ * ref={gridRef}
414
+ * data={tiles}
415
+ * columns={4}
416
+ * rowHeight={100}
417
+ * gap={8}
418
+ * collisionBehavior="push"
419
+ * gravity="up"
420
+ * isEditing={isEditing}
421
+ * onLayoutChange={(layout) =>
422
+ * setTiles(prev =>
423
+ * prev.map(t => {
424
+ * const updated = layout.find(l => l.id === t.id);
425
+ * return updated ? { ...t, ...updated } : t;
426
+ * })
427
+ * )
428
+ * }
429
+ * renderTile={({ item, isActive, isSelected }) => (
430
+ * <View style={{ flex: 1, opacity: isActive ? 0.5 : 1,
431
+ * borderWidth: isSelected ? 2 : 0, borderColor: '#fff' }}>
432
+ * <Text>{item.data.label}</Text>
433
+ * </View>
434
+ * )}
435
+ * />
436
+ * ```
437
+ *
438
+ * ---
439
+ *
440
+ * ### Selection (long press + release)
441
+ * Long-pressing a tile and releasing without moving selects it.
442
+ * Tapping any tile while a selection is active toggles that tile too.
443
+ *
444
+ * ```tsx
445
+ * // Multi-select (default) — accumulate selections
446
+ * <SmartGrid
447
+ * multiSelect // true by default, can omit
448
+ * onSelectionChange={(ids) => console.log('selected:', ids)}
449
+ * renderTile={({ item, isSelected }) => (
450
+ * <View>
451
+ * <Text>{item.data.label}</Text>
452
+ * {isSelected && <Text>✕ Delete</Text>}
453
+ * </View>
454
+ * )}
455
+ * />
456
+ *
457
+ * // Single-select — picking a new tile deselects the previous one
458
+ * <SmartGrid
459
+ * multiSelect={false}
460
+ * onSelectionChange={([id]) => setActive(id ?? null)}
461
+ * />
462
+ *
463
+ * // Clear selection imperatively
464
+ * gridRef.current?.clearSelection();
465
+ *
466
+ * // Or set it programmatically
467
+ * gridRef.current?.setSelection(['tile-1', 'tile-3']);
468
+ * ```
469
+ *
470
+ * ---
471
+ *
472
+ * ### Grid-level draggable / selectable master switches
473
+ * These override all per-tile flags when set to `false`.
474
+ * ```tsx
475
+ * // View-only mode — nothing can be dragged or selected
476
+ * <SmartGrid draggable={false} selectable={false} data={tiles} ... />
477
+ *
478
+ * // Read-only layout — tiles are visible but locked in place
479
+ * <SmartGrid draggable={false} data={tiles} ... />
480
+ *
481
+ * // Selection disabled — long press does nothing (onTilePress still fires on tap)
482
+ * <SmartGrid selectable={false} data={tiles} ... />
483
+ * ```
484
+ *
485
+ * ### Per-tile draggable / selectable flags
486
+ * Fine-grained control per tile when the grid-level switches are on.
487
+ * ```tsx
488
+ * const tiles: Tile<MyData>[] = [
489
+ * { id: '1', data: { label: 'Free' } },
490
+ * // Stays pinned in place — cannot be dragged
491
+ * { id: '2', data: { label: 'Pinned' }, draggable: false },
492
+ * // Ignored by selection — tapping fires onTilePress instead
493
+ * { id: '3', data: { label: 'Info' }, selectable: false },
494
+ * ];
495
+ * ```
496
+ */
497
+ // Cast preserves the generic type parameter through forwardRef
498
+ export const SmartGrid = forwardRef(SmartGridWithRef) as <TData = unknown>(
499
+ props: SmartGridProps<TData> & { ref?: React.Ref<SmartGridRef> }
500
+ ) => React.ReactElement | null;
501
+
502
+ const styles = StyleSheet.create({
503
+ root: { flex: 1 },
504
+ scroll: { flex: 1 },
505
+ canvas: { position: 'relative' },
506
+ });
@@ -0,0 +1,191 @@
1
+ import React, { createContext, useContext, useRef, useState } from 'react';
2
+ import { useSharedValue } from 'react-native-reanimated';
3
+ import type { SharedValue } from 'react-native-reanimated';
4
+ import type { PlacedTile, TilePosition, TileSize, HapticEvent } from '../types';
5
+ import type { PixelRect } from '../layout/LayoutCalculator';
6
+
7
+ export type DragState = {
8
+ dragAbsX: SharedValue<number>;
9
+ dragAbsY: SharedValue<number>;
10
+ isDragging: SharedValue<boolean>;
11
+
12
+ activeTile: PlacedTile | null;
13
+ ghostPosition: TilePosition | null;
14
+ initialRect: PixelRect | null;
15
+ selectedTileIds: string[];
16
+
17
+ containerPageX: React.MutableRefObject<number>;
18
+ containerPageY: React.MutableRefObject<number>;
19
+ scrollYRef: React.MutableRefObject<number>;
20
+
21
+ startDrag: (tile: PlacedTile, rect: PixelRect) => void;
22
+ updateGhost: (pos: TilePosition) => void;
23
+ endDrag: (finalPosition: TilePosition | null) => void;
24
+ selectTile: (tile: PlacedTile) => void;
25
+ onTilePress: (tile: PlacedTile) => void;
26
+
27
+ isEditing: boolean;
28
+ /** Grid-level master switch — false disables drag on every tile. */
29
+ draggable: boolean;
30
+ /** Grid-level master switch — false disables selection on every tile. */
31
+ selectable: boolean;
32
+ commitResize: (tile: PlacedTile, newSize: TileSize) => void;
33
+ };
34
+
35
+ const GridDragContext = createContext<DragState | null>(null);
36
+
37
+ export function useGridDrag(): DragState {
38
+ const ctx = useContext(GridDragContext);
39
+ if (!ctx) throw new Error('useGridDrag must be used within SmartGrid');
40
+ return ctx;
41
+ }
42
+
43
+ type Props = {
44
+ children: React.ReactNode;
45
+ isEditing: boolean;
46
+ draggable: boolean;
47
+ selectable: boolean;
48
+ multiSelect: boolean;
49
+ selectedTileIds: string[];
50
+ onDrop: (tile: PlacedTile, newPosition: TilePosition) => void;
51
+ onResize: (tile: PlacedTile, newSize: TileSize) => void;
52
+ onSelect: (ids: string[]) => void;
53
+ onTilePress?: (tile: PlacedTile) => void;
54
+ onHaptic?: (event: HapticEvent) => void;
55
+ };
56
+
57
+ export function GridDragProvider({
58
+ children,
59
+ isEditing,
60
+ draggable,
61
+ selectable,
62
+ multiSelect,
63
+ selectedTileIds,
64
+ onDrop,
65
+ onResize,
66
+ onSelect,
67
+ onTilePress,
68
+ onHaptic,
69
+ }: Props) {
70
+ const dragAbsX = useSharedValue(0);
71
+ const dragAbsY = useSharedValue(0);
72
+ const isDragging = useSharedValue(false);
73
+
74
+ const [activeTile, setActiveTile] = useState<PlacedTile | null>(null);
75
+ const [ghostPosition, setGhostPosition] = useState<TilePosition | null>(null);
76
+ const [initialRect, setInitialRect] = useState<PixelRect | null>(null);
77
+
78
+ const ghostRef = useRef<TilePosition | null>(null);
79
+ const activeTileRef = useRef<PlacedTile | null>(null);
80
+
81
+ const containerPageX = useRef(0);
82
+ const containerPageY = useRef(0);
83
+ const scrollYRef = useRef(0);
84
+
85
+ function startDrag(tile: PlacedTile, rect: PixelRect) {
86
+ activeTileRef.current = tile;
87
+ ghostRef.current = tile.position;
88
+ isDragging.value = true;
89
+ setActiveTile(tile);
90
+ setInitialRect(rect);
91
+ setGhostPosition(tile.position);
92
+ onHaptic?.('pick-up');
93
+ }
94
+
95
+ function selectTile(tile: PlacedTile) {
96
+ const already = selectedTileIds.includes(tile.id);
97
+ if (already) {
98
+ onSelect(selectedTileIds.filter((id) => id !== tile.id));
99
+ } else if (multiSelect) {
100
+ onSelect([...selectedTileIds, tile.id]);
101
+ } else {
102
+ onSelect([tile.id]);
103
+ }
104
+ onHaptic?.('pick-up');
105
+ }
106
+
107
+ function updateGhost(pos: TilePosition) {
108
+ if (pos.x === ghostRef.current?.x && pos.y === ghostRef.current?.y) return;
109
+ ghostRef.current = pos;
110
+ setGhostPosition(pos);
111
+ onHaptic?.('snap');
112
+ }
113
+
114
+ function endDrag(finalPosition: TilePosition | null) {
115
+ const tile = activeTileRef.current;
116
+ isDragging.value = false;
117
+ activeTileRef.current = null;
118
+ ghostRef.current = null;
119
+ setActiveTile(null);
120
+ setGhostPosition(null);
121
+ setInitialRect(null);
122
+ onHaptic?.('drop');
123
+
124
+ if (!tile || !finalPosition) return;
125
+
126
+ const stationary =
127
+ finalPosition.x === tile.position.x && finalPosition.y === tile.position.y;
128
+
129
+ if (stationary && selectable && tile.selectable !== false) {
130
+ const already = selectedTileIds.includes(tile.id);
131
+ if (already) {
132
+ onSelect(selectedTileIds.filter((id) => id !== tile.id));
133
+ } else if (multiSelect) {
134
+ onSelect([...selectedTileIds, tile.id]);
135
+ } else {
136
+ onSelect([tile.id]);
137
+ }
138
+ } else if (!stationary) {
139
+ onSelect([]);
140
+ onDrop(tile, finalPosition);
141
+ }
142
+ }
143
+
144
+ function commitResize(tile: PlacedTile, newSize: TileSize) {
145
+ onHaptic?.('resize');
146
+ onResize(tile, newSize);
147
+ }
148
+
149
+ function handleTilePress(tile: PlacedTile) {
150
+ if (selectable && selectedTileIds.length > 0 && tile.selectable !== false) {
151
+ const already = selectedTileIds.includes(tile.id);
152
+ if (already) {
153
+ onSelect(selectedTileIds.filter((id) => id !== tile.id));
154
+ } else if (multiSelect) {
155
+ onSelect([...selectedTileIds, tile.id]);
156
+ } else {
157
+ onSelect([tile.id]);
158
+ }
159
+ } else {
160
+ onTilePress?.(tile);
161
+ }
162
+ }
163
+
164
+ return (
165
+ <GridDragContext.Provider
166
+ value={{
167
+ dragAbsX,
168
+ dragAbsY,
169
+ isDragging,
170
+ activeTile,
171
+ ghostPosition,
172
+ initialRect,
173
+ selectedTileIds,
174
+ containerPageX,
175
+ containerPageY,
176
+ scrollYRef,
177
+ startDrag,
178
+ updateGhost,
179
+ endDrag,
180
+ selectTile,
181
+ onTilePress: handleTilePress,
182
+ isEditing,
183
+ draggable,
184
+ selectable,
185
+ commitResize,
186
+ }}
187
+ >
188
+ {children}
189
+ </GridDragContext.Provider>
190
+ );
191
+ }