@runstack/ui
Version:
React UI components library for runstack
727 lines (715 loc) • 22.9 kB
JavaScript
// src/draggable-list/index.tsx
import * as React from "react";
import { motion } from "motion/react";
import { createContext, useContext } from "react";
// src/lib/utils.ts
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs) {
return twMerge(clsx(inputs));
}
// src/draggable-list/index.tsx
import {
DndContext,
DragOverlay,
PointerSensor,
useDndMonitor,
useDraggable,
useDroppable,
useSensor,
useSensors
} from "@dnd-kit/core";
// src/draggable-list/utils/shared.ts
function sortByRank(a, b) {
return Number(a.rank) - Number(b.rank);
}
// src/draggable-list/utils/tree-traversal.ts
function getChildren(id, items) {
return items.filter((item) => item.parent_id === id).sort(sortByRank);
}
function getSiblings(id, items) {
if (id === "root")
return items.filter((item2) => !item2.parent_id).sort(sortByRank);
const item = items.find((i) => i.sync_id === id);
if (!item) return [];
const parent = items.find((i) => i.sync_id === item.parent_id);
if (!parent) return items.filter((item2) => !item2.parent_id).sort(sortByRank);
return items.filter((item2) => item2.parent_id === parent?.sync_id).sort(sortByRank);
}
function isFirstChild(id, items) {
const siblings = getSiblings(id, items);
return siblings.findIndex((s) => s.sync_id === id) === 0;
}
function isLastChild(id, items) {
const siblings = getSiblings(id, items);
return siblings.findIndex((s) => s.sync_id === id) === siblings.length - 1;
}
function getPreviousSibling(id, items) {
const siblings = getSiblings(id, items);
const index = siblings.findIndex((s) => s.sync_id === id);
return siblings[index - 1];
}
function getNextSibling(id, items) {
const siblings = getSiblings(id, items);
const index = siblings.findIndex((s) => s.sync_id === id);
return siblings[index + 1];
}
function getAllDescendants(id, items) {
const result = [];
const children = getChildren(id, items);
for (const child of children) {
result.push(child);
result.push(...getAllDescendants(child.sync_id, items));
}
return result;
}
function getRootItems(items) {
return items.filter((item) => !item.parent_id).sort(sortByRank);
}
// src/draggable-list/utils/constants.ts
var ENTRY_GAP = 1e5;
// src/draggable-list/utils/indentation.ts
function calculateIndentIncrease(item, items = []) {
const previousSibling = getPreviousSibling(item.sync_id, items);
if (!previousSibling) return [];
const previousSiblingChildren = getChildren(previousSibling.sync_id, items);
const updates = [
...previousSiblingChildren,
{ ...item, parent_id: previousSibling.sync_id }
].map((child, index) => {
return {
...child,
sync_id: child.sync_id,
parent_id: child.parent_id,
rank: BigInt(index * ENTRY_GAP)
// Rank system: 0, 100000, 200000, etc.
};
});
return updates;
}
function calculateIndentDecrease(item, items = []) {
const parent = items.find((i) => i.sync_id === item.parent_id);
if (!parent) return [];
const parentSiblings = getSiblings(parent.sync_id, items);
const parentIndex = parentSiblings.findIndex(
(s) => s.sync_id === parent.sync_id
);
const targetIndex = parentIndex + 1;
const updates = [
...parentSiblings.slice(0, targetIndex),
{ ...item, parent_id: parent.parent_id ?? null },
// Promote to parent's level
...parentSiblings.slice(targetIndex)
].map((sibling, index) => {
return {
...sibling,
sync_id: sibling.sync_id,
parent_id: sibling.parent_id,
rank: BigInt(index * ENTRY_GAP)
// Recalculate ranks with gaps
};
});
return updates;
}
// src/draggable-list/utils/flatten.ts
function flattenTreePreorder(tree) {
const result = {};
const index = { current: 0 };
const sortedTree = tree.sort(sortByRank);
const walk = (item) => {
result[item.sync_id] = index.current++;
const children = getChildren(item.sync_id, sortedTree);
for (const child of children) {
walk(child);
}
};
const rootItems = sortedTree.filter((item) => !item.parent_id);
for (const root of rootItems) {
walk(root);
}
return result;
}
// src/draggable-list/handlers/drag-handlers.ts
var createDragEndHandler = (data, onDrop) => {
return (event) => {
console.log("createDragEndHandler");
if (!event.over) return;
const itemId = event.active.id;
const position = getPositionFromDroppable(event.over.id.toString());
console.log("createDragEndHandler: position", position);
if (!position) return;
let updates;
if (position === "last_root") {
console.log("createDragEndHandler: dropEntryAsLastRoot");
updates = dropEntryAsLastRoot(itemId, data);
} else {
console.log("createDragEndHandler: dropEntry");
const dropId = getEntryIdFromDroppable(event.over.id.toString());
console.log("createDragEndHandler: dropId", dropId);
updates = dropEntry(itemId, dropId, position, data);
}
console.log("createDragEndHandler: updates", updates);
if (!updates.length) return;
console.log("actually drop");
onDrop?.(updates);
};
};
function getEntryIdFromDroppable(droppableId) {
return droppableId.split("--")[2] ?? "";
}
function getPositionFromDroppable(droppableId) {
const position = droppableId.split("--")[1];
const isValid = position && ["before", "after", "last_root"].includes(position);
return isValid ? position : null;
}
function dropEntryAsLastRoot(entryId, items) {
const entry = items.find((i) => i.sync_id === entryId);
if (!entry) return [];
const rootItems = getRootItems(items);
return [...rootItems, { ...entry, parent_id: null }].map(
(child, index) => {
return {
...entry,
sync_id: child.sync_id,
parent_id: child.parent_id,
rank: BigInt(Number(index) * ENTRY_GAP),
title: child.title
};
}
);
}
function dropEntry(entryId, targetId, position, items) {
const entry = items.find((i) => i.sync_id === entryId);
const target = items.find((i) => i.sync_id === targetId);
if (!entry || !target) return [];
const targetHasChildren = position === "after" && getChildren(targetId, items).length > 0;
if (targetHasChildren) {
const targetChildren = getChildren(targetId, items).filter(
(child) => child.sync_id !== entryId
);
const updates = [
{ ...entry, parent_id: target.sync_id },
...targetChildren
].map((child, index) => {
return {
...child,
sync_id: child.sync_id,
parent_id: child.parent_id,
rank: BigInt(Number(index) * ENTRY_GAP),
title: child.title
};
});
return updates;
} else {
const newParentId = target.parent_id;
const targetSiblings = getSiblings(target.sync_id, items).filter(
(s) => s.sync_id !== entryId
);
const targetIndex = targetSiblings.findIndex((s) => s.sync_id === targetId);
const insertIndex = position === "before" ? targetIndex : targetIndex + 1;
const updates = [
...targetSiblings.slice(0, insertIndex),
{ ...entry, parent_id: newParentId },
...targetSiblings.slice(insertIndex)
].map((sibling, index) => {
return {
...sibling,
sync_id: sibling.sync_id,
parent_id: sibling.parent_id,
rank: BigInt(Number(index) * ENTRY_GAP),
// Recalculate ranks with gaps
title: sibling.title
};
});
return updates;
}
}
// src/draggable-list/index.tsx
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
var DraggableListContext = createContext({
items: [],
index: -1,
level: 0,
hasAnimated: false,
updateHasAnimated: () => {
},
updateFocusItemId: () => {
},
onAddEntry: () => {
},
focusItemId: "",
updateTitle: () => {
},
updateTree: () => {
},
renderProps: () => null
});
var useDraggableListContext = () => {
const context = useContext(DraggableListContext);
if (!context) {
throw new Error(
"useDraggableListContext must be used within a DraggableListProvider"
);
}
return context;
};
var DraggableListProvider = (props) => {
const sensors = useSensors(useSensor(PointerSensor));
const rootItems = getRootItems(props.data);
const [focusItemId, setFocusItemId] = React.useState("");
const [hasAnimated, setHasAnimated] = React.useState(false);
React.useEffect(() => {
setHasAnimated(false);
}, [props._animationKey]);
const handleDragEnd = React.useMemo(
() => createDragEndHandler(props.data, props.onDrop),
[props.data, props.onDrop]
);
const initialContext = {
items: props.data,
index: -1,
level: -1,
hasAnimated,
updateHasAnimated: setHasAnimated,
focusItemId,
updateFocusItemId: setFocusItemId,
updateTitle: props.onTitleChange ?? (() => {
}),
updateTree: props.onTreeChange ?? (() => {
}),
renderProps: props.children,
onAddEntry: props.onAddEntry ?? (() => {
})
};
return /* @__PURE__ */ jsx(DndContext, { sensors, onDragEnd: handleDragEnd, children: /* @__PURE__ */ jsx(DraggableListContext.Provider, { value: initialContext, children: /* @__PURE__ */ jsxs(DraggableOverlayMonitor, { children: [
rootItems.map((item, index) => {
return /* @__PURE__ */ jsx(
DraggableListContext.Provider,
{
value: {
...initialContext,
items: props.data,
index,
level: 0,
currentItem: item
},
children: props.children(
item,
{
index,
level: 0,
focusItemId,
updateFocusItemId: setFocusItemId
},
initialContext
)
},
item.sync_id
);
}),
/* @__PURE__ */ jsx(DroppableSensorLast, {})
] }) }) });
};
var DraggableOverlayMonitor = (props) => {
const [active, updateActive] = React.useState(null);
useDndMonitor({
onDragStart(event) {
updateActive(event.active);
},
onDragEnd() {
updateActive(null);
}
});
const context = useDraggableListContext();
const dragged = context.items.find((item) => item.sync_id === active?.id);
const width = active?.data.current?.width;
return /* @__PURE__ */ jsxs(Fragment, { children: [
props.children,
/* @__PURE__ */ jsx(DragOverlay, { children: dragged ? /* @__PURE__ */ jsx(DraggableListContext, { value: { ...context, currentItem: dragged }, children: /* @__PURE__ */ jsx("div", { className: "opacity-75", style: width ? { width } : {}, children: context.renderProps(dragged, {
index: context.index,
level: context.level,
focusItemId: context.focusItemId,
updateFocusItemId: context.updateFocusItemId
}) }) }) : null })
] });
};
var DraggableListMain = (props) => {
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(DroppableIndicatorBefore, {}),
/* @__PURE__ */ jsxs("div", { className: cn("flex mb-1 gap-2 relative", props.className), children: [
/* @__PURE__ */ jsx(DroppableSensorBefore, {}),
props.children,
/* @__PURE__ */ jsx(DroppableSensorAfter, {})
] }),
/* @__PURE__ */ jsx(DroppableIndicatorAfter, {})
] });
};
var DroppableSensorBefore = () => {
const context = useDraggableListContext();
const droppable = useDroppable({
id: _makeDroppableId("before", context.currentItem?.sync_id)
});
return /* @__PURE__ */ jsx(
"div",
{
ref: droppable.setNodeRef,
className: cn(
"bg-red-500 opacity-50 w-full h-1/2 absolute top-0 left-0 z-10 pointer-events-none opacity-0",
{
"bg-blue-300": droppable.isOver
}
)
}
);
};
var DroppableSensorAfter = () => {
const context = useDraggableListContext();
const droppable = useDroppable({
id: _makeDroppableId("after", context.currentItem?.sync_id)
});
return /* @__PURE__ */ jsx(
"div",
{
ref: droppable.setNodeRef,
className: cn(
"bg-yellow-500 opacity-50 w-full h-1/2 absolute bottom-0 left-0 z-10 opacity-0 pointer-events-none",
{
"bg-blue-300": droppable.isOver
}
)
}
);
};
var DroppableSensorLast = () => {
const droppable = useDroppable({
id: "droppable--last_root"
});
return /* @__PURE__ */ jsx("div", { ref: droppable.setNodeRef, className: cn("w-full h-10", {}), children: /* @__PURE__ */ jsx(
"div",
{
className: cn({
"bg-blue-500 h-[5px] rounded-full": droppable.isOver
})
}
) });
};
var DroppableIndicatorBefore = () => {
const context = useDraggableListContext();
const [over, setOver] = React.useState(null);
useDndMonitor({
onDragOver(event) {
setOver(event.over);
},
onDragEnd() {
setOver(null);
}
});
if (!context.currentItem) return null;
const sibling = {
previous: {
...getPreviousSibling(context.currentItem.sync_id, context.items),
get hasNoChildren() {
return this.sync_id ? getChildren(this.sync_id, context.items).length === 0 : false;
}
}
};
const hover = {
overParent: over?.id === _makeDroppableId("after", context.currentItem.parent_id),
overSelf: over?.id === _makeDroppableId("before", context.currentItem.sync_id),
overPreviousSiblingBottom: over?.id === _makeDroppableId("after", sibling.previous?.sync_id)
};
const currentItem = {
isFirstChild: isFirstChild(context.currentItem.sync_id, context.items)
};
const isActive = hover.overParent && currentItem.isFirstChild || hover.overSelf || hover.overPreviousSiblingBottom && sibling.previous.hasNoChildren;
const height = isActive ? 5 : 0;
return /* @__PURE__ */ jsx(
motion.div,
{
className: cn("w-full rounded-lg bg-gray-500 invisible", {
"visible my-2 bg-blue-500": isActive
}),
style: { height }
}
);
};
var DroppableIndicatorAfter = () => {
const context = useDraggableListContext();
const [over, setOver] = React.useState(null);
useDndMonitor({
onDragOver(event) {
setOver(event.over);
},
onDragEnd() {
setOver(null);
}
});
if (!context.currentItem) return null;
const hover = {
overSelf: over?.id === _makeDroppableId("after", context.currentItem.sync_id)
};
const currentItem = {
isLastChild: isLastChild(context.currentItem.sync_id, context.items),
hasNoChildren: getChildren(context.currentItem.sync_id, context.items).length === 0
};
const isActive = hover.overSelf && currentItem.isLastChild && currentItem.hasNoChildren;
const height = isActive ? 5 : 0;
return /* @__PURE__ */ jsx(
motion.div,
{
className: cn("w-full bg-yellow-500 rounded-lg invisible ", {
"visible my-2 bg-blue-500": isActive
}),
style: { height }
}
);
};
var DraggableListItem = (props) => {
const context = useDraggableListContext();
const children = context.currentItem ? getChildren(context.currentItem.sync_id, context.items) : [];
const flatIndexMap = flattenTreePreorder(context.items);
const flatIndex = flatIndexMap[context.currentItem?.sync_id ?? ""] ?? 0;
return /* @__PURE__ */ jsx(
DraggableItemContainer,
{
id: context.currentItem?.sync_id ?? "NOOP",
className: props.className,
children: /* @__PURE__ */ jsxs("div", { className: cn("relative w-full"), children: [
/* @__PURE__ */ jsx(
motion.div,
{
initial: context.hasAnimated ? "visible" : "hidden",
animate: "visible",
variants: {
hidden: { opacity: 0, translateX: -10 },
visible: { opacity: 1, translateX: 0 }
},
onAnimationComplete: () => {
context.updateHasAnimated(true);
},
transition: { delay: flatIndex * 0.1 },
children: props.children
},
context.currentItem?.sync_id
),
/* @__PURE__ */ jsx("div", { className: "pl-5", children: children.map((item, index) => {
return /* @__PURE__ */ jsx(
DraggableListContext.Provider,
{
value: {
...context,
currentItem: item,
index,
level: context.level + 1
},
children: context.renderProps(item, {
index,
level: context.level + 1,
focusItemId: context.focusItemId,
updateFocusItemId: context.updateFocusItemId
})
},
item.sync_id
);
}) })
] })
}
);
};
var DraggableListItemStart = (props) => {
return /* @__PURE__ */ jsx("div", { className: cn(props.className), children: props.children });
};
var DraggableListItemContent = (props) => {
return /* @__PURE__ */ jsx("div", { className: "flex-1", children: props.children });
};
var DraggableListItemBottom = (props) => {
return /* @__PURE__ */ jsx("div", { className: "", children: props.children });
};
var DraggableListItemEnd = (props) => {
return /* @__PURE__ */ jsx("div", { className: props.className, children: props.children });
};
var DragHandle = (props) => {
const context = useDraggableListContext();
const draggable = useDraggable({ id: `${context.currentItem?.sync_id}` });
return /* @__PURE__ */ jsx(
"div",
{
className: "flex items-center justify-center w-8 h-8 cursor-grab",
...draggable.listeners,
children: props.children
}
);
};
var DraggableItemContainer = (props) => {
const elRef = React.useRef(null);
const draggable = useDraggable({
id: `${props.id}`,
data: { width: elRef.current?.getBoundingClientRect().width }
});
return /* @__PURE__ */ jsx(
"div",
{
ref: (node) => {
draggable.setNodeRef(node);
elRef.current = node;
},
...draggable.attributes,
className: cn(props.className, {
"opacity-0 invisible": draggable.isDragging
}),
children: props.children
}
);
};
function _makeDroppableId(position, itemId = "") {
return `droppable--${position}--${itemId}`;
}
// src/draggable-list/draggable-list-editable-text.tsx
import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
import TextareaAutosize from "react-textarea-autosize";
import { invert } from "lodash-es";
import { jsx as jsx2 } from "react/jsx-runtime";
var DraggableListEditableText = (props) => {
const context = useDraggableListContext();
const [value, updateValue] = useState2(context.currentItem?.title ?? "");
useEffect2(() => {
updateValue(context.currentItem?.title ?? "");
}, [context.currentItem?.title]);
const textareaRef = useRef2(null);
useEffect2(() => {
if (context.focusItemId === context.currentItem?.sync_id) {
textareaRef.current?.focus();
}
}, [context.focusItemId, context.currentItem?.sync_id]);
useEffect2(() => {
if (!textareaRef.current) return;
moveCursorToEnd(textareaRef.current);
}, []);
return /* @__PURE__ */ jsx2("div", { className: cn("w-full", props.className), children: /* @__PURE__ */ jsx2(
TextareaAutosize,
{
value,
ref: textareaRef,
onFocus: () => {
if (!context.currentItem) return;
context.updateFocusItemId(context.currentItem.sync_id);
},
onBlur: () => {
context.updateFocusItemId("");
},
style: { resize: "none" },
onChange: (e) => {
updateValue(e.target.value);
if (!context.currentItem) return;
context.updateTitle(context.currentItem.sync_id, e.target.value);
},
className: cn(
"w-full bg-transparent m-0 p-0 outline-none",
props.className
),
onKeyDown: createKeydownHandlers(context, value),
placeholder: props.placeholder ?? "Enter a title"
}
) });
};
function createKeydownHandlers(context, value) {
return (e) => {
if (!context.currentItem) return;
const item = { ...context.currentItem, title: value };
const flatIndexMap = flattenTreePreorder(context.items);
const flatIndexMapInvert = invert(flatIndexMap);
if (e.key === "Tab") {
e.preventDefault();
if (e.shiftKey) {
context.updateTree(calculateIndentDecrease(item, context.items));
return;
}
context.updateTree(calculateIndentIncrease(item, context.items));
}
if (e.key === "ArrowUp") {
e.preventDefault();
const currentFlatIndex = flatIndexMap[item.sync_id] ?? 0;
const prevIndex = currentFlatIndex - 1;
const upperItemId = flatIndexMapInvert[prevIndex];
if (!upperItemId) return;
context.updateFocusItemId(upperItemId ?? "");
}
if (e.key === "ArrowDown") {
e.preventDefault();
const currentFlatIndex = flatIndexMap[item.sync_id] ?? 0;
const nextIndex = currentFlatIndex + 1;
const upperItemId = flatIndexMapInvert[nextIndex];
if (!upperItemId) return;
context.updateFocusItemId(upperItemId ?? "");
}
if (e.key === "Enter") {
if (e.shiftKey) {
return;
}
e.preventDefault();
const hasChildren = getChildren(item.sync_id, context.items).length > 0;
const children = getChildren(item.sync_id, context.items);
const firstChild = children[0];
let parentId;
let rank;
if (hasChildren) {
parentId = item.sync_id;
rank = firstChild ? BigInt(firstChild.rank) - BigInt(ENTRY_GAP / 2) : BigInt(ENTRY_GAP);
} else {
parentId = item.parent_id ?? null;
const nextSibling = getNextSibling(item.sync_id, context.items);
if (nextSibling) {
rank = BigInt(Number(item.rank + nextSibling.rank) / 2);
} else {
rank = BigInt(Number(item.rank) + ENTRY_GAP);
}
}
const payload = {
title: "",
parent_id: parentId,
rank
};
context.onAddEntry(payload, item, context);
}
};
}
function moveCursorToEnd(textarea) {
setTimeout(() => {
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
}, 0);
}
// src/button.tsx
import { jsx as jsx3 } from "react/jsx-runtime";
var Button = ({ children, className, appName }) => {
return /* @__PURE__ */ jsx3(
"button",
{
className: cn("bg-red-500", className),
onClick: () => alert(`Hello from your ${appName} app!`),
children
}
);
};
export {
Button,
DragHandle,
DraggableListEditableText,
DraggableListItem,
DraggableListItemBottom,
DraggableListItemContent,
DraggableListItemEnd,
DraggableListItemStart,
DraggableListMain,
DraggableListProvider,
ENTRY_GAP,
calculateIndentDecrease,
calculateIndentIncrease,
cn,
flattenTreePreorder,
getAllDescendants,
getChildren,
getPreviousSibling,
getRootItems,
getSiblings,
useDraggableListContext
};