Keep keyboard drag targets aligned with movement axis
This commit is contained in:
1 parent
ca2fcb4016
commit
6afffe9f08
3 files changed
+37
-2
No files matched your search
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user