UNPKG

@runstack/ui

Version:

React UI components library for runstack

727 lines (715 loc) 22.9 kB
// 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 };