react-native-layout-dnd 0.1.0-alpha.0 → 0.1.0-alpha.2

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 (38) hide show
  1. package/README.md +130 -63
  2. package/lib/module/components/DndProvider.js +44 -5
  3. package/lib/module/components/DndProvider.js.map +1 -1
  4. package/lib/module/components/GridController.js +48 -0
  5. package/lib/module/components/GridController.js.map +1 -1
  6. package/lib/module/components/SortableGrid.js +31 -4
  7. package/lib/module/components/SortableGrid.js.map +1 -1
  8. package/lib/module/components/SortableList.js +15 -1
  9. package/lib/module/components/SortableList.js.map +1 -1
  10. package/lib/module/controller/dndSession.js +8 -1
  11. package/lib/module/controller/dndSession.js.map +1 -1
  12. package/lib/module/pointer/geometry.js +5 -2
  13. package/lib/module/pointer/geometry.js.map +1 -1
  14. package/lib/module/runtime/committedSize.js +42 -0
  15. package/lib/module/runtime/committedSize.js.map +1 -0
  16. package/lib/typescript/src/components/DndProvider.d.ts +1 -1
  17. package/lib/typescript/src/components/DndProvider.d.ts.map +1 -1
  18. package/lib/typescript/src/components/GridController.d.ts +1 -1
  19. package/lib/typescript/src/components/GridController.d.ts.map +1 -1
  20. package/lib/typescript/src/components/SortableGrid.d.ts.map +1 -1
  21. package/lib/typescript/src/components/SortableList.d.ts.map +1 -1
  22. package/lib/typescript/src/components/gridTypes.d.ts +6 -0
  23. package/lib/typescript/src/components/gridTypes.d.ts.map +1 -1
  24. package/lib/typescript/src/controller/dndSession.d.ts +6 -0
  25. package/lib/typescript/src/controller/dndSession.d.ts.map +1 -1
  26. package/lib/typescript/src/pointer/geometry.d.ts +1 -1
  27. package/lib/typescript/src/pointer/geometry.d.ts.map +1 -1
  28. package/lib/typescript/src/runtime/committedSize.d.ts +19 -0
  29. package/lib/typescript/src/runtime/committedSize.d.ts.map +1 -0
  30. package/package.json +1 -1
  31. package/src/components/DndProvider.tsx +55 -5
  32. package/src/components/GridController.tsx +57 -0
  33. package/src/components/SortableGrid.tsx +65 -6
  34. package/src/components/SortableList.tsx +38 -1
  35. package/src/components/gridTypes.ts +6 -0
  36. package/src/controller/dndSession.ts +14 -0
  37. package/src/pointer/geometry.ts +4 -0
  38. package/src/runtime/committedSize.ts +65 -0
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # React Native Layout DnD
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/react-native-layout-dnd)](https://www.npmjs.com/package/react-native-layout-dnd)
4
+ [![CI](https://github.com/baesumin/react-native-layout-dnd/actions/workflows/ci.yml/badge.svg)](https://github.com/baesumin/react-native-layout-dnd/actions/workflows/ci.yml)
5
+
3
6
  Drag and drop for React Native grids and lists, built on Reanimated worklets and
4
7
  Gesture Handler.
5
8
 
@@ -8,8 +11,8 @@ list, placed by coordinates in a grid, or moved between the two, and every move
8
11
  arrives at your state layer as one complete candidate that you can accept or
9
12
  reject.
10
13
 
11
- > **Pre-release.** This package is not on npm yet, and its peer versions are
12
- > pinned to the exact combination it has been verified against. Read
14
+ > **Alpha.** Only one combination of peer versions has been built and run, and
15
+ > device verification is incomplete. Read
13
16
  > [Status and limitations](#status-and-limitations) before adopting it.
14
17
 
15
18
  ## Why this library
@@ -48,13 +51,8 @@ React is a peer too, at 19 or newer, but your React Native version already
48
51
  decides it. The New Architecture is required, because Reanimated 4 supports
49
52
  nothing else.
50
53
 
51
- Each lower bound is the release that introduced an API this package calls: the
52
- v3 gesture hooks in Gesture Handler 3.0.0, the worklets package split in
53
- Reanimated 4.0.0, and `scheduleOnUI`, `scheduleOnRN` and `runOnUISync` in
54
- Worklets 0.5.0. Only the **Verified** column has actually been built and run
55
- end to end, so treat the rest of the range as accepted rather than tested, and
56
- pin what works for you. Reanimated and Worklets constrain your React Native
57
- version further through their own peer ranges.
54
+ Only the **Verified** column has been built and run end to end. The rest of
55
+ each range is accepted but untested, so pin what works for you.
58
56
 
59
57
  Your app must also render the provider under `GestureHandlerRootView` and enable
60
58
  the Worklets Babel plugin. This package ships worklet directives uncompiled so
@@ -70,23 +68,18 @@ module.exports = {
70
68
 
71
69
  ## Installation
72
70
 
73
- Not published yet. Build a tarball from a clone and install it by path:
74
-
75
71
  ```sh
76
- git clone https://github.com/baesumin/react-native-layout-dnd.git
77
- cd react-native-layout-dnd
78
- yarn install
79
- npm pack
72
+ yarn add react-native-layout-dnd
73
+ # or: npm install react-native-layout-dnd
80
74
  ```
81
75
 
76
+ Install the peers too if your app does not have them yet, then rebuild the
77
+ native app:
78
+
82
79
  ```sh
83
- # in your app
84
- yarn add /absolute/path/to/react-native-layout-dnd-0.1.0-alpha.0.tgz
80
+ yarn add react-native-gesture-handler react-native-reanimated react-native-worklets
85
81
  ```
86
82
 
87
- Installing straight from the Git URL does not work, because the entry points
88
- come from a build that only runs while packing.
89
-
90
83
  ## Quick start
91
84
 
92
85
  A sortable vertical list. `useDndState` holds the layout and acknowledges each
@@ -147,34 +140,80 @@ footprint, so `getGridItemLayout` decides the span an incoming item takes. Keep
147
140
  that function stable, because it is captured once per drag.
148
141
 
149
142
  ```tsx
150
- <DndProvider
151
- value={value}
152
- onChange={onChange}
153
- getGridItemLayout={({ item }) => ({
154
- span: item.data.span,
155
- placement: 'insert',
156
- })}
157
- style={{ flex: 1 }}
158
- >
159
- <SortableList<Card> zoneId="library" virtualization ... />
160
- <SortableGrid<Card>
161
- zoneId="board"
162
- style={{ height: 240 }}
163
- geometry={{
164
- mode: 'fit',
165
- rowGap: 8,
166
- columnGap: 8,
167
- padding: { top: 8, right: 8, bottom: 8, left: 8 },
168
- }}
169
- renderItem={({ item, width, height }) => (
170
- <View style={{ width, height }}>
171
- <DragHandle>
172
- <Text>{item.data.title}</Text>
173
- </DragHandle>
174
- </View>
175
- )}
176
- />
177
- </DndProvider>
143
+ import { Text, View } from 'react-native';
144
+ import {
145
+ DndProvider,
146
+ DragHandle,
147
+ SortableGrid,
148
+ SortableList,
149
+ useDndState,
150
+ type CellSpan,
151
+ type GridItemLayoutResolverArgs,
152
+ type LayoutState,
153
+ } from 'react-native-layout-dnd';
154
+
155
+ type Card = { title: string; span: CellSpan };
156
+
157
+ const initial: LayoutState<Card> = {
158
+ revision: 0,
159
+ items: [
160
+ { id: 'notes', data: { title: 'Notes', span: { rows: 1, cols: 2 } } },
161
+ ],
162
+ zones: [
163
+ {
164
+ id: 'library',
165
+ kind: 'list',
166
+ orientation: 'vertical',
167
+ itemIds: ['notes'],
168
+ },
169
+ { id: 'board', kind: 'grid', rows: 3, columns: 4, placements: [] },
170
+ ],
171
+ };
172
+
173
+ function getGridItemLayout({ item }: GridItemLayoutResolverArgs<Card>) {
174
+ return { span: item.data.span, placement: 'insert' as const };
175
+ }
176
+
177
+ export function Board() {
178
+ const { value, onChange } = useDndState(initial);
179
+ return (
180
+ <DndProvider
181
+ value={value}
182
+ onChange={onChange}
183
+ getGridItemLayout={getGridItemLayout}
184
+ style={{ flex: 1 }}
185
+ >
186
+ <SortableList<Card>
187
+ zoneId="library"
188
+ virtualization
189
+ style={{ flex: 1 }}
190
+ estimatedItemSize={48}
191
+ renderItem={({ item }) => (
192
+ <DragHandle>
193
+ <Text>{item.data.title}</Text>
194
+ </DragHandle>
195
+ )}
196
+ />
197
+ <SortableGrid<Card>
198
+ zoneId="board"
199
+ style={{ height: 240 }}
200
+ geometry={{
201
+ mode: 'fit',
202
+ rowGap: 8,
203
+ columnGap: 8,
204
+ padding: { top: 8, right: 8, bottom: 8, left: 8 },
205
+ }}
206
+ renderItem={({ item, width, height }) => (
207
+ <View style={{ width, height }}>
208
+ <DragHandle>
209
+ <Text>{item.data.title}</Text>
210
+ </DragHandle>
211
+ </View>
212
+ )}
213
+ />
214
+ </DndProvider>
215
+ );
216
+ }
178
217
  ```
179
218
 
180
219
  A grid zone with `rows`, `columns` and `placements` is spatial. Add `itemSpan`
@@ -212,6 +251,43 @@ const result = computeLayoutMove({
212
251
  if (result.status === 'ok' && result.changed) setValue(result.value);
213
252
  ```
214
253
 
254
+ ## Validation issues
255
+
256
+ `DndProvider` and `GridController` report issues through two callbacks. In
257
+ `DndProvider`, any issue also disables dragging until it is resolved.
258
+
259
+ - `onValidationError(issues)` is called when new issues appear. It is never
260
+ called when they are resolved.
261
+ - `onValidationChange(issues)` is called with the complete current list
262
+ whenever it changes, and with `[]` once everything is resolved. It runs after
263
+ the commit that changed the list has settled, so it does not see the
264
+ in-between states of one update.
265
+
266
+ Use `onValidationChange` to show an error state that also clears itself:
267
+
268
+ ```tsx
269
+ import { useState } from 'react';
270
+ import type { LayoutValidationIssue } from 'react-native-layout-dnd';
271
+
272
+ function useValidationState() {
273
+ const [issues, setIssues] = useState<LayoutValidationIssue[]>([]);
274
+ return { issues, onValidationChange: setIssues };
275
+ }
276
+ ```
277
+
278
+ What an issue means depends on where it comes from. Some codes come from more
279
+ than one source, so read `zoneId`, `itemId` and `message` together with the code.
280
+
281
+ | Source | Codes | Resolved when |
282
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
283
+ | The value (data) | `invalid-structure`, `duplicate-id` (item IDs), `invalid-dimension`, `invalid-span`, `invalid-placement`, `missing-item-span`, `span-mismatch`, `invalid-position`, `out-of-bounds`, `overlap`, `capacity-exceeded`, and for `LayoutState` also `invalid-revision`, `unknown-item`, `unplaced-item`, `duplicate-reference`, `invalid-orientation` | The app passes a corrected value |
284
+ | Props and component tree | `invalid-budget`, `invalid-configuration` (provider, list or handle options, a handle outside its item, a zone component whose zone is not in the value), `invalid-geometry` for a malformed geometry object, `duplicate-id` for two components mounted for one zone | The props or the mounted components change |
285
+ | Layout | `invalid-geometry` when a fixed geometry does not fit the laid-out zone | The zone or the geometry changes so that cells fit |
286
+
287
+ A layout issue follows the measured size of the zone. The grid checks a fixed
288
+ geometry against the size of the same commit, so a geometry and a zone size that
289
+ change together, as on rotation, are not reported.
290
+
215
291
  ## Documentation
216
292
 
217
293
  - [Lists and scrolling](docs/list-scroll-api.md) covers `DndProvider`,
@@ -220,30 +296,21 @@ if (result.status === 'ok' && result.changed) setValue(result.value);
220
296
  - [Grids and mixed layouts](docs/mixed-layout-api.md) covers `SortableGrid`,
221
297
  spans, transfers between zones, approval and failure reasons.
222
298
  - [Grid contracts and the `GridController` API](docs/api.md) covers the shared
223
- layout contract, movement policies and preview rules, plus the grid-only API
224
- kept for existing consumers, which is now an adapter over the same provider.
299
+ layout contract, movement policies and preview rules, plus `GridController`,
300
+ a grid-only interface over the same provider.
225
301
  - [Contributing](CONTRIBUTING.md) lists the checks and the native test
226
302
  checklist.
227
303
 
228
304
  Runnable examples live in [`example/`](example/), including a 580-item
229
- `FlatList` tab and a mixed grid and list screen:
230
-
231
- ```sh
232
- yarn install
233
- yarn example start
234
- yarn example ios # or: yarn example android
235
- ```
305
+ `FlatList` tab and a mixed grid and list screen. To run them, clone the
306
+ repository and follow
307
+ [Run the native example](CONTRIBUTING.md#run-the-native-example).
236
308
 
237
309
  ## Status and limitations
238
310
 
239
- Automated verification passes 51 suites and 1,215 tests, seven
240
- performance-tooling tests, TypeScript, ESLint, the build, and an isolated check
241
- of the packed API and its types.
242
-
243
311
  Known gaps, stated so you can judge the risk:
244
312
 
245
- - **Not published.** The package stays `private` until the release criteria are
246
- met, and the public API may still change.
313
+ - **Alpha.** The public API may still change before `0.1.0`.
247
314
  - **One verified combination.** The peer ranges above come from the APIs this
248
315
  package calls, not from a test matrix. Only the versions in the Verified
249
316
  column have been built and run, and Expo has not been tried.
@@ -66,6 +66,7 @@ export function DndProvider({
66
66
  onDragEnd,
67
67
  onDragSettled,
68
68
  onValidationError,
69
+ onValidationChange,
69
70
  onDiagnostic,
70
71
  responseTimeoutMs,
71
72
  getGridItemLayout,
@@ -137,6 +138,39 @@ export function DndProvider({
137
138
  if (!deferred) reportSettled(event);
138
139
  }
139
140
  }, [reportSettled]);
141
+ // Issues of the value and options (from the coordinator) and of mounted
142
+ // adapters and handles (by owner), published together as one list. A new
143
+ // value reaches the adapters over several synchronous commits, so the list
144
+ // is read once they are done; intermediate lists are never published.
145
+ const issues = useRef(new Map());
146
+ const sessionIssues = useRef([]);
147
+ const validation = useRef({
148
+ published: '[]',
149
+ queued: false,
150
+ onChange: onValidationChange
151
+ }).current;
152
+ const mounted = useRef(true);
153
+ const publishValidation = useCallback(() => {
154
+ if (!mounted.current || validation.queued) return;
155
+ validation.queued = true;
156
+ globalThis.queueMicrotask(() => {
157
+ validation.queued = false;
158
+ if (!mounted.current) return;
159
+ const current = [...sessionIssues.current, ...Array.from(issues.current.keys()).sort().flatMap(owner => issues.current.get(owner).issues)];
160
+ const key = JSON.stringify(current);
161
+ if (key === validation.published) return;
162
+ validation.published = key;
163
+ try {
164
+ validation.onChange?.(current);
165
+ } catch (error) {
166
+ console.error('[LayoutDnD] Validation callback failed', error);
167
+ }
168
+ });
169
+ }, [validation]);
170
+ const reportSessionIssues = useCallback(next => {
171
+ sessionIssues.current = next;
172
+ publishValidation();
173
+ }, [publishValidation]);
140
174
  const [coordinator] = useState(() => new DndSessionCoordinator({
141
175
  value,
142
176
  onChange,
@@ -144,6 +178,7 @@ export function DndProvider({
144
178
  onDragStart,
145
179
  onDragEnd: endSession,
146
180
  onValidationError,
181
+ onValidationChange: reportSessionIssues,
147
182
  onDiagnostic,
148
183
  responseTimeoutMs,
149
184
  getGridItemLayout,
@@ -195,9 +230,7 @@ export function DndProvider({
195
230
  const nextHandleId = useRef(0);
196
231
  const [gestureRegistry] = useState(createDndGestureRegistry);
197
232
  const registry = useRef(new Map());
198
- const issues = useRef(new Map());
199
233
  const observer = useRef(onValidationError);
200
- const mounted = useRef(true);
201
234
  const handlePublication = useRef({
202
235
  queued: false,
203
236
  generation: 0,
@@ -354,10 +387,14 @@ export function DndProvider({
354
387
  }, [handlePublication, publishHandles]);
355
388
  const reportIssues = useCallback((owner, next) => {
356
389
  const key = JSON.stringify(next);
357
- const old = issues.current.get(owner);
358
- if (next.length) issues.current.set(owner, key);else issues.current.delete(owner);
390
+ const old = issues.current.get(owner)?.key;
391
+ if (next.length) issues.current.set(owner, {
392
+ key,
393
+ issues: next
394
+ });else issues.current.delete(owner);
359
395
  if (!mounted.current) return;
360
396
  setRegistryInvalid(issues.current.size > 0);
397
+ publishValidation();
361
398
  if (next.length && old !== key) {
362
399
  coordinator.cancel('disabled');
363
400
  try {
@@ -366,7 +403,7 @@ export function DndProvider({
366
403
  console.error('[LayoutDnD] Validation callback failed', error);
367
404
  }
368
405
  }
369
- }, [coordinator]);
406
+ }, [coordinator, publishValidation]);
370
407
  useLayoutEffect(() => {
371
408
  mounted.current = true;
372
409
  if (currentCanDrop.current !== canDrop || committedValue.current !== value) policyRevision.current++;
@@ -381,6 +418,7 @@ export function DndProvider({
381
418
  });
382
419
  });
383
420
  observer.current = onValidationError;
421
+ validation.onChange = onValidationChange;
384
422
  if (currentConfig.current.dragAxis !== dragAxis || currentConfig.current.delayMs !== activation?.delayMs) coordinator.cancel('input-changed');
385
423
  currentConfig.current = {
386
424
  dragAxis,
@@ -398,6 +436,7 @@ export function DndProvider({
398
436
  onDragStart,
399
437
  onDragEnd: endSession,
400
438
  onValidationError,
439
+ onValidationChange: reportSessionIssues,
401
440
  onDiagnostic,
402
441
  responseTimeoutMs,
403
442
  getGridItemLayout,