@noya-app/noya-designsystem 0.1.55 → 0.1.57

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,376 @@
1
+ import {
2
+ Active,
3
+ closestCenter,
4
+ CollisionDetection,
5
+ DndContext,
6
+ DragEndEvent,
7
+ DragMoveEvent,
8
+ DragOverlay,
9
+ DragStartEvent,
10
+ Over,
11
+ PointerSensor,
12
+ pointerWithin,
13
+ useSensor,
14
+ useSensors,
15
+ } from "@dnd-kit/core";
16
+ import { Point } from "@noya-app/noya-geometry";
17
+ import * as React from "react";
18
+ import { useMemo } from "react";
19
+ import { createPortal } from "react-dom";
20
+ import {
21
+ ActiveDragContext,
22
+ ActiveDragContextType,
23
+ AnyDragContext,
24
+ DragRegistrationContext,
25
+ DragRegistrationContextType,
26
+ RegisteredList,
27
+ SharedDragProps,
28
+ useDragRegistrationManager,
29
+ } from "./DragRegistration";
30
+ import { DragItem, DragState, RelativeDropPosition } from "./sorting";
31
+
32
+ type SharedDragProviderProps = {
33
+ children: React.ReactNode;
34
+ sharedDragProps?: SharedDragProps;
35
+ };
36
+
37
+ export function SharedDragProvider({
38
+ children,
39
+ sharedDragProps = {
40
+ dragRegistrationContext: DragRegistrationContext,
41
+ activeDragContext: ActiveDragContext,
42
+ },
43
+ }: SharedDragProviderProps) {
44
+ const sensors = useSensors(
45
+ useSensor(PointerSensor, {
46
+ activationConstraint: {
47
+ distance: 4,
48
+ },
49
+ })
50
+ );
51
+
52
+ const mounted = useMounted();
53
+ const [dragState, setDragState] = React.useState<DragState>({
54
+ source: null,
55
+ target: null,
56
+ indicator: null,
57
+ });
58
+
59
+ const { registerList, unregisterList, registeredLists } =
60
+ useDragRegistrationManager();
61
+
62
+ const activatorEventRef = React.useRef<PointerEvent | null>(null);
63
+
64
+ const handleDragStart = React.useCallback(
65
+ (event: DragStartEvent) => {
66
+ // Capture the activator event for position calculations
67
+ activatorEventRef.current = event.activatorEvent as PointerEvent;
68
+
69
+ // Find which list contains this item
70
+ for (const [listId, list] of registeredLists) {
71
+ const itemIndex = list.keys.findIndex((key) => key === event.active.id);
72
+
73
+ if (itemIndex >= 0) {
74
+ const dragItem: DragItem = {
75
+ itemId: event.active.id,
76
+ listId,
77
+ index: itemIndex,
78
+ };
79
+
80
+ setDragState({
81
+ source: dragItem,
82
+ target: null,
83
+ indicator: null,
84
+ });
85
+ break;
86
+ }
87
+ }
88
+ },
89
+ [registeredLists]
90
+ );
91
+
92
+ const handleDragMove = React.useCallback(
93
+ (event: DragMoveEvent) => {
94
+ const { active, over } = event;
95
+
96
+ if (!activatorEventRef.current || !dragState.source) return;
97
+
98
+ const dropTarget = findDropTarget({
99
+ activationPoint: {
100
+ x: activatorEventRef.current.clientX,
101
+ y: activatorEventRef.current.clientY,
102
+ },
103
+ delta: { x: event.delta.x, y: event.delta.y },
104
+ over,
105
+ active,
106
+ source: dragState.source,
107
+ registeredLists,
108
+ });
109
+
110
+ setDragState({
111
+ source: dragState.source,
112
+ target: dropTarget?.target ?? null,
113
+ indicator: dropTarget?.indicator ?? null,
114
+ });
115
+ },
116
+ [registeredLists, dragState.source]
117
+ );
118
+
119
+ const handleDragEnd = React.useCallback(
120
+ (event: DragEndEvent) => {
121
+ const { active, over } = event;
122
+
123
+ if (!activatorEventRef.current || !dragState.source) return;
124
+
125
+ const dropTarget = findDropTarget({
126
+ activationPoint: {
127
+ x: activatorEventRef.current.clientX,
128
+ y: activatorEventRef.current.clientY,
129
+ },
130
+ delta: { x: event.delta.x, y: event.delta.y },
131
+ over,
132
+ active,
133
+ source: dragState.source,
134
+ registeredLists,
135
+ });
136
+
137
+ setDragState({
138
+ source: null,
139
+ target: null,
140
+ indicator: null,
141
+ });
142
+ activatorEventRef.current = null;
143
+
144
+ if (!dropTarget) return;
145
+
146
+ const sourceList = registeredLists.get(dropTarget.source.listId);
147
+ const targetList = registeredLists.get(dropTarget.target.listId);
148
+
149
+ if (!sourceList || !targetList) return;
150
+
151
+ sourceList.onMoveItem(
152
+ dropTarget.source.index,
153
+ dropTarget.target.index,
154
+ dropTarget.indicator,
155
+ dropTarget.source.listId,
156
+ dropTarget.target.listId
157
+ );
158
+ },
159
+ [registeredLists, dragState.source]
160
+ );
161
+
162
+ const contextValue: DragRegistrationContextType = React.useMemo(
163
+ () => ({
164
+ registerList,
165
+ unregisterList,
166
+ }),
167
+ [registerList, unregisterList]
168
+ );
169
+
170
+ const activeList = dragState.source
171
+ ? registeredLists.get(dragState.source.listId)
172
+ : null;
173
+ const activeDragContextValue: ActiveDragContextType = React.useMemo(
174
+ () => dragState,
175
+ [dragState]
176
+ );
177
+
178
+ const collisionDetection = useMemo(() => {
179
+ return getItemFirstCollisionDetection(Array.from(registeredLists.keys()));
180
+ }, [registeredLists]);
181
+
182
+ return (
183
+ <AnyDragContext.Provider value={true}>
184
+ <sharedDragProps.dragRegistrationContext.Provider value={contextValue}>
185
+ <sharedDragProps.activeDragContext.Provider
186
+ value={activeDragContextValue}
187
+ >
188
+ <DndContext
189
+ sensors={sensors}
190
+ collisionDetection={collisionDetection}
191
+ onDragStart={handleDragStart}
192
+ onDragMove={handleDragMove}
193
+ onDragEnd={handleDragEnd}
194
+ >
195
+ {children}
196
+ {mounted &&
197
+ activeList?.renderOverlay &&
198
+ dragState.source &&
199
+ createPortal(
200
+ <DragOverlay dropAnimation={null}>
201
+ {activeList.renderOverlay(dragState.source.itemId)}
202
+ </DragOverlay>,
203
+ document.body
204
+ )}
205
+ </DndContext>
206
+ </sharedDragProps.activeDragContext.Provider>
207
+ </sharedDragProps.dragRegistrationContext.Provider>
208
+ </AnyDragContext.Provider>
209
+ );
210
+ }
211
+
212
+ function useMounted() {
213
+ const [mounted, setMounted] = React.useState(false);
214
+
215
+ React.useEffect(() => {
216
+ setMounted(true);
217
+ }, []);
218
+
219
+ return mounted;
220
+ }
221
+
222
+ export function withDragProvider<T extends object>(
223
+ Component: React.ComponentType<T>
224
+ ) {
225
+ return function WithDragProvider(props: T) {
226
+ const anyDragContext = React.useContext(AnyDragContext);
227
+
228
+ if (!anyDragContext) {
229
+ return (
230
+ <SharedDragProvider>
231
+ <Component {...props} />
232
+ </SharedDragProvider>
233
+ );
234
+ }
235
+
236
+ return <Component {...props} />;
237
+ };
238
+ }
239
+
240
+ // Custom collision detection that prioritizes items over containers
241
+ export const getItemFirstCollisionDetection =
242
+ (registeredListIds: string[]): CollisionDetection =>
243
+ (parameters) => {
244
+ const pointerCollisions = pointerWithin(parameters);
245
+
246
+ if (pointerCollisions.length > 0) {
247
+ const itemCollisions = pointerCollisions.filter((collision) => {
248
+ for (const listId of registeredListIds) {
249
+ if (collision.id === listId) {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ return true;
255
+ });
256
+
257
+ if (itemCollisions.length > 0) {
258
+ return itemCollisions;
259
+ }
260
+ }
261
+
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);
267
+ };
268
+
269
+ function findDropTarget({
270
+ activationPoint,
271
+ delta,
272
+ over,
273
+ active,
274
+ source,
275
+ registeredLists,
276
+ }: {
277
+ activationPoint: Point;
278
+ delta: Point;
279
+ over: Over | null;
280
+ active: Active;
281
+ source: DragItem;
282
+ registeredLists: Map<string, RegisteredList>;
283
+ }):
284
+ | {
285
+ source: DragItem;
286
+ target: DragItem;
287
+ indicator: RelativeDropPosition;
288
+ }
289
+ | undefined {
290
+ if (!over || active.id === over.id) return;
291
+
292
+ // Find target list and item
293
+ let targetListId: string | null = null;
294
+ let targetIndex: number = -1;
295
+
296
+ for (const [listId, list] of registeredLists) {
297
+ const itemIndex = list.keys.findIndex((key) => key === over.id);
298
+
299
+ if (itemIndex >= 0) {
300
+ targetListId = listId;
301
+ targetIndex = itemIndex;
302
+ break;
303
+ }
304
+
305
+ // Also check if dropping on the list container itself
306
+ if (over.id === listId) {
307
+ targetListId = listId;
308
+ targetIndex = list.keys.length; // Append to end
309
+ break;
310
+ }
311
+ }
312
+
313
+ const targetList =
314
+ targetListId !== null ? registeredLists.get(targetListId) : null;
315
+ const sourceList = registeredLists.get(source.listId);
316
+
317
+ if (
318
+ !targetList ||
319
+ !sourceList ||
320
+ !targetList.acceptsFromList({
321
+ sourceListId: sourceList.id,
322
+ targetListId: targetList.id,
323
+ })
324
+ ) {
325
+ return;
326
+ }
327
+
328
+ // Dropping on the list container itself
329
+ 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
+ );
337
+
338
+ if (canDrop) {
339
+ return {
340
+ source,
341
+ target: {
342
+ itemId: over.id,
343
+ listId: targetList.id,
344
+ index: targetIndex,
345
+ },
346
+ indicator: "below",
347
+ };
348
+ }
349
+ }
350
+
351
+ const eventX = activationPoint.x;
352
+ const eventY = activationPoint.y;
353
+ const currentX = eventX + delta.x;
354
+ const currentY = eventY + delta.y;
355
+ const absolutePosition = { x: currentX, y: currentY };
356
+
357
+ const indicator = targetList.getDropIndicator({
358
+ absolutePosition,
359
+ active,
360
+ over,
361
+ sourceListId: sourceList.id,
362
+ targetListId: targetList.id,
363
+ });
364
+
365
+ if (indicator) {
366
+ return {
367
+ source,
368
+ target: {
369
+ itemId: over.id,
370
+ listId: targetList.id,
371
+ index: targetIndex,
372
+ },
373
+ indicator,
374
+ };
375
+ }
376
+ }
@@ -0,0 +1,357 @@
1
+ import { UniqueIdentifier, useDroppable } from "@dnd-kit/core";
2
+ import {
3
+ horizontalListSortingStrategy,
4
+ SortableContext,
5
+ useSortable,
6
+ verticalListSortingStrategy,
7
+ } from "@dnd-kit/sortable";
8
+ import * as React from "react";
9
+ import {
10
+ ActiveDragContext,
11
+ ActiveDragContextType,
12
+ DragRegistrationContext,
13
+ RegisteredList,
14
+ SharedDragProps,
15
+ useActiveDrag,
16
+ useDragRegistration,
17
+ } from "./DragRegistration";
18
+ import { withDragProvider } from "./SharedDragProvider";
19
+ import {
20
+ AcceptsFromList,
21
+ createDragItemKey,
22
+ defaultAcceptsDrop,
23
+ DropValidator,
24
+ MoveDragItemHandler,
25
+ RelativeDropPosition,
26
+ validateDropIndicator,
27
+ } from "./sorting";
28
+
29
+ type UseSortableReturnType = ReturnType<typeof useSortable>;
30
+
31
+ type ItemChildrenProps<T> = {
32
+ ref: React.Ref<T>;
33
+ relativeDropPosition?: RelativeDropPosition;
34
+ style?: React.CSSProperties;
35
+ } & UseSortableReturnType["attributes"];
36
+
37
+ // Context for drop indicators within a sortable list
38
+ interface SortableItemContextProps {
39
+ acceptsDrop: DropValidator;
40
+ listId: string;
41
+ getDropTargetParentIndex?: (index: number) => number | undefined;
42
+ activeDragContext: React.Context<ActiveDragContextType | null>;
43
+ }
44
+
45
+ const SortableItemContext = React.createContext<SortableItemContextProps>({
46
+ acceptsDrop: defaultAcceptsDrop,
47
+ listId: "",
48
+ getDropTargetParentIndex: undefined,
49
+ activeDragContext: ActiveDragContext,
50
+ });
51
+
52
+ // Custom hook for drop indicators in sortable lists
53
+ function useSortableDropIndicator({
54
+ index,
55
+ itemId,
56
+ listId,
57
+ acceptsDrop,
58
+ getDropTargetParentIndex,
59
+ }: {
60
+ index: number;
61
+ itemId: UniqueIdentifier;
62
+ listId: string;
63
+ acceptsDrop: DropValidator;
64
+ getDropTargetParentIndex?: (index: number) => number | undefined;
65
+ }): RelativeDropPosition | undefined {
66
+ const { activeDragContext } = React.useContext(SortableItemContext);
67
+ const activeDrag = useActiveDrag(activeDragContext);
68
+ const { source, target } = activeDrag;
69
+
70
+ if (!source || !target) return;
71
+
72
+ const indicator =
73
+ target.listId === listId && target.itemId === itemId
74
+ ? activeDrag.indicator
75
+ : undefined;
76
+
77
+ const targetParentIndex =
78
+ target.index === -1 ? undefined : getDropTargetParentIndex?.(target.index);
79
+
80
+ const isValidParent =
81
+ targetParentIndex === undefined || targetParentIndex === -1
82
+ ? false
83
+ : acceptsDrop(
84
+ source.index,
85
+ targetParentIndex,
86
+ "inside",
87
+ source.listId,
88
+ target.listId
89
+ );
90
+
91
+ const overIdAcceptsDrop =
92
+ target.index === -1
93
+ ? undefined
94
+ : acceptsDrop(
95
+ source.index,
96
+ target.index,
97
+ "inside",
98
+ source.listId,
99
+ target.listId
100
+ );
101
+
102
+ const thisItemIsTargetParent = targetParentIndex === index;
103
+
104
+ const showDragInsideIndicatorOnParent =
105
+ !overIdAcceptsDrop && isValidParent && thisItemIsTargetParent;
106
+
107
+ return indicator ?? (showDragInsideIndicatorOnParent ? "inside" : undefined);
108
+ }
109
+
110
+ interface RootProps {
111
+ id?: string;
112
+ keys: UniqueIdentifier[];
113
+ axis?: "x" | "y";
114
+ children?: React.ReactNode;
115
+ renderOverlay?: (index: number) => React.ReactNode;
116
+ onMoveItem?: MoveDragItemHandler;
117
+ acceptsFromList?: AcceptsFromList;
118
+ acceptsDrop?: DropValidator;
119
+ getDropTargetParentIndex?: (index: number) => number | undefined;
120
+ sharedDragProps?: SharedDragProps;
121
+ containerRef?: React.RefObject<HTMLElement>;
122
+ }
123
+
124
+ function SortableItem<TElement extends HTMLElement>({
125
+ id,
126
+ disabled,
127
+ children,
128
+ }: {
129
+ id: UniqueIdentifier;
130
+ disabled?: boolean;
131
+ children: (props: ItemChildrenProps<TElement>) => React.ReactNode;
132
+ }) {
133
+ const { acceptsDrop, listId, getDropTargetParentIndex } =
134
+ React.useContext(SortableItemContext);
135
+
136
+ const dragItemKey = createDragItemKey(listId, id);
137
+
138
+ const { attributes, listeners, setNodeRef, isDragging, index } = useSortable({
139
+ id: dragItemKey,
140
+ disabled,
141
+ });
142
+
143
+ const ref = React.useCallback(
144
+ (node: TElement | null) => setNodeRef(node),
145
+ [setNodeRef]
146
+ );
147
+
148
+ const relativeDropPosition = useSortableDropIndicator({
149
+ index,
150
+ itemId: dragItemKey,
151
+ listId,
152
+ acceptsDrop,
153
+ getDropTargetParentIndex,
154
+ });
155
+
156
+ return children({
157
+ ref,
158
+ ...attributes,
159
+ ...listeners,
160
+ relativeDropPosition,
161
+ style: {
162
+ opacity: isDragging ? 0.5 : 1,
163
+ },
164
+ });
165
+ }
166
+
167
+ function SortableRoot_({
168
+ id: idProp,
169
+ keys: keysProp,
170
+ onMoveItem,
171
+ renderOverlay,
172
+ acceptsFromList,
173
+ acceptsDrop = defaultAcceptsDrop,
174
+ axis = "y",
175
+ children,
176
+ getDropTargetParentIndex,
177
+ sharedDragProps,
178
+ containerRef,
179
+ }: RootProps) {
180
+ const defaultId = React.useId();
181
+ const id = idProp ?? defaultId;
182
+ const keys = React.useMemo(() => {
183
+ return keysProp.map((key) => createDragItemKey(id, key));
184
+ }, [keysProp, id]);
185
+
186
+ const { registerList, unregisterList } = useDragRegistration(
187
+ sharedDragProps?.dragRegistrationContext ?? DragRegistrationContext
188
+ );
189
+
190
+ const { setNodeRef: setDroppableRef } = useDroppable({
191
+ 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
+ });
199
+
200
+ // Register this list with the global drag system
201
+ React.useEffect(() => {
202
+ const listConfig: RegisteredList = {
203
+ id,
204
+ keys,
205
+ onMoveItem: onMoveItem ?? (() => {}),
206
+ renderOverlay: (id) => {
207
+ const index = keys.findIndex((key) => key === id);
208
+ return renderOverlay?.(index) ?? null;
209
+ },
210
+ acceptsFromList: acceptsFromList ?? (() => true),
211
+ acceptsDrop,
212
+ getDropIndicator: (parameters) => {
213
+ const { absolutePosition, active, over, sourceListId, targetListId } =
214
+ parameters;
215
+
216
+ const offsetStart =
217
+ axis === "x" ? absolutePosition.x : absolutePosition.y;
218
+ const elementStart = axis === "x" ? over.rect.left : over.rect.top;
219
+ const elementSize = axis === "x" ? over.rect.width : over.rect.height;
220
+
221
+ const relativeDropPosition = validateDropIndicator({
222
+ acceptsDrop,
223
+ keys,
224
+ activeId: active.id,
225
+ overId: over.id,
226
+ offsetStart,
227
+ elementStart,
228
+ elementSize,
229
+ sourceListId,
230
+ targetListId,
231
+ });
232
+
233
+ return relativeDropPosition;
234
+ },
235
+ };
236
+
237
+ registerList(listConfig);
238
+
239
+ return () => {
240
+ unregisterList(id);
241
+ };
242
+ }, [
243
+ id,
244
+ onMoveItem,
245
+ renderOverlay,
246
+ acceptsFromList,
247
+ acceptsDrop,
248
+ axis,
249
+ registerList,
250
+ unregisterList,
251
+ keys,
252
+ ]);
253
+
254
+ const contextValue: SortableItemContextProps = React.useMemo(
255
+ () => ({
256
+ keys,
257
+ acceptsDrop,
258
+ listId: id,
259
+ getDropTargetParentIndex,
260
+ activeDragContext:
261
+ sharedDragProps?.activeDragContext ?? ActiveDragContext,
262
+ }),
263
+ [keys, acceptsDrop, id, getDropTargetParentIndex, sharedDragProps]
264
+ );
265
+
266
+ // Set the droppable ref after every render.
267
+ // Since we don't render the dom node internally, we aggressively set it
268
+ // after every render to make sure we are using the latest.
269
+ React.useEffect(() => {
270
+ if (containerRef) {
271
+ setDroppableRef(containerRef.current);
272
+ }
273
+ });
274
+
275
+ return (
276
+ <SortableItemContext.Provider value={contextValue}>
277
+ <SortableContext
278
+ items={keys}
279
+ strategy={
280
+ axis === "x"
281
+ ? horizontalListSortingStrategy
282
+ : verticalListSortingStrategy
283
+ }
284
+ >
285
+ {children}
286
+ </SortableContext>
287
+ </SortableItemContext.Provider>
288
+ );
289
+ }
290
+
291
+ const SortableRoot = withDragProvider(SortableRoot_) as typeof SortableRoot_;
292
+
293
+ export interface SortableProps<TItem, TElement extends HTMLElement>
294
+ extends Omit<RootProps, "children" | "keys" | "renderOverlay"> {
295
+ items: TItem[];
296
+ keyExtractor: (item: TItem) => UniqueIdentifier;
297
+ shouldRenderOverlay?: boolean;
298
+ renderItem: (
299
+ item: TItem,
300
+ props: ItemChildrenProps<TElement>,
301
+ { isOverlay }: { isOverlay: boolean }
302
+ ) => React.ReactNode;
303
+ }
304
+
305
+ const Sortable_ = function Sortable_<TItem, TElement extends HTMLElement>({
306
+ items,
307
+ keyExtractor,
308
+ shouldRenderOverlay = true,
309
+ renderItem,
310
+ ...rest
311
+ }: SortableProps<TItem, TElement>) {
312
+ const keys = React.useMemo(
313
+ () => items.map(keyExtractor),
314
+ [items, keyExtractor]
315
+ );
316
+
317
+ const renderOverlay = React.useCallback(
318
+ (index: number) => {
319
+ return renderItem(
320
+ items[index],
321
+ {
322
+ ref: () => {},
323
+ style: {},
324
+ tabIndex: -1,
325
+ "aria-disabled": true,
326
+ "aria-pressed": false,
327
+ "aria-roledescription": "Item being dragged",
328
+ "aria-describedby": "Item being dragged",
329
+ role: "button",
330
+ },
331
+ { isOverlay: true }
332
+ );
333
+ },
334
+ [renderItem, items]
335
+ );
336
+
337
+ return (
338
+ <SortableRoot
339
+ {...rest}
340
+ keys={keys}
341
+ renderOverlay={shouldRenderOverlay ? renderOverlay : undefined}
342
+ >
343
+ {keys.map((key, index) => (
344
+ <SortableItem<TElement> key={key} id={key}>
345
+ {(props) => {
346
+ return renderItem(items[index], props, { isOverlay: false });
347
+ }}
348
+ </SortableItem>
349
+ ))}
350
+ </SortableRoot>
351
+ );
352
+ };
353
+
354
+ export const Sortable = Object.assign(Sortable_, {
355
+ Root: SortableRoot,
356
+ Item: SortableItem,
357
+ });