Keep keyboard drag targets aligned with movement axis
Quality / quality (ubuntu-latest) (push) Failing after 1s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-09-05 16:55:47 +05:30
1 parent ca2fcb4016
commit 6afffe9f08
3 files changed
+37 -2

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/dnd", "name": "@wrnexus/dnd",
"version": "0.1.4", "version": "0.1.5",
"type": "module", "type": "module",
"description": "Stable accessible drag-and-drop primitives for WRNexus.", "description": "Stable accessible drag-and-drop primitives for WRNexus.",
"exports": { "exports": {
+16 -1
View File
@@ -23,10 +23,24 @@ export function createDndManager(options: DndManagerOptions): DndManager {
let activeIds: Array<string | number> = []; let activeIds: Array<string | number> = [];
let generation = 0; let generation = 0;
let destroyed = false; 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) => { manager.monitor.addEventListener("dragstart", (event) => {
if (destroyed) return; if (destroyed) return;
generation++; generation++;
keyboardAxis = undefined;
const source = event.operation.source; const source = event.operation.source;
if (!source) return; if (!source) return;
activeIds = [source.id]; 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 (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 (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."); 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 } }); zones.set(registration.id, { droppable, location: { group: registration.group, index: registration.index } });
return () => { return () => {
if (zones.get(registration.id)?.droppable !== droppable) return; if (zones.get(registration.id)?.droppable !== droppable) return;
@@ -122,6 +136,7 @@ export function createDndManager(options: DndManagerOptions): DndManager {
handle: registration.handle, handle: registration.handle,
type: registration.type, type: registration.type,
accept: (source) => { accept: (source) => {
if (!keyboardAligned(source.id, registration.element)) return false;
const origin = entries.get(source.id)?.current.element; const origin = entries.get(source.id)?.current.element;
// A nested ancestor overlaps its own descendants and must not steal // A nested ancestor overlaps its own descendants and must not steal
// their next keyboard/pointer target just because its box is larger. // their next keyboard/pointer target just because its box is larger.
@@ -54,6 +54,7 @@ function fixture(overrides: Partial<DndManagerOptions> = {}) {
manager, manager,
announcements, announcements,
instances, instances,
move: (code: string) => listeners.dragmove!({ nativeEvent: { code } }),
start: (id: string) => listeners.dragstart!(event(id)), start: (id: string) => listeners.dragstart!(event(id)),
end: (id: string, target?: { id: string }) => listeners.dragend!({ ...event(id), operation: { ...event(id).operation, target } }), end: (id: string, target?: { id: string }) => listeners.dragend!({ ...event(id), operation: { ...event(id).operation, target } }),
over: (target: { id?: string; group?: string; index?: number }) => { 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", () => { test("collision candidates exclude overlapping ancestors and invalid destinations before sorting", () => {
const f = fixture({ accept: (_ids, to) => to.group !== "blocked" }); const f = fixture({ accept: (_ids, to) => to.group !== "blocked" });
const child = {} as HTMLElement; const child = {} as HTMLElement;