UNPKG

framer-motion

Version:

A simple and powerful JavaScript animation library

285 lines (282 loc) • 12.5 kB
import { motionValue, isMotionValue, transformValue, visualElementStore } from 'motion-dom'; import { moveItem } from 'motion-utils'; import { createElement, ref, on } from 'remix/component'; import { resetAutoScrollState, autoScrollIfNeeded } from './components/Reorder/utils/auto-scroll.mjs'; import { checkReorder, keyTarget } from './components/Reorder/utils/check-reorder.mjs'; import { detectAxis } from './components/Reorder/utils/detect-axis.mjs'; const keys = ["ArrowUp", "ArrowLeft", "ArrowDown", "ArrowRight", "Home", "End"]; function createReorder(motion) { /** * A list of items that can be reordered by drag or keyboard, with the * same props as React's `Reorder.Group`. */ function Group(handle) { const itemLayouts = new Map(); const items = new Map(); let active; let detectedAxis = "y"; let isReordering = false; let element; let renderedValues; const getOrder = () => handle.props.values.flatMap((value) => { const layout = itemLayouts.get(value); return layout ? [{ value, layout }] : []; }); const isRTL = () => !!element && getComputedStyle(element).direction === "rtl"; /** * Write the new order of the measured items back into the slots they * hold in `values`, leaving unmeasured values in place. */ const commit = (newOrder, moved) => { const { values, onReorder } = handle.props; let i = 0; const newValues = values.map((value) => itemLayouts.has(value) ? newOrder[i++].value : value); newValues.some((value, j) => value !== values[j]) && onReorder(newValues, moved); }; const getTabStop = () => { const { values } = handle.props; return values.includes(active) ? active : values[0]; }; const isRendered = (value) => items.get(value)?.element?.isConnected; /** * If the tab stop isn't rendered (e.g. it's virtualized), the first * rendered item takes its place. Items check again when removed. */ const checkTabStop = () => { isRendered(getTabStop()) || context.setActive(handle.props.values.find(isRendered)); }; const context = { axis: "y", hasReordered: false, getElement: () => element, items, isTabStop: (value) => value === getTabStop() || !handle.props.values.includes(value), checkTabStop, /** * Only values in `values` can be the tab stop. */ setActive: (value) => { const tabStop = getTabStop(); if (!handle.props.values.includes(value)) return; active = value; if (value !== tabStop) { items.get(tabStop)?.update(); items.get(value)?.update(); } }, /** * Space grabs and drops the item, arrows along `axis` move the * grabbed item one place, or a row in a grid (see keyTarget), or * move focus that way if it isn't grabbed. Home and End go to * either end. Escape restores the order in `from`, the values * when it was grabbed. */ onKeyDown: (event, item, from, grab) => { const { key, currentTarget } = event; if (event.defaultPrevented || event.target !== currentTarget || event.altKey || event.ctrlKey || event.metaKey) { return; } const { axis } = context; const order = getOrder(); const keyIndex = keys.indexOf(key); const isX = keyIndex % 2; if (key === " ") { event.repeat || grab(currentTarget, from ? undefined : handle.props.values); } else if (key === "Escape" && from) { const kept = from.flatMap((value) => order.filter((data) => data.value === value)); commit(order.map((data) => from.includes(data.value) ? kept.shift() : data), item); grab(currentTarget); } else if (keyIndex < 0 || (keyIndex < 4 && axis !== "xy" && axis !== (isX ? "x" : "y"))) { return; } else { /** * Focus only moves between rendered items. */ const list = from ? order : order.filter(({ value }) => isRendered(value)); const index = list.findIndex(({ value }) => value === item); const target = keyTarget(list, index, keyIndex, axis, !!isX && isRTL()); if (target > -1) { from ? commit(moveItem(order, index, target), item) : items.get(list[target].value)?.element?.focus(); } } event.preventDefault(); }, registerItem: (value, layout) => { itemLayouts.set(value, layout); if (!handle.props.axis) { const nextAxis = detectAxis(handle.props.values.flatMap((itemValue) => { const itemLayout = itemLayouts.get(itemValue); return itemLayout ? [itemLayout] : []; })); if (nextAxis !== detectedAxis) { detectedAxis = nextAxis; handle.update(); } } }, updateOrder: (item, offset, velocity) => { if (isReordering) return; const order = getOrder(); const newOrder = checkReorder(order, item, offset, velocity, context.axis, isRTL() ? "rtl" : "ltr"); if (order !== newOrder) { isReordering = context.hasReordered = true; commit(newOrder, item); } }, }; return () => { const { as = "ul", axis, values, onReorder, style, mix, children, ...props } = handle.props; const valuesSet = new Set(values); for (const map of [itemLayouts, items]) { map.forEach((_, value) => valuesSet.has(value) || map.delete(value)); } /** * Only allow the next reorder once the new order has rendered. */ if (values !== renderedValues) { renderedValues = values; handle.queueTask(() => (isReordering = false)); } handle.queueTask(checkTabStop); context.axis = axis || detectedAxis; handle.context.set(context); return createElement(motion[as], { ...props, /** * Scroll anchoring would move the page while items * reorder, and with it the dragged item. */ style: { overflowAnchor: "none", ...style }, mix: [ ref((node) => (element = node)), /** * The release that ends a reordering drag isn't a * click. Clicks with no pointer press behind them * (detail 0, e.g. keyboard) are left alone. */ on("pointerdown", () => { context.hasReordered = false; }, true), on("click", (event) => { if (context.hasReordered && event.detail) { context.hasReordered = false; event.stopPropagation(); event.preventDefault(); } }, true), mix, ], }, children); }; } /** * An item in a `Reorder.Group`, with the same props as React's * `Reorder.Item`. */ function Item(handle) { /** * The group's values when this item was grabbed with the keyboard, for * Escape to restore. Set only while grabbed. */ let grabbedFrom; const defaultX = motionValue(0); const defaultY = motionValue(0); let zIndex; const self = { update: () => handle.update() }; const getGroup = () => handle.context.get(Group); /** * A keyboard grab is a drag without a pointer, so it shares whileDrag. */ const grab = (element, from) => { grabbedFrom = from; visualElementStore .get(element) ?.animationState?.setActive("whileDrag", !!from); }; return () => { const { as = "li", value, style = {}, layout = true, onDrag, onDragEnd, mix, children, ...props } = handle.props; const group = getGroup(); const { axis } = group; group.items.set(value, self); const x = isMotionValue(style.x) ? style.x : defaultX; const y = isMotionValue(style.y) ? style.y : defaultY; if (zIndex?.x !== x || zIndex.y !== y) { zIndex = { x, y, value: transformValue(() => x.get() || y.get() ? 1 : "unset"), }; } return createElement(motion[as], { drag: axis === "xy" ? true : axis, tabIndex: group.isTabStop(value) ? 0 : -1, ...props, dragSnapToOrigin: true, style: { ...style, x, y, zIndex: zIndex.value }, layout, onDrag: (event, info) => { const { velocity, point } = info; group.updateOrder(handle.props.value, { x: x.get(), y: y.get() }, velocity); const scrollAxis = group.axis === "xy" ? Math.abs(velocity.x) > Math.abs(velocity.y) ? "x" : "y" : group.axis; autoScrollIfNeeded(group.getElement() || null, point[scrollAxis], scrollAxis, velocity[scrollAxis]); onDrag?.(event, info); }, onDragEnd: (event, info) => { // Runs a frame after release, so after the release's click group.hasReordered = false; resetAutoScrollState(); onDragEnd?.(event, info); }, onLayoutMeasure: (measured) => group.registerItem(handle.props.value, measured), mix: [ mix, ref((node, signal) => { self.element = node; signal.addEventListener("abort", () => queueMicrotask(group.checkTabStop)); }), /** * The item focused last, or holding focus, is the * group's tab stop. */ on("focusin", () => group.setActive(handle.props.value)), on("keydown", (event) => group.onKeyDown(event, handle.props.value, grabbedFrom, grab)), /** * Remix blurs the item when it moves its node, and * focuses it again in the same flush. So only drop * it if focus is elsewhere once that's done. */ on("blur", ({ currentTarget }) => queueMicrotask(() => grabbedFrom && currentTarget.ownerDocument .activeElement !== currentTarget && grab(currentTarget))), ], }, children); }; } return { Group, Item }; } export { createReorder }; //# sourceMappingURL=remix-reorder.mjs.map