diff --git a/packages/dnd/package.json b/packages/dnd/package.json index beefae78..31fc39d1 100644 --- a/packages/dnd/package.json +++ b/packages/dnd/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dnd", - "version": "0.1.3", + "version": "0.1.4", "type": "module", "description": "Stable accessible drag-and-drop primitives for WRNexus.", "exports": { diff --git a/packages/dnd/src/manager.ts b/packages/dnd/src/manager.ts index 7aacbec5..72694138 100644 --- a/packages/dnd/src/manager.ts +++ b/packages/dnd/src/manager.ts @@ -121,7 +121,15 @@ export function createDndManager(options: DndManagerOptions): DndManager { element: registration.element, handle: registration.handle, type: registration.type, - accept: registration.accepts, + accept: (source) => { + const origin = entries.get(source.id)?.current.element; + // A nested ancestor overlaps its own descendants and must not steal + // their next keyboard/pointer target just because its box is larger. + if (source.id !== registration.id && origin && registration.element.contains?.(origin)) return false; + if (registration.accepts && !registration.accepts.some(type => type === source.type)) return false; + const target = entries.get(registration.id)?.sortable; + return options.accept?.([source.id], { group: target?.group ?? registration.group, index: target?.index ?? registration.index }) ?? true; + }, disabled: registration.disabled, transition: reducedMotion ? null : undefined, data: { ...registration.data, direction }, diff --git a/packages/dnd/test/manager-lifecycle.test.ts b/packages/dnd/test/manager-lifecycle.test.ts index 36bdcd9e..54306196 100644 --- a/packages/dnd/test/manager-lifecycle.test.ts +++ b/packages/dnd/test/manager-lifecycle.test.ts @@ -12,7 +12,7 @@ const compiled = new Bun.Transpiler({ loader: "ts" }) function fixture(overrides: Partial = {}) { const listeners: Record any> = {}; const announcements: DndAnnouncement[] = []; - const instances: Array<{ destroyed: number }> = []; + const instances: Array<{ destroyed: number; input: any }> = []; let managerDestructions = 0; class Backend { monitor = { @@ -26,7 +26,7 @@ function fixture(overrides: Partial = {}) { } class Sortable { destroyed = 0; - constructor() { + constructor(public input: any) { instances.push(this); } destroy() { @@ -94,6 +94,21 @@ test("late drop resolution cannot clear or announce a newer gesture", async () = ]); }); +test("collision candidates exclude overlapping ancestors and invalid destinations before sorting", () => { + const f = fixture({ accept: (_ids, to) => to.group !== "blocked" }); + const child = {} as HTMLElement; + f.manager.registerSortable({ id: "child", group: "content", index: 0, element: child, type: "text" }); + f.manager.registerSortable({ id: "parent", group: "root", index: 0, element: { contains: (element: unknown) => element === child } as HTMLElement }); + f.manager.registerSortable({ id: "sibling", group: "content", index: 1, element: {} as HTMLElement, accepts: ["text"] }); + f.manager.registerSortable({ id: "invalid", group: "blocked", index: 0, element: {} as HTMLElement }); + const source = { id: "child", type: "text" }; + expect(f.instances[1]!.input.accept(source)).toBe(false); + expect(f.instances[2]!.input.accept(source)).toBe(true); + expect(f.instances[2]!.input.accept({ ...source, type: "image" })).toBe(false); + expect(f.instances[3]!.input.accept(source)).toBe(false); + f.manager.destroy(); +}); + test("empty drop zones use explicit locations for acceptance and commit", async () => { const commits: unknown[] = []; const f = fixture({ accept: (_ids, to) => to.group === "empty", onMoveIntent: intent => { commits.push(intent.to); } });