framer-motion
Version:
A simple and powerful JavaScript animation library
285 lines (282 loc) • 12.5 kB
JavaScript
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