UNPKG

framer-motion

Version:

A simple and powerful JavaScript animation library

338 lines (335 loc) • 13.6 kB
"use client"; import { jsx } from 'react/jsx-runtime'; import { visualElementStore, elementDragControls, microtask } from 'motion-dom'; import { useRef } from 'react'; import { ReorderBoardContext } from '../../context/ReorderBoardContext.mjs'; import { useConstant } from '../../utils/use-constant.mjs'; import { useIsomorphicLayoutEffect } from '../../utils/use-isomorphic-effect.mjs'; const centre = (element) => { const { left, top, width, height } = element.getBoundingClientRect(); return { x: left + width / 2, y: top + height / 2 }; }; /** * Its centre in page coordinates. */ const pageCentre = (element) => { const { x, y } = centre(element); const { scrollX, scrollY } = element.ownerDocument.defaultView; return { x: x + scrollX, y: y + scrollY }; }; /** * Whether `point` comes before `element` in its group's order: before its * centre on `axis` or, on "xy", above its line or before its centre on it. */ function isBefore(point, element, axis, isRTL) { const { left, right, top, bottom } = element.getBoundingClientRect(); const x = isRTL ? point.x > (left + right) / 2 : point.x <= (left + right) / 2; const y = point.y <= (top + bottom) / 2; return axis === "x" ? x : axis === "y" ? y : point.y < top || (point.y < bottom && x); } /** * Whether one group is, or holds, the other: like a list of columns and the * list of cards in one of them. Items only move between unrelated groups. */ const isNested = (a, b) => { const x = a.groupRef.current; const y = b.groupRef.current; return x.contains(y) || y.contains(x); }; function createBoard(props) { const groups = new Set(); let handoff; /** * Whether `value` can move between these groups, if the app lets it. */ const canMove = (from, to, value) => !isNested(from, to) && props.current.canMove?.(value, from.values, to.values) !== false; /** * Where the latest keyboard grab began, for Escape to return it there. */ let origin; /** * Move `value` from one group to another: to `index` in its values or, * by default, the slot under the item's centre. */ const move = (from, to, value, element, props, index) => { const point = centre(element); const { values } = to; if (index === undefined) { /** * Only the items it shows: a group keeps unmounted (e.g. * virtualized) items' data while their values remain. */ const order = to .getOrder() .filter((item) => item.ref?.current?.isConnected); const isRTL = getComputedStyle(to.groupRef.current).direction === "rtl"; const next = order.find((item) => isBefore(point, item.ref.current, to.axis, isRTL)); const last = order[order.length - 1]; index = next ? values.indexOf(next.value) : last ? values.indexOf(last.value) + 1 : values.length; } handoff = { value, to: to.groupRef, element, centre: pageCentre(element), ...props, }; /** * The release that ends a drag lands over the new group. */ to.hasReordered.current = true; from.onReorder(from.values.filter((item) => item !== value), value); to.onReorder([...values.slice(0, index), value, ...values.slice(index)], value); }; return { add: (group) => { groups.add(group); return () => groups.delete(group); }, drag: (from, value, element, event) => { /** * The item can unmount mid-drag (e.g. the app removed it), while * its gesture carries on until release. */ if (!element?.isConnected) return false; /** * Wait for the item to arrive in its new group before moving * it again, keeping up with where it's dragged meanwhile, for * its drag to carry on from there. If the groups don't take * it, it stays here until this gesture ends. */ if (handoff?.controls?.isDragging) { if (handoff.value === value) { handoff.centre = pageCentre(element); handoff.event = event; } return true; } const point = centre(element); let to; for (const hit of element.ownerDocument.elementsFromPoint(point.x, point.y)) { // Skip the item, which is drawn above everything else if (element.contains(hit)) continue; for (const group of groups) { if (group.groupRef.current === hit) to = group; } if (to) break; } if (!to || !canMove(from, to, value)) return false; const visualElement = visualElementStore.get(element); move(from, to, value, element, { controls: visualElement && elementDragControls.get(visualElement), event, }); return true; }, key: (from, value, keyIndex, element, grabbed) => { const point = centre(element); const axis = keyIndex % 2 ? "x" : "y"; const direction = keyIndex > 1 ? 1 : -1; let to; let nearest = Infinity; for (const group of groups) { const target = group.groupRef.current; if (!target) continue; const { left, right, top, bottom, width, height } = target.getBoundingClientRect(); // Hidden, e.g. display: none if (!width && !height) continue; /** * Its near edge is past the item that way, so a group beside * this one isn't below its last item, whatever their heights. */ const edge = [bottom, right, top, left][keyIndex]; if ((edge - point[axis]) * direction <= 0) continue; const x = Math.max(left - point.x, 0, point.x - right); const y = Math.max(top - point.y, 0, point.y - bottom); if (x * x + y * y < nearest && canMove(from, group, value)) { nearest = x * x + y * y; to = group; } } if (!to) return false; if (origin?.from !== grabbed) { origin = { group: from.groupRef, from: grabbed }; } move(from, to, value, element, { keys: true, from: grabbed }); return true; }, restore: (from, value, element, grabbed) => { const to = origin?.from === grabbed && [...groups].find((group) => group.groupRef === origin.group); if (!to || to.groupRef === from.groupRef) return false; /** * Before the first value that followed it when it was grabbed, * and is still there. */ const after = grabbed.slice(grabbed.indexOf(value) + 1); const index = to.values.findIndex((item) => after.includes(item)); origin = undefined; move(from, to, value, element, { keys: true }, index < 0 ? to.values.length : index); return true; }, end: (value) => { if (handoff?.value === value) handoff = undefined; }, isMoving: (element) => handoff?.element === element, receive: (to, value, element, grab) => { const visualElement = visualElementStore.get(element); const nextControls = visualElement && elementDragControls.get(visualElement); /** * React 19's Strict Mode remounts a new item, which resets its * whileDrag, after its drag may already have carried on. */ nextControls?.isDragging && visualElement.animationState?.setActive("whileDrag", true); const latest = handoff; if (!latest || latest.value !== value || latest.to !== to.groupRef) { return false; } const { keys, from, controls } = latest; /** * Whether the groups moved the item, rather than copied it. */ const isMoved = !latest.element.isConnected; if (keys) { handoff = undefined; /** * Unless focus has moved on since, e.g. the groups took it * much later. */ const { activeElement, body } = element.ownerDocument; if (isMoved && (!activeElement || activeElement === body)) { element.focus(); grab(element, from); } return false; } if (!nextControls || !controls?.isDragging) return false; /** * Once the commit that moved the item has been measured, and * before it's drawn. Until then, the item's drag in its * previous group waits. */ microtask.read(() => { if (handoff !== latest) return; handoff = undefined; /** * Start it where, and as, the item was dragged to (e.g. * mid-way through its whileDrag animation), and carry on * dragging it from there. Values are read from the item * first, for its whileDrag to return to on release. */ visualElementStore .get(latest.element) ?.values.forEach((motionValue, key) => { const current = motionValue.get(); visualElement .getValue(key, visualElement.readValue(key, current)) ?.set(current); }); /** * Into the item's own coordinates, e.g. in a scaled parent. */ const transform = visualElement.getTransformPagePoint() || ((p) => p); const a = transform(latest.centre); const b = transform(pageCentre(element)); visualElement.getValue("x").set(a.x - b.x); visualElement.getValue("y").set(a.y - b.y); visualElement.render(); /** * If the item is still in its previous group, animate it * back to its slot there. */ isMoved ? controls.cancel() : controls.stop(); nextControls.start(latest.event, { immediate: true, }); }); /** * Its drag already started in its previous group. */ return isMoved; }, column: ({ groupRef }) => { let element = groupRef.current; const holdsOther = (ancestor) => [...groups].some((group) => group.groupRef !== groupRef && ancestor.contains(group.groupRef.current)); while (element?.parentElement?.parentElement && !holdsOther(element.parentElement)) { element = element.parentElement; } return element; }, }; } /** * Lets items be dragged between the `Reorder.Group`s inside it. * * ```jsx * <Reorder.Board> * <Reorder.Group values={todo} onReorder={setTodo}> * {todo.map((item) => <Reorder.Item key={item} value={item} />)} * </Reorder.Group> * <Reorder.Group values={done} onReorder={setDone}> * {done.map((item) => <Reorder.Item key={item} value={item} />)} * </Reorder.Group> * </Reorder.Board> * ``` * * When an item's centre is dragged over another group, it moves there: * the group it left calls `onReorder` without it, and the group it joined * calls `onReorder` with it, both passing it as the moved value. Their * other items animate to make room, and the drag carries on in the new * group. * * Grabbed with the keyboard, an item moves to the nearest group in the * direction of an arrow key across its group's axis, or along it from * either end. Escape returns it to the group and slot it was grabbed from. * * `canMove` can refuse a move before either group updates. * * Values must be unique across the groups. If the app keeps the groups' * values in one state, update it from the latest state (e.g. * `setColumns((columns) => ...)`), as both groups update it in turn. * * @public */ function ReorderBoard({ children, ...props }) { const latest = useRef(props); /** * In a layout effect, so a render React discards can't leave its * props here. */ useIsomorphicLayoutEffect(() => { latest.current = props; }); return (jsx(ReorderBoardContext.Provider, { value: useConstant(() => createBoard(latest)), children: children })); } export { ReorderBoard }; //# sourceMappingURL=Board.mjs.map