chore: update package.json with new Atlaskit drag-and-drop dependencies; refactor TypeTag component for improved state management

This commit is contained in:
faisolavolut 2025-02-12 15:07:05 +07:00
parent fbfa075c51
commit 410ba207de
3 changed files with 512 additions and 19 deletions

View File

@ -1,19 +1,4 @@
import { useLocal } from "@/lib/utils/use-local";
import { Input } from "../../ui/input";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import {
useEditor,
EditorContent,
useCurrentEditor,
EditorProvider,
} from "@tiptap/react";
import Link from "@tiptap/extension-link";
import StarterKit from "@tiptap/starter-kit";
import { Color } from "@tiptap/extension-color";
import ListItem from "@tiptap/extension-list-item";
import TextStyle from "@tiptap/extension-text-style";
import { Popover } from "../../Popover/Popover";
import { ButtonBetter } from "../../ui/button";
import get from "lodash.get"; import get from "lodash.get";
export const TypeTag: React.FC<any> = ({ export const TypeTag: React.FC<any> = ({
@ -31,17 +16,17 @@ export const TypeTag: React.FC<any> = ({
const [editingIndex, setEditingIndex] = useState<number | null>(null); // Index tag yang sedang diedit const [editingIndex, setEditingIndex] = useState<number | null>(null); // Index tag yang sedang diedit
const [tempValue, setTempValue] = useState<string>(""); // Nilai sementara untuk pengeditan const [tempValue, setTempValue] = useState<string>(""); // Nilai sementara untuk pengeditan
const tagRefs = useRef<(HTMLDivElement | null)[]>([]); const tagRefs = useRef<(HTMLDivElement | null)[]>([]);
const val = fm?.data?.[name];
useEffect(() => { useEffect(() => {
if (get(fm, `data.[${name}].length`)) { if (get(fm, `data.[${name}].length`)) {
setTags(fm.data?.[name]); setTags(fm.data?.[name]);
} }
}, []); }, [val]);
useEffect(() => { useEffect(() => {
console.log("NEW"); console.log("MASUK", tags);
fm.data[name] = tags; fm.data[name] = tags;
fm.render(); fm.render();
console.log("MASUK");
if (typeof onChange === "function") { if (typeof onChange === "function") {
onChange(tags); onChange(tags);
} }

503
components/ui/list-drag.tsx Normal file
View File

@ -0,0 +1,503 @@
"use client";
import {
createContext,
FC,
Fragment,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import ReactDOM from "react-dom";
import invariant from "tiny-invariant";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import {
attachClosestEdge,
type Edge,
extractClosestEdge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
monitorForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import { Box, xcss } from "@atlaskit/primitives";
import { token } from "@atlaskit/tokens";
import { cn } from "@/lib/utils";
import { ButtonBetter } from "./button";
import { RiDraggable } from "react-icons/ri";
type ItemPosition = "first" | "last" | "middle" | "only";
type CleanupFn = () => void;
type ItemEntry = { itemId: string; element: HTMLElement };
type ListContextValue = {
getListLength: () => number;
registerItem: (entry: ItemEntry) => CleanupFn;
reorderItem: (args: {
startIndex: number;
indexOfTarget: number;
closestEdgeOfTarget: Edge | null;
}) => void;
instanceId: symbol;
};
const ListContext = createContext<ListContextValue | null>(null);
function useListContext() {
const listContext = useContext(ListContext);
invariant(listContext !== null);
return listContext;
}
type Item = {
id: string;
label: string;
};
const itemKey = Symbol("item");
type ItemData = {
[itemKey]: true;
item: Item;
index: number;
instanceId: symbol;
};
function getItemData({
item,
index,
instanceId,
}: {
item: Item;
index: number;
instanceId: symbol;
}): ItemData {
return {
[itemKey]: true,
item,
index,
instanceId,
};
}
function isItemData(data: Record<string | symbol, unknown>): data is ItemData {
return data[itemKey] === true;
}
function getItemPosition({
index,
items,
}: {
index: number;
items: Item[];
}): ItemPosition {
if (items.length === 1) {
return "only";
}
if (index === 0) {
return "first";
}
if (index === items.length - 1) {
return "last";
}
return "middle";
}
const listItemContainerStyles = xcss({
position: "relative",
backgroundColor: "elevation.surface",
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-unsafe-selectors -- Ignored via go/DSP-18766
});
const listItemStyles = xcss({
position: "relative",
padding: "space.100",
});
const listItemDisabledStyles = xcss({ opacity: 0.4 });
type DraggableState =
| { type: "idle" }
| { type: "preview"; container: HTMLElement }
| { type: "dragging" };
const idleState: DraggableState = { type: "idle" };
const draggingState: DraggableState = { type: "dragging" };
const listItemPreviewStyles = xcss({
paddingBlock: "space.050",
paddingInline: "space.100",
borderRadius: "border.radius.100",
backgroundColor: "elevation.surface.overlay",
maxWidth: "360px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
});
const itemLabelStyles = xcss({
flexGrow: 1,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
});
function ListItem({
item,
index,
position,
children,
className,
classDragBtn,
}: {
item: Item;
index: number;
position: ItemPosition;
children: (data?: any, index?: number) => any;
className?: string;
classDragBtn?: string;
}) {
const { registerItem, instanceId } = useListContext();
const ref = useRef<HTMLDivElement>(null);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const dragHandleRef = useRef<HTMLButtonElement>(null);
const [draggableState, setDraggableState] =
useState<DraggableState>(idleState);
useEffect(() => {
const element = ref.current;
const dragHandle = dragHandleRef?.current;
invariant(element);
invariant(dragHandle);
const data = getItemData({ item, index, instanceId });
return combine(
registerItem({ itemId: item.id, element }),
draggable({
element: dragHandle,
getInitialData: () => data,
onGenerateDragPreview({ nativeSetDragImage }) {
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: pointerOutsideOfPreview({
x: token("space.200", "16px"),
y: token("space.100", "8px"),
}),
render({ container }) {
setDraggableState({ type: "preview", container });
return () => setDraggableState(draggingState);
},
});
},
onDragStart() {
setDraggableState(draggingState);
},
onDrop() {
setDraggableState(idleState);
},
}),
dropTargetForElements({
element,
canDrop({ source }) {
return (
isItemData(source.data) && source.data.instanceId === instanceId
);
},
getData({ input }) {
return attachClosestEdge(data, {
element,
input,
allowedEdges: ["top", "bottom"],
});
},
onDrag({ self, source }) {
const isSource = source.element === element;
if (isSource) {
setClosestEdge(null);
return;
}
const closestEdge = extractClosestEdge(self.data);
const sourceIndex = source.data.index;
invariant(typeof sourceIndex === "number");
const isItemBeforeSource = index === sourceIndex - 1;
const isItemAfterSource = index === sourceIndex + 1;
const isDropIndicatorHidden =
(isItemBeforeSource && closestEdge === "bottom") ||
(isItemAfterSource && closestEdge === "top");
if (isDropIndicatorHidden) {
setClosestEdge(null);
return;
}
setClosestEdge(closestEdge);
},
onDragLeave() {
setClosestEdge(null);
},
onDrop() {
setClosestEdge(null);
},
})
);
}, [instanceId, item, index, registerItem]);
return (
<Fragment>
<Box ref={ref} xcss={listItemContainerStyles}>
<div
className={cn(
"group relative flex flex-row items-center gap-x-1 pt-2",
className
)}
>
<div
className={cn(
"drag-grab hidden group-hover:flex absolute top-0",
css`
left: 50%;
transform: translateX(-50%);
`
)}
>
<ButtonBetter
ref={dragHandleRef}
variant={"outline"}
className={cn(
classDragBtn,
"p-0 bg-transparent h-auto shadow-none hover:shadow-none hover:bg-transparent border-none "
)}
>
<RiDraggable className="text-gray-500 rotate-90" />
</ButtonBetter>
</div>
<div className="flex flex-col flex-grow">
{typeof children === "function" ? children(item, index) : <></>}
</div>
</div>
{closestEdge && <DropIndicator edge={closestEdge} gap="1px" />}
</Box>
{draggableState.type === "preview" &&
ReactDOM.createPortal(
<Box xcss={listItemPreviewStyles}>{item.label}</Box>,
draggableState.container
)}
</Fragment>
);
}
function getItemRegistry() {
const registry = new Map<string, HTMLElement>();
function register({ itemId, element }: ItemEntry) {
registry.set(itemId, element);
return function unregister() {
registry.delete(itemId);
};
}
function getElement(itemId: string): HTMLElement | null {
return registry.get(itemId) ?? null;
}
return { register, getElement };
}
type ListState = {
items: Item[];
lastCardMoved: {
item: Item;
previousIndex: number;
currentIndex: number;
numberOfItems: number;
} | null;
};
export const ListBetterDragDrop: FC<{
data: any[];
onChange: (data: any) => void | Promise<void>;
className?: string;
classContainer?: string;
children: (data?: any, index?: number) => any;
classDragBtn?: string;
}> = ({
children,
data,
onChange,
className,
classContainer,
classDragBtn,
}) => {
const [item, setItem] = useState(data);
const [{ items, lastCardMoved }, setListState] = useState<ListState>({
items: data,
lastCardMoved: null,
});
const [registry] = useState(getItemRegistry);
// Isolated instances of this component from one another
const [instanceId] = useState(() => Symbol("instance-id"));
const reorderItem = useCallback(
({
startIndex,
indexOfTarget,
closestEdgeOfTarget,
}: {
startIndex: number;
indexOfTarget: number;
closestEdgeOfTarget: Edge | null;
}) => {
const finishIndex = getReorderDestinationIndex({
startIndex,
closestEdgeOfTarget,
indexOfTarget,
axis: "vertical",
});
if (finishIndex === startIndex) {
// If there would be no change, we skip the update
return;
}
setListState((listState) => {
const item = listState.items[startIndex];
return {
items: reorder({
list: listState.items,
startIndex,
finishIndex,
}),
lastCardMoved: {
item,
previousIndex: startIndex,
currentIndex: finishIndex,
numberOfItems: listState.items.length,
},
};
});
const moveItem = (fromIndex: any, toIndex: any) => {
setItem((prevItems) => {
const updatedItems = [...prevItems];
const [movedItem] = updatedItems.splice(fromIndex, 1); // Hapus elemen
updatedItems.splice(toIndex, 0, movedItem); // Masukkan ke target index
return updatedItems;
});
};
// finishIndex === startIndex
moveItem(startIndex, finishIndex);
onChange(items);
},
[]
);
useEffect(() => {
console.log(data);
return monitorForElements({
canMonitor({ source }) {
return isItemData(source.data) && source.data.instanceId === instanceId;
},
onDrop({ location, source }) {
const target = location.current.dropTargets[0];
if (!target) {
return;
}
const sourceData = source.data;
const targetData = target.data;
if (!isItemData(sourceData) || !isItemData(targetData)) {
return;
}
const indexOfTarget = items.findIndex(
(item) => item.id === targetData.item.id
);
if (indexOfTarget < 0) {
return;
}
const closestEdgeOfTarget = extractClosestEdge(targetData);
reorderItem({
startIndex: sourceData.index,
indexOfTarget,
closestEdgeOfTarget,
});
},
});
}, [instanceId, items, reorderItem]);
// once a drag is finished, we have some post drop actions to take
useEffect(() => {
if (lastCardMoved === null) {
return;
}
const { item, previousIndex, currentIndex, numberOfItems } = lastCardMoved;
const element = registry.getElement(item.id);
if (element) {
triggerPostMoveFlash(element);
}
}, [lastCardMoved, registry]);
// cleanup the live region when this component is finished
useEffect(() => {
return function cleanup() {
liveRegion.cleanup();
};
}, []);
const getListLength = useCallback(() => items.length, [items.length]);
const contextValue: ListContextValue = useMemo(() => {
return {
registerItem: registry.register,
reorderItem,
instanceId,
getListLength,
};
}, [registry.register, reorderItem, instanceId, getListLength]);
if (!items?.length) return <></>;
return (
<ListContext.Provider value={contextValue}>
<div className={cn("flex flex-col gap-y-1 w-full", classContainer)}>
{items.map((item, index) => (
<ListItem
key={item.id}
item={item}
index={index}
position={getItemPosition({ index, items })}
children={children}
className={className}
classDragBtn={classDragBtn}
/>
))}
</div>
</ListContext.Provider>
);
};

View File

@ -3,6 +3,11 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop-flourish": "^1.2.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@atlaskit/pragmatic-drag-and-drop-live-region": "^1.3.0",
"@atlaskit/pragmatic-drag-and-drop-react-accessibility": "^1.3.1",
"@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "^1.2.0",
"@emotion/css": "^11.13.5", "@emotion/css": "^11.13.5",
"@faker-js/faker": "^9.2.0", "@faker-js/faker": "^9.2.0",
"@floating-ui/react": "^0.26.28", "@floating-ui/react": "^0.26.28",