From 9128fca18bbee19294029e4ba9d35e2ade6c9242 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 18:30:32 +0100 Subject: [PATCH 01/44] wip: revision 1 --- .../patterns/nested-drag-and-drop/data.ts | 113 ++++++++++ .../patterns/nested-drag-and-drop/demo.tsx | 82 +++++++ .../nested-drag-and-drop/draggable-panel.tsx | 211 ++++++++++++++++++ .../patterns/nested-drag-and-drop/index.mdx | 7 + packages/website/package.json | 2 + yarn.lock | 30 +++ 6 files changed, 445 insertions(+) create mode 100644 packages/website/docs/patterns/nested-drag-and-drop/data.ts create mode 100644 packages/website/docs/patterns/nested-drag-and-drop/demo.tsx create mode 100644 packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx create mode 100644 packages/website/docs/patterns/nested-drag-and-drop/index.mdx diff --git a/packages/website/docs/patterns/nested-drag-and-drop/data.ts b/packages/website/docs/patterns/nested-drag-and-drop/data.ts new file mode 100644 index 00000000000..f30ae9e391a --- /dev/null +++ b/packages/website/docs/patterns/nested-drag-and-drop/data.ts @@ -0,0 +1,113 @@ +export interface TreeItem { + id: string; + title: string; + children?: TreeItem[]; +} + +export type Tree = TreeItem[]; + +export const initialData: Tree = [ + { id: 'panel-1', title: 'Panel 1' }, + { + 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' }, + ], + }, + { id: 'subpanel-3-2', title: 'Subpanel 3.2' }, + ], + }, + { id: 'panel-4', title: 'Panel 4' }, +]; + +export const findItem = (items: Tree, itemId: string): TreeItem | undefined => { + for (const item of items) { + if (item.id === itemId) return item; + if (item.children) { + const foundItem = findItem(item.children, itemId); + if (foundItem) return foundItem; + } + } +}; + +export const removeItem = (items: Tree, itemId: string): Tree => { + return items + .filter((item) => item.id !== itemId) + .map((item) => { + if (!!item.children?.length) { + const newChildren = removeItem(item.children, itemId); + if (newChildren !== item.children) { + return { ...item, children: newChildren }; + } + } + + return item; + }); +}; + +export 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; + }); +}; + +export 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]; + }); +}; + +export 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]; + }); +}; diff --git a/packages/website/docs/patterns/nested-drag-and-drop/demo.tsx b/packages/website/docs/patterns/nested-drag-and-drop/demo.tsx new file mode 100644 index 00000000000..3b21bc85243 --- /dev/null +++ b/packages/website/docs/patterns/nested-drag-and-drop/demo.tsx @@ -0,0 +1,82 @@ +/** @jsxImportSource @emotion/react */ + +import { EuiCodeBlock, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useEffect, useRef, useState } from 'react'; +import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { + extractInstruction, + type Instruction, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/list-item'; + +import { DraggablePanel } from './draggable-panel'; +import { + findItem, + initialData, + insertChild, + insertBefore, + insertAfter, + removeItem, + type Tree, +} from './data'; + +export default () => { + const { euiTheme } = useEuiTheme(); + + const [items, setItems] = useState(initialData); + + useEffect(() => { + return monitorForElements({ + onDrop({ source, location }) { + 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); + + 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 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) => ( + + ))} + + {JSON.stringify(items, null, 2)} + +
+ ); +}; diff --git a/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx b/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx new file mode 100644 index 00000000000..195d6f77cf9 --- /dev/null +++ b/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx @@ -0,0 +1,211 @@ +/** @jsxImportSource @emotion/react */ + +import { memo, useEffect, useRef, useState } from 'react'; +import invariant from 'tiny-invariant'; +import { + draggable, + dropTargetForElements, +} 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, + EuiIcon, + EuiPanel, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; + +import { TreeItem } from './data'; + +interface DraggablePanelProps extends TreeItem { + index: number; + level?: number; +} + +export const DraggablePanel = memo(function DraggablePanel({ + children, + id, + index, + level = 0, + title, +}: DraggablePanelProps) { + const { euiTheme } = useEuiTheme(); + + const ref = useRef(null); + + const [isExpanded, setIsExpanded] = useState(true); + const [isDraggedOver, setIsDraggedOver] = useState(false); + const [instruction, setInstruction] = useState(null); + + useEffect(() => { + const el = ref.current; + invariant(el); + + return combine( + draggable({ + element: el, + getInitialData: () => ({ id, index }), + onDragStart: () => { + setIsExpanded(false); + }, + }), + dropTargetForElements({ + element: el, + getData: ({ input, element }) => + attachInstruction( + { id, index, level }, + { + input, + element, + operations: { + combine: 'available', + 'reorder-before': 'available', + 'reorder-after': + isExpanded && !!children?.length + ? 'not-available' + : 'available', + }, + } + ), + onDragEnter: ({ self }) => { + setInstruction(extractInstruction(self.data)); + setIsDraggedOver(true); + }, + onDragLeave: () => { + setInstruction(null); + setIsDraggedOver(false); + }, + onDrop: () => { + setInstruction(null); + setIsDraggedOver(false); + }, + }) + ); + }, [id, index, children, level, title]); + + 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}; + `; + + /** + * Leaves need 1px padding override to avoid border clipping. + * `EuiPanel` doesn't support `hasBorder` for `color="subdued"`. + */ + const leafStyles = css` + padding: ${euiTheme.size.s} 1px 1px 1px; + `; + + const childrenWrapperStyles = css` + ${isExpanded && !!children?.length && leafStyles} + display: flex; + flex-direction: column; + gap: ${euiTheme.size.s}; + `; + + const headerStyles = 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}; + `; + + const indicatorStyles = css` + position: absolute; + z-index: 10; + left: 0; + right: 0; + height: 2px; + background-color: ${euiTheme.colors.borderStrongAccentSecondary}; + pointer-events: none; + `; + + const topIndicatorStyles = css` + ${indicatorStyles} + top: -${euiTheme.size.s}; + `; + + const bottomIndicatorStyles = css` + ${indicatorStyles} + bottom: -${euiTheme.size.s}; + `; + + return ( +
+ {instruction?.operation === 'reorder-before' && ( +
+ )} + + + {!!children?.length && ( + + + + )} + + {title} + + + } + > +
+ {children?.map((child, index) => ( + + ))} +
+
+
+ {instruction?.operation === 'reorder-after' && ( +
+ )} +
+ ); +}); 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..7d5fd4cc308 --- /dev/null +++ b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx @@ -0,0 +1,7 @@ +# Nested drag and drop + +```mdx-code-block +import Demo from './demo'; +``` + + 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/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" From 8a3716fee99b296429aaad577665999db7b7e03b Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 19:13:22 +0100 Subject: [PATCH 02/44] fix: update instruction when dragging --- .../docs/patterns/nested-drag-and-drop/draggable-panel.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx b/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx index 195d6f77cf9..3a66b11f3fb 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx @@ -77,6 +77,7 @@ export const DraggablePanel = memo(function DraggablePanel({ setInstruction(extractInstruction(self.data)); setIsDraggedOver(true); }, + onDrag: ({ self }) => setInstruction(extractInstruction(self.data)), onDragLeave: () => { setInstruction(null); setIsDraggedOver(false); @@ -87,7 +88,7 @@ export const DraggablePanel = memo(function DraggablePanel({ }, }) ); - }, [id, index, children, level, title]); + }, [id, index, children, level, title, isExpanded]); const wrapperStyles = css` position: relative; From e092e98be6818810e5464961cbede6e18a9711aa Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 19:33:12 +0100 Subject: [PATCH 03/44] fix: change indicator placement --- .../patterns/nested-drag-and-drop/draggable-panel.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx b/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx index 3a66b11f3fb..9c525e14ca9 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx @@ -137,22 +137,22 @@ export const DraggablePanel = memo(function DraggablePanel({ const indicatorStyles = css` position: absolute; - z-index: 10; + z-index: ${euiTheme.levels.flyout}; left: 0; right: 0; - height: 2px; + height: 1px; background-color: ${euiTheme.colors.borderStrongAccentSecondary}; pointer-events: none; `; const topIndicatorStyles = css` ${indicatorStyles} - top: -${euiTheme.size.s}; + top: -${euiTheme.size.xs}; `; const bottomIndicatorStyles = css` ${indicatorStyles} - bottom: -${euiTheme.size.s}; + bottom: -${euiTheme.size.xs}; `; return ( From 9693c1e8e46b88b136a72dda678ac1a51ae31761 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 19:37:34 +0100 Subject: [PATCH 04/44] fix: auto-open newly nested accordions --- .../docs/patterns/nested-drag-and-drop/draggable-panel.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx b/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx index 9c525e14ca9..2d6d4adb478 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx @@ -43,6 +43,12 @@ export const DraggablePanel = memo(function DraggablePanel({ const [isDraggedOver, setIsDraggedOver] = useState(false); const [instruction, setInstruction] = useState(null); + useEffect(() => { + if (!!children?.length) { + setIsExpanded(true); + } + }, [children?.length]); + useEffect(() => { const el = ref.current; invariant(el); From 5e2f9ce0cb8fff571e7e1ff11b779c50aef1115e Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 19:43:07 +0100 Subject: [PATCH 05/44] fix: only show inner-most indicator --- .../nested-drag-and-drop/draggable-panel.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx b/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx index 2d6d4adb478..8764d5a773a 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx @@ -79,11 +79,21 @@ export const DraggablePanel = memo(function DraggablePanel({ }, } ), - onDragEnter: ({ self }) => { - setInstruction(extractInstruction(self.data)); - setIsDraggedOver(true); + onDragEnter: ({ self, location }) => { + if (location.current.dropTargets[0]?.element === self.element) { + setInstruction(extractInstruction(self.data)); + setIsDraggedOver(true); + } + }, + onDrag: ({ self, location }) => { + if (location.current.dropTargets[0]?.element === self.element) { + setInstruction(extractInstruction(self.data)); + setIsDraggedOver(true); + } else { + setInstruction(null); + setIsDraggedOver(false); + } }, - onDrag: ({ self }) => setInstruction(extractInstruction(self.data)), onDragLeave: () => { setInstruction(null); setIsDraggedOver(false); From 4e8a719750cd19d542e5dab5d6b499a478206acd Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 19:55:50 +0100 Subject: [PATCH 06/44] fix: remove unused import --- packages/website/docs/patterns/nested-drag-and-drop/demo.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/demo.tsx b/packages/website/docs/patterns/nested-drag-and-drop/demo.tsx index 3b21bc85243..e55ea123a14 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/demo.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/demo.tsx @@ -2,7 +2,7 @@ import { EuiCodeBlock, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { extractInstruction, From ba539054f120f28bbcfca53e9eda3dfff1941594 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 20:31:12 +0100 Subject: [PATCH 07/44] feat: add consolidated file demo --- .../patterns/nested-drag-and-drop/data.ts | 113 ----------- .../patterns/nested-drag-and-drop/demo.tsx | 82 -------- .../{draggable-panel.tsx => example.tsx} | 186 +++++++++++++++++- .../patterns/nested-drag-and-drop/index.mdx | 42 +++- 4 files changed, 219 insertions(+), 204 deletions(-) delete mode 100644 packages/website/docs/patterns/nested-drag-and-drop/data.ts delete mode 100644 packages/website/docs/patterns/nested-drag-and-drop/demo.tsx rename packages/website/docs/patterns/nested-drag-and-drop/{draggable-panel.tsx => example.tsx} (57%) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/data.ts b/packages/website/docs/patterns/nested-drag-and-drop/data.ts deleted file mode 100644 index f30ae9e391a..00000000000 --- a/packages/website/docs/patterns/nested-drag-and-drop/data.ts +++ /dev/null @@ -1,113 +0,0 @@ -export interface TreeItem { - id: string; - title: string; - children?: TreeItem[]; -} - -export type Tree = TreeItem[]; - -export const initialData: Tree = [ - { id: 'panel-1', title: 'Panel 1' }, - { - 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' }, - ], - }, - { id: 'subpanel-3-2', title: 'Subpanel 3.2' }, - ], - }, - { id: 'panel-4', title: 'Panel 4' }, -]; - -export const findItem = (items: Tree, itemId: string): TreeItem | undefined => { - for (const item of items) { - if (item.id === itemId) return item; - if (item.children) { - const foundItem = findItem(item.children, itemId); - if (foundItem) return foundItem; - } - } -}; - -export const removeItem = (items: Tree, itemId: string): Tree => { - return items - .filter((item) => item.id !== itemId) - .map((item) => { - if (!!item.children?.length) { - const newChildren = removeItem(item.children, itemId); - if (newChildren !== item.children) { - return { ...item, children: newChildren }; - } - } - - return item; - }); -}; - -export 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; - }); -}; - -export 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]; - }); -}; - -export 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]; - }); -}; diff --git a/packages/website/docs/patterns/nested-drag-and-drop/demo.tsx b/packages/website/docs/patterns/nested-drag-and-drop/demo.tsx deleted file mode 100644 index e55ea123a14..00000000000 --- a/packages/website/docs/patterns/nested-drag-and-drop/demo.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** @jsxImportSource @emotion/react */ - -import { EuiCodeBlock, useEuiTheme } from '@elastic/eui'; -import { css } from '@emotion/react'; -import { useEffect, useState } from 'react'; -import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import { - extractInstruction, - type Instruction, -} from '@atlaskit/pragmatic-drag-and-drop-hitbox/list-item'; - -import { DraggablePanel } from './draggable-panel'; -import { - findItem, - initialData, - insertChild, - insertBefore, - insertAfter, - removeItem, - type Tree, -} from './data'; - -export default () => { - const { euiTheme } = useEuiTheme(); - - const [items, setItems] = useState(initialData); - - useEffect(() => { - return monitorForElements({ - onDrop({ source, location }) { - 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); - - 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 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) => ( - - ))} - - {JSON.stringify(items, null, 2)} - -
- ); -}; diff --git a/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx similarity index 57% rename from packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx rename to packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 8764d5a773a..27fa9f75642 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/draggable-panel.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -1,10 +1,9 @@ -/** @jsxImportSource @emotion/react */ - import { memo, useEffect, useRef, useState } from 'react'; -import invariant from 'tiny-invariant'; +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 { @@ -14,21 +13,133 @@ import { } from '@atlaskit/pragmatic-drag-and-drop-hitbox/list-item'; import { EuiAccordion, + EuiCodeBlock, EuiIcon, EuiPanel, EuiText, useEuiTheme, } from '@elastic/eui'; -import { css } from '@emotion/react'; -import { TreeItem } from './data'; +interface TreeItem { + id: string; + title: string; + children?: TreeItem[]; +} + +type Tree = TreeItem[]; + +const initialData: Tree = [ + { id: 'panel-1', title: 'Panel 1' }, + { + 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' }, + ], + }, + { id: 'subpanel-3-2', title: 'Subpanel 3.2' }, + ], + }, + { id: 'panel-4', title: 'Panel 4' }, +]; + +const findItem = (items: Tree, itemId: string): TreeItem | undefined => { + for (const item of items) { + if (item.id === itemId) return item; + if (item.children) { + const foundItem = findItem(item.children, itemId); + if (foundItem) return foundItem; + } + } +}; + +const removeItem = (items: Tree, itemId: string): Tree => { + return items + .filter((item) => item.id !== itemId) + .map((item) => { + if (!!item.children?.length) { + const newChildren = removeItem(item.children, itemId); + if (newChildren !== item.children) { + return { ...item, children: newChildren }; + } + } + + return 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]; + }); +}; interface DraggablePanelProps extends TreeItem { index: number; level?: number; } -export const DraggablePanel = memo(function DraggablePanel({ +const DraggablePanel = memo(function DraggablePanel({ children, id, index, @@ -51,7 +162,7 @@ export const DraggablePanel = memo(function DraggablePanel({ useEffect(() => { const el = ref.current; - invariant(el); + if (!el) return; return combine( draggable({ @@ -226,3 +337,64 @@ export const DraggablePanel = memo(function DraggablePanel({
); }); + +export default () => { + const { euiTheme } = useEuiTheme(); + + const [items, setItems] = useState(initialData); + + useEffect(() => { + return monitorForElements({ + onDrop({ source, location }) { + 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); + + 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 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) => ( + + ))} + + {JSON.stringify(items, null, 2)} + +
+ ); +}; diff --git a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx index 7d5fd4cc308..514472b444c 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx +++ b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx @@ -1,7 +1,45 @@ # Nested drag and drop ```mdx-code-block -import Demo from './demo'; +import { css } from '@emotion/react'; +import { + EuiAccordion, + EuiCodeBlock, + 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} + From 043b5c3652a4614370b0e77006befd7efb4cbfc5 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 20:50:01 +0100 Subject: [PATCH 08/44] feat: add a simple documentation --- .../patterns/nested-drag-and-drop/index.mdx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx index 514472b444c..03f0087b87e 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx +++ b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx @@ -1,5 +1,23 @@ # 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 while maintaining full accessibility. + +:::warning +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 { From bf22470e1c5b447aaac14f3bbed664967242c7c3 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 20:50:50 +0100 Subject: [PATCH 09/44] feat: remove code-block --- .../website/docs/patterns/nested-drag-and-drop/example.tsx | 4 ---- packages/website/docs/patterns/nested-drag-and-drop/index.mdx | 2 -- 2 files changed, 6 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 27fa9f75642..1513faecfc2 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -13,7 +13,6 @@ import { } from '@atlaskit/pragmatic-drag-and-drop-hitbox/list-item'; import { EuiAccordion, - EuiCodeBlock, EuiIcon, EuiPanel, EuiText, @@ -392,9 +391,6 @@ export default () => { {items.map((item, index) => ( ))} - - {JSON.stringify(items, null, 2)} -
); }; diff --git a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx index 03f0087b87e..10d130af205 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx +++ b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx @@ -22,7 +22,6 @@ yarn add @atlaskit/pragmatic-drag-and-drop @atlaskit/pragmatic-drag-and-drop-hit import { css } from '@emotion/react'; import { EuiAccordion, - EuiCodeBlock, EuiIcon, EuiPanel, EuiText, @@ -46,7 +45,6 @@ import ExampleCode from '!!raw-loader!./example'; scope={{ css, EuiAccordion, - EuiCodeBlock, EuiIcon, EuiPanel, EuiText, From 2d2558d02b0b8d2f502d339d83748861dadb3f8e Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 21:20:45 +0100 Subject: [PATCH 10/44] fix: custom_typings type augmentation for css prop --- .../docs/patterns/nested-drag-and-drop/example.tsx | 2 ++ packages/website/src/custom_typings/index.d.ts | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 1513faecfc2..17e168fad1a 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -1,3 +1,5 @@ +/** @jsxImportSource @emotion/react */ + import { memo, useEffect, useRef, useState } from 'react'; import { css } from '@emotion/react'; import { 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; + } +} From 0f70cb7db0e6d58b13218707953bf0b37497d8b7 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 21:41:38 +0100 Subject: [PATCH 11/44] feat: update current dnd docs --- .../docs/components/display/drag-and-drop.mdx | 66 +++++++++++++------ 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/packages/website/docs/components/display/drag-and-drop.mdx b/packages/website/docs/components/display/drag-and-drop.mdx index 8697b439bbd..749dabd5f41 100644 --- a/packages/website/docs/components/display/drag-and-drop.mdx +++ b/packages/website/docs/components/display/drag-and-drop.mdx @@ -2,19 +2,27 @@ keywords: [EuiDragDropContext, EuiDroppable, EuiDraggable] --- +```mdx-code-block +import { EuiLink } from '@elastic/eui'; +``` + # 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: +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'; +``` From ef6fcda448b2ae5184378bfcfa4deec2eec2369f Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 22:34:09 +0100 Subject: [PATCH 12/44] feat: add grab icon --- .../patterns/nested-drag-and-drop/example.tsx | 63 ++++++++++++------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 17e168fad1a..6c4b9621646 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @emotion/react */ -import { memo, useEffect, useRef, useState } from 'react'; +import { memo, useEffect, useRef, useState, type MouseEvent } from 'react'; import { css } from '@emotion/react'; import { draggable, @@ -135,6 +135,22 @@ const insertAfter = ( }); }; +const useHover = () => { + const [isHovered, setIsHovered] = useState(false); + + const onMouseOver = (e: MouseEvent) => { + e.stopPropagation(); + setIsHovered(true); + }; + + const onMouseOut = (e: MouseEvent) => { + e.stopPropagation(); + setIsHovered(false); + }; + + return { isHovered, onMouseOver, onMouseOut }; +}; + interface DraggablePanelProps extends TreeItem { index: number; level?: number; @@ -152,9 +168,10 @@ const DraggablePanel = memo(function DraggablePanel({ const ref = useRef(null); const [isExpanded, setIsExpanded] = useState(true); - const [isDraggedOver, setIsDraggedOver] = useState(false); const [instruction, setInstruction] = useState(null); + const { isHovered, onMouseOver, onMouseOut } = useHover(); + useEffect(() => { if (!!children?.length) { setIsExpanded(true); @@ -169,9 +186,7 @@ const DraggablePanel = memo(function DraggablePanel({ draggable({ element: el, getInitialData: () => ({ id, index }), - onDragStart: () => { - setIsExpanded(false); - }, + onDragStart: () => setIsExpanded(false), }), dropTargetForElements({ element: el, @@ -194,26 +209,17 @@ const DraggablePanel = memo(function DraggablePanel({ onDragEnter: ({ self, location }) => { if (location.current.dropTargets[0]?.element === self.element) { setInstruction(extractInstruction(self.data)); - setIsDraggedOver(true); } }, onDrag: ({ self, location }) => { if (location.current.dropTargets[0]?.element === self.element) { setInstruction(extractInstruction(self.data)); - setIsDraggedOver(true); } else { setInstruction(null); - setIsDraggedOver(false); } }, - onDragLeave: () => { - setInstruction(null); - setIsDraggedOver(false); - }, - onDrop: () => { - setInstruction(null); - setIsDraggedOver(false); - }, + onDragLeave: () => setInstruction(null), + onDrop: () => setInstruction(null), }) ); }, [id, index, children, level, title, isExpanded]); @@ -234,10 +240,20 @@ const DraggablePanel = memo(function DraggablePanel({ : euiTheme.colors.borderBaseSubdued}; `; - /** - * Leaves need 1px padding override to avoid border clipping. - * `EuiPanel` doesn't support `hasBorder` for `color="subdued"`. - */ + const grabIconStyles = css` + width: ${isHovered ? euiTheme.size.l : 0}; + min-width: 0; + opacity: ${isHovered ? 1 : 0}; + margin-inline-end: ${isHovered ? 0 : `calc(${euiTheme.size.xs} * -1)`}; + overflow: hidden; + transform: scale(${isHovered ? 1 : 0}); + transition: + width 0.2s ease-in-out, + opacity 0.2s ease-in-out, + margin-inline-end 0.2s ease-in-out, + transform 0.2s ease-in-out; + `; + const leafStyles = css` padding: ${euiTheme.size.s} 1px 1px 1px; `; @@ -284,7 +300,7 @@ const DraggablePanel = memo(function DraggablePanel({ `; return ( -
+
{instruction?.operation === 'reorder-before' && (
)} @@ -309,7 +325,10 @@ const DraggablePanel = memo(function DraggablePanel({ arrowDisplay="none" buttonContent={ - {!!children?.length && ( + + + + {children?.length && ( From c2d97e18089f044e07631a02a60aec00d9dbc719 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 22:36:15 +0100 Subject: [PATCH 13/44] refactor: add LineIndicator component --- .../patterns/nested-drag-and-drop/example.tsx | 64 ++++++++++++------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 6c4b9621646..e01dd4f7a9e 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -151,6 +151,40 @@ const useHover = () => { return { isHovered, onMouseOver, onMouseOut }; }; +interface LineIndicatorProps { + position: 'top' | 'bottom'; +} + +const LineIndicator = ({ position }: LineIndicatorProps) => { + const { euiTheme } = useEuiTheme(); + + const indicatorStyles = css` + position: absolute; + z-index: ${euiTheme.levels.flyout}; + left: 0; + right: 0; + height: 1px; + background-color: ${euiTheme.colors.borderStrongAccentSecondary}; + pointer-events: none; + `; + + const topIndicatorStyles = css` + ${indicatorStyles} + top: -${euiTheme.size.xs}; + `; + + const bottomIndicatorStyles = css` + ${indicatorStyles} + bottom: -${euiTheme.size.xs}; + `; + + return ( +
+ ); +}; + interface DraggablePanelProps extends TreeItem { index: number; level?: number; @@ -254,6 +288,10 @@ const DraggablePanel = memo(function DraggablePanel({ transform 0.2s ease-in-out; `; + /** + * Leaves need 1px padding override to avoid border clipping. + * `EuiPanel` doesn't support `hasBorder` for `color="subdued"`. + */ const leafStyles = css` padding: ${euiTheme.size.s} 1px 1px 1px; `; @@ -279,30 +317,10 @@ const DraggablePanel = memo(function DraggablePanel({ width: ${euiTheme.size.l}; `; - const indicatorStyles = css` - position: absolute; - z-index: ${euiTheme.levels.flyout}; - left: 0; - right: 0; - height: 1px; - background-color: ${euiTheme.colors.borderStrongAccentSecondary}; - pointer-events: none; - `; - - const topIndicatorStyles = css` - ${indicatorStyles} - top: -${euiTheme.size.xs}; - `; - - const bottomIndicatorStyles = css` - ${indicatorStyles} - bottom: -${euiTheme.size.xs}; - `; - return (
{instruction?.operation === 'reorder-before' && ( -
+ )} @@ -352,7 +370,7 @@ const DraggablePanel = memo(function DraggablePanel({ {instruction?.operation === 'reorder-after' && ( -
+ )}
); From a4b9381ac959c8065a6c7f4b4225187431ff646c Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Mon, 15 Dec 2025 22:39:15 +0100 Subject: [PATCH 14/44] feat: implement blocked panels --- .../patterns/nested-drag-and-drop/example.tsx | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index e01dd4f7a9e..e9f16a75dd2 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -25,12 +25,13 @@ interface TreeItem { id: string; title: string; children?: TreeItem[]; + isBlocked?: boolean; } type Tree = TreeItem[]; const initialData: Tree = [ - { id: 'panel-1', title: 'Panel 1' }, + { id: 'panel-1', title: 'Panel 1 (blocked)', isBlocked: true }, { id: 'panel-2', title: 'Panel 2', @@ -45,7 +46,11 @@ const initialData: Tree = [ 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' }, + { + id: 'subpanel-3-1-2', + title: 'Subpanel 3.1.2 (blocked)', + isBlocked: true, + }, ], }, { id: 'subpanel-3-2', title: 'Subpanel 3.2' }, @@ -194,6 +199,7 @@ const DraggablePanel = memo(function DraggablePanel({ children, id, index, + isBlocked, level = 0, title, }: DraggablePanelProps) { @@ -230,14 +236,20 @@ const DraggablePanel = memo(function DraggablePanel({ { input, element, - operations: { - combine: 'available', - 'reorder-before': 'available', - 'reorder-after': - isExpanded && !!children?.length - ? 'not-available' - : 'available', - }, + operations: isBlocked + ? { + combine: 'not-available', + 'reorder-before': 'not-available', + 'reorder-after': 'not-available', + } + : { + combine: 'available', + 'reorder-before': 'available', + 'reorder-after': + isExpanded && !!children?.length + ? 'not-available' + : 'available', + }, } ), onDragEnter: ({ self, location }) => { @@ -256,7 +268,7 @@ const DraggablePanel = memo(function DraggablePanel({ onDrop: () => setInstruction(null), }) ); - }, [id, index, children, level, title, isExpanded]); + }, [id, index, children, level, title, isExpanded, isBlocked]); const wrapperStyles = css` position: relative; @@ -346,7 +358,7 @@ const DraggablePanel = memo(function DraggablePanel({ - {children?.length && ( + {!!children?.length && ( From b5956f267d7fabcce495186e87f798fe9c0a5d17 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 14:45:15 +0100 Subject: [PATCH 15/44] fix: drag and drop page title --- packages/website/docs/components/display/drag-and-drop.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/website/docs/components/display/drag-and-drop.mdx b/packages/website/docs/components/display/drag-and-drop.mdx index 749dabd5f41..ada40ac1427 100644 --- a/packages/website/docs/components/display/drag-and-drop.mdx +++ b/packages/website/docs/components/display/drag-and-drop.mdx @@ -2,12 +2,12 @@ keywords: [EuiDragDropContext, EuiDroppable, EuiDraggable] --- +# Drag and drop + ```mdx-code-block import { EuiLink } from '@elastic/eui'; ``` -# Drag and drop - 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. From 91e9e1467bbd02cac14dc09383332c60f18d6cd8 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 15:33:17 +0100 Subject: [PATCH 16/44] fix: change panel padding size --- .../docs/patterns/nested-drag-and-drop/example.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index e9f16a75dd2..863233d3720 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -245,10 +245,7 @@ const DraggablePanel = memo(function DraggablePanel({ : { combine: 'available', 'reorder-before': 'available', - 'reorder-after': - isExpanded && !!children?.length - ? 'not-available' - : 'available', + 'reorder-after': 'available', }, } ), @@ -305,7 +302,7 @@ const DraggablePanel = memo(function DraggablePanel({ * `EuiPanel` doesn't support `hasBorder` for `color="subdued"`. */ const leafStyles = css` - padding: ${euiTheme.size.s} 1px 1px 1px; + padding: ${euiTheme.size.m} 1px 1px 1px; `; const childrenWrapperStyles = css` @@ -341,7 +338,6 @@ const DraggablePanel = memo(function DraggablePanel({ hasShadow={false} borderRadius="m" grow={false} - paddingSize="s" > Date: Tue, 16 Dec 2025 18:53:00 +0100 Subject: [PATCH 17/44] fix: make overflow visible in accordion content --- .../patterns/nested-drag-and-drop/example.tsx | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 863233d3720..dab089c3f24 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -160,6 +160,10 @@ interface LineIndicatorProps { position: 'top' | 'bottom'; } +/** + * TODO: make into a reusable style / component + * with the current drag and drop components + */ const LineIndicator = ({ position }: LineIndicatorProps) => { const { euiTheme } = useEuiTheme(); @@ -283,6 +287,21 @@ const DraggablePanel = memo(function DraggablePanel({ : euiTheme.colors.borderBaseSubdued}; `; + /** + * 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; + } + `; + + /** + * TODO: reuse the animation + */ const grabIconStyles = css` width: ${isHovered ? euiTheme.size.l : 0}; min-width: 0; @@ -298,15 +317,14 @@ const DraggablePanel = memo(function DraggablePanel({ `; /** - * Leaves need 1px padding override to avoid border clipping. - * `EuiPanel` doesn't support `hasBorder` for `color="subdued"`. + * Gap between the accordion header and accordion content. */ - const leafStyles = css` - padding: ${euiTheme.size.m} 1px 1px 1px; + const groupStyles = css` + padding-top: ${euiTheme.size.m}; `; const childrenWrapperStyles = css` - ${isExpanded && !!children?.length && leafStyles} + ${isExpanded && !!children?.length && groupStyles} display: flex; flex-direction: column; gap: ${euiTheme.size.s}; @@ -341,6 +359,7 @@ const DraggablePanel = memo(function DraggablePanel({ > Date: Tue, 16 Dec 2025 19:17:45 +0100 Subject: [PATCH 18/44] chore: add comments --- .../patterns/nested-drag-and-drop/example.tsx | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index dab089c3f24..924620b92ae 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -216,6 +216,9 @@ const DraggablePanel = memo(function DraggablePanel({ const { isHovered, onMouseOver, onMouseOut } = useHover(); + /* + * Auto-expand accordion on having dropped an element. + */ useEffect(() => { if (!!children?.length) { setIsExpanded(true); @@ -226,6 +229,16 @@ const DraggablePanel = memo(function DraggablePanel({ const el = ref.current; if (!el) return; + /* + * `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 + */ return combine( draggable({ element: el, @@ -240,17 +253,11 @@ const DraggablePanel = memo(function DraggablePanel({ { input, element, - operations: isBlocked - ? { - combine: 'not-available', - 'reorder-before': 'not-available', - 'reorder-after': 'not-available', - } - : { - combine: 'available', - 'reorder-before': 'available', - 'reorder-after': 'available', - }, + operations: { + combine: isBlocked ? 'not-available' : 'available', + 'reorder-before': isBlocked ? 'not-available' : 'available', + 'reorder-after': isBlocked ? 'not-available' : 'available', + }, } ), onDragEnter: ({ self, location }) => { @@ -300,6 +307,8 @@ const DraggablePanel = memo(function DraggablePanel({ `; /** + * Grab icon gives affordance to the draggable elements. + * * TODO: reuse the animation */ const grabIconStyles = css` @@ -409,8 +418,17 @@ export default () => { const [items, setItems] = useState(initialData); 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; @@ -430,6 +448,17 @@ export default () => { 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') { From 292a30e28e6fd38d1704f200ecd4b840f48fc3c9 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 19:24:01 +0100 Subject: [PATCH 19/44] fix: remove collapsing on drag, z-index and add comment --- .../docs/patterns/nested-drag-and-drop/example.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 924620b92ae..7d26abaf09c 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -169,7 +169,6 @@ const LineIndicator = ({ position }: LineIndicatorProps) => { const indicatorStyles = css` position: absolute; - z-index: ${euiTheme.levels.flyout}; left: 0; right: 0; height: 1px; @@ -243,7 +242,6 @@ const DraggablePanel = memo(function DraggablePanel({ draggable({ element: el, getInitialData: () => ({ id, index }), - onDragStart: () => setIsExpanded(false), }), dropTargetForElements({ element: el, @@ -266,6 +264,12 @@ const DraggablePanel = memo(function DraggablePanel({ } }, onDrag: ({ self, location }) => { + /* + * 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 (location.current.dropTargets[0]?.element === self.element) { setInstruction(extractInstruction(self.data)); } else { From 95a795c04996a4046f2ae11958ca9be323a6db2e Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 19:26:25 +0100 Subject: [PATCH 20/44] fix: remove redundant onDragEnter --- .../website/docs/patterns/nested-drag-and-drop/example.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 7d26abaf09c..5045de80112 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -258,11 +258,6 @@ const DraggablePanel = memo(function DraggablePanel({ }, } ), - onDragEnter: ({ self, location }) => { - if (location.current.dropTargets[0]?.element === self.element) { - setInstruction(extractInstruction(self.data)); - } - }, onDrag: ({ self, location }) => { /* * When you hover over a deeply nested child, you are technically From 7c6d66a3a4e9cf1877fdfaafdf3c3b66c3a72fae Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 19:27:55 +0100 Subject: [PATCH 21/44] chore: add a helpful comment about key prop --- .../website/docs/patterns/nested-drag-and-drop/example.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 5045de80112..4be8f45ffe5 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -30,6 +30,13 @@ interface TreeItem { 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 }, { From 9346fa67464d6fedf4d0b7abb472de3d37b4c9a5 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 19:33:04 +0100 Subject: [PATCH 22/44] chore: add another helpful comment --- packages/website/docs/patterns/nested-drag-and-drop/example.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 4be8f45ffe5..6b6f9cecbab 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -275,9 +275,11 @@ const DraggablePanel = memo(function DraggablePanel({ if (location.current.dropTargets[0]?.element === self.element) { setInstruction(extractInstruction(self.data)); } else { + /* This means that mouse left the nested child */ setInstruction(null); } }, + /* This means that mouse left the component entirely */ onDragLeave: () => setInstruction(null), onDrop: () => setInstruction(null), }) From 5996ca76260a4f9b162c769a5f4d17fae43a5d00 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 19:33:56 +0100 Subject: [PATCH 23/44] chore: add a comment regarding relative position --- .../website/docs/patterns/nested-drag-and-drop/example.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 6b6f9cecbab..f83c6e24fee 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -286,6 +286,9 @@ const DraggablePanel = memo(function DraggablePanel({ ); }, [id, index, children, level, title, isExpanded, isBlocked]); + /** + * Necessary styles for absolutely-positioned line indicators. + */ const wrapperStyles = css` position: relative; `; From 0009d73903ab57b389435de9dee5459e49faa954 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 19:48:13 +0100 Subject: [PATCH 24/44] refactor: dry out the children... wth --- .../patterns/nested-drag-and-drop/example.tsx | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index f83c6e24fee..4ef1c68efb4 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -1,6 +1,13 @@ /** @jsxImportSource @emotion/react */ -import { memo, useEffect, useRef, useState, type MouseEvent } from 'react'; +import { + memo, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent, +} from 'react'; import { css } from '@emotion/react'; import { draggable, @@ -200,6 +207,8 @@ const LineIndicator = ({ position }: LineIndicatorProps) => { ); }; +const EXPAND_ON_HOVER_TIME = 300; + interface DraggablePanelProps extends TreeItem { index: number; level?: number; @@ -216,25 +225,38 @@ const DraggablePanel = memo(function DraggablePanel({ const { euiTheme } = useEuiTheme(); const ref = useRef(null); + const expandTimeout = useRef>(); const [isExpanded, setIsExpanded] = useState(true); const [instruction, setInstruction] = useState(null); const { isHovered, onMouseOver, onMouseOut } = useHover(); + const hasChildren = useMemo(() => { + return !!children?.length; + }, [children]); + /* * Auto-expand accordion on having dropped an element. */ useEffect(() => { - if (!!children?.length) { - setIsExpanded(true); - } - }, [children?.length]); + 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 @@ -245,10 +267,11 @@ const DraggablePanel = memo(function DraggablePanel({ * `combine` is a utility that enables both behaviors. * See: https://atlassian.design/components/pragmatic-drag-and-drop/core-package/utilities#combine */ - return combine( + const cleanup = combine( draggable({ element: el, getInitialData: () => ({ id, index }), + onDragStart: () => setIsExpanded(false), }), dropTargetForElements({ element: el, @@ -273,17 +296,35 @@ const DraggablePanel = memo(function DraggablePanel({ * We only update the `instruction` state if the element is innermost. */ if (location.current.dropTargets[0]?.element === self.element) { - setInstruction(extractInstruction(self.data)); + const newInstruction = extractInstruction(self.data); + setInstruction(newInstruction); + + if ( + newInstruction?.operation === 'combine' && + hasChildren && + !isExpanded && + !expandTimeout.current + ) { + expandTimeout.current = setTimeout(() => { + setIsExpanded(true); + expandTimeout.current = undefined; + }, EXPAND_ON_HOVER_TIME); + } else if (newInstruction?.operation !== 'combine') { + cancelExpand(); + } } else { - /* This means that mouse left the nested child */ - setInstruction(null); + reset(); } }, - /* This means that mouse left the component entirely */ - onDragLeave: () => setInstruction(null), - onDrop: () => setInstruction(null), + onDragLeave: reset, + onDrop: reset, }) ); + + return () => { + cleanup(); + cancelExpand(); + }; }, [id, index, children, level, title, isExpanded, isBlocked]); /** @@ -344,7 +385,7 @@ const DraggablePanel = memo(function DraggablePanel({ `; const childrenWrapperStyles = css` - ${isExpanded && !!children?.length && groupStyles} + ${isExpanded && hasChildren && groupStyles} display: flex; flex-direction: column; gap: ${euiTheme.size.s}; @@ -393,7 +434,7 @@ const DraggablePanel = memo(function DraggablePanel({ - {!!children?.length && ( + {hasChildren && ( From 7dd264792240848dd2bc71bd6918fc57be3fc452 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 20:12:47 +0100 Subject: [PATCH 25/44] fix: incorrect hover effects --- .../patterns/nested-drag-and-drop/example.tsx | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 4ef1c68efb4..d39eddef3e9 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -154,20 +154,38 @@ const insertAfter = ( }); }; +/** + * A custom hook that defines and returns hover listeners. + * + * We use `isHovered` state to conditionally render the `grab` icon + * as a draggable affordance. + * + * We rely on `mousemove` instead of `mouseover` to avoid "stuck" hover + * states after a drop because `mousemove` only fires on actual movement. + * Native drag also suppresses `mousemove`, preventing unwanted hover + * effects during drag operations. + */ const useHover = () => { const [isHovered, setIsHovered] = useState(false); - const onMouseOver = (e: MouseEvent) => { - e.stopPropagation(); - setIsHovered(true); - }; + useEffect(() => { + return monitorForElements({ + onDragStart: () => setIsHovered(false), + onDrop: () => setIsHovered(false), + }); + }, []); const onMouseOut = (e: MouseEvent) => { e.stopPropagation(); setIsHovered(false); }; - return { isHovered, onMouseOver, onMouseOut }; + const onMouseMove = (e: MouseEvent) => { + e.stopPropagation(); + if (!isHovered) setIsHovered(true); + }; + + return { isHovered, onMouseOut, onMouseMove }; }; interface LineIndicatorProps { @@ -230,7 +248,7 @@ const DraggablePanel = memo(function DraggablePanel({ const [isExpanded, setIsExpanded] = useState(true); const [instruction, setInstruction] = useState(null); - const { isHovered, onMouseOver, onMouseOut } = useHover(); + const { isHovered, onMouseOut, onMouseMove } = useHover(); const hasChildren = useMemo(() => { return !!children?.length; @@ -406,7 +424,7 @@ const DraggablePanel = memo(function DraggablePanel({ `; return ( -
+
{instruction?.operation === 'reorder-before' && ( )} From 65ad3508530f5c178c9b71a35374787374a94e49 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 20:13:25 +0100 Subject: [PATCH 26/44] fix: make reordering possible for blocked panels --- .../website/docs/patterns/nested-drag-and-drop/example.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index d39eddef3e9..23cd18dc834 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -301,8 +301,8 @@ const DraggablePanel = memo(function DraggablePanel({ element, operations: { combine: isBlocked ? 'not-available' : 'available', - 'reorder-before': isBlocked ? 'not-available' : 'available', - 'reorder-after': isBlocked ? 'not-available' : 'available', + 'reorder-before': 'available', + 'reorder-after': 'available', }, } ), From f8a7bcafdc160d5c80053045b0302f7d321f2a0a Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 20:14:45 +0100 Subject: [PATCH 27/44] chore: set module to nodenext --- packages/website/tsconfig.json | 1 + 1 file changed, 1 insertion(+) 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" } } From 1ec2ebe39947c726c2cf89b106bff2f4131b4b7a Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 20:15:14 +0100 Subject: [PATCH 28/44] chore: remove redundant pragma --- packages/website/docs/patterns/nested-drag-and-drop/example.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 23cd18dc834..f024433b5ec 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -1,5 +1,3 @@ -/** @jsxImportSource @emotion/react */ - import { memo, useEffect, From b45b40fc4774f12c511ea0fc1ee6a3dab2e99aa9 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 22:03:06 +0100 Subject: [PATCH 29/44] fix: block descendant drop --- .../patterns/nested-drag-and-drop/example.tsx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index f024433b5ec..c782f5a87a8 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -152,6 +152,19 @@ const insertAfter = ( }); }; +const getDescendantIds = (item: TreeItem): string[] => { + let ids: string[] = []; + + if (item.children) { + for (const child of item.children) { + ids.push(child.id); + ids = ids.concat(getDescendantIds(child)); + } + } + + return ids; +}; + /** * A custom hook that defines and returns hover listeners. * @@ -286,8 +299,11 @@ const DraggablePanel = memo(function DraggablePanel({ const cleanup = combine( draggable({ element: el, - getInitialData: () => ({ id, index }), - onDragStart: () => setIsExpanded(false), + getInitialData: () => ({ + id, + index, + descendantIds: getDescendantIds({ id, children, title }), + }), }), dropTargetForElements({ element: el, @@ -304,6 +320,10 @@ const DraggablePanel = memo(function DraggablePanel({ }, } ), + canDrop: ({ source }) => { + const descendantIds = source.data.descendantIds as string[]; + return !descendantIds.includes(id); + }, onDrag: ({ self, location }) => { /* * When you hover over a deeply nested child, you are technically From dc40d1472e47c71c23884fb4ac0c66324c5005f1 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Tue, 16 Dec 2025 22:13:59 +0100 Subject: [PATCH 30/44] refactor: create meaningful variables --- .../docs/patterns/nested-drag-and-drop/example.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index c782f5a87a8..80de3224301 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -325,18 +325,23 @@ const DraggablePanel = memo(function DraggablePanel({ 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 (location.current.dropTargets[0]?.element === self.element) { + if (isInnerMost) { const newInstruction = extractInstruction(self.data); setInstruction(newInstruction); if ( - newInstruction?.operation === 'combine' && + isNesting && hasChildren && !isExpanded && !expandTimeout.current @@ -345,7 +350,7 @@ const DraggablePanel = memo(function DraggablePanel({ setIsExpanded(true); expandTimeout.current = undefined; }, EXPAND_ON_HOVER_TIME); - } else if (newInstruction?.operation !== 'combine') { + } else if (!isNesting) { cancelExpand(); } } else { From 139d32d51e698d377212f643f95063de41fd6ea5 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Fri, 19 Dec 2025 12:06:02 +0100 Subject: [PATCH 31/44] feat: add a11y roles --- .../patterns/nested-drag-and-drop/example.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 80de3224301..8a140499de8 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -5,6 +5,7 @@ import { useRef, useState, type MouseEvent, + type KeyboardEvent, } from 'react'; import { css } from '@emotion/react'; import { @@ -447,7 +448,7 @@ const DraggablePanel = memo(function DraggablePanel({ `; return ( -
+
  • {instruction?.operation === 'reorder-before' && ( )} @@ -465,6 +466,9 @@ const DraggablePanel = memo(function DraggablePanel({ id={id} forceState={isExpanded ? 'open' : 'closed'} onToggle={setIsExpanded} + buttonProps={{ + role: 'treeitem', + }} /* * We render plain `EuiIcon`, not interactive `EuiButtonIcon`, * and let the underlying button handle the (un)collapse behavior. @@ -486,7 +490,7 @@ const DraggablePanel = memo(function DraggablePanel({ } > -
    +
      {children?.map((child, index) => ( ))} -
    + {instruction?.operation === 'reorder-after' && ( )} -
  • + ); }); @@ -575,10 +579,10 @@ export default () => { `; return ( -
    +
      {items.map((item, index) => ( ))} -
    + ); }; From 58b111faa970b298123b2070c5051ad4a6558e12 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Fri, 19 Dec 2025 12:11:33 +0100 Subject: [PATCH 32/44] feat: add aria attributes to tree --- .../website/docs/patterns/nested-drag-and-drop/example.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 8a140499de8..f4c207c9a5b 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -579,7 +579,11 @@ export default () => { `; return ( -
      +
        {items.map((item, index) => ( ))} From 26047bc00e8d66022d0d0b16f8c5ef6f8b4ae749 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Fri, 19 Dec 2025 12:49:39 +0100 Subject: [PATCH 33/44] feat: add a11y admonition --- .../website/docs/patterns/nested-drag-and-drop/index.mdx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx index 10d130af205..28e3a183340 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx +++ b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx @@ -10,10 +10,16 @@ For complex use cases like this, we recommend Tree pattern, adapted to use EUI components and design tokens while maintaining full accessibility. -:::warning +:::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. ::: +:::accessibility Accessibility + +This pattern was tested against the [Tree view pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). When making modifications, please make sure there is no accessibility regression. +::: + ```bash yarn add @atlaskit/pragmatic-drag-and-drop @atlaskit/pragmatic-drag-and-drop-hitbox ``` From 2f74b15f4f0ad341f1db0668eeb3b8610543cc87 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Fri, 19 Dec 2025 14:14:05 +0100 Subject: [PATCH 34/44] feat: update styles --- .../docs/patterns/nested-drag-and-drop/example.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index f4c207c9a5b..bc5924040ee 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -204,10 +204,6 @@ interface LineIndicatorProps { position: 'top' | 'bottom'; } -/** - * TODO: make into a reusable style / component - * with the current drag and drop components - */ const LineIndicator = ({ position }: LineIndicatorProps) => { const { euiTheme } = useEuiTheme(); @@ -215,19 +211,22 @@ const LineIndicator = ({ position }: LineIndicatorProps) => { position: absolute; left: 0; right: 0; - height: 1px; + 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 ( @@ -386,6 +385,9 @@ const DraggablePanel = memo(function DraggablePanel({ 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'}; `; /** From bd9a95d439c5d8e54a75f69d1191a757f744fe20 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Fri, 19 Dec 2025 14:48:48 +0100 Subject: [PATCH 35/44] feat: remove grab transition --- .../patterns/nested-drag-and-drop/example.tsx | 66 ++++--------------- 1 file changed, 14 insertions(+), 52 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index bc5924040ee..83c6b9f23a4 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -4,7 +4,6 @@ import { useMemo, useRef, useState, - type MouseEvent, type KeyboardEvent, } from 'react'; import { css } from '@emotion/react'; @@ -166,40 +165,6 @@ const getDescendantIds = (item: TreeItem): string[] => { return ids; }; -/** - * A custom hook that defines and returns hover listeners. - * - * We use `isHovered` state to conditionally render the `grab` icon - * as a draggable affordance. - * - * We rely on `mousemove` instead of `mouseover` to avoid "stuck" hover - * states after a drop because `mousemove` only fires on actual movement. - * Native drag also suppresses `mousemove`, preventing unwanted hover - * effects during drag operations. - */ -const useHover = () => { - const [isHovered, setIsHovered] = useState(false); - - useEffect(() => { - return monitorForElements({ - onDragStart: () => setIsHovered(false), - onDrop: () => setIsHovered(false), - }); - }, []); - - const onMouseOut = (e: MouseEvent) => { - e.stopPropagation(); - setIsHovered(false); - }; - - const onMouseMove = (e: MouseEvent) => { - e.stopPropagation(); - if (!isHovered) setIsHovered(true); - }; - - return { isHovered, onMouseOut, onMouseMove }; -}; - interface LineIndicatorProps { position: 'top' | 'bottom'; } @@ -259,8 +224,6 @@ const DraggablePanel = memo(function DraggablePanel({ const [isExpanded, setIsExpanded] = useState(true); const [instruction, setInstruction] = useState(null); - const { isHovered, onMouseOut, onMouseMove } = useHover(); - const hasChildren = useMemo(() => { return !!children?.length; }, [children]); @@ -404,21 +367,11 @@ const DraggablePanel = memo(function DraggablePanel({ /** * Grab icon gives affordance to the draggable elements. - * - * TODO: reuse the animation */ const grabIconStyles = css` - width: ${isHovered ? euiTheme.size.l : 0}; - min-width: 0; - opacity: ${isHovered ? 1 : 0}; - margin-inline-end: ${isHovered ? 0 : `calc(${euiTheme.size.xs} * -1)`}; - overflow: hidden; - transform: scale(${isHovered ? 1 : 0}); - transition: - width 0.2s ease-in-out, - opacity 0.2s ease-in-out, - margin-inline-end 0.2s ease-in-out, - transform 0.2s ease-in-out; + svg { + fill: ${euiTheme.colors.textDisabled}; + } `; /** @@ -435,6 +388,14 @@ const DraggablePanel = memo(function DraggablePanel({ gap: ${euiTheme.size.s}; `; + const buttonStyles = css` + &:hover .grab-icon { + svg { + fill: ${euiTheme.colors.textParagraph}; + } + } + `; + const headerStyles = css` display: flex; gap: ${euiTheme.size.xs}; @@ -450,7 +411,7 @@ const DraggablePanel = memo(function DraggablePanel({ `; return ( -
      • +
      • {instruction?.operation === 'reorder-before' && ( )} @@ -469,6 +430,7 @@ const DraggablePanel = memo(function DraggablePanel({ forceState={isExpanded ? 'open' : 'closed'} onToggle={setIsExpanded} buttonProps={{ + css: buttonStyles, role: 'treeitem', }} /* @@ -478,7 +440,7 @@ const DraggablePanel = memo(function DraggablePanel({ */ buttonContent={ - + {hasChildren && ( From 4d1b4c78be4f7c2098b625146751833ff72c8b21 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Fri, 19 Dec 2025 14:52:08 +0100 Subject: [PATCH 36/44] refactor: rename style --- .../website/docs/patterns/nested-drag-and-drop/example.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 83c6b9f23a4..ac1e58c4560 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -396,7 +396,7 @@ const DraggablePanel = memo(function DraggablePanel({ } `; - const headerStyles = css` + const buttonContentStyles = css` display: flex; gap: ${euiTheme.size.xs}; align-items: center; @@ -439,7 +439,7 @@ const DraggablePanel = memo(function DraggablePanel({ * See: https://eui.elastic.co/docs/components/containers/accordion/#interactive-content-in-the-trigger */ buttonContent={ - + From f49811a2ea6257d6a7423e364aa065c7758f0f06 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Fri, 19 Dec 2025 16:18:16 +0100 Subject: [PATCH 37/44] feat: handle keyboard navigation --- .../patterns/nested-drag-and-drop/example.tsx | 129 ++++++++++++++++-- .../patterns/nested-drag-and-drop/index.mdx | 2 +- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index ac1e58c4560..92315312754 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -24,6 +24,7 @@ import { EuiPanel, EuiText, useEuiTheme, + useGeneratedHtmlId, } from '@elastic/eui'; interface TreeItem { @@ -206,6 +207,8 @@ const EXPAND_ON_HOVER_TIME = 300; interface DraggablePanelProps extends TreeItem { index: number; level?: number; + activeId: string; + setActiveId: (id: string) => void; } const DraggablePanel = memo(function DraggablePanel({ @@ -215,12 +218,16 @@ const DraggablePanel = memo(function DraggablePanel({ isBlocked, level = 0, title, + activeId, + setActiveId, }: DraggablePanelProps) { const { euiTheme } = useEuiTheme(); const ref = useRef(null); const expandTimeout = useRef>(); + const buttonId = useGeneratedHtmlId({ prefix: id, suffix: 'button' }); + const [isExpanded, setIsExpanded] = useState(true); const [instruction, setInstruction] = useState(null); @@ -228,6 +235,103 @@ const DraggablePanel = memo(function DraggablePanel({ return !!children?.length; }, [children]); + /** + * Handle the keyboard navigation in accordance with the Tree view a11y pattern. + * + * See: @{link https://www.w3.org/WAI/ARIA/apg/patterns/treeview/}. + */ + 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) => { + if (el.offsetParent === null) return false; + + let current = el; + while (true) { + const group = current.closest('ul[data-group]'); + if (!group) break; + + const parentId = group.getAttribute('aria-labelledby'); + if (parentId) { + const parent = document.getElementById(parentId); + if (parent && parent.getAttribute('aria-expanded') === 'false') { + return false; + } + if (!parent) break; + current = parent as HTMLElement; + } else { + break; + } + } + return true; + }); + }; + + 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. */ @@ -430,8 +534,12 @@ const DraggablePanel = memo(function DraggablePanel({ forceState={isExpanded ? 'open' : 'closed'} onToggle={setIsExpanded} buttonProps={{ + id: buttonId, css: buttonStyles, - role: 'treeitem', + 'data-item': true, + onKeyDown: handleKeyDown, + tabIndex: activeId === id ? 0 : -1, + onFocus: () => setActiveId(id), }} /* * We render plain `EuiIcon`, not interactive `EuiButtonIcon`, @@ -454,12 +562,14 @@ const DraggablePanel = memo(function DraggablePanel({ } > -
          +
            {children?.map((child, index) => ( ))} @@ -477,6 +587,7 @@ export default () => { const { euiTheme } = useEuiTheme(); const [items, setItems] = useState(initialData); + const [activeId, setActiveId] = useState(initialData[0].id); useEffect(() => { /* @@ -543,13 +654,15 @@ export default () => { `; 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 index 28e3a183340..dc54699c5ce 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx +++ b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx @@ -8,7 +8,7 @@ EUI exposes `EuiDraggable` and `EuiDroppable` components (see [documentation](.. 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 while maintaining full accessibility. +The example below demonstrates a simplified version of Pragmatic's Tree pattern, adapted to use EUI components and design tokens with enhanced keyboard navigation. :::warning Package versions From 5457455084156d2aa394941723f04e5cf9243cfd Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Fri, 19 Dec 2025 16:39:24 +0100 Subject: [PATCH 38/44] feat: add more actions --- .../patterns/nested-drag-and-drop/example.tsx | 203 ++++++++++++++++-- 1 file changed, 180 insertions(+), 23 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 92315312754..a3ecaf9d534 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -20,8 +20,11 @@ import { } from '@atlaskit/pragmatic-drag-and-drop-hitbox/list-item'; import { EuiAccordion, + EuiButtonIcon, + EuiContextMenu, EuiIcon, EuiPanel, + EuiPopover, EuiText, useEuiTheme, useGeneratedHtmlId, @@ -82,6 +85,18 @@ const findItem = (items: Tree, itemId: string): TreeItem | undefined => { } }; +const findParent = (items: Tree, itemId: string): TreeItem | undefined => { + for (const item of items) { + if (item.children?.some((child) => child.id === itemId)) return item; + + if (item.children) { + const parent = findParent(item.children, itemId); + + if (parent) return parent; + } + } +}; + const removeItem = (items: Tree, itemId: string): Tree => { return items .filter((item) => item.id !== itemId) @@ -153,6 +168,47 @@ const insertAfter = ( }); }; +const moveItem = ( + items: Tree, + itemId: string, + direction: 'up' | 'down' +): Tree => { + const recursiveMove = (currentItems: Tree): Tree => { + const index = currentItems.findIndex((i) => i.id === itemId); + if (index !== -1) { + if (direction === 'up') { + if (index === 0) return currentItems; + const newItems = [...currentItems]; + [newItems[index - 1], newItems[index]] = [ + newItems[index], + newItems[index - 1], + ]; + return newItems; + } else { + if (index === currentItems.length - 1) return currentItems; + const newItems = [...currentItems]; + [newItems[index], newItems[index + 1]] = [ + newItems[index + 1], + newItems[index], + ]; + return newItems; + } + } + + return currentItems.map((item) => { + if (item.children) { + const newChildren = recursiveMove(item.children); + if (newChildren !== item.children) { + return { ...item, children: newChildren }; + } + } + return item; + }); + }; + + return recursiveMove(items); +}; + const getDescendantIds = (item: TreeItem): string[] => { let ids: string[] = []; @@ -209,6 +265,9 @@ interface DraggablePanelProps extends TreeItem { 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({ @@ -220,6 +279,9 @@ const DraggablePanel = memo(function DraggablePanel({ title, activeId, setActiveId, + onMove, + isFirst, + isLast, }: DraggablePanelProps) { const { euiTheme } = useEuiTheme(); @@ -229,6 +291,7 @@ const DraggablePanel = memo(function DraggablePanel({ 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(() => { @@ -248,28 +311,7 @@ const DraggablePanel = memo(function DraggablePanel({ if (!tree) return []; return Array.from( tree.querySelectorAll('[data-item]') - ).filter((el) => { - if (el.offsetParent === null) return false; - - let current = el; - while (true) { - const group = current.closest('ul[data-group]'); - if (!group) break; - - const parentId = group.getAttribute('aria-labelledby'); - if (parentId) { - const parent = document.getElementById(parentId); - if (parent && parent.getAttribute('aria-expanded') === 'false') { - return false; - } - if (!parent) break; - current = parent as HTMLElement; - } else { - break; - } - } - return true; - }); + ).filter((el) => !el.closest('[inert]')); }; switch (e.key) { @@ -541,6 +583,67 @@ const DraggablePanel = memo(function DraggablePanel({ tabIndex: activeId === id ? 0 : -1, onFocus: () => setActiveId(id), }} + extraAction={ + setIsPopoverOpen(false)} + button={ + setIsPopoverOpen(!prevState)} + /> + } + > + { + 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. @@ -562,7 +665,7 @@ const DraggablePanel = memo(function DraggablePanel({ } > -
                +
                  {children?.map((child, index) => ( ))} @@ -644,6 +750,54 @@ export default () => { }); }, [items]); + const handleMove = ( + id: string, + direction: 'up' | 'down' | 'indent' | 'outdent' + ) => { + setItems((items) => { + const itemToMove = findItem(items, id); + + if (!itemToMove) return items; + + if (direction === 'up' || direction === 'down') + return moveItem(items, id, direction); + + if (direction === 'outdent') { + const parent = findParent(items, id); + if (!parent) return items; + + const newItems = removeItem(items, id); + + return insertAfter(newItems, parent.id, itemToMove); + } + + if (direction === 'indent') { + const findPrevSibling = (currentItems: Tree): TreeItem | undefined => { + const index = currentItems.findIndex((i) => i.id === id); + + if (index !== -1) + return index > 0 ? currentItems[index - 1] : undefined; + + for (const item of currentItems) { + if (item.children) { + const found = findPrevSibling(item.children); + + if (found) return found; + } + } + }; + + const prevSibling = findPrevSibling(items); + if (!prevSibling) return items; + + const newItems = removeItem(items, id); + return insertChild(newItems, prevSibling.id, itemToMove); + } + + return items; + }); + }; + const wrapperStyles = css` background-color: ${euiTheme.colors.backgroundBasePlain}; display: flex; @@ -661,6 +815,9 @@ export default () => { index={index} activeId={activeId} setActiveId={setActiveId} + onMove={handleMove} + isFirst={index === 0} + isLast={index === items.length - 1} {...item} /> ))} From 027345abf238957ccc714cfd38614fb5769176a4 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Fri, 19 Dec 2025 16:40:30 +0100 Subject: [PATCH 39/44] feat: update a11y admonition --- packages/website/docs/patterns/nested-drag-and-drop/index.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx index dc54699c5ce..82b43e55dd1 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx +++ b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx @@ -17,7 +17,9 @@ This pattern may work differently depending on which version of **@atlaskit/prag :::accessibility Accessibility -This pattern was tested against the [Tree view pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). When making modifications, please make sure there is no accessibility regression. +This example uses nested **EuiAccordion** components with roving index 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 put them in the `extraAction` prop inside the **EuiContextMenu**. ::: ```bash From 3f9b1335d2004691f13d4e709eccdb9e1840ca4b Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Fri, 19 Dec 2025 16:44:04 +0100 Subject: [PATCH 40/44] fix: rephrase a11y admonition --- packages/website/docs/patterns/nested-drag-and-drop/index.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx index 82b43e55dd1..a74ff01bc3f 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx +++ b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx @@ -17,9 +17,9 @@ This pattern may work differently depending on which version of **@atlaskit/prag :::accessibility Accessibility -This example uses nested **EuiAccordion** components with roving index to improve usability, similarly as in [Tree view pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). +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 put them in the `extraAction` prop inside the **EuiContextMenu**. +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. ::: ```bash From 666bf113d1d54ef6d17e6ce438e6ba05cf2e9359 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Fri, 19 Dec 2025 17:02:54 +0100 Subject: [PATCH 41/44] refactor: utility functions --- .../patterns/nested-drag-and-drop/example.tsx | 165 ++++++------------ 1 file changed, 58 insertions(+), 107 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index a3ecaf9d534..29449465ecf 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -75,41 +75,40 @@ const initialData: Tree = [ { id: 'panel-4', title: 'Panel 4' }, ]; -const findItem = (items: Tree, itemId: string): TreeItem | undefined => { +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.id === itemId) return item; if (item.children) { - const foundItem = findItem(item.children, itemId); - if (foundItem) return foundItem; + const result = findNodeLocation(item.children, itemId, item); + + if (result) return result; } } }; -const findParent = (items: Tree, itemId: string): TreeItem | undefined => { - for (const item of items) { - if (item.children?.some((child) => child.id === itemId)) return item; - - if (item.children) { - const parent = findParent(item.children, itemId); +const findItem = (items: Tree, itemId: string): TreeItem | undefined => { + const location = findNodeLocation(items, itemId); - if (parent) return parent; - } - } + return location ? location.list[location.index] : undefined; }; const removeItem = (items: Tree, itemId: string): Tree => { - return items - .filter((item) => item.id !== itemId) - .map((item) => { - if (!!item.children?.length) { - const newChildren = removeItem(item.children, itemId); - if (newChildren !== item.children) { - return { ...item, children: newChildren }; - } - } + return items.reduce((acc: Tree, item) => { + if (item.id === itemId) return acc; - return item; - }); + if (item.children) { + return [...acc, { ...item, children: removeItem(item.children, itemId) }]; + } + + return [...acc, item]; + }, []); }; const insertChild = ( @@ -124,12 +123,14 @@ const insertChild = ( children: [newItem, ...(item.children || [])], }; } + if (item.children) { return { ...item, children: insertChild(item.children, targetId, newItem), }; } + return item; }); }; @@ -171,55 +172,46 @@ const insertAfter = ( const moveItem = ( items: Tree, itemId: string, - direction: 'up' | 'down' + direction: 'up' | 'down' | 'indent' | 'outdent' ): Tree => { - const recursiveMove = (currentItems: Tree): Tree => { - const index = currentItems.findIndex((i) => i.id === itemId); - if (index !== -1) { - if (direction === 'up') { - if (index === 0) return currentItems; - const newItems = [...currentItems]; - [newItems[index - 1], newItems[index]] = [ - newItems[index], - newItems[index - 1], - ]; - return newItems; - } else { - if (index === currentItems.length - 1) return currentItems; - const newItems = [...currentItems]; - [newItems[index], newItems[index + 1]] = [ - newItems[index + 1], - newItems[index], - ]; - return newItems; - } - } + const location = findNodeLocation(items, itemId); + if (!location) return items; - return currentItems.map((item) => { - if (item.children) { - const newChildren = recursiveMove(item.children); - if (newChildren !== item.children) { - return { ...item, children: newChildren }; - } - } - return item; - }); - }; + const { list, index, parent } = location; + const itemToMove = list[index]; - return recursiveMove(items); -}; + const newItems = removeItem(items, itemId); -const getDescendantIds = (item: TreeItem): string[] => { - let ids: string[] = []; + 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]; - if (item.children) { - for (const child of item.children) { - ids.push(child.id); - ids = ids.concat(getDescendantIds(child)); + 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 ids; + return items; +}; + +const getDescendantIds = (item: TreeItem): string[] => { + return (item.children || []).flatMap((child) => [ + child.id, + ...getDescendantIds(child), + ]); }; interface LineIndicatorProps { @@ -754,48 +746,7 @@ export default () => { id: string, direction: 'up' | 'down' | 'indent' | 'outdent' ) => { - setItems((items) => { - const itemToMove = findItem(items, id); - - if (!itemToMove) return items; - - if (direction === 'up' || direction === 'down') - return moveItem(items, id, direction); - - if (direction === 'outdent') { - const parent = findParent(items, id); - if (!parent) return items; - - const newItems = removeItem(items, id); - - return insertAfter(newItems, parent.id, itemToMove); - } - - if (direction === 'indent') { - const findPrevSibling = (currentItems: Tree): TreeItem | undefined => { - const index = currentItems.findIndex((i) => i.id === id); - - if (index !== -1) - return index > 0 ? currentItems[index - 1] : undefined; - - for (const item of currentItems) { - if (item.children) { - const found = findPrevSibling(item.children); - - if (found) return found; - } - } - }; - - const prevSibling = findPrevSibling(items); - if (!prevSibling) return items; - - const newItems = removeItem(items, id); - return insertChild(newItems, prevSibling.id, itemToMove); - } - - return items; - }); + setItems((items) => moveItem(items, id, direction)); }; const wrapperStyles = css` From df2370b21cf63d2674ee206b1a66e41fd87887c8 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Fri, 19 Dec 2025 17:15:11 +0100 Subject: [PATCH 42/44] fix: remove misleading comment --- .../website/docs/patterns/nested-drag-and-drop/example.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 29449465ecf..90b4fd89a50 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -290,11 +290,6 @@ const DraggablePanel = memo(function DraggablePanel({ return !!children?.length; }, [children]); - /** - * Handle the keyboard navigation in accordance with the Tree view a11y pattern. - * - * See: @{link https://www.w3.org/WAI/ARIA/apg/patterns/treeview/}. - */ const handleKeyDown = (e: KeyboardEvent) => { const target = e.currentTarget; From f6516d7206423ab4b14f282d98df3c9d4bbd8547 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak Date: Fri, 19 Dec 2025 17:30:12 +0100 Subject: [PATCH 43/44] fix: small issue --- packages/website/docs/patterns/nested-drag-and-drop/example.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx index 90b4fd89a50..0b6f06e8bf4 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/example.tsx +++ b/packages/website/docs/patterns/nested-drag-and-drop/example.tsx @@ -579,7 +579,7 @@ const DraggablePanel = memo(function DraggablePanel({ setIsPopoverOpen(!prevState)} + onClick={() => setIsPopoverOpen((isOpen) => !isOpen)} /> } > From 34aee0f1c201e395850852c3dcffd2c0a2e01050 Mon Sep 17 00:00:00 2001 From: Weronika Olejniczak <32842468+weronikaolejniczak@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:13:13 +0100 Subject: [PATCH 44/44] Update packages/website/docs/patterns/nested-drag-and-drop/index.mdx Co-authored-by: Arturo Castillo Delgado --- .../docs/patterns/nested-drag-and-drop/index.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx index a74ff01bc3f..31f20f6fae2 100644 --- a/packages/website/docs/patterns/nested-drag-and-drop/index.mdx +++ b/packages/website/docs/patterns/nested-drag-and-drop/index.mdx @@ -10,11 +10,6 @@ For complex use cases like this, we recommend Tree pattern, adapted to use EUI components and design tokens with enhanced keyboard navigation. -:::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. -::: - :::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/). @@ -22,6 +17,11 @@ This example uses nested **EuiAccordion** components with roving tabindex to imp 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 ```