Skip to content

[dropzone] Add Dropzone component#4907

Open
mbrookes wants to merge 23 commits into
mui:masterfrom
mbrookes:dropzone
Open

[dropzone] Add Dropzone component#4907
mbrookes wants to merge 23 commits into
mui:masterfrom
mbrookes:dropzone

Conversation

@mbrookes
Copy link
Copy Markdown
Member

@mbrookes mbrookes commented May 25, 2026

Base UI Dropzone

Background

This component was originally developed as a personal project, but seemed like a useful addition to Base UI, so submitting here for consideration. Colm had input on the API, and I've reviewed the code, but it was developed with agent assistance, therefore all errors and omissions are mine.

Overview

Dropzone is an unstyled drop target primitive that provides both drag-and-drop and click-to-select file picking in a single composable component. It is intentionally scoped to the drop/selection surface only — it does not manage file state, upload logic, or progress — making it composable with whatever file handling the consuming application needs.

API:

 import { Dropzone } from '@base-ui/react/dropzone';

 <Dropzone.Root onFilesDrop={(files) => console.log(files)}>
   <Dropzone.HiddenInput />
   Drop files here, or click to browse
 </Dropzone.Root>

Components

  • Dropzone.Root — the interactive drop target <div role="button">. Handles drag events, click-to-open, and keyboard activation.
  • Dropzone.HiddenInput — a visually hidden <input type="file"> that powers the native file picker and enables form participation when a name prop is provided.

Key features:

  • Drag-and-drop with data-dragging state attribute for styling
  • Click and keyboard (Enter/Space) activation of the native file picker
  • Uncontrolled dragging state (onDraggingChange)
  • Disabled support with data-disabled attribute
  • Render function children receive isDragging for inline conditional rendering
  • Built-in aria-live region announces drag enter, drag leave, and drop events to screen readers
  • Nested interactive elements (buttons, links) inside the dropzone do not accidentally trigger the file picker
  • suppressHydrationWarning on the hidden input to avoid SSR mismatches

Documentation

  • Component page with usage guidelines, anatomy, and labeling examples: react/components/dropzone/page.mdx
  • Hero demo (CSS Modules and Tailwind CSS versions): /react/components/dropzone/demos/hero/

Comparison with other implementations

Most existing dropzone libraries (react-dropzone, react-files, etc.) are monolithic hooks or components that bundle file validation, preview, upload queuing, and state management together. Dropzone follows the Base UI philosophy of doing one thing: providing the interactive drop target surface. Consumers own file handling.

Notable differences:

  • No bundled file management — just gives you the File[] array
  • Composable — works standalone or wrapped with other components
  • Fully styleable — completely unstyled, with presence-based data attributes (data-dragging, data-disabled) rather than className injection
  • render prop — like all Base UI components, the root element can be replaced via the render prop

Architecture

Follows standard Base UI compound component conventions:

  • useRenderElement for the render prop / className / style / state-attribute contract
  • useStableCallback for all event handlers to avoid stale closures and unnecessary re-renders
  • Context (DropzoneRootContext) to connect HiddenInput to Root without prop drilling — HiddenInput registers its ref into Root so Root can imperatively call .click() on it
  • Uses contains from the floating-ui-react utils for correct drag-leave detection across child elements (avoids false drag-end when cursor moves over children)
  • State attributes use stateAttributesMapping + DropzoneRootDataAttributes enum, consistent with the rest of the library
  • visuallyHidden/visuallyHiddenInput utilities for the hidden input

Deploy preview

https://deploy-preview-4907--base-ui.netlify.app/react/components/dropzone

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 25, 2026

commit: d7b4f93

@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented May 25, 2026

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+2.46KB(+0.53%) 🔺+781B(+0.53%)

Details of bundle changes

Performance

Total duration: 1,151.31 ms -70.44 ms(-5.8%) | Renders: 50 (+0) | Paint: 1,764.35 ms -94.37 ms(-5.1%)

Test Duration Renders
Tooltip mount (300 contained roots) 58.93 ms 🔺+10.68 ms(+22.1%) 1 (+0)
Checkbox mount (500 instances) 53.51 ms ▼-13.69 ms(-20.4%) 1 (+0)

10 tests within noise — details


Check out the code infra dashboard for more information about this PR.

@netlify
Copy link
Copy Markdown

netlify Bot commented May 25, 2026

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit d7b4f93
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a170ae89d03fd000826821f
😎 Deploy Preview https://deploy-preview-4907--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

mbrookes and others added 2 commits May 25, 2026 21:51
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove stale dropzone-input.json (leftover from Input → HiddenInput rename)
- Clarify onOpen JSDoc: only fires when no HiddenInput is present
- CSS modules demo: replace hardcoded hex colors with design tokens,
  wrap hover in @media (hover: hover), fix :focus → :focus-visible,
  remove redundant .input class (HiddenInput handles its own visibility)
- Tailwind demo: fix focus: → focus-visible: variants
- public-types: fix Dropzone.Props/State → Dropzone.Root.Props/State
- Update generated types.md for onOpen description change

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mbrookes and others added 3 commits May 25, 2026 21:52
- Add './dropzone' to packages/react/package.json exports so @base-ui/react/dropzone resolves correctly
- Fix non-breaking space in 'Base UI' brand name in dropzone/page.mdx and components/page.mdx (vale MuiBrandName rule)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use 'Base\u00a0UI' as a standalone keyword to match the convention
of all other components, so docs:validate generates the correct
non-breaking space in the component index (vale MuiBrandName rule).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mbrookes mbrookes added the status: waiting for maintainer These issues haven't been looked at yet by a maintainer. label May 25, 2026
mbrookes and others added 4 commits May 25, 2026 22:00
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- CSS modules demo: replace undefined --color-gray-* vars with direct
  oklch() values; set explicit color on container to prevent currentColor
  from inheriting red from the docs demo sentinel
- Tailwind demo: replace gray-* with neutral-* (the project's defined
  color tokens) and blue-600/50/100 with available blue-500/blue-500/10
  blue-500/15 tokens; add text-neutral-900 to the container for
  correct currentColor baseline

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…sions

CSS module <p> elements inherit UA default margins and line-heights
because all: revert-layer removes Tailwind preflight. Add explicit
margin: 0 and line-height to match Tailwind's preflight behaviour.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Use 'Whether the component should ignore user interaction.' for disabled
- Use 'Event handler called when...' for event handler props
- Add docs link and 'Renders a <X> element.' to component JSDoc
- Simplify children description to match Dialog/Collapsible patterns
- Regenerate types.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mbrookes mbrookes removed the request for review from romgrk May 25, 2026 21:56
The dragging state is driven purely by browser drag events — there's
no meaningful external trigger to set it programmatically. onDraggingChange
is sufficient for observing the state.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mj12albert mj12albert added type: new feature Expand the scope of the product to solve a new problem. and removed status: waiting for maintainer These issues haven't been looked at yet by a maintainer. labels May 26, 2026
@mbrookes mbrookes requested a review from Copilot May 26, 2026 20:33

This comment was marked as resolved.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Matt <github@nospam.33m.co>

This comment was marked as resolved.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Matt <github@nospam.33m.co>

This comment was marked as resolved.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Matt <github@nospam.33m.co>
@mbrookes mbrookes marked this pull request as draft May 26, 2026 21:58
@mbrookes
Copy link
Copy Markdown
Member Author

Converted to draft until the Copilot review fixes are complete

mbrookes and others added 2 commits May 27, 2026 07:42
- Restore HTMLInputElement.prototype.click spy in try/finally to prevent
  leaking into subsequent tests (vi.resetAllMocks does not restore spies)
- Guard handleDragEnter to short-circuit when already dragging, preventing
  repeated onDraggingChange(true) calls and screen reader announcements
  as the cursor moves over descendant elements
- Guard handleKeyDown to bail out when event.target !== event.currentTarget,
  matching the existing click handler behavior for nested interactive elements
- Move [role=status] live region back inside the dropzone element so
  dropzone.querySelector('[role=status]') resolves correctly in tests
- Add test: onDraggingChange called only once for repeated dragenter
- Add test: keyboard does not open picker when nested button has focus

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The [role=status] live region was inside role=button. Since
position:absolute does not remove elements from the accessibility tree,
its text content ('Ready to drop files', 'Dropped N files', etc.) was
being included in the button's computed accessible name, causing the
control's name to change dynamically during drag/drop.

Move it to a sibling element (rendered via React.Fragment) so it
announces state changes without affecting the dropzone's accessible name.

Update accessibility tests to use screen.getByRole('status') rather than
dropzone.querySelector('[role=status]') to reflect the new structure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

This comment was marked as resolved.

mbrookes and others added 4 commits May 27, 2026 08:15
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Matt <github@nospam.33m.co>
File-drag guard:
- Add hasFiles() helper that checks event.dataTransfer.types.includes('Files')
- Guard handleDragEnter, handleDragOver, and handleDrop with hasFiles() so
  dragging text, links or DOM elements over the dropzone doesn't enter the
  dragging state, set dropEffect='copy', or fire onFilesDrop
  file-drag context

Interactive-element selector:
- Import TYPEABLE_SELECTOR from floating-ui-react/utils/constants
- Replace the hand-rolled click handler selector with the same one used by
  isInteractiveElement() — adds [tabindex]:not([tabindex=-1]), a[href]
  specificity, and [contenteditable] to the existing set

Tests:
- Add createFileDragTransfer() helper for dragenter/dragover event mocking
  (security: types includes 'Files' but files list is inaccessible)
- Update createDataTransfer() to explicitly set types (JSDOM does not
  populate types automatically when items are added)
- Add test: non-file drags are ignored (no state change, no announcement)
- Add test: element with tabindex=0 does not open picker on click
- Update 'announces when no files are dropped' to reflect new behavior

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Replace <div role="status"> with <output> — semantic element with
  implicit role="status" and aria-live="polite"
- Add role="menuitem" to the tabIndex test div so keyboard users
  have expected behavior on focus

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

This comment was marked as resolved.

mbrookes and others added 3 commits May 27, 2026 09:55
Testing Library's fireEvent creates a new DataTransfer() internally and
copies only own properties from the provided object. Browser DataTransfer
stores its data in internal slots (not own properties), so the types and
files values were lost in Chromium.

Fix: always shadow the relevant properties with Object.defineProperty so
they are preserved when Testing Library copies them. Also mark the
'announces when a file drop yields no files' edge case test as JSDOM-only
since the types=['Files']+empty files scenario is impossible in real browsers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
These files don't exist for any other component and are not referenced
by the docs pipeline. The API reference is powered by types.md, which is
auto-generated from types.ts by `pnpm docs:validate`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mbrookes mbrookes marked this pull request as ready for review May 27, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants