Skip to content

feat(useDraggable): add public facing hook #7356

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open

Conversation

tomdavies73
Copy link
Contributor

@tomdavies73 tomdavies73 commented May 30, 2025

FYI: All commits will be squashed into one final commit, with any relevant issues also linked.

Proposed behaviour

This PR introduces a comprehensive drag-and-drop system built on top of Atlassian's Pragmatic Drag & Drop library.

For information about the Pragmatic Drag & Drop library, see here.

The implementation provides a flexible, reusable solution for creating draggable lists with automatic reordering capabilities.

Consumers can now use the useDraggable hook, which will be externally available to make any array of React Elements draggable.

Key Components Added:

Core Architecture:

  • DraggableContainer - Main container component that manages drag operations and list state
  • DraggableItem - Individual draggable item wrapper with drop target capabilities
  • useDraggable - Hook that simplifies usage by wrapping items automatically
  • Context providers for sharing state between container and items

Screenshot 2025-05-30 at 16 56 37

Key Features:

Dual Drag Modes:

  • continuous - Items reorder in real-time as user drags
  • onDrop - Items only reorder when dropped (better performance for large lists)

Smart State Management:

  • Automatic list synchronisation when items are added/removed
  • Rollback to previous state on invalid drops (outside valid targets)
  • Tracks original order for reset functionality

Robust ID Handling:

  • Automatic unique ID generation for items
  • Duplicate ID detection with console warnings
  • Parent ID resolution for nested elements

Imperative API:

  • Exposed reOrder(itemId, toIndex) method for programmatic moves
  • Supports both direct item IDs and child element IDs

Edge Detection:

  • Visual feedback for drop zones (top/bottom edges)
  • Prevents dropping items onto themselves
  • Container boundary validation

Technical Implementation:

  • Built on @atlaskit/pragmatic-drag-and-drop for reliable cross-browser support
  • Uses React Context for efficient state sharing
  • Implements useImperativeHandle for external control

Benefits:

  • Developer Experience: Simple hook-based API with sensible defaults
  • Flexibility: Supports custom container/item elements and props
  • Reliability: Comprehensive error handling and state recovery
  • Performance: Efficient updates with minimal re-renders
  • Optomisation: Consumers will no longer have to install third-party draggable libraries and can instead use the useDraggable hook directly if needed. There has also been a major net improvement in bundle size within Carbon and Carbon components. Our current draggable library react-dnd has a minified size of 33.9kb whereas pragmatic-drag-and-drop has a minified size of 176b which is a x200 reduction in size.

Within our components, the biggest win is in FlatTableBodyDraggable with a with 52% reduction (~3KB saved). Overall, there has been a net saving across affected components of 3.7KB. The public facing hookuseDraggable has a rendered size of 815B making it an attractive alternative for consumers.

  • Accessibility: Built on a proven drag-and-drop foundation, the useImperativeHandle can be used for manual re-ordering. Supporting graceful degradation.

This implementation provides a production-ready drag-and-drop solution that can be easily integrated into existing components while maintaining full control over styling and behaviour.

Coming soon:

Once this work is completed and available in Carbon, a DraggableProvider will be developed which can be used to wrap multiple containers returned by useDraggable hook(s) which will facilitate the movement of items across containers.

Once this is available and we can update uses of the DndProvider with this new provider in Carbon, the react-dnd depdendency will be removed.

Current behaviour

Currently, consumers have to use third-party draggable libraries to build out draggable functionality. Internally, we use and re-use react-dnd which is now out of support and results in code repetition and complexity.

Checklist

  • Commits follow our style guide
  • Related issues linked in commit messages if required
  • Screenshots are included in the PR if useful
  • All themes are supported if required
  • Unit tests added or updated if required
  • Playwright automation tests added or updated if required
  • Storybook added or updated if required
  • Translations added or updated (including creating or amending translation keys table in storybook) if required
  • Typescript d.ts file added or updated if required
  • Related docs have been updated if required

QA

  • Tested in provided StackBlitz sandbox/Storybook
  • Add new Playwright test coverage if required
  • Carbon implementation matches Design System/designs
  • UI Tests GitHub check reviewed if required

Additional context

Testing instructions

@tomdavies73 tomdavies73 self-assigned this May 30, 2025
@tomdavies73 tomdavies73 requested review from a team as code owners May 30, 2025 15:20
@tomdavies73 tomdavies73 added DO NOT MERGE Work in progress This is a WIP PR so may not be ready for review labels May 30, 2025
@tomdavies73 tomdavies73 marked this pull request as draft May 30, 2025 15:26
@tomdavies73 tomdavies73 force-pushed the FE-6907 branch 2 times, most recently from aaad243 to d9688df Compare May 30, 2025 15:43
@nineteen88 nineteen88 self-requested a review June 4, 2025 09:05
@tomdavies73 tomdavies73 marked this pull request as ready for review June 4, 2025 09:06
@nineteen88 nineteen88 requested a review from Copilot June 4, 2025 09:30
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Introduces a new useDraggable hook and refactors the existing drag-and-drop components to use Atlassian’s Pragmatic Drag & Drop under the hood, exposing an imperative API for manual re-ordering.

  • Swap out react-dnd for @atlaskit/pragmatic-drag-and-drop in both container and item implementations.
  • Expose a DraggableHandle interface via forwardRef on DraggableContainer for programmatic reOrder.
  • Update tests, stories, and documentation to cover real‐time and manual re-ordering scenarios.

Reviewed Changes

Copilot reviewed 44 out of 44 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/components/draggable/index.ts Export DraggableHandle type from the new useDraggable hook.
src/components/draggable/draggable.test.tsx Adjusted unit tests for polyfills, fake timers, and imperative re-order.
src/components/draggable/draggable.stories.tsx Added manual re-ordering story and updated IDs to strings.
src/components/draggable/draggable.pw.tsx Removed an outdated multi-container test; adjusted opacity assertion.
src/components/draggable/draggable.mdx Documented the new manual re-ordering API with DraggableHandle.
src/components/draggable/draggable-test.stories.tsx Updated storybook examples to use string IDs consistently.
src/components/draggable/draggable-item/draggable-item.style.ts Refactored styling to handle drag-state data attributes at the container level.
src/components/draggable/draggable-item/draggable-item.component.tsx Simplified the item component by removing react-dnd internals.
src/components/draggable/draggable-container.component.tsx Major refactor: remove react-dnd provider, integrate useDraggable, add forwardRef.
src/components/draggable/internal/drop-target.component.tsx Removed now-unnecessary internal DropTarget.
src/spec_helper/internal/drag-event-polyfill.ts Added pragmatic-drag-and-drop polyfills for unit tests.
src/internal/utils/helpers/guid/index.ts Added isGuid helper alongside existing GUID generator.
src/internal/utils/helpers/guid/guid.test.ts Comprehensive tests for the new isGuid function.
src/internal/draggable/draggable-utils.tsx Utility functions for internal drag-state and item data handling.
src/internal/draggable/draggable-item.tsx Core implementation of the new Pragmatic DnD-based DraggableItem.
src/internal/draggable/draggable-item-context.ts Introduced context for item indexing.
src/internal/draggable/draggable-container-context.ts Introduced context for container ID and drag mode.
src/internal/draggable/components.test-pw.tsx Playwright CT configs updated to reflect new internal implementations.
playwright-ct.config.ts Changed testDir to include the entire src/ directory.
package.json Added new Atlaskit dependencies under dependencies and optionalDependencies.
Comments suppressed due to low confidence (2)

src/components/draggable/draggable.test.tsx:143

  • The tests reference user but the imported helper is userEvent; replace user with userEvent to avoid undefined variable errors.
await user.pointer({ keys: "[MouseLeft>]", target: apple });

src/components/draggable/draggable-container.component.tsx:48

  • Relying on displayName for type checking is brittle and may break during minification; compare child.type directly to the DraggableItem component reference for a more robust validation.
(child.type as React.FunctionComponent).displayName !== "DraggableItem"

{...paddingProps}
id={id}
Copy link
Preview

Copilot AI Jun 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Passing id directly to the styled component sets a DOM id attribute, which can clash; consider using a data-item-id attribute for internal tracking instead.

Suggested change
id={id}
data-item-id={id}

Copilot uses AI. Check for mistakes.

@DipperTheDan DipperTheDan self-requested a review June 5, 2025 08:43
@@ -8,7 +8,7 @@ const playwrightDir = resolve(__dirname, "./playwright");
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: resolve(__dirname, "./src/components"),
testDir: resolve(__dirname, "./src/"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): Why has this been changed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So playwright can run on the .pw.tsx file added to the useDraggable hook, previously it would only run over components which would mean this test wouldn't be included.

Comment on lines +367 to +376
if (
!Number.isNaN(indexOfTarget) &&
indexOfTarget >= 0 &&
destinationId !== undefined &&
destinationId !== null &&
(lastMoveRef.current.indexOfTarget !== indexOfTarget ||
lastMoveRef.current.destinationId !== destinationId)
) {
localMove(destinationId, indexOfTarget);
lastMoveRef.current = { indexOfTarget, destinationId };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion(non-blocking): Something like this here might make this a bit easier to read:

const isValidIndex = Number.isInteger(indexOfTarget) && indexOfTarget >= 0
const hasDestination = destinationId != null
const isNewMove =
  lastMoveRef.current.indexOfTarget !== indexOfTarget ||
  lastMoveRef.current.destinationId !== destinationId

if (isValidIndex && hasDestination && isNewMove) {
  localMove(destinationId, indexOfTarget)
  lastMoveRef.current = { indexOfTarget, destinationId };
}

Obviously feel free to choose better names for the const declarations.

Comment on lines +403 to +409
if (
!Number.isNaN(indexOfTarget) &&
indexOfTarget >= 0 &&
destinationId !== undefined &&
destinationId !== null &&
lastMoveRef.current.indexOfTarget !== indexOfTarget
) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion(non-blocking): Same thing here. It might be best to make const declarations where you can to make these easier to read.

});

it("handles ref prop when ref is null", () => {
// This should not throw an error
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): You have the .not.toThrow check here. Is the comment meant to be here or was this just left over from dev work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just looks like a leftover comment - I'll remove as the assertion is pretty self-descriptive

});

it("handles ref prop when ref is undefined", () => {
// This should not throw an error
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here with regards to the comment being here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just looks like a leftover comment - I'll remove as the assertion is pretty self-descriptive

*/
useLayoutEffect(() => {
// This runs after the component mounts and the children are rendered
if (itemRef.current && itemRef.current.children.length > 0) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion(non-blocking): You should be able to do this here:

Suggested change
if (itemRef.current && itemRef.current.children.length > 0) {
if (itemRef?.current.children.length > 0) {

return () => cleanup();
}, [uniqueId, draggableItemData, setDragState]);

return React.createElement(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just an FYI here, that createElement is classed as a legacy React API. React themselves state that they are not recommended for use in newly written code.

Copy link
Contributor Author

@tomdavies73 tomdavies73 Jun 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good spot mate, we can look at other alternatives which will let us create an element on the fly based on a prop - most likely jSX

@@ -151,6 +173,36 @@ test("items are reordered when their order is manually changed", () => {
expect(allItems[1]).toHaveTextContent("Apple");
});

test("items are reordered when their order is manually changed via imperative reo-order", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
test("items are reordered when their order is manually changed via imperative reo-order", () => {
test("items are reordered when their order is manually changed via imperative re-order", () => {


return (
<DraggableContainerContext.Provider value={contextValue}>
{React.createElement(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I've mentioned it elsewhere but createElement is part of React's legacy API's now and they state that they are not recommended for use in newly written code.

@Parsium Parsium self-requested a review June 5, 2025 15:00
@tomdavies73 tomdavies73 force-pushed the FE-6907 branch 3 times, most recently from 0c024f4 to 3de4923 Compare June 6, 2025 16:00
…Draggable hook

Rebuilds the draggable component using the new useDraggable hook. During this work
the type of the `id` prop on `DraggableItem` has been limited to string only to align
with HTML types as the `id` prop is now passed to the DOM.

BREAKING CHANGE: The `id` prop on `DraggableItem` now only accepts a string type
rebuilds flat-table-row-draggable, flat-table-body-draggable and changes logic within flat-table-row

BREAKING CHANGE: consumers will now have to pass their own cells with draggable icons to indicate
draggable behaviour. This is no longer done as standard
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Development

Successfully merging this pull request may close these issues.

2 participants