Skip to content

feat(drag-and-drop): cross-list DnD, useDragAndDrop hook, and DataTable integration#1556

Open
ByronDWall wants to merge 15 commits into
mainfrom
bw/cross-list-dnd
Open

feat(drag-and-drop): cross-list DnD, useDragAndDrop hook, and DataTable integration#1556
ByronDWall wants to merge 15 commits into
mainfrom
bw/cross-list-dnd

Conversation

@ByronDWall
Copy link
Copy Markdown
Contributor

@ByronDWall ByronDWall commented May 29, 2026

Closes #1554

Motivation

DraggableList.Root uses a single hardcoded drag type (nimbus-draggable-list-item) for all instances on a page. Every list accepts every other list's items, with no public API to opt out. Consumers rendering two independent lists — say, a "selected columns" list and an "available filters" list — have no way to prevent items from leaking between them. The getDropOperation callback is hardcoded to "move" and the internally-constructed dragAndDropHooks silently overrides any consumer-supplied value.

The only workaround today is reverting foreign items inside onUpdateItems (which flashes the wrong state) or forking the component entirely.

This PR solves the problem with a dragNamespace prop (option 1 from the issue) and, while doing so, extracts the drag-and-drop wiring into a shared useDragAndDrop hook that also works with DataTable and any other React Aria collection component.

What changed

New useDragAndDrop hook

Rather than fixing DraggableList in isolation, the DnD configuration is extracted into a reusable hook. It wraps React Aria's useDragAndDrop with:

  • Namespace isolationdragNamespace appends a suffix to the internal drag type, so only collections sharing the same namespace can exchange items. Omitting it preserves backward-compatible behavior where all lists interoperate.
  • External drop supportonExternalDrop + acceptExternalTypes let a collection accept drops from outside the app (text, files, etc.). resolveDropOperation validates incoming types against acceptExternalTypes before returning the configured operation.
  • Outgoing format serializationserializeDragItem adds custom MIME representations alongside the internal format when items are dragged out.
  • Auto-keying — items returned from onExternalDrop without key or id get a crypto.randomUUID() key so React Aria's collection can track them.

State handler factories

Consumers need to wire up insert/append/reorder/remove callbacks. Two factories make this a one-liner:

  • createArrayHandlers(setItems, getKey?) — for useState arrays
  • createListDataHandlers(list) — for React Aria's useListData

Both return an object meant to be spread into the hook options.

External drop helpers

Six composable utilities for converting raw DropItem[] inside onExternalDrop:

Helper Accepts Produces
createItemsFromTextDrop text/plain (or custom) { key, label }
createItemsFromFileDrop File drops { key, label, fileName, fileType, file }
createItemsFromDirectoryDrop Directory drops Flattened files with { directory }
createItemsFromJsonDrop application/json { key, label, data }
createItemsFromCsvDrop text/csv { key, label, headers, row }
createItemsFromImageDrop Image file drops { key, label, fileType, objectUrl, file }

DraggableList changes

  • New props: dragNamespace, onExternalDrop, acceptExternalTypes, externalDropOperation, serializeDragItem
  • Internal implementation now delegates to the shared hook instead of calling React Aria directly
  • DraggableList.Root continues to manage its own useListData state; the hook handles the drag-and-drop configuration

DataTable integration

The DataTable previously had no drag-and-drop support. This PR adds:

  • Optional dragAndDropHooks prop on both DataTable and DataTable.Table
  • When provided, a sticky drag handle column (DragIndicator icon) is automatically prepended
  • Sticky column offset cascade handles all combinations of drag + selection + expand columns in base, hover, and pinned row contexts
  • draggable="true" on rows suppresses native double-click text selection; a programmatic selection.modify() workaround restores it using caretPositionFromPoint (Chrome/Firefox) with caretRangeFromPoint fallback (Safari)
  • Row click handler uses native DOM capture-phase listeners to coexist with React Aria's press handling

Architecture

hooks/use-drag-and-drop/
├── use-drag-and-drop.ts        # Main hook + resolveDropOperation
├── use-drag-and-drop.types.ts  # DragAndDropProps, UseDragAndDropOptions, DragAndDropItemData
├── state-handlers.ts           # createArrayHandlers, createListDataHandlers
├── process-drop-items.ts       # Internal/external item separation + auto-keying
├── external-drop-helpers.ts    # Six composable drop converters
├── index.ts                    # Public barrel export
├── use-drag-and-drop.mdx       # Hook documentation
├── use-drag-and-drop.spec.ts   # Unit tests (63 tests)
├── use-drag-and-drop.stories.tsx
└── utils/
    └── use-drag-and-drop.test-utils.ts

resolveDropOperation is extracted as a pure function so the drop-operation decision logic (internal vs. external type validation vs. cancel) is unit-testable independent of React.

Testing

Unit tests (63 tests in use-drag-and-drop.spec.ts):

  • createArrayHandlers — insert, append, reorder, remove, default key extraction, missing key error
  • createListDataHandlers — all delegation paths
  • processDropItems — internal deserialization, external routing, auto-keying, corrupted JSON
  • All six external drop helpers — type filtering, field extraction, edge cases
  • resolveDropOperation — internal move/cancel, external type accepted/rejected, operation allowed/denied

Storybook interaction tests:

  • Reorder within a single list
  • Namespace isolation (reorder alpha, verify beta unchanged)
  • Cross-list transfer (drag from source to target)
  • DataTable integration (drag handle rendering, keyboard reorder, row click coexistence)

Test plan

  • Unit tests pass (pnpm test:dev — 110 tests across 4 files)
  • TypeScript compiles cleanly (pnpm typecheck)
  • Storybook stories render for useDragAndDrop, DraggableList, and DataTable DnD variants
  • Keyboard drag-and-drop works (Enter to grab, Arrow to move, Enter to drop)
  • Cross-list transfer moves items between lists sharing a namespace
  • Namespace isolation prevents drops between lists with different namespaces
  • DataTable drag handles appear when dragAndDropHooks is provided
  • Pinned rows + drag column sticky offsets render correctly
  • Double-click text selection works on draggable DataTable rows

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 29, 2026

🦋 Changeset detected

Latest commit: ac2ab7c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@commercetools/nimbus Minor
@commercetools/nimbus-tokens Minor
@commercetools/nimbus-icons Minor
@commercetools/nimbus-design-token-ts-plugin Minor
@commercetools/nimbus-mcp Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented May 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nimbus-documentation Ready Ready Preview, Comment May 29, 2026 2:41pm
nimbus-storybook Ready Ready Preview, Comment May 29, 2026 2:41pm

Request Review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

Localization reminder: This PR adds or modifies `.i18n.ts` files.

Please create a Jira ticket for the localization manager to initiate translation of any new or updated strings in Transifex. See LOC-1766 as an example.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

Bundle Size Report

Last updated: 2026-05-29 14:40:48 UTC

Package Format Current Baseline Delta Status
@commercetools/nimbus dist 15756.9 KB 15682.8 KB +0.5% ✅ ok
@commercetools/nimbus-icons dist 4787.6 KB 4787.6 KB +0.0% ✅ ok
@commercetools/nimbus-tokens dist 408.1 KB 408.1 KB +0.0% ✅ ok

Baseline source: comment-chain

ByronDWall added 14 commits May 29, 2026 10:38
…solation and external drop support

Extract drag-and-drop logic from DraggableList into a shared
useDragAndDrop hook that any React Aria collection component can adopt.
Adds dragNamespace prop for cross-list isolation, external drop support
(text, files, directories, JSON, images, CSV), outgoing format
serialization, and state handler factories (createListDataHandlers,
createArrayHandlers). Refactors DraggableList to use the shared hook.
Forward optional dragAndDropHooks to the underlying React Aria Table,
enabling consumers to use useDragAndDrop for row reordering and
cross-table drag without bundling DnD code by default.
… and changeset

Add MDX documentation for the shared useDragAndDrop hook covering
namespace isolation, external drops, outgoing formats, state handler
factories, and DataTable usage. Update DraggableList dev docs with
external drop and outgoing format sections. Add changeset for the
minor release.
…dicator, and text selection

- Add drag handle column to Header and drag button (IconButton slot="drag")
  to Row when dragAndDropHooks is provided
- Use RaCell for drag cell to preserve React Aria slot context
- Add drop indicator styles to recipe
- Add grab cursor on draggable rows
- Restore double-click word selection on draggable rows via
  caretPositionFromPoint in handleRowDoubleClick
- Remove hasSelectedText guard from handleRowClick so clicks work
  after text was previously selected
- Add DragAndDropRows story with selection and row click
- Add ExternalTextDrop and OutgoingFormat stories to DraggableList
Move dragAndDropHooks from the internal DataTable.Table compound
component to the top-level DataTable props so consumers use the
simple API. Update story and docs to match.
…est coverage

- Add missing aria-label to drag handle button with i18n key "dragRow"
- Use dedicated i18n key "dragRowsColumn" for drag column header
- Fix colSpan calculation for nested rows when drag is enabled
- Add Safari fallback (caretRangeFromPoint) for double-click text selection
- Add keyboard drag reorder test to DragAndDropRows story
- Add source list count assertion to ExternalTextDrop story
- Remove unused isTextDropItem import from useDragAndDrop hook
- Remove internal processDropItems from public exports
- Add runtime guard in createArrayHandlers for missing key/id
- Add try/catch for malformed JSON in createItemsFromJsonDrop
- Fix misleading MDX example that created hooks but discarded them
- Align keyboard instruction docs (Enter/Space) across components
- Update changeset to mention DataTable base component prop
…docs, and hook stories

- Add cursor: grabbing style for data-dragging state on DataTable rows
- Cast Selection.modify() calls for TypeScript type safety
- Fix DataTable DnD story import to use public API path
- Add type guard in createItemsFromTextDrop to skip items without requested type
- Document CSV parser limitation in createItemsFromCsvDrop JSDoc
- Add DnD + pinning interaction note in DataTable docs
- Add useDragAndDrop hook-level stories (reorder, namespace isolation, cross-list transfer)
Cover createArrayHandlers, createListDataHandlers, processDropItems,
and all external-drop-helpers (text, file, directory, JSON, CSV, image).
- Guard JSON.parse in processDropItems against corrupted drag data
- Prefix internal drag column id to avoid consumer column collisions
- Add dev-mode console.warn for invalid reorder target key
- Add explicit drag handle assertion in DragAndDropRows story
- Merge NamespaceIsolation steps into self-contained verification
- Add DirectHookWithTable story demonstrating standalone hook usage
- Fix FileDropItem mock missing getText method
…stories

- getDropOperation now validates against allowedOperations to prevent
  silently cancelling valid external drops
- Remove unnecessary type cast in createArrayHandlers usage
- Remove redundant Record cast in processDropItems auto-keying
- Add drag-column-header recipe style for consistency
- Clarify ExternalTextDrop story text to reflect copy semantics
- Remove unnecessary Escape keypress in NamespacedIsolation test
… and drag keyboard tests

- Add sticky positioning rules for drag + selection + expand column
  combinations in recipe, using size tokens instead of raw px values
- Make drag handle cells sticky in table body rows
- Fix event listener lifecycle: replace broken callback-ref-attach +
  effect-cleanup pattern with stable-identity delegates through refs,
  fixing capture flag mismatch that silently prevented cleanup
- Guard programmatic word selection on dblclick to only run when the
  row is actually draggable
- Fix keyboard drag tests to use row focus + ArrowRight instead of
  direct button focus, matching React Aria Table navigation
- Add selectionMode and onRowClick to hook DataTable story for
  realistic coverage
- Extract shared dragItem/dragItemToList test helpers to
  use-drag-and-drop.test-utils.ts
- Fix composed helper docs example to avoid double-processing images
…s, and consistency

- Extract resolveDropOperation into testable function and validate
  external types against acceptExternalTypes before returning operation
- Add unit tests for resolveDropOperation covering all branches
- Add drag-column sticky offsets to pinned row selectors in recipe
- Move test-utils to utils/ subdirectory matching codebase convention
- Replace inline setTimeout delays with shared wait() helper in stories
- Improve JSDoc for externalDropOperation cancel behavior
- Rename discarded params for transparency (_keys, _target)
…test, and link React Aria docs

Use == null instead of falsy check for auto-keying so items with key: 0 or
key: "" are not incorrectly overwritten. Add explicit test for cross-namespace
rejection in resolveDropOperation. Type the hook stories Meta generic to
eliminate unnecessary as-casts. Document tab-order assumption in the namespaced
isolation story. Link to React Aria DnD docs from the state handler factories
section.
…rows

feat(draggable-list): add selection support with stories and documentation

Chrome 128+ added caretPositionFromPoint, which skips elements with
user-select:none — causing wrong text to be selected and no visible
highlight. Temporarily set user-select:text on the row before resolving
the caret position, then reset on the next pointerdown. Also removed an
unnecessary intermediate sel.extend() call from the selection algorithm.

Added SingleSelection and MultipleSelection stories with play functions
testing click, deselect, and keyboard interactions. Added selected-state
styling (colorPalette.5 background) to the DraggableList recipe and
documented both selection modes in the dev.mdx with live examples.
// Prevent row click when clicking on interactive elements to avoid conflicts
const isInteractiveElement = getIsTableRowChildElementInteractive(e);
// Prevent row click when text is selected
const hasSelectedText =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

from claude:

hasSelectedText guard removed unconditionally — regression for non-draggable rows

In data-table.row.tsx, the old handleRowClick path skipped navigation if the user had selected text (!hasSelectedText). That guard is gone now. On any row with onRowClick — draggable or not — a mouse-up after text selection will trigger navigation. The dblclick programmatic-selection workaround only applies to [draggable='true'] rows, so non-draggable rows lose the protection.

But honestly if the row has an onRowClick, shouldn't the navigation or other onRowClick operations take precedence over selecting text?

Copy link
Copy Markdown
Contributor

@tylermorrisford tylermorrisford left a comment

Choose a reason for hiding this comment

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

one Q, otherwise 💰

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DraggableList: no way to opt out of cross-list drops

3 participants