@noya-app/noya-designsystem 0.1.57 → 0.1.59

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-designsystem",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -22,7 +22,7 @@
22
22
  "dependencies": {
23
23
  "@dnd-kit/core": "6.3.1",
24
24
  "@dnd-kit/sortable": "10.0.0",
25
- "@noya-app/noya-colorpicker": "0.1.21",
25
+ "@noya-app/noya-colorpicker": "0.1.23",
26
26
  "@noya-app/noya-utils": "0.1.5",
27
27
  "@noya-app/noya-geometry": "0.1.11",
28
28
  "@noya-app/noya-icons": "0.1.10",
@@ -1,88 +1,121 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
3
  defaultAcceptsDrop,
4
+ MoveDragItemParameters,
4
5
  validateDropIndicator,
5
6
  } from "../components/sorting/sorting";
6
7
 
7
8
  describe("defaultAcceptsDrop", () => {
8
9
  it("should return false if the active item is the same as the over item", () => {
9
- const result = defaultAcceptsDrop(0, 0, "inside", "", "");
10
+ const result = defaultAcceptsDrop({
11
+ sourceIndex: 0,
12
+ targetIndex: 0,
13
+ position: "inside",
14
+ sourceListId: "",
15
+ targetListId: "",
16
+ });
10
17
  expect(result).toBe(false);
11
18
  });
12
19
 
13
20
  it("should return false for 'inside' position", () => {
14
- const result = defaultAcceptsDrop(0, 1, "inside", "", "");
21
+ const result = defaultAcceptsDrop({
22
+ sourceIndex: 0,
23
+ targetIndex: 1,
24
+ position: "inside",
25
+ sourceListId: "",
26
+ targetListId: "",
27
+ });
15
28
  expect(result).toBe(false);
16
29
  });
17
30
 
18
31
  it("should return false when dropping above the same item", () => {
19
- const result = defaultAcceptsDrop(0, 0, "above", "", "");
32
+ const result = defaultAcceptsDrop({
33
+ sourceIndex: 0,
34
+ targetIndex: 0,
35
+ position: "above",
36
+ sourceListId: "",
37
+ targetListId: "",
38
+ });
20
39
  expect(result).toBe(false);
21
40
  });
22
41
 
23
42
  it("should return false when dropping below the same item", () => {
24
- const result = defaultAcceptsDrop(0, 0, "below", "", "");
43
+ const result = defaultAcceptsDrop({
44
+ sourceIndex: 0,
45
+ targetIndex: 0,
46
+ position: "below",
47
+ sourceListId: "",
48
+ targetListId: "",
49
+ });
25
50
  expect(result).toBe(false);
26
51
  });
27
52
 
28
53
  it("should return false when dropping above the item below the source", () => {
29
- const result = defaultAcceptsDrop(0, 1, "above", "", "");
54
+ const result = defaultAcceptsDrop({
55
+ sourceIndex: 0,
56
+ targetIndex: 1,
57
+ position: "above",
58
+ sourceListId: "",
59
+ targetListId: "",
60
+ });
30
61
  expect(result).toBe(false);
31
62
  });
32
63
 
33
64
  it("should return false when dropping below the item above the source", () => {
34
- const result = defaultAcceptsDrop(1, 0, "below", "", "");
65
+ const result = defaultAcceptsDrop({
66
+ sourceIndex: 1,
67
+ targetIndex: 0,
68
+ position: "below",
69
+ sourceListId: "",
70
+ targetListId: "",
71
+ });
35
72
  expect(result).toBe(false);
36
73
  });
37
74
 
38
75
  it("should return true when dropping above a valid target", () => {
39
- const result = defaultAcceptsDrop(0, 2, "above", "", "");
76
+ const result = defaultAcceptsDrop({
77
+ sourceIndex: 0,
78
+ targetIndex: 2,
79
+ position: "above",
80
+ sourceListId: "",
81
+ targetListId: "",
82
+ });
40
83
  expect(result).toBe(true);
41
84
  });
42
85
 
43
86
  it("should return true when dropping below a valid target", () => {
44
- const result = defaultAcceptsDrop(2, 0, "below", "", "");
87
+ const result = defaultAcceptsDrop({
88
+ sourceIndex: 2,
89
+ targetIndex: 0,
90
+ position: "below",
91
+ sourceListId: "",
92
+ targetListId: "",
93
+ });
45
94
  expect(result).toBe(true);
46
95
  });
47
96
  });
48
97
 
49
98
  describe("validateDropIndicator", () => {
50
- const mockAcceptsDrop = (
51
- sourceIndex: number,
52
- targetIndex: number,
53
- position: "above" | "below" | "inside"
54
- ) => {
99
+ const mockAcceptsDrop = (parameters: MoveDragItemParameters) => {
55
100
  // Mock that only allows dropping inside
56
- if (position === "inside") return true;
101
+ if (parameters.position === "inside") return true;
57
102
  return false;
58
103
  };
59
104
 
60
- const mockAcceptsDropAboveBelow = (
61
- sourceIndex: number,
62
- targetIndex: number,
63
- position: "above" | "below" | "inside"
64
- ) => {
105
+ const mockAcceptsDropAboveBelow = (parameters: MoveDragItemParameters) => {
65
106
  // Mock that only allows dropping above/below
66
- if (position === "inside") return false;
107
+ if (parameters.position === "inside") return false;
67
108
  return true;
68
109
  };
69
110
 
70
- const mockAcceptsAll = (
71
- sourceIndex: number,
72
- targetIndex: number,
73
- position: "above" | "below" | "inside"
74
- ) => {
111
+ const mockAcceptsAll = (_: MoveDragItemParameters) => {
75
112
  // Mock that allows all drop positions
76
113
  return true;
77
114
  };
78
115
 
79
- const mockAcceptsAboveAndInside = (
80
- sourceIndex: number,
81
- targetIndex: number,
82
- position: "above" | "below" | "inside"
83
- ) => {
116
+ const mockAcceptsAboveAndInside = (parameters: MoveDragItemParameters) => {
84
117
  // Mock that allows dropping above and inside, but not below
85
- return position !== "below";
118
+ return parameters.position !== "below";
86
119
  };
87
120
 
88
121
  const keys = ["item1", "item2", "item3"];
@@ -13,7 +13,9 @@ import {
13
13
  useSensor,
14
14
  useSensors,
15
15
  } from "@dnd-kit/core";
16
- import { Point } from "@noya-app/noya-geometry";
16
+ import { Point, rectContainsPoint, unionRects } from "@noya-app/noya-geometry";
17
+ import { windowsOf } from "@noya-app/noya-utils";
18
+ import { useStableCallback } from "@noya-app/react-utils";
17
19
  import * as React from "react";
18
20
  import { useMemo } from "react";
19
21
  import { createPortal } from "react-dom";
@@ -27,15 +29,23 @@ import {
27
29
  SharedDragProps,
28
30
  useDragRegistrationManager,
29
31
  } from "./DragRegistration";
30
- import { DragItem, DragState, RelativeDropPosition } from "./sorting";
32
+ import {
33
+ DragItem,
34
+ DragState,
35
+ MoveDragItemHandler,
36
+ MoveDragItemParameters,
37
+ RelativeDropPosition,
38
+ } from "./sorting";
31
39
 
32
40
  type SharedDragProviderProps = {
33
41
  children: React.ReactNode;
42
+ onMoveItem?: MoveDragItemHandler;
34
43
  sharedDragProps?: SharedDragProps;
35
44
  };
36
45
 
37
46
  export function SharedDragProvider({
38
47
  children,
48
+ onMoveItem,
39
49
  sharedDragProps = {
40
50
  dragRegistrationContext: DragRegistrationContext,
41
51
  activeDragContext: ActiveDragContext,
@@ -56,6 +66,8 @@ export function SharedDragProvider({
56
66
  indicator: null,
57
67
  });
58
68
 
69
+ // console.log("dragState", dragState.target);
70
+
59
71
  const { registerList, unregisterList, registeredLists } =
60
72
  useDragRegistrationManager();
61
73
 
@@ -116,6 +128,8 @@ export function SharedDragProvider({
116
128
  [registeredLists, dragState.source]
117
129
  );
118
130
 
131
+ const stableOnMoveItem = useStableCallback(onMoveItem);
132
+
119
133
  const handleDragEnd = React.useCallback(
120
134
  (event: DragEndEvent) => {
121
135
  const { active, over } = event;
@@ -148,15 +162,24 @@ export function SharedDragProvider({
148
162
 
149
163
  if (!sourceList || !targetList) return;
150
164
 
151
- sourceList.onMoveItem(
152
- dropTarget.source.index,
153
- dropTarget.target.index,
154
- dropTarget.indicator,
155
- dropTarget.source.listId,
156
- dropTarget.target.listId
157
- );
165
+ const parameters: MoveDragItemParameters = {
166
+ sourceIndex: dropTarget.source.index,
167
+ targetIndex: dropTarget.target.index,
168
+ position: dropTarget.indicator,
169
+ sourceListId: dropTarget.source.listId,
170
+ targetListId: dropTarget.target.listId,
171
+ };
172
+
173
+ // Both lists have the opportunity to handle the move
174
+ sourceList.onMoveItem(parameters);
175
+
176
+ if (dropTarget.target.listId !== dropTarget.source.listId) {
177
+ targetList.onMoveItem(parameters);
178
+ }
179
+
180
+ stableOnMoveItem?.(parameters);
158
181
  },
159
- [registeredLists, dragState.source]
182
+ [registeredLists, dragState.source, stableOnMoveItem]
160
183
  );
161
184
 
162
185
  const contextValue: DragRegistrationContextType = React.useMemo(
@@ -176,7 +199,7 @@ export function SharedDragProvider({
176
199
  );
177
200
 
178
201
  const collisionDetection = useMemo(() => {
179
- return getItemFirstCollisionDetection(Array.from(registeredLists.keys()));
202
+ return getItemFirstCollisionDetection(registeredLists);
180
203
  }, [registeredLists]);
181
204
 
182
205
  return (
@@ -239,13 +262,13 @@ export function withDragProvider<T extends object>(
239
262
 
240
263
  // Custom collision detection that prioritizes items over containers
241
264
  export const getItemFirstCollisionDetection =
242
- (registeredListIds: string[]): CollisionDetection =>
265
+ (registeredLists: Map<string, RegisteredList>): CollisionDetection =>
243
266
  (parameters) => {
244
267
  const pointerCollisions = pointerWithin(parameters);
245
268
 
246
269
  if (pointerCollisions.length > 0) {
247
270
  const itemCollisions = pointerCollisions.filter((collision) => {
248
- for (const listId of registeredListIds) {
271
+ for (const listId of registeredLists.keys()) {
249
272
  if (collision.id === listId) {
250
273
  return false;
251
274
  }
@@ -259,11 +282,42 @@ export const getItemFirstCollisionDetection =
259
282
  }
260
283
  }
261
284
 
262
- // Fall back to pointer-based detection (includes containers)
263
- // This handles empty lists and edge cases
264
- return pointerCollisions.length > 0
265
- ? pointerCollisions
266
- : closestCenter(parameters);
285
+ const listCollision = pointerCollisions.at(0);
286
+ const nearestCenters = closestCenter(parameters);
287
+
288
+ // Handle a pointer between items by checking if the pointer is within
289
+ // a window of two items.
290
+ if (parameters.pointerCoordinates && listCollision) {
291
+ const list = registeredLists.get(listCollision.id as string);
292
+
293
+ if (list) {
294
+ const pairs = windowsOf(list.keys, 2);
295
+
296
+ for (const pair of pairs) {
297
+ const clientRects = pair.map(
298
+ (key) => parameters.droppableRects.get(key)!
299
+ );
300
+
301
+ const merged = unionRects(
302
+ ...clientRects.map((rect) => ({
303
+ x: rect.left,
304
+ y: rect.top,
305
+ width: rect.width,
306
+ height: rect.height,
307
+ }))
308
+ );
309
+
310
+ if (rectContainsPoint(merged, parameters.pointerCoordinates)) {
311
+ return nearestCenters.filter((center) =>
312
+ pair.some((id) => id === center.id)
313
+ );
314
+ }
315
+ }
316
+ }
317
+ }
318
+
319
+ // This handles empty lists
320
+ return pointerCollisions;
267
321
  };
268
322
 
269
323
  function findDropTarget({
@@ -327,13 +381,13 @@ function findDropTarget({
327
381
 
328
382
  // Dropping on the list container itself
329
383
  if (over.id === targetList.id) {
330
- const canDrop = targetList.acceptsDrop(
331
- source.index,
332
- Math.max(0, targetList.keys.length - 1), // Use last valid index or 0 for empty lists
333
- "below",
334
- sourceList.id,
335
- targetList.id
336
- );
384
+ const canDrop = targetList.acceptsDrop({
385
+ sourceIndex: source.index,
386
+ targetIndex: Math.max(0, targetList.keys.length - 1), // Use last valid index or 0 for empty lists
387
+ position: "below",
388
+ sourceListId: sourceList.id,
389
+ targetListId: targetList.id,
390
+ });
337
391
 
338
392
  if (canDrop) {
339
393
  return {
@@ -69,10 +69,9 @@ function useSortableDropIndicator({
69
69
 
70
70
  if (!source || !target) return;
71
71
 
72
- const indicator =
73
- target.listId === listId && target.itemId === itemId
74
- ? activeDrag.indicator
75
- : undefined;
72
+ if (target.listId === listId && target.itemId === itemId) {
73
+ return activeDrag.indicator ?? undefined;
74
+ }
76
75
 
77
76
  const targetParentIndex =
78
77
  target.index === -1 ? undefined : getDropTargetParentIndex?.(target.index);
@@ -80,31 +79,31 @@ function useSortableDropIndicator({
80
79
  const isValidParent =
81
80
  targetParentIndex === undefined || targetParentIndex === -1
82
81
  ? false
83
- : acceptsDrop(
84
- source.index,
85
- targetParentIndex,
86
- "inside",
87
- source.listId,
88
- target.listId
89
- );
82
+ : acceptsDrop({
83
+ sourceIndex: source.index,
84
+ targetIndex: targetParentIndex,
85
+ position: "inside",
86
+ sourceListId: source.listId,
87
+ targetListId: target.listId,
88
+ });
90
89
 
91
90
  const overIdAcceptsDrop =
92
91
  target.index === -1
93
92
  ? undefined
94
- : acceptsDrop(
95
- source.index,
96
- target.index,
97
- "inside",
98
- source.listId,
99
- target.listId
100
- );
93
+ : acceptsDrop({
94
+ sourceIndex: source.index,
95
+ targetIndex: target.index,
96
+ position: "inside",
97
+ sourceListId: source.listId,
98
+ targetListId: target.listId,
99
+ });
101
100
 
102
101
  const thisItemIsTargetParent = targetParentIndex === index;
103
102
 
104
- const showDragInsideIndicatorOnParent =
103
+ const showIndicatorOnParent =
105
104
  !overIdAcceptsDrop && isValidParent && thisItemIsTargetParent;
106
105
 
107
- return indicator ?? (showDragInsideIndicatorOnParent ? "inside" : undefined);
106
+ return showIndicatorOnParent ? "inside" : undefined;
108
107
  }
109
108
 
110
109
  interface RootProps {
@@ -189,12 +188,6 @@ function SortableRoot_({
189
188
 
190
189
  const { setNodeRef: setDroppableRef } = useDroppable({
191
190
  id,
192
- // Make the container droppable only when the list is empty
193
- // This avoids conflicts with individual sortable items.
194
- // This isn't strictly necessary since we also use a custom collision detection
195
- // that prioritizes items over containers, but it helps with dropping into
196
- // the best position (either above or below, rather than always below).
197
- disabled: keys.length > 0,
198
191
  });
199
192
 
200
193
  // Register this list with the global drag system
@@ -3,29 +3,19 @@ import * as React from "react";
3
3
 
4
4
  export type RelativeDropPosition = "above" | "below" | "inside";
5
5
 
6
- export type DropValidator = (
7
- sourceIndex: number,
8
- targetIndex: number,
9
- position: RelativeDropPosition,
10
- sourceListId: string,
11
- targetListId: string
12
- ) => boolean;
13
-
14
- export type AcceptsDrop = (
15
- sourceIndex: number,
16
- targetIndex: number,
17
- position: RelativeDropPosition,
18
- sourceListId: string,
19
- targetListId: string
20
- ) => boolean;
21
-
22
- export type MoveDragItemHandler = (
23
- sourceIndex: number,
24
- targetIndex: number,
25
- position: RelativeDropPosition,
26
- sourceListId: string,
27
- targetListId: string
28
- ) => void;
6
+ export type MoveDragItemParameters = {
7
+ sourceIndex: number;
8
+ sourceListId: string;
9
+ targetIndex: number;
10
+ targetListId: string;
11
+ position: RelativeDropPosition;
12
+ };
13
+
14
+ export type DropValidator = (parameters: MoveDragItemParameters) => boolean;
15
+
16
+ export type AcceptsDrop = (parameters: MoveDragItemParameters) => boolean;
17
+
18
+ export type MoveDragItemHandler = (parameters: MoveDragItemParameters) => void;
29
19
 
30
20
  export type AcceptsFromList = ({
31
21
  sourceListId,
@@ -54,11 +44,11 @@ export const normalizeListTargetIndex = (
54
44
  return position === "above" ? index : index + 1;
55
45
  };
56
46
 
57
- export const defaultAcceptsDrop: DropValidator = (
47
+ export const defaultAcceptsDrop: DropValidator = ({
58
48
  sourceIndex,
59
49
  targetIndex,
60
- position
61
- ) => {
50
+ position,
51
+ }) => {
62
52
  if (position === "inside") return false;
63
53
 
64
54
  const normalized = normalizeListTargetIndex(targetIndex, position);
@@ -120,13 +110,13 @@ export function validateDropIndicator({
120
110
  if (overIndex === -1) return undefined;
121
111
  if (activeIndex === -1 && sourceListId === targetListId) return undefined;
122
112
 
123
- const acceptsDropInside = acceptsDrop(
124
- activeIndex,
125
- overIndex,
126
- "inside",
113
+ const acceptsDropInside = acceptsDrop({
114
+ sourceIndex: activeIndex,
115
+ targetIndex: overIndex,
116
+ position: "inside",
127
117
  sourceListId,
128
- targetListId
129
- );
118
+ targetListId,
119
+ });
130
120
 
131
121
  // Drop into the middle third if possible
132
122
  if (
@@ -142,27 +132,14 @@ export function validateDropIndicator({
142
132
  offsetStart
143
133
  );
144
134
 
145
- // For moving within the same list, we are more strict about the drop position.
146
- // The source item must actually be contained in the target element.
147
- // Otherwise the user may unintentionally drop the item into target,
148
- // since it’s difficult to select no target at all.
149
- // When dropping into a different list, the user definitely wants the item within that list,
150
- // and it also handles the empty list case.
151
- if (
152
- !isContained(elementStart, elementSize, offsetStart) &&
153
- sourceListId === targetListId
154
- ) {
155
- return undefined;
156
- }
157
-
158
135
  // Drop above or below if possible
159
- const acceptedHalf = acceptsDrop(
160
- activeIndex,
161
- overIndex,
162
- containingHalf,
136
+ const acceptedHalf = acceptsDrop({
137
+ sourceIndex: activeIndex,
138
+ targetIndex: overIndex,
139
+ position: containingHalf,
163
140
  sourceListId,
164
- targetListId
165
- );
141
+ targetListId,
142
+ });
166
143
 
167
144
  return acceptedHalf
168
145
  ? containingHalf
@@ -183,16 +160,6 @@ function isContainedInMiddleThird(
183
160
  );
184
161
  }
185
162
 
186
- function isContained(
187
- elementStart: number,
188
- elementSize: number,
189
- offsetStart: number
190
- ): boolean {
191
- return (
192
- offsetStart >= elementStart && offsetStart <= elementStart + elementSize
193
- );
194
- }
195
-
196
163
  function getContainingHalf(
197
164
  elementStart: number,
198
165
  elementSize: number,
@@ -202,14 +169,16 @@ function getContainingHalf(
202
169
  return "below";
203
170
  }
204
171
 
172
+ export const dragItemKeySeparator = "-~-";
173
+
205
174
  export function createDragItemKey(listId: string, itemId: UniqueIdentifier) {
206
- return `${listId}-~-${itemId}`;
175
+ return `${listId}${dragItemKeySeparator}${itemId}`;
207
176
  }
208
177
 
209
178
  export function parseDragItemKey(key: string): {
210
179
  listId: string;
211
180
  itemId: UniqueIdentifier;
212
181
  } {
213
- const [listId, itemId] = key.split("-~-");
182
+ const [listId, itemId] = key.split(dragItemKeySeparator);
214
183
  return { listId, itemId };
215
184
  }
@@ -88,13 +88,13 @@ export function acceptsDrop({
88
88
  return false;
89
89
  }
90
90
 
91
- return defaultAcceptsDrop(
92
- sourceIndexPath.at(-1)!,
93
- targetIndexPath.at(-1)!,
91
+ return defaultAcceptsDrop({
92
+ sourceIndex: sourceIndexPath.at(-1)!,
93
+ targetIndex: targetIndexPath.at(-1)!,
94
94
  position,
95
- "",
96
- ""
97
- );
95
+ sourceListId: "",
96
+ targetListId: "",
97
+ });
98
98
  }
99
99
 
100
100
  return true;