diff --git a/packages/dnd/package.json b/packages/dnd/package.json index 31fc39d1..93567113 100644 --- a/packages/dnd/package.json +++ b/packages/dnd/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dnd", - "version": "0.1.4", + "version": "0.1.5", "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 72694138..553b4b8e 100644 --- a/packages/dnd/src/manager.ts +++ b/packages/dnd/src/manager.ts @@ -23,10 +23,24 @@ export function createDndManager(options: DndManagerOptions): DndManager { let activeIds: Array = []; let generation = 0; let destroyed = false; + let keyboardAxis: "x" | "y" | undefined; + const keyboardAligned = (id: string | number, target: HTMLElement) => { + if (!keyboardAxis) return true; + const element = entries.get(id)?.current.element; + const a = element?.getBoundingClientRect?.(), b = target.getBoundingClientRect?.(); + if (!a || !b) return true; + return keyboardAxis === "x" ? Math.min(a.bottom, b.bottom) > Math.max(a.top, b.top) : Math.min(a.right, b.right) > Math.max(a.left, b.left); + }; + manager.monitor.addEventListener("dragmove", event => { + const code = (event.nativeEvent as KeyboardEvent | undefined)?.code; + if (code === "ArrowLeft" || code === "ArrowRight") keyboardAxis = "x"; + else if (code === "ArrowUp" || code === "ArrowDown") keyboardAxis = "y"; + }); manager.monitor.addEventListener("dragstart", (event) => { if (destroyed) return; generation++; + keyboardAxis = undefined; const source = event.operation.source; if (!source) return; activeIds = [source.id]; @@ -101,7 +115,7 @@ export function createDndManager(options: DndManagerOptions): DndManager { if (destroyed) throw new Error("Cannot register a drop zone after manager destruction."); if (entries.has(registration.id) || zones.has(registration.id)) throw new Error(`Duplicate draggable or drop zone id ${String(registration.id)}.`); if (!Number.isSafeInteger(registration.index) || registration.index < 0) throw new Error("Drop zone index must be a non-negative safe integer."); - const droppable = new Droppable({ id: registration.id, element: registration.element, accept: registration.accepts, disabled: registration.disabled }, manager); + const droppable = new Droppable({ id: registration.id, element: registration.element, accept: source => keyboardAligned(source.id, registration.element) && (!registration.accepts || registration.accepts.some(type => type === source.type)) && (options.accept?.([source.id], { group: registration.group, index: registration.index }) ?? true), disabled: registration.disabled }, manager); zones.set(registration.id, { droppable, location: { group: registration.group, index: registration.index } }); return () => { if (zones.get(registration.id)?.droppable !== droppable) return; @@ -122,6 +136,7 @@ export function createDndManager(options: DndManagerOptions): DndManager { handle: registration.handle, type: registration.type, accept: (source) => { + if (!keyboardAligned(source.id, registration.element)) return false; 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. diff --git a/packages/dnd/test/manager-lifecycle.test.ts b/packages/dnd/test/manager-lifecycle.test.ts index 54306196..569e69ec 100644 --- a/packages/dnd/test/manager-lifecycle.test.ts +++ b/packages/dnd/test/manager-lifecycle.test.ts @@ -54,6 +54,7 @@ function fixture(overrides: Partial = {}) { manager, announcements, instances, + move: (code: string) => listeners.dragmove!({ nativeEvent: { code } }), start: (id: string) => listeners.dragstart!(event(id)), end: (id: string, target?: { id: string }) => listeners.dragend!({ ...event(id), operation: { ...event(id).operation, target } }), over: (target: { id?: string; group?: string; index?: number }) => { @@ -94,6 +95,25 @@ test("late drop resolution cannot clear or announce a newer gesture", async () = ]); }); +test("keyboard candidates stay in the requested geometric row or column", () => { + const f = fixture(); + const element = (left: number, top: number, right: number, bottom: number) => ({ getBoundingClientRect: () => ({ left, top, right, bottom }) }) as HTMLElement; + f.manager.registerSortable({ id: "source", group: "a", index: 0, element: element(0, 100, 100, 150) }); + f.manager.registerDropZone({ id: "right", group: "b", index: 0, element: element(110, 100, 200, 160) }); + f.manager.registerSortable({ id: "above", group: "c", index: 0, element: element(0, 0, 300, 90) }); + const source = { id: "source", type: "text" }; + f.start("source"); + f.move("ArrowRight"); + expect(f.instances[1]!.input.accept(source)).toBe(true); + expect(f.instances[2]!.input.accept(source)).toBe(false); + f.move("ArrowUp"); + expect(f.instances[1]!.input.accept(source)).toBe(false); + expect(f.instances[2]!.input.accept(source)).toBe(true); + f.start("source"); + expect(f.instances[2]!.input.accept(source)).toBe(true); + f.manager.destroy(); +}); + test("collision candidates exclude overlapping ancestors and invalid destinations before sorting", () => { const f = fixture({ accept: (_ids, to) => to.group !== "blocked" }); const child = {} as HTMLElement;