📚 Reference


📜 Chapter


react-draggable

Radix Vue

React DnD

React Smooth DnD

Vue Smooth DnD

pragmatic-drag-and-drop


Grid


/**
 * @jsxRuntime classic
 * @jsx jsx
 */
import { createContext, memo, useContext, useEffect, useRef, useState } from 'react';

// eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled -- Ignored via go/DSP-18766
import { css, jsx, type SerializedStyles } from '@emotion/react';
import invariant from 'tiny-invariant';

import { easeInOut } from '@atlaskit/motion/curves';
import { durations } from '@atlaskit/motion/durations';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
	draggable,
	dropTargetForElements,
	monitorForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { token } from '@atlaskit/tokens';

import battery from './icons/battery.png';
import drill from './icons/drill.png';
import koala from './icons/koala.png';
import ui from './icons/ui.png';
import wallet from './icons/wallet.png';
import yeti from './icons/yeti.png';
import { GlobalStyles } from './util/global-styles';

function getInstanceId() {
	return Symbol('instance-id');
}

const InstanceIdContext = createContext<symbol | null>(null);

const itemStyles = css({
	objectFit: 'cover',
	width: '100%',
	boxSizing: 'border-box',
	background: token('elevation.surface.raised', '#FFF'),
	padding: token('space.050', '4px'),
	borderRadius: token('border.radius.100', '4px'),
	boxShadow: token('elevation.shadow.raised', 'none'),
	// eslint-disable-next-line @atlaskit/ui-styling-standard/no-unsafe-values, @atlaskit/ui-styling-standard/no-imported-style-values
	transition: `all ${durations.small}ms ${easeInOut}`,
	'-webkit-touch-callout': 'none', // needed to avoid a "save image" popup on iOS
});

type State = 'idle' | 'dragging' | 'over';

const itemStateStyles: { [Key in State]: undefined | SerializedStyles } = {
	idle: css({
		// eslint-disable-next-line @atlaskit/ui-styling-standard/no-unsafe-selectors -- Ignored via go/DSP-18766
		':hover': {
			background: token('elevation.surface.overlay', '#FFF'),
			boxShadow: token('elevation.shadow.overlay', 'none'),
		},
	}),
	dragging: css({
		filter: 'grayscale(0.8)',
	}),
	over: css({
		transform: 'scale(1.1) rotate(8deg)',
		filter: 'brightness(1.15)',
		boxShadow: token('elevation.shadow.overlay', 'none'),
	}),
};

const Item = memo(function Item({ src }: { src: string }) {
	const ref = useRef<HTMLImageElement | null>(null);
	const [state, setState] = useState<State>('idle');

	const instanceId = useContext(InstanceIdContext);

	useEffect(() => {
		const el = ref.current;
		invariant(el);

		return combine(
			draggable({
				element: el,
				getInitialData: () => ({ type: 'grid-item', src, instanceId }),
				onDragStart: () => setState('dragging'),
				onDrop: () => setState('idle'),
			}),
			dropTargetForElements({
				element: el,
				getData: () => ({ src }),
				getIsSticky: () => true,
				canDrop: ({ source }) =>
					source.data.instanceId === instanceId &&
					source.data.type === 'grid-item' &&
					source.data.src !== src,
				onDragEnter: () => setState('over'),
				onDragLeave: () => setState('idle'),
				onDrop: () => setState('idle'),
			}),
		);
	}, [instanceId, src]);

	// eslint-disable-next-line jsx-a11y/alt-text
	return <img css={[itemStyles, itemStateStyles[state]]} ref={ref} src={src} />;
});

const gridStyles = css({
	display: 'grid',
	gridTemplateColumns: 'repeat(3, 96px)',
	gap: 'var(--grid)',
});

export default function Grid() {
	const [items, setItems] = useState<string[]>(() => [battery, drill, koala, ui, wallet, yeti]);

	const [instanceId] = useState(getInstanceId);

	useEffect(() => {
		return monitorForElements({
			canMonitor({ source }) {
				return source.data.instanceId === instanceId;
			},
			onDrop({ source, location }) {
				const destination = location.current.dropTargets[0];
				if (!destination) {
					return;
				}
				const destinationSrc = destination.data.src;
				const startSrc = source.data.src;

				if (typeof destinationSrc !== 'string') {
					return;
				}

				if (typeof startSrc !== 'string') {
					return;
				}

				// swapping item positions
				const updated = [...items];
				updated[items.indexOf(startSrc)] = destinationSrc;
				updated[items.indexOf(destinationSrc)] = startSrc;

				setItems(updated);
			},
		});
	}, [instanceId, items]);

	return (
		<InstanceIdContext.Provider value={instanceId}>
			<GlobalStyles />
			<div css={gridStyles}>
				{items.map((src) => (
					<Item src={src} key={src} />
				))}
			</div>
		</InstanceIdContext.Provider>
	);
}

Tree


/**
 * @jsxRuntime classic
 * @jsx jsx
 */
import { useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState } from 'react';

// eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled -- Ignored via go/DSP-18766
import { css, jsx } from '@emotion/react';
import memoizeOne from 'memoize-one';
import invariant from 'tiny-invariant';

import { triggerPostMoveFlash } from '@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash';
import {
	type Instruction,
	type ItemMode,
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
import * as liveRegion from '@atlaskit/pragmatic-drag-and-drop-live-region';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { token } from '@atlaskit/tokens';

import {
	getInitialTreeState,
	tree,
	type TreeItem as TreeItemType,
	treeStateReducer,
} from './data/tree';
import { DependencyContext, TreeContext, type TreeContextValue } from './pieces/tree/tree-context';
import TreeItem from './pieces/tree/tree-item';

const treeStyles = css({
	display: 'flex',
	boxSizing: 'border-box',
	width: 280,
	padding: 8,
	flexDirection: 'column',
	background: token('elevation.surface.sunken', '#F7F8F9'),
});

type CleanupFn = () => void;

function createTreeItemRegistry() {
	const registry = new Map<string, { element: HTMLElement; actionMenuTrigger: HTMLElement }>();

	const registerTreeItem = ({
		itemId,
		element,
		actionMenuTrigger,
	}: {
		itemId: string;
		element: HTMLElement;
		actionMenuTrigger: HTMLElement;
	}): CleanupFn => {
		registry.set(itemId, { element, actionMenuTrigger });
		return () => {
			registry.delete(itemId);
		};
	};

	return { registry, registerTreeItem };
}

export default function Tree() {
	const [state, updateState] = useReducer(treeStateReducer, null, getInitialTreeState);
	const ref = useRef<HTMLDivElement>(null);
	const { extractInstruction } = useContext(DependencyContext);

	const [{ registry, registerTreeItem }] = useState(createTreeItemRegistry);

	const { data, lastAction } = state;
	let lastStateRef = useRef<TreeItemType[]>(data);
	useEffect(() => {
		lastStateRef.current = data;
	}, [data]);

	useEffect(() => {
		if (lastAction === null) {
			return;
		}

		if (lastAction.type === 'modal-move') {
			const parentName = lastAction.targetId === '' ? 'the root' : `Item ${lastAction.targetId}`;

			liveRegion.announce(
				`You've moved Item ${lastAction.itemId} to position ${
					lastAction.index + 1
				} in ${parentName}.`,
			);

			const { element, actionMenuTrigger } = registry.get(lastAction.itemId) ?? {};
			if (element) {
				triggerPostMoveFlash(element);
			}

			/**
			 * Only moves triggered by the modal will result in focus being
			 * returned to the trigger.
			 */
			actionMenuTrigger?.focus();

			return;
		}

		if (lastAction.type === 'instruction') {
			const { element } = registry.get(lastAction.itemId) ?? {};
			if (element) {
				triggerPostMoveFlash(element);
			}

			return;
		}
	}, [lastAction, registry]);

	useEffect(() => {
		return () => {
			liveRegion.cleanup();
		};
	}, []);

	/**
	 * Returns the items that the item with `itemId` can be moved to.
	 *
	 * Uses a depth-first search (DFS) to compile a list of possible targets.
	 */
	const getMoveTargets = useCallback(({ itemId }: { itemId: string }) => {
		const data = lastStateRef.current;

		const targets = [];

		const searchStack = Array.from(data);
		while (searchStack.length > 0) {
			const node = searchStack.pop();

			if (!node) {
				continue;
			}

			/**
			 * If the current node is the item we want to move, then it is not a valid
			 * move target and neither are its children.
			 */
			if (node.id === itemId) {
				continue;
			}

			/**
			 * Draft items cannot have children.
			 */
			if (node.isDraft) {
				continue;
			}

			targets.push(node);

			node.children.forEach((childNode) => searchStack.push(childNode));
		}

		return targets;
	}, []);

	const getChildrenOfItem = useCallback((itemId: string) => {
		const data = lastStateRef.current;

		/**
		 * An empty string is representing the root
		 */
		if (itemId === '') {
			return data;
		}

		const item = tree.find(data, itemId);
		invariant(item);
		return item.children;
	}, []);

	const context = useMemo<TreeContextValue>(
		() => ({
			dispatch: updateState,
			uniqueContextId: Symbol('unique-id'),
			// memoizing this function as it is called by all tree items repeatedly
			// An ideal refactor would be to update our data shape
			// to allow quick lookups of parents
			getPathToItem: memoizeOne(
				(targetId: string) => tree.getPathToItem({ current: lastStateRef.current, targetId }) ?? [],
			),
			getMoveTargets,
			getChildrenOfItem,
			registerTreeItem,
		}),
		[getChildrenOfItem, getMoveTargets, registerTreeItem],
	);

	useEffect(() => {
		invariant(ref.current);
		return combine(
			monitorForElements({
				canMonitor: ({ source }) => source.data.uniqueContextId === context.uniqueContextId,
				onDrop(args) {
					const { location, source } = args;
					// didn't drop on anything
					if (!location.current.dropTargets.length) {
						return;
					}

					if (source.data.type === 'tree-item') {
						const itemId = source.data.id as string;

						const target = location.current.dropTargets[0];
						const targetId = target.data.id as string;

						const instruction: Instruction | null = extractInstruction(target.data);

						if (instruction !== null) {
							updateState({
								type: 'instruction',
								instruction,
								itemId,
								targetId,
							});
						}
					}
				},
			}),
		);
	}, [context, extractInstruction]);

	return (
		<TreeContext.Provider value={context}>
			{/* eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop -- Ignored via go/DSP-18766 */}
			<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
				<div css={treeStyles} id="tree" ref={ref}>
					{data.map((item, index, array) => {
						const type: ItemMode = (() => {
							if (item.children.length && item.isOpen) {
								return 'expanded';
							}

							if (index === array.length - 1) {
								return 'last-in-group';
							}

							return 'standard';
						})();

						return <TreeItem item={item} level={0} key={item.id} mode={type} index={index} />;
					})}
				</div>
			</div>
		</TreeContext.Provider>
	);
}