UNPKG

framer-motion

Version:

A simple and powerful JavaScript animation library

1 lines • 17.8 kB
{"version":3,"file":"Board.mjs","sources":["../../../../src/components/Reorder/Board.tsx"],"sourcesContent":["\"use client\"\n\nimport {\n elementDragControls,\n microtask,\n visualElementStore,\n type VisualElementDragControls,\n} from \"motion-dom\"\nimport type { Point } from \"motion-utils\"\nimport * as React from \"react\"\nimport { useRef } from \"react\"\nimport { ReorderBoardContext } from \"../../context/ReorderBoardContext\"\nimport { useConstant } from \"../../utils/use-constant\"\nimport { useIsomorphicLayoutEffect } from \"../../utils/use-isomorphic-effect\"\nimport type {\n ReorderAxis,\n ReorderBoardContextProps,\n ReorderContextProps,\n} from \"./types\"\n\nexport interface Props<V> {\n /**\n * Whether `value` can move between two groups, from the group whose\n * values are `from` to the group whose values are `to`. It's called\n * before either group's `onReorder`. Return `false` to leave the item\n * where it is: a dragged item stays in its group until it's dragged\n * over another, and a keyboard move skips on to the next group that\n * way. Escape always returns a grabbed item to where it was grabbed.\n *\n * While a dragged item is over a group it refuses, it's called every\n * frame, so keep it fast and free of side effects.\n *\n * ```jsx\n * <Reorder.Board canMove={(card, from, to) => to.length < 5}>\n * ```\n *\n * @public\n */\n canMove?: (value: V, from: V[], to: V[]) => boolean\n}\n\ntype Group = ReorderContextProps<any>\ntype DragEvent = MouseEvent | TouchEvent | PointerEvent\n\n/**\n * An item moved to another group, to be picked up by its new component.\n */\ninterface Handoff {\n value: unknown\n to: Group[\"groupRef\"]\n /**\n * The element it was moved from, and its centre in page coordinates.\n */\n element: Element\n centre: Point\n /**\n * When dragged: the gesture, and its latest event to continue it from.\n */\n controls?: VisualElementDragControls\n event?: DragEvent\n /**\n * When moved with the keyboard: the values to keep it grabbed with, if\n * it's still grabbed.\n */\n keys?: boolean\n from?: unknown[]\n}\n\nconst centre = (element: Element): Point => {\n const { left, top, width, height } = element.getBoundingClientRect()\n return { x: left + width / 2, y: top + height / 2 }\n}\n\n/**\n * Its centre in page coordinates.\n */\nconst pageCentre = (element: Element): Point => {\n const { x, y } = centre(element)\n const { scrollX, scrollY } = element.ownerDocument.defaultView!\n return { x: x + scrollX, y: y + scrollY }\n}\n\n/**\n * Whether `point` comes before `element` in its group's order: before its\n * centre on `axis` or, on \"xy\", above its line or before its centre on it.\n */\nfunction isBefore(\n point: Point,\n element: Element,\n axis: ReorderAxis,\n isRTL: boolean\n) {\n const { left, right, top, bottom } = element.getBoundingClientRect()\n const x = isRTL\n ? point.x > (left + right) / 2\n : point.x <= (left + right) / 2\n const y = point.y <= (top + bottom) / 2\n\n return axis === \"x\"\n ? x\n : axis === \"y\"\n ? y\n : point.y < top || (point.y < bottom && x)\n}\n\n/**\n * Whether one group is, or holds, the other: like a list of columns and the\n * list of cards in one of them. Items only move between unrelated groups.\n */\nconst isNested = (a: Group, b: Group) => {\n const x = a.groupRef.current!\n const y = b.groupRef.current!\n return x.contains(y) || y.contains(x)\n}\n\nfunction createBoard(props: {\n current: Props<unknown>\n}): ReorderBoardContextProps {\n const groups = new Set<Group>()\n let handoff: Handoff | undefined\n\n /**\n * Whether `value` can move between these groups, if the app lets it.\n */\n const canMove = (from: Group, to: Group, value: unknown) =>\n !isNested(from, to) &&\n props.current.canMove?.(value, from.values, to.values) !== false\n\n /**\n * Where the latest keyboard grab began, for Escape to return it there.\n */\n let origin: { group: Group[\"groupRef\"]; from: unknown[] } | undefined\n\n /**\n * Move `value` from one group to another: to `index` in its values or,\n * by default, the slot under the item's centre.\n */\n const move = (\n from: Group,\n to: Group,\n value: unknown,\n element: Element,\n props: Partial<Handoff>,\n index?: number\n ) => {\n const point = centre(element)\n const { values } = to\n\n if (index === undefined) {\n /**\n * Only the items it shows: a group keeps unmounted (e.g.\n * virtualized) items' data while their values remain.\n */\n const order = to\n .getOrder()\n .filter((item) => item.ref?.current?.isConnected)\n const isRTL =\n getComputedStyle(to.groupRef.current!).direction === \"rtl\"\n const next = order.find((item) =>\n isBefore(point, item.ref!.current!, to.axis, isRTL)\n )\n const last = order[order.length - 1]\n index = next\n ? values.indexOf(next.value)\n : last\n ? values.indexOf(last.value) + 1\n : values.length\n }\n\n handoff = {\n value,\n to: to.groupRef,\n element,\n centre: pageCentre(element),\n ...props,\n }\n\n /**\n * The release that ends a drag lands over the new group.\n */\n to.hasReordered.current = true\n\n from.onReorder(\n from.values.filter((item) => item !== value),\n value\n )\n to.onReorder(\n [...values.slice(0, index), value, ...values.slice(index)],\n value\n )\n }\n\n return {\n add: (group) => {\n groups.add(group)\n return () => groups.delete(group)\n },\n\n drag: (from, value, element, event) => {\n /**\n * The item can unmount mid-drag (e.g. the app removed it), while\n * its gesture carries on until release.\n */\n if (!element?.isConnected) return false\n\n /**\n * Wait for the item to arrive in its new group before moving\n * it again, keeping up with where it's dragged meanwhile, for\n * its drag to carry on from there. If the groups don't take\n * it, it stays here until this gesture ends.\n */\n if (handoff?.controls?.isDragging) {\n if (handoff.value === value) {\n handoff.centre = pageCentre(element)\n handoff.event = event\n }\n return true\n }\n\n const point = centre(element)\n let to: Group | undefined\n\n for (const hit of element.ownerDocument.elementsFromPoint(\n point.x,\n point.y\n )) {\n // Skip the item, which is drawn above everything else\n if (element.contains(hit)) continue\n\n for (const group of groups) {\n if (group.groupRef.current === hit) to = group\n }\n\n if (to) break\n }\n\n if (!to || !canMove(from, to, value)) return false\n\n const visualElement = visualElementStore.get(element)\n move(from, to, value, element, {\n controls:\n visualElement && elementDragControls.get(visualElement),\n event,\n })\n\n return true\n },\n\n key: (from, value, keyIndex, element, grabbed) => {\n const point = centre(element)\n const axis = keyIndex % 2 ? \"x\" : \"y\"\n const direction = keyIndex > 1 ? 1 : -1\n let to: Group | undefined\n let nearest = Infinity\n\n for (const group of groups) {\n const target = group.groupRef.current\n if (!target) continue\n\n const { left, right, top, bottom, width, height } =\n target.getBoundingClientRect()\n\n // Hidden, e.g. display: none\n if (!width && !height) continue\n\n /**\n * Its near edge is past the item that way, so a group beside\n * this one isn't below its last item, whatever their heights.\n */\n const edge = [bottom, right, top, left][keyIndex]\n if ((edge - point[axis]) * direction <= 0) continue\n\n const x = Math.max(left - point.x, 0, point.x - right)\n const y = Math.max(top - point.y, 0, point.y - bottom)\n if (x * x + y * y < nearest && canMove(from, group, value)) {\n nearest = x * x + y * y\n to = group\n }\n }\n\n if (!to) return false\n\n if (origin?.from !== grabbed) {\n origin = { group: from.groupRef, from: grabbed }\n }\n move(from, to, value, element, { keys: true, from: grabbed })\n\n return true\n },\n\n restore: (from, value, element, grabbed) => {\n const to =\n origin?.from === grabbed &&\n [...groups].find((group) => group.groupRef === origin!.group)\n\n if (!to || to.groupRef === from.groupRef) return false\n\n /**\n * Before the first value that followed it when it was grabbed,\n * and is still there.\n */\n const after = grabbed.slice(grabbed.indexOf(value) + 1)\n const index = to.values.findIndex((item) => after.includes(item))\n\n origin = undefined\n move(\n from,\n to,\n value,\n element,\n { keys: true },\n index < 0 ? to.values.length : index\n )\n\n return true\n },\n\n end: (value) => {\n if (handoff?.value === value) handoff = undefined\n },\n\n isMoving: (element) => handoff?.element === element,\n\n receive: (to, value, element, grab) => {\n const visualElement = visualElementStore.get(element)\n const nextControls =\n visualElement && elementDragControls.get(visualElement)\n\n /**\n * React 19's Strict Mode remounts a new item, which resets its\n * whileDrag, after its drag may already have carried on.\n */\n nextControls?.isDragging &&\n visualElement!.animationState?.setActive(\"whileDrag\", true)\n\n const latest = handoff\n if (\n !latest ||\n latest.value !== value ||\n latest.to !== to.groupRef\n ) {\n return false\n }\n\n const { keys, from, controls } = latest\n /**\n * Whether the groups moved the item, rather than copied it.\n */\n const isMoved = !latest.element.isConnected\n\n if (keys) {\n handoff = undefined\n /**\n * Unless focus has moved on since, e.g. the groups took it\n * much later.\n */\n const { activeElement, body } = element.ownerDocument\n if (isMoved && (!activeElement || activeElement === body)) {\n ;(element as HTMLElement).focus()\n grab(element, from)\n }\n return false\n }\n\n if (!nextControls || !controls?.isDragging) return false\n\n /**\n * Once the commit that moved the item has been measured, and\n * before it's drawn. Until then, the item's drag in its\n * previous group waits.\n */\n microtask.read(() => {\n if (handoff !== latest) return\n handoff = undefined\n\n /**\n * Start it where, and as, the item was dragged to (e.g.\n * mid-way through its whileDrag animation), and carry on\n * dragging it from there. Values are read from the item\n * first, for its whileDrag to return to on release.\n */\n visualElementStore\n .get(latest.element)\n ?.values.forEach((motionValue, key) => {\n const current = motionValue.get()\n visualElement!\n .getValue(\n key,\n visualElement!.readValue(key, current)\n )\n ?.set(current)\n })\n\n /**\n * Into the item's own coordinates, e.g. in a scaled parent.\n */\n const transform =\n visualElement!.getTransformPagePoint() || ((p: Point) => p)\n const a = transform(latest.centre)\n const b = transform(pageCentre(element))\n visualElement!.getValue(\"x\")!.set(a.x - b.x)\n visualElement!.getValue(\"y\")!.set(a.y - b.y)\n visualElement!.render()\n\n /**\n * If the item is still in its previous group, animate it\n * back to its slot there.\n */\n isMoved ? controls.cancel() : controls.stop()\n nextControls.start(latest.event as PointerEvent, {\n immediate: true,\n })\n })\n\n /**\n * Its drag already started in its previous group.\n */\n return isMoved\n },\n\n column: ({ groupRef }) => {\n let element = groupRef.current\n const holdsOther = (ancestor: Element) =>\n [...groups].some(\n (group) =>\n group.groupRef !== groupRef &&\n ancestor.contains(group.groupRef.current)\n )\n\n while (\n element?.parentElement?.parentElement &&\n !holdsOther(element.parentElement)\n ) {\n element = element.parentElement\n }\n\n return element\n },\n }\n}\n\n/**\n * Lets items be dragged between the `Reorder.Group`s inside it.\n *\n * ```jsx\n * <Reorder.Board>\n * <Reorder.Group values={todo} onReorder={setTodo}>\n * {todo.map((item) => <Reorder.Item key={item} value={item} />)}\n * </Reorder.Group>\n * <Reorder.Group values={done} onReorder={setDone}>\n * {done.map((item) => <Reorder.Item key={item} value={item} />)}\n * </Reorder.Group>\n * </Reorder.Board>\n * ```\n *\n * When an item's centre is dragged over another group, it moves there:\n * the group it left calls `onReorder` without it, and the group it joined\n * calls `onReorder` with it, both passing it as the moved value. Their\n * other items animate to make room, and the drag carries on in the new\n * group.\n *\n * Grabbed with the keyboard, an item moves to the nearest group in the\n * direction of an arrow key across its group's axis, or along it from\n * either end. Escape returns it to the group and slot it was grabbed from.\n *\n * `canMove` can refuse a move before either group updates.\n *\n * Values must be unique across the groups. If the app keeps the groups'\n * values in one state, update it from the latest state (e.g.\n * `setColumns((columns) => ...)`), as both groups update it in turn.\n *\n * @public\n */\nexport function ReorderBoard<V = any>({\n children,\n ...props\n}: React.PropsWithChildren<Props<V>>) {\n const latest = useRef(props as Props<unknown>)\n\n /**\n * In a layout effect, so a render React discards can't leave its\n * props here.\n */\n useIsomorphicLayoutEffect(() => {\n latest.current = props as Props<unknown>\n })\n\n return (\n <ReorderBoardContext.Provider\n value={useConstant(() => createBoard(latest))}\n >\n {children}\n </ReorderBoardContext.Provider>\n )\n}\n"],"names":[],"mappings":";;;;;;;;AAoEA;AACI;AACA;AACJ;AAEA;;AAEG;AACH;;;AAGI;AACJ;AAEA;;;AAGG;AACH;AAMI;;;AAGI;AACJ;;AAGI;;AAEA;AACA;AACR;AAEA;;;AAGG;AACH;AACI;AACA;AACA;AACJ;AAEA;AAGI;AACA;AAEA;;AAEG;AACH;AAEI;AAEJ;;AAEG;AACH;AAEA;;;AAGG;AACH;AAQI;AACA;AAEA;AACI;;;AAGG;;AAEE;AACA;AACL;AAEA;;AAIA;;AAEI;;AAEA;;AAGR;;;;AAII;AACA;;AAGJ;;AAEG;AACH;;;AAUJ;;AAGI;AACI;;;;AAKA;;;AAGG;;AACwB;AAE3B;;;;;AAKG;AACH;AACI;AACI;AACA;;AAEJ;;AAGJ;AACA;AAEA;;AAKI;;AAEA;AACI;;;AAGJ;;;;AAGkC;;;;;AAOrC;AAED;;AAGJ;AACI;AACA;AACA;AACA;;AAGA;AACI;AACA;;AAEA;;AAIA;;AAEA;;;AAGG;AACH;;;;;AAKA;;;;;AAMJ;AAAS;AAET;AACI;;AAEJ;AAEA;;;AAIA;AAEI;;AAEsC;AAE1C;;;AAGG;AACH;;;AAIA;AASA;;AAGJ;AACI;;;;;;;AAUA;;;AAGG;AACH;;;AAIA;;AAGI;AAEA;;;AAIJ;;AAEG;;;;AAKC;;;AAGG;;;;AAIC;;AAEJ;;AAGJ;AAA4C;AAE5C;;;;AAIG;AACH;;;;AAII;;;;;AAKG;;AAEE;;AAEG;;;AAMI;AACR;AAEJ;;AAEG;AACH;;;AAIA;AACA;;AAGA;;;AAGG;AACH;AACA;AACI;AACH;AACL;AAEA;;AAEG;AACH;;AAGJ;AACI;;;AAQA;AAEI;AAEA;;AAGJ;;;AAGZ;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BG;AACG;AAIF;AAEA;;;AAGG;;AAEC;AACJ;;AASJ;;"}