diff --git a/packages/website/docs/components/display/drag-and-drop.mdx b/packages/website/docs/components/display/drag-and-drop.mdx index 8697b439bbd..ada40ac1427 100644 --- a/packages/website/docs/components/display/drag-and-drop.mdx +++ b/packages/website/docs/components/display/drag-and-drop.mdx @@ -4,17 +4,25 @@ keywords: [EuiDragDropContext, EuiDroppable, EuiDraggable] # Drag and drop -An extension of [@hello-pangea/dnd](https://github.com/hello-pangea/dnd) (which is an actively maintained fork of [react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd)) with a compatible API and built-in style opinions. Functionality results from 3 components working together: +```mdx-code-block +import { EuiLink } from '@elastic/eui'; +``` + +An extension of @hello-pangea/dnd (which is an actively maintained fork of react-beautiful-dnd) with a compatible API and built-in style opinions. Functionality results from 3 components working together: + +- ``: Section of your application containing the draggable elements and the drop targets. +- ``: Area into which items can be dropped. Contains one or more ``. +- ``: Items that can be dragged. Must be part of an ``. -* ``: Section of your application containing the draggable elements and the drop targets. -* ``: Area into which items can be dropped. Contains one or more ``. -* ``: Items that can be dragged. Must be part of an `` +:::accessibility Consider your users and use case -:::warning Consider your users and use case +Drag and drop is often less suitable than standard form inputs. It relies on spatial orientation, which can be difficult for screen reader users. Keyboard navigation typically does not afford the same nuanced manipulation as a mouse. EUI maintains accessibility support but carefully consider your users' context when choosing this pattern. + +::: -Drag and drop interfaces are not well-adapted to many cases, and may be less suitable than other form types for data operations. For instance, drag and drop interaction relies heavily on spatial orientation that may not be entirely valid to all users (e.g., screen readers as the sole source of information). Similarly, users navigating by keyboard may not be afforded nuanced, dual-axis drag item manipulation. +:::warning Limitations -EUI (largely due to the great work already in @hello-pangea/dnd) has and will continue to ensure accessibility where possible. With that in mind, keep your users' working context in mind. +One of the limitations of @hello-pangea/dnd is **nested drag and drop** (dragging elements between nesting levels). For this use case, we recommend using Pragmatic drag and drop. Check out our [Nested drag and drop pattern](../../patterns/nested-drag-and-drop/index.mdx) for a simplified example of how to implement it. ::: @@ -28,16 +36,16 @@ All **EuiDragDropContext** elements are discrete and isolated; **EuiDroppables** **EuiDragDropContext** handles all events but makes no assumptions about the result of a drop event. As such, the following event handlers are available: -* `onBeforeDragStart` -* `onDragStart` -* `onDragUpdate` -* `onDragEnd` (required) +- `onBeforeDragStart` +- `onDragStart` +- `onDragUpdate` +- `onDragEnd` (required) EUI also provides methods for helping to deal to common action types: -* `reorder`: change an item's location in a droppable area -* `copy`: create a duplicate of an item in a different droppable area -* `move`: move an item to a different droppable area +- `reorder`: change an item's location in a droppable area +- `copy`: create a duplicate of an item in a different droppable area +- `move`: move an item to a different droppable area ```tsx interactive import React, { useState } from 'react'; @@ -77,7 +85,6 @@ export default () => { ); }; - ``` ## Simple item reorder @@ -109,6 +116,7 @@ const makeList = (number, start = 1) => export default () => { const [list, setList] = useState(makeList(3)); + const onDragEnd = ({ source, destination }) => { if (source && destination) { const items = euiDragDropReorder(list, source.index, destination.index); @@ -116,6 +124,7 @@ export default () => { setList(items); } }; + return ( @@ -133,7 +142,6 @@ export default () => { ); }; - ``` ## Custom drag handle @@ -143,7 +151,9 @@ By default the entire element surface can initiate a drag. To specify an element The `provided` parameter on the **EuiDraggable** `children` render prop has all data required for functionality. Along with the `customDragHandle` flag,`provided.dragHandleProps` needs to be added to the intended handle element. :::accessibility Accessibility requirement + **Icon-only** custom drag handles require an accessible label. Add an `aria-label="Drag handle"` attribute to your React component or HTML element that receives`provided.dragHandleProps`. + ::: ```tsx interactive @@ -172,6 +182,7 @@ const makeList = (number, start = 1) => export default () => { const [list, setList] = useState(makeList(3)); + const onDragEnd = ({ source, destination }) => { if (source && destination) { const items = euiDragDropReorder(list, source.index, destination.index); @@ -179,6 +190,7 @@ export default () => { setList(items); } }; + return ( { ); }; - ``` ## Interactive elements @@ -252,6 +263,7 @@ const makeList = (number, start = 1) => export default () => { const [list, setList] = useState(makeList(3)); + const onDragEnd = ({ source, destination }) => { if (source && destination) { const items = euiDragDropReorder(list, source.index, destination.index); @@ -259,6 +271,7 @@ export default () => { setList(items); } }; + return ( { ); }; - ``` ## Move between lists @@ -366,6 +378,7 @@ export default () => { } } }; + return ( @@ -437,7 +450,6 @@ export default () => { ); }; - ``` ## Distinguish droppable areas by type @@ -474,6 +486,7 @@ export default () => { const [list1, setList1] = useState(makeList(3)); const [list2, setList2] = useState(makeList(3, 4)); const [list3, setList3] = useState(makeList(3, 7)); + const onDragEnd = ({ source, destination }) => { const lists = { DROPPABLE_AREA_TYPE_1: list1, @@ -509,6 +522,7 @@ export default () => { } } }; + return ( @@ -576,7 +590,6 @@ export default () => { ); }; - ``` ## Copyable items @@ -617,22 +630,27 @@ export default () => { const [isItemRemovable, setIsItemRemovable] = useState(false); const [list1, setList1] = useState(makeList(3)); const [list2, setList2] = useState([]); + const lists = { DROPPABLE_AREA_COPY_1: list1, DROPPABLE_AREA_COPY_2: list2 }; + const actions = { DROPPABLE_AREA_COPY_1: setList1, DROPPABLE_AREA_COPY_2: setList2, }; + const remove = (droppableId, index) => { const list = Array.from(lists[droppableId]); list.splice(index, 1); actions[droppableId](list); }; + const onDragUpdate = ({ source, destination }) => { const shouldRemove = !destination && source.droppableId === 'DROPPABLE_AREA_COPY_2'; setIsItemRemovable(shouldRemove); }; + const onDragEnd = ({ source, destination }) => { if (source && destination) { if (source.droppableId === destination.droppableId) { @@ -664,6 +682,7 @@ export default () => { remove(source.droppableId, source.index); } }; + return ( @@ -787,6 +806,7 @@ export default () => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [list, setList] = useState(makeList(3)); + const onDragEnd: OnDragEndResponder = ({ source, destination }) => { if (source && destination) { const items = euiDragDropReorder(list, source.index, destination.index); @@ -945,16 +965,19 @@ export default () => { const [list, setList] = useState([1, 2]); const [list1, setList1] = useState(makeList(3)); const [list2, setList2] = useState(makeList(3, 4)); + const lists = { COMPLEX_DROPPABLE_PARENT: list, COMPLEX_DROPPABLE_AREA_1: list1, COMPLEX_DROPPABLE_AREA_2: list2, }; + const actions = { COMPLEX_DROPPABLE_PARENT: setList, COMPLEX_DROPPABLE_AREA_1: setList1, COMPLEX_DROPPABLE_AREA_2: setList2, }; + const onDragEnd = ({ source, destination }) => { if (source && destination) { if (source.droppableId === destination.droppableId) { @@ -980,6 +1003,7 @@ export default () => { } } }; + return ( { ## Props +```mdx-code-block import docgen from '@elastic/eui-docgen/dist/components/drag_and_drop'; +``` diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx new file mode 100644 index 00000000000..0b6f06e8bf4 --- /dev/null +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -0,0 +1,772 @@ +import { + memo, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from 'react'; +import { css } from '@emotion/react'; +import { + draggable, + dropTargetForElements, + monitorForElements, +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { + attachInstruction, + extractInstruction, + type Instruction, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/list-item'; +import { + EuiAccordion, + EuiButtonIcon, + EuiContextMenu, + EuiIcon, + EuiPanel, + EuiPopover, + EuiText, + useEuiTheme, + useGeneratedHtmlId, +} from '@elastic/eui'; + +interface TreeItem { + id: string; + title: string; + children?: TreeItem[]; + isBlocked?: boolean; +} + +type Tree = TreeItem[]; + +/** + * In order for the nested drag and drop pattern to work flawlessly, + * make sure your data has unique and stable `id` that you can use for + * the `key` prop when mapping children. + * + * DO NOT use indices! + */ +const initialData: Tree = [ + { id: 'panel-1', title: 'Panel 1 (blocked)', isBlocked: true }, + { + id: 'panel-2', + title: 'Panel 2', + children: [{ id: 'subpanel-2-1', title: 'Subpanel 2.1' }], + }, + { + id: 'panel-3', + title: 'Panel 3', + children: [ + { + id: 'subpanel-3-1', + title: 'Subpanel 3.1', + children: [ + { id: 'subpanel-3-1-1', title: 'Subpanel 3.1.1' }, + { + id: 'subpanel-3-1-2', + title: 'Subpanel 3.1.2 (blocked)', + isBlocked: true, + }, + ], + }, + { id: 'subpanel-3-2', title: 'Subpanel 3.2' }, + ], + }, + { id: 'panel-4', title: 'Panel 4' }, +]; + +const findNodeLocation = ( + items: Tree, + itemId: string, + parent?: TreeItem +): { list: Tree; index: number; parent?: TreeItem } | undefined => { + const index = items.findIndex((i) => i.id === itemId); + + if (index !== -1) return { list: items, index, parent }; + + for (const item of items) { + if (item.children) { + const result = findNodeLocation(item.children, itemId, item); + + if (result) return result; + } + } +}; + +const findItem = (items: Tree, itemId: string): TreeItem | undefined => { + const location = findNodeLocation(items, itemId); + + return location ? location.list[location.index] : undefined; +}; + +const removeItem = (items: Tree, itemId: string): Tree => { + return items.reduce((acc: Tree, item) => { + if (item.id === itemId) return acc; + + if (item.children) { + return [...acc, { ...item, children: removeItem(item.children, itemId) }]; + } + + return [...acc, item]; + }, []); +}; + +const insertChild = ( + items: Tree, + targetId: string, + newItem: TreeItem +): Tree => { + return items.map((item) => { + if (item.id === targetId) { + return { + ...item, + children: [newItem, ...(item.children || [])], + }; + } + + if (item.children) { + return { + ...item, + children: insertChild(item.children, targetId, newItem), + }; + } + + return item; + }); +}; + +const insertBefore = ( + items: Tree, + targetId: string, + newItem: TreeItem +): Tree => { + return items.flatMap((item) => { + if (item.id === targetId) return [newItem, item]; + if (item.children) { + return [ + { ...item, children: insertBefore(item.children, targetId, newItem) }, + ]; + } + + return [item]; + }); +}; + +const insertAfter = ( + items: Tree, + targetId: string, + newItem: TreeItem +): Tree => { + return items.flatMap((item) => { + if (item.id === targetId) return [item, newItem]; + if (item.children) { + return [ + { ...item, children: insertAfter(item.children, targetId, newItem) }, + ]; + } + + return [item]; + }); +}; + +const moveItem = ( + items: Tree, + itemId: string, + direction: 'up' | 'down' | 'indent' | 'outdent' +): Tree => { + const location = findNodeLocation(items, itemId); + if (!location) return items; + + const { list, index, parent } = location; + const itemToMove = list[index]; + + const newItems = removeItem(items, itemId); + + if (direction === 'up') { + if (index > 0) { + const prevSibling = list[index - 1]; + + return insertBefore(newItems, prevSibling.id, itemToMove); + } + } else if (direction === 'down') { + if (index < list.length - 1) { + const nextSibling = list[index + 1]; + + return insertAfter(newItems, nextSibling.id, itemToMove); + } + } else if (direction === 'indent') { + if (index > 0) { + const prevSibling = list[index - 1]; + + return insertChild(newItems, prevSibling.id, itemToMove); + } + } else if (direction === 'outdent') { + if (parent) return insertAfter(newItems, parent.id, itemToMove); + } + + return items; +}; + +const getDescendantIds = (item: TreeItem): string[] => { + return (item.children || []).flatMap((child) => [ + child.id, + ...getDescendantIds(child), + ]); +}; + +interface LineIndicatorProps { + position: 'top' | 'bottom'; +} + +const LineIndicator = ({ position }: LineIndicatorProps) => { + const { euiTheme } = useEuiTheme(); + + const indicatorStyles = css` + position: absolute; + left: 0; + right: 0; + height: ${euiTheme.size.xxs}; + background-color: ${euiTheme.colors.borderStrongAccentSecondary}; + pointer-events: none; + border-radius: 1px; + `; + + const topIndicatorStyles = css` + ${indicatorStyles} + top: -${euiTheme.size.xs}; + transform: translateY(-50%); + `; + + const bottomIndicatorStyles = css` + ${indicatorStyles} + bottom: -${euiTheme.size.xs}; + transform: translateY(50%); + `; + + return ( +
+ ); +}; + +const EXPAND_ON_HOVER_TIME = 300; + +interface DraggablePanelProps extends TreeItem { + index: number; + level?: number; + activeId: string; + setActiveId: (id: string) => void; + onMove: (id: string, direction: 'up' | 'down' | 'indent' | 'outdent') => void; + isFirst: boolean; + isLast: boolean; +} + +const DraggablePanel = memo(function DraggablePanel({ + children, + id, + index, + isBlocked, + level = 0, + title, + activeId, + setActiveId, + onMove, + isFirst, + isLast, +}: DraggablePanelProps) { + const { euiTheme } = useEuiTheme(); + + const ref = useRef(null); + const expandTimeout = useRef>(); + + const buttonId = useGeneratedHtmlId({ prefix: id, suffix: 'button' }); + + const [isExpanded, setIsExpanded] = useState(true); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [instruction, setInstruction] = useState(null); + + const hasChildren = useMemo(() => { + return !!children?.length; + }, [children]); + + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.currentTarget; + + const getTreeItems = () => { + const tree = target.closest('[data-list]'); + if (!tree) return []; + return Array.from( + tree.querySelectorAll('[data-item]') + ).filter((el) => !el.closest('[inert]')); + }; + + switch (e.key) { + case 'ArrowDown': { + e.preventDefault(); + const treeItems = getTreeItems(); + const currentIndex = treeItems.indexOf(target); + const next = treeItems[currentIndex + 1]; + if (next) next.focus(); + break; + } + case 'ArrowUp': { + e.preventDefault(); + const treeItems = getTreeItems(); + const currentIndex = treeItems.indexOf(target); + const prev = treeItems[currentIndex - 1]; + if (prev) prev.focus(); + break; + } + case 'Home': { + e.preventDefault(); + const treeItems = getTreeItems(); + if (treeItems.length > 0) treeItems[0].focus(); + break; + } + case 'End': { + e.preventDefault(); + const treeItems = getTreeItems(); + if (treeItems.length > 0) treeItems[treeItems.length - 1].focus(); + break; + } + case 'ArrowRight': { + e.preventDefault(); + if (hasChildren) { + if (!isExpanded) { + setIsExpanded(true); + } else { + const treeItems = getTreeItems(); + const currentIndex = treeItems.indexOf(target); + const next = treeItems[currentIndex + 1]; + if (next) next.focus(); + } + } + break; + } + case 'ArrowLeft': { + e.preventDefault(); + if (hasChildren && isExpanded) { + setIsExpanded(false); + } else { + const parentList = target.closest('ul[data-group]'); + const parentId = parentList?.getAttribute('aria-labelledby'); + if (parentId) { + const parentEl = document.getElementById(parentId); + parentEl?.focus(); + } + } + break; + } + } + }; + + /* + * Auto-expand accordion on having dropped an element. + */ + useEffect(() => { + if (hasChildren) setIsExpanded(true); + }, [hasChildren]); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const cancelExpand = () => { + clearTimeout(expandTimeout.current); + expandTimeout.current = undefined; + }; + + const reset = () => { + setInstruction(null); + cancelExpand(); + }; + + /* + * `draggable` enables the dragging of an element. + * See: https://atlassian.design/components/pragmatic-drag-and-drop/core-package/adapters/element/about#draggable + * + * `dropTargetForElements` makes an element a drop target. + * See: https://atlassian.design/components/pragmatic-drag-and-drop/core-package/adapters/element/about#drop-target-for-elements + * + * `combine` is a utility that enables both behaviors. + * See: https://atlassian.design/components/pragmatic-drag-and-drop/core-package/utilities#combine + */ + const cleanup = combine( + draggable({ + element: el, + getInitialData: () => ({ + id, + index, + descendantIds: getDescendantIds({ id, children, title }), + }), + }), + dropTargetForElements({ + element: el, + getData: ({ input, element }) => + attachInstruction( + { id, index, level }, + { + input, + element, + operations: { + combine: isBlocked ? 'not-available' : 'available', + 'reorder-before': 'available', + 'reorder-after': 'available', + }, + } + ), + canDrop: ({ source }) => { + const descendantIds = source.data.descendantIds as string[]; + return !descendantIds.includes(id); + }, + onDrag: ({ self, location }) => { + const newInstruction = extractInstruction(self.data); + const isInnerMost = + location.current.dropTargets[0]?.element === self.element; + const isNesting = newInstruction?.operation === 'combine'; + + /* + * When you hover over a deeply nested child, you are technically + * hovering over its parent and grandparent too. Without this check + * you would see group and line indicators for the entire tree branch. + * We only update the `instruction` state if the element is innermost. + */ + if (isInnerMost) { + const newInstruction = extractInstruction(self.data); + setInstruction(newInstruction); + + if ( + isNesting && + hasChildren && + !isExpanded && + !expandTimeout.current + ) { + expandTimeout.current = setTimeout(() => { + setIsExpanded(true); + expandTimeout.current = undefined; + }, EXPAND_ON_HOVER_TIME); + } else if (!isNesting) { + cancelExpand(); + } + } else { + reset(); + } + }, + onDragLeave: reset, + onDrop: reset, + }) + ); + + return () => { + cleanup(); + cancelExpand(); + }; + }, [id, index, children, level, title, isExpanded, isBlocked]); + + /** + * Necessary styles for absolutely-positioned line indicators. + */ + const wrapperStyles = css` + position: relative; + `; + + /** + * `EuiPanel` doesn't support `color="subdued"` and `hasBorder`, + * therefore we need a style override. + */ + const panelStyles = css` + box-sizing: border-box; + border: ${euiTheme.border.thin}; + border-color: ${instruction?.operation === 'combine' + ? euiTheme.colors.borderStrongAccentSecondary + : euiTheme.colors.borderBaseSubdued}; + box-shadow: ${instruction?.operation === 'combine' + ? `inset 0 0 0 1px ${euiTheme.colors.borderStrongAccentSecondary}` + : 'none'}; + `; + + /** + * We need to override the default `overflow: hidden` behavior of `EuiAccordion` to + * allow the `LineIndicator` to be visible when dragging nested panels + * and assure leaf nodes' bottom border doesn't gets cut off. + * Double-check this doesn't cause any issues with your content type. + */ + const accordionStyles = css` + .euiAccordion__childWrapper { + overflow: visible; + } + `; + + /** + * Grab icon gives affordance to the draggable elements. + */ + const grabIconStyles = css` + svg { + fill: ${euiTheme.colors.textDisabled}; + } + `; + + /** + * Gap between the accordion header and accordion content. + */ + const groupStyles = css` + padding-top: ${euiTheme.size.m}; + `; + + const childrenWrapperStyles = css` + ${isExpanded && hasChildren && groupStyles} + display: flex; + flex-direction: column; + gap: ${euiTheme.size.s}; + `; + + const buttonStyles = css` + &:hover .grab-icon { + svg { + fill: ${euiTheme.colors.textParagraph}; + } + } + `; + + const buttonContentStyles = css` + display: flex; + gap: ${euiTheme.size.xs}; + align-items: center; + `; + + const iconStyles = css` + display: flex; + align-items: center; + justify-content: center; + height: ${euiTheme.size.l}; + width: ${euiTheme.size.l}; + `; + + return ( +
  • + {instruction?.operation === 'reorder-before' && ( + + )} + + setActiveId(id), + }} + extraAction={ + setIsPopoverOpen(false)} + button={ + setIsPopoverOpen((isOpen) => !isOpen)} + /> + } + > + { + onMove(id, 'up'); + setIsPopoverOpen(false); + }, + disabled: isFirst, + }, + { + name: 'Move down', + icon: 'arrowDown', + onClick: () => { + onMove(id, 'down'); + setIsPopoverOpen(false); + }, + disabled: isLast, + }, + { + name: 'Indent', + icon: 'arrowRight', + onClick: () => { + onMove(id, 'indent'); + setIsPopoverOpen(false); + }, + disabled: isFirst, + }, + { + name: 'Outdent', + icon: 'arrowLeft', + onClick: () => { + onMove(id, 'outdent'); + setIsPopoverOpen(false); + }, + disabled: level === 0, + }, + ], + }, + ]} + /> + + } + /* + * We render plain `EuiIcon`, not interactive `EuiButtonIcon`, + * and let the underlying button handle the (un)collapse behavior. + * See: https://eui.elastic.co/docs/components/containers/accordion/#interactive-content-in-the-trigger + */ + buttonContent={ + + + + + {hasChildren && ( + + + + )} + + {title} + + + } + > +
      + {children?.map((child, index) => ( + + ))} +
    +
    +
    + {instruction?.operation === 'reorder-after' && ( + + )} +
  • + ); +}); + +export default () => { + const { euiTheme } = useEuiTheme(); + + const [items, setItems] = useState(initialData); + const [activeId, setActiveId] = useState(initialData[0].id); + + useEffect(() => { + /* + * Monitors listen to all events for a draggable entity. + * See: https://atlassian.design/components/pragmatic-drag-and-drop/core-package/monitors + */ + return monitorForElements({ + onDrop({ source, location }) { + /** + * The inner-most drop target. We look from the deepest possible + * drop target upwards. + * See: https://atlassian.design/components/pragmatic-drag-and-drop/core-package/drop-targets#nested-drop-targets + */ + const target = location.current.dropTargets[0]; + + if (!target) return; + + const sourceId = source.data.id as string; + const targetId = target.data.id as string; + + if (sourceId === targetId) return; + + const instruction: Instruction | null = extractInstruction(target.data); + + if (!instruction) return; + if (instruction.blocked) return; + + const itemToMove = findItem(items, sourceId); + if (!itemToMove) return; + + let updatedTree = removeItem(items, sourceId); + + /* + * `@atlaskit/pragmatic-drag-and-drop-hitbox` calculates the user intent. + * We can get that user intent using `extractInstruction`. + * + * The type of operation can be: + * - `combine` - it means that the user is hovering over the center of a drop target. + * - `reorder-before` - it means that the user is hovering close to the upper edge of a drop target. + * - `reorder-after` - it means that the user is hovering close to the lower edge of a drop target. + * + * See: https://atlassian.design/components/pragmatic-drag-and-drop/optional-packages/hitbox/about + */ + if (instruction.operation === 'combine') { + updatedTree = insertChild(updatedTree, targetId, itemToMove); + } else if (instruction.operation === 'reorder-before') { + updatedTree = insertBefore(updatedTree, targetId, itemToMove); + } else if (instruction.operation === 'reorder-after') { + updatedTree = insertAfter(updatedTree, targetId, itemToMove); + } + + setItems(updatedTree); + }, + }); + }, [items]); + + const handleMove = ( + id: string, + direction: 'up' | 'down' | 'indent' | 'outdent' + ) => { + setItems((items) => moveItem(items, id, direction)); + }; + + const wrapperStyles = css` + background-color: ${euiTheme.colors.backgroundBasePlain}; + display: flex; + flex-direction: column; + gap: ${euiTheme.size.s}; + min-height: 100%; + padding: ${euiTheme.size.base}; + `; + + return ( +
      + {items.map((item, index) => ( + + ))} +
    + ); +}; diff --git a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx new file mode 100644 index 00000000000..31f20f6fae2 --- /dev/null +++ b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx @@ -0,0 +1,69 @@ +# Nested drag and drop + +```mdx-code-block +import { EuiLink } from '@elastic/eui'; +``` + +EUI exposes `EuiDraggable` and `EuiDroppable` components (see [documentation](../../components/display/drag-and-drop.mdx)), which are wrappers around @hello-pangea/dnd. While excellent for simple lists, `@hello-pangea/dnd` is heavy and has limitations such as not being able to move items between different levels of nesting. + +For complex use cases like this, we recommend Pragmatic drag and drop by Atlassian. It is a performance-focused, flexible library that handles these scenarios efficiently. + +The example below demonstrates a simplified version of Pragmatic's Tree pattern, adapted to use EUI components and design tokens with enhanced keyboard navigation. + +:::accessibility Accessibility + +This example uses nested **EuiAccordion** components with roving tabindex to improve usability, similarly as in [Tree view pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). + +For complex trees, the recommended accessibility pattern is not to mimic mouse dragging with keys but provide a "Move" action. In this case, we use **EuiPopover** and **EuiContextMenu** composition inside the `extraAction` prop. +::: + +:::warning Package versions + +This pattern may work differently depending on which version of **@atlaskit/pragmatic-drag-and-drop** you use. Please check the versions used in our package.json. +::: + +```bash +yarn add @atlaskit/pragmatic-drag-and-drop @atlaskit/pragmatic-drag-and-drop-hitbox +``` + +```mdx-code-block +import { css } from '@emotion/react'; +import { + EuiAccordion, + EuiIcon, + EuiPanel, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import { + draggable, + dropTargetForElements, + monitorForElements, +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { + attachInstruction, + extractInstruction, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/list-item'; + +import ExampleCode from '!!raw-loader!./example'; +``` + + + {ExampleCode} + diff --git a/packages/website/package.json b/packages/website/package.json index 0b836ef4e9e..f09cdff764c 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -18,6 +18,8 @@ "postrelease": "node ./scripts/update-versions.js" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@docusaurus/core": "^3.7.0", "@docusaurus/preset-classic": "^3.7.0", "@elastic/charts": "^70.0.1", diff --git a/packages/website/src/custom_typings/index.d.ts b/packages/website/src/custom_typings/index.d.ts index 1ee41bdb8e1..569b5bb573a 100644 --- a/packages/website/src/custom_typings/index.d.ts +++ b/packages/website/src/custom_typings/index.d.ts @@ -1,4 +1,12 @@ +import { SerializedStyles } from '@emotion/react'; + declare module '!!raw-loader!*' { const content: string; export default content; } + +declare module 'react' { + interface HTMLAttributes { + css?: SerializedStyles; + } +} diff --git a/packages/website/tsconfig.json b/packages/website/tsconfig.json index 2b4ad206caa..be87fa6a730 100644 --- a/packages/website/tsconfig.json +++ b/packages/website/tsconfig.json @@ -4,6 +4,7 @@ "compilerOptions": { "baseUrl": ".", "jsxImportSource": "@emotion/react", + "module": "nodenext", "moduleResolution": "nodenext" } } diff --git a/yarn.lock b/yarn.lock index 98dcc71d443..57bca1ae64f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -387,6 +387,27 @@ __metadata: languageName: node linkType: hard +"@atlaskit/pragmatic-drag-and-drop-hitbox@npm:^1.1.0": + version: 1.1.0 + resolution: "@atlaskit/pragmatic-drag-and-drop-hitbox@npm:1.1.0" + dependencies: + "@atlaskit/pragmatic-drag-and-drop": "npm:^1.6.0" + "@babel/runtime": "npm:^7.0.0" + checksum: 10c0/65155bc980696abe9600bcf25d7dd9ea520e37f2bb68627a8dc7a3eb1c4f30588c95accbb00dd4720b86c0b15e51cd53d7633de427569a7a67e1cf614e425eee + languageName: node + linkType: hard + +"@atlaskit/pragmatic-drag-and-drop@npm:^1.6.0, @atlaskit/pragmatic-drag-and-drop@npm:^1.7.7": + version: 1.7.7 + resolution: "@atlaskit/pragmatic-drag-and-drop@npm:1.7.7" + dependencies: + "@babel/runtime": "npm:^7.0.0" + bind-event-listener: "npm:^3.0.0" + raf-schd: "npm:^4.0.3" + checksum: 10c0/cf10ddc3decaa00623ffd06179b27302385ba6bbf047a93009dea1c61569019dd19936efd7270411feccb955e74c706a367829e72b04ee634589b9a9b1435930 + languageName: node + linkType: hard + "@babel/cli@npm:^7.21.5": version: 7.24.6 resolution: "@babel/cli@npm:7.24.6" @@ -7259,6 +7280,8 @@ __metadata: version: 0.0.0-use.local resolution: "@elastic/eui-website@workspace:packages/website" dependencies: + "@atlaskit/pragmatic-drag-and-drop": "npm:^1.7.7" + "@atlaskit/pragmatic-drag-and-drop-hitbox": "npm:^1.1.0" "@babel/preset-react": "npm:^7.24.7" "@docusaurus/core": "npm:^3.7.0" "@docusaurus/module-type-aliases": "npm:^3.7.0" @@ -15161,6 +15184,13 @@ __metadata: languageName: node linkType: hard +"bind-event-listener@npm:^3.0.0": + version: 3.0.0 + resolution: "bind-event-listener@npm:3.0.0" + checksum: 10c0/08eadf1c7d3a58633f25c2bbd8dc066f77ef4e5df1049e81ff2f43a40c00f6581aba37387caa4878782b1f1f7c337b827757f52b637052a465ad74a7e1db8def + languageName: node + linkType: hard + "bl@npm:^1.0.0": version: 1.2.3 resolution: "bl@npm:1.2.3"