📚 Reference
📜 Chapter
‣
/**
* @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>
);
}
/**
* @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>
);
}