diff --git a/package.json b/package.json index 0013ba5..39fac93 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "typesVersions": {}, "sideEffects": false, "scripts": { + "dev": "tsup --watch", "build": "tsup", "prepublishOnly": "npm run build", "release": "release-it" diff --git a/src/create-draggable.ts b/src/create-draggable.ts index b1cfb36..9029235 100644 --- a/src/create-draggable.ts +++ b/src/create-draggable.ts @@ -76,9 +76,17 @@ const createDraggable = (id: Id, data: Record = {}): Draggable => { createEffect(() => { const resolvedTransform = transform(); + if (state.active.forceImmediateTransition) { + element.style.setProperty("transition-property", "transform"); + element.style.setProperty("transition-duration", "0ms"); + } else { + element.style.removeProperty("transition-property"); + element.style.removeProperty("transition-duration"); + } + if (!transformsAreEqual(resolvedTransform, noopTransform())) { - const style = transformStyle(transform()); - element.style.setProperty("transform", style.transform ?? null); + const style = transformStyle(resolvedTransform); + element.style.setProperty("transform", style.transform); } else { element.style.removeProperty("transform"); } diff --git a/src/create-droppable.ts b/src/create-droppable.ts index 8e605bd..1d4f8e0 100644 --- a/src/create-droppable.ts +++ b/src/create-droppable.ts @@ -54,8 +54,8 @@ const createDroppable = (id: Id, data: Record = {}): Droppable => { createEffect(() => { const resolvedTransform = transform(); if (!transformsAreEqual(resolvedTransform, noopTransform())) { - const style = transformStyle(transform()); - element.style.setProperty("transform", style.transform ?? null); + const style = transformStyle(resolvedTransform); + element.style.setProperty("transform", style.transform); } else { element.style.removeProperty("transform"); } diff --git a/src/create-keyboard-sensor.ts b/src/create-keyboard-sensor.ts new file mode 100644 index 0000000..9de95e5 --- /dev/null +++ b/src/create-keyboard-sensor.ts @@ -0,0 +1,163 @@ +import { onCleanup, onMount, untrack } from "solid-js"; + +import { + Coordinates, + Id, + SensorActivator, + useDragDropContext, +} from "./drag-drop-context"; +import { Transform } from "./layout"; + +const activateKeys = [ + " ", + "Enter", +] as const + +type ActivateKeys = typeof activateKeys[number] + +const sensorKeys = [ + ...activateKeys, + "Escape", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", +] as const; + +type SensorKey = typeof sensorKeys[number]; + +const createKeyboardSensor = (id: Id = "keyboard-sensor"): void => { + const [ + state, + { + addSensor, + removeSensor, + sensorStart, + sensorMove, + sensorEnd, + dragStart, + dragEnd, + }, + ] = useDragDropContext()!; + const activationDelay = 250; // milliseconds + const activationDistance = 10; // pixels + const speed = 5 // pixels per keypress + + onMount(() => { + addSensor({ + id, + activators: { keydown: attach }, + }); + }); + + onCleanup(() => { + removeSensor(id); + }); + + const isActiveSensor = () => state.active.sensorId === id; + + const initialCoordinates: Coordinates = { x: 0, y: 0 }; + + let activationDelayTimeoutId: number | null = null; + let activationDraggableId: Id | null = null; + + const attach: SensorActivator<"keydown"> = (event, draggableId) => { + if (activateKeys.includes(event.key as ActivateKeys)) { + event.preventDefault(); + event.stopPropagation(); + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + document.addEventListener("keydown", onKeyDown); + activationDraggableId = draggableId; + initialCoordinates.x = rect.x + rect.width / 2; + initialCoordinates.y = rect.y + rect.height / 2; + + activationDelayTimeoutId = window.setTimeout(onActivate, activationDelay); + } + }; + + const detach = (): void => { + if (activationDelayTimeoutId) { + clearTimeout(activationDelayTimeoutId); + activationDelayTimeoutId = null; + } + + document.removeEventListener("keydown", onKeyDown); + }; + + const onActivate = (): void => { + if (!state.active.sensor) { + sensorStart(id, initialCoordinates); + dragStart(activationDraggableId!); + + clearSelection(); + document.addEventListener("selectionchange", clearSelection); + } else if (!isActiveSensor()) { + detach(); + } + }; + + const onKeyDown = (event: KeyboardEvent): void => { + if (sensorKeys.includes(event.key as SensorKey)) { + const sensor = untrack(() => state.active.sensor); + if (sensor) { + const coordinates: Coordinates = { ...sensor.coordinates.current }; + const prevCoordinates: Coordinates = { ...coordinates }; + switch (event.key as SensorKey) { + case "Escape": + sensorMove(initialCoordinates); + case " ": + case "Enter": + detach(); + if (isActiveSensor()) { + event.preventDefault(); + dragEnd(); + sensorEnd(); + } + break; + case "ArrowLeft": + coordinates.x -= speed; + break; + case "ArrowRight": + coordinates.x += speed; + break; + case "ArrowUp": + coordinates.y -= speed; + break; + case "ArrowDown": + coordinates.y += speed; + break; + } + + if ( + prevCoordinates.x !== coordinates.x || + prevCoordinates.y !== coordinates.y + ) { + event.preventDefault(); + if (!state.active.sensor) { + const transform: Transform = { + x: coordinates.x - initialCoordinates.x, + y: coordinates.y - initialCoordinates.y, + }; + + if ( + Math.sqrt(transform.x ** 2 + transform.y ** 2) > + activationDistance + ) { + onActivate(); + } + } + + if (isActiveSensor()) { + sensorMove(coordinates); + } + } + } + } + }; + + const clearSelection = () => { + window.getSelection()?.removeAllRanges(); + }; +}; + +export { createKeyboardSensor }; diff --git a/src/create-pointer-sensor.ts b/src/create-pointer-sensor.ts index 3696983..5450006 100644 --- a/src/create-pointer-sensor.ts +++ b/src/create-pointer-sensor.ts @@ -1,4 +1,4 @@ -import { onCleanup, onMount } from "solid-js"; +import { onCleanup, onMount, untrack } from "solid-js"; import { Coordinates, @@ -91,11 +91,15 @@ const createPointerSensor = (id: Id = "pointer-sensor"): void => { if (isActiveSensor()) { event.preventDefault(); - sensorMove(coordinates); + sensorMove(coordinates, true); } }; const onPointerUp = (event: PointerEvent): void => { + const sensor = untrack(() => state.active.sensor); + if (sensor) { + sensorMove(sensor.coordinates.current); + } detach(); if (isActiveSensor()) { event.preventDefault(); diff --git a/src/create-sortable.ts b/src/create-sortable.ts index a185ef1..9909d4f 100644 --- a/src/create-sortable.ts +++ b/src/create-sortable.ts @@ -83,8 +83,8 @@ const createSortable = (id: Id, data: Record = {}): Sortable => { createEffect(() => { const resolvedTransform = transform(); if (!transformsAreEqual(resolvedTransform, noopTransform())) { - const style = transformStyle(transform()); - element.style.setProperty("transform", style.transform ?? null); + const style = transformStyle(resolvedTransform); + element.style.setProperty("transform", style.transform); } else { element.style.removeProperty("transform"); } diff --git a/src/drag-drop-context.tsx b/src/drag-drop-context.tsx index a18c541..d06a7c5 100644 --- a/src/drag-drop-context.tsx +++ b/src/drag-drop-context.tsx @@ -82,6 +82,7 @@ interface DragDropState { draggable: Draggable | null; droppableId: Id | null; droppable: Droppable | null; + forceImmediateTransition: boolean; sensorId: Id | null; sensor: Sensor | null; overlay: Overlay | null; @@ -115,7 +116,10 @@ interface DragDropActions { detectCollisions(): void; draggableActivators(draggableId: Id, asHandlers?: boolean): Listeners; sensorStart(id: Id, coordinates: Coordinates): void; - sensorMove(coordinates: Coordinates): void; + sensorMove( + coordinates: Coordinates, + forceImmediateTransition?: boolean + ): void; sensorEnd(): void; dragStart(draggableId: Id): void; dragEnd(): void; @@ -170,6 +174,7 @@ const DragDropProvider: ParentComponent = ( ? state.droppables[state.active.droppableId] : null; }, + forceImmediateTransition: false, sensorId: null, get sensor(): Sensor | null { return state.active.sensorId !== null @@ -229,7 +234,10 @@ const DragDropProvider: ParentComponent = ( }) => { const existingDraggable = state.draggables[id]; - const draggable = { + const draggable: Omit< + Draggable, + "transform" | "transformed" | "transformers" + > = { id, node, layout, @@ -346,7 +354,10 @@ const DragDropProvider: ParentComponent = ( }) => { const existingDroppable = state.droppables[id]; - const droppable = { + const droppable: Omit< + Droppable, + "transform" | "transformed" | "transformers" + > = { id, node, layout, @@ -536,15 +547,21 @@ const DragDropProvider: ParentComponent = ( }); }; - const sensorMove: DragDropActions["sensorMove"] = (coordinates) => { + const sensorMove: DragDropActions["sensorMove"] = ( + coordinates, + forceImmediateTransition = false + ) => { const sensorId = state.active.sensorId; if (!sensorId) { console.warn("Cannot move sensor when no sensor active."); return; } - setState("sensors", sensorId, "coordinates", "current", { - ...coordinates, + batch(() => { + setState("sensors", sensorId, "coordinates", "current", { + ...coordinates, + }); + setState("active", "forceImmediateTransition", forceImmediateTransition); }); }; @@ -820,4 +837,4 @@ export type { Overlay, SensorActivator, Transformer, -}; \ No newline at end of file +}; diff --git a/src/drag-drop-debugger.tsx b/src/drag-drop-debugger.tsx index 54509cd..23a3181 100644 --- a/src/drag-drop-debugger.tsx +++ b/src/drag-drop-debugger.tsx @@ -10,7 +10,7 @@ import { import { Portal } from "solid-js/web"; import { Id, useDragDropContext } from "./drag-drop-context"; -import { Layout, Transform } from "./layout"; +import { Layout } from "./layout"; import { layoutStyle, transformStyle } from "./style"; interface HighlighterProps { diff --git a/src/drag-drop-sensors.tsx b/src/drag-drop-sensors.tsx index a6ba945..ff29494 100644 --- a/src/drag-drop-sensors.tsx +++ b/src/drag-drop-sensors.tsx @@ -1,9 +1,11 @@ import { ParentComponent } from "solid-js"; import { createPointerSensor } from "./create-pointer-sensor"; +import { createKeyboardSensor } from "./create-keyboard-sensor"; const DragDropSensors: ParentComponent = (props) => { createPointerSensor(); + createKeyboardSensor(); return <>{props.children}; }; diff --git a/src/drag-overlay.tsx b/src/drag-overlay.tsx index 039e205..31b74c8 100644 --- a/src/drag-overlay.tsx +++ b/src/drag-overlay.tsx @@ -46,7 +46,7 @@ const DragOverlay: ParentComponent = (props) => { return { position: "fixed", - transition: "transform 0s", + transition: state.active.forceImmediateTransition ? "transform 0s" : "", top: `${overlay.layout.top}px`, left: `${overlay.layout.left}px`, "min-width": `${draggable.layout.width}px`, diff --git a/src/index.tsx b/src/index.tsx index 4f05634..40d2c44 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,7 @@ export { DragDropProvider, useDragDropContext } from "./drag-drop-context"; export { DragDropSensors } from "./drag-drop-sensors"; export { createPointerSensor } from "./create-pointer-sensor"; +export { createKeyboardSensor } from "./create-keyboard-sensor"; export { createDraggable } from "./create-draggable"; export { createDroppable } from "./create-droppable"; export { DragOverlay } from "./drag-overlay"; diff --git a/src/style.ts b/src/style.ts index d6e93c0..df4d64f 100644 --- a/src/style.ts +++ b/src/style.ts @@ -11,7 +11,7 @@ const layoutStyle = (layout: Layout): JSX.CSSProperties => { }; }; -const transformStyle = (transform: Transform): JSX.CSSProperties => { +const transformStyle = (transform: Transform): JSX.CSSProperties & { transform: string } => { return { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` }; };