Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## [3.3.0] - 2025-11-12

## Added
- [#3464](https://github.com/plotly/dash/issues/3464) Add folder upload functionality to `dcc.Upload` component. When `multiple=True`, users can now select and upload entire folders in addition to individual files. The folder hierarchy is preserved in filenames (e.g., `folder/subfolder/file.txt`). Files within folders are filtered according to the `accept` prop. Folder support is available in Chrome, Edge, and Opera; other browsers gracefully fall back to file-only mode. The uploaded files use the same output API as multiple file uploads.
- [#3395](https://github.com/plotly/dash/pull/3396) Add position argument to hooks.devtool
- [#3403](https://github.com/plotly/dash/pull/3403) Add app_context to get_app, allowing to get the current app in routes.
- [#3407](https://github.com/plotly/dash/pull/3407) Add `hidden` to callback arguments, hiding the callback from appearing in the devtool callback graph.
Expand Down
15 changes: 14 additions & 1 deletion components/dash-core-components/src/components/Upload.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,22 @@ Upload.propTypes = {
min_size: PropTypes.number,

/**
* Allow dropping multiple files
* Allow dropping multiple files.
* When true, enables folder drag-and-drop support.
* The folder hierarchy is preserved in filenames (e.g., 'folder/subfolder/file.txt').
* Note: Folder drag-and-drop is supported in Chrome, Edge, and Opera.
*/
multiple: PropTypes.bool,

/**
* Enable folder selection in the file picker dialog.
* When true with multiple=True, the file picker allows selecting folders instead of files.
* Note: When folder selection is enabled, individual files cannot be selected via the button.
* Use separate Upload components if you need both file and folder selection options.
* Folder selection is supported in Chrome, Edge, and Opera.
*/
enable_folder_selection: PropTypes.bool,

/**
* HTML class name of the component
*/
Expand Down Expand Up @@ -166,6 +178,7 @@ Upload.defaultProps = {
max_size: -1,
min_size: 0,
multiple: false,
enable_folder_selection: false,
style: {},
style_active: {
borderStyle: 'solid',
Expand Down
150 changes: 150 additions & 0 deletions components/dash-core-components/src/fragments/Upload.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,142 @@ export default class Upload extends Component {
constructor() {
super();
this.onDrop = this.onDrop.bind(this);
this.getDataTransferItems = this.getDataTransferItems.bind(this);
}

// Check if file matches the accept criteria
fileMatchesAccept(file, accept) {
if (!accept) {
return true;
}

const acceptList = Array.isArray(accept) ? accept : accept.split(',');
const fileName = file.name.toLowerCase();
const fileType = file.type.toLowerCase();

return acceptList.some(acceptItem => {
const item = acceptItem.trim().toLowerCase();

// Exact MIME type match
if (item === fileType) {
return true;
}

// Wildcard MIME type (e.g., image/*)
if (item.endsWith('/*')) {
const wildcardSuffixLength = 2;
const baseType = item.slice(0, -wildcardSuffixLength);
return fileType.startsWith(baseType + '/');
}

// File extension match (e.g., .jpg)
if (item.startsWith('.')) {
return fileName.endsWith(item);
}

return false;
});
}

// Recursively traverse folder structure and extract all files
async traverseFileTree(item, path = '') {
const {accept} = this.props;
const files = [];

if (item.isFile) {
return new Promise(resolve => {
item.file(file => {
// Check if file matches accept criteria
if (!this.fileMatchesAccept(file, accept)) {
resolve([]);
return;
}

// Preserve folder structure in file name
const relativePath = path + file.name;
Object.defineProperty(file, 'name', {
writable: true,
value: relativePath,
});
resolve([file]);
});
});
} else if (item.isDirectory) {
const dirReader = item.createReader();
return new Promise(resolve => {
const readEntries = () => {
dirReader.readEntries(async entries => {
if (entries.length === 0) {
resolve(files);
} else {
for (const entry of entries) {
const entryFiles = await this.traverseFileTree(
entry,
path + item.name + '/'
);
files.push(...entryFiles);
}
// Continue reading (directories may have more than 100 entries)
readEntries();
}
});
};
readEntries();
});
}
return files;
}

// Custom data transfer handler that supports folders
async getDataTransferItems(event) {
const {multiple} = this.props;

// If multiple is not enabled, use default behavior (files only)
if (!multiple) {
if (event.dataTransfer) {
return Array.from(event.dataTransfer.files);
} else if (event.target && event.target.files) {
return Array.from(event.target.files);
}
return [];
}

// Handle drag-and-drop with folder support when multiple=true
if (event.dataTransfer && event.dataTransfer.items) {
const items = Array.from(event.dataTransfer.items);
const files = [];

for (const item of items) {
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry
? item.webkitGetAsEntry()
: null;
if (entry) {
const entryFiles = await this.traverseFileTree(entry);
files.push(...entryFiles);
} else {
// Fallback for browsers without webkitGetAsEntry
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
}
return files;
}

// Handle file picker (already works with webkitdirectory attribute)
if (event.target && event.target.files) {
return Array.from(event.target.files);
}

// Fallback
if (event.dataTransfer && event.dataTransfer.files) {
return Array.from(event.dataTransfer.files);
}

return [];
}

onDrop(files) {
Expand Down Expand Up @@ -55,6 +191,7 @@ export default class Upload extends Component {
max_size,
min_size,
multiple,
enable_folder_selection,
className,
className_active,
className_reject,
Expand All @@ -69,6 +206,17 @@ export default class Upload extends Component {
const disabledStyle = className_disabled ? undefined : style_disabled;
const rejectStyle = className_reject ? undefined : style_reject;

// Enable folder selection in file picker when explicitly requested
// Note: This makes individual files unselectable in the file picker
const inputProps =
multiple && enable_folder_selection
? {
webkitdirectory: 'true',
directory: 'true',
mozdirectory: 'true',
}
: {};

return (
<LoadingElement id={id}>
<Dropzone
Expand All @@ -79,6 +227,8 @@ export default class Upload extends Component {
maxSize={max_size === -1 ? Infinity : max_size}
minSize={min_size}
multiple={multiple}
inputProps={inputProps}
getDataTransferItems={this.getDataTransferItems}
className={className}
activeClassName={className_active}
rejectClassName={className_reject}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
from dash import Dash, Input, Output, dcc, html


def test_upfd001_folder_upload_with_enable_folder_selection(dash_dcc):
"""
Test that folder upload is enabled when enable_folder_selection=True.

Note: Full end-to-end testing of folder upload functionality is limited
by Selenium's capabilities. This test verifies the component renders
correctly with enable_folder_selection=True which enables folder support.
"""
app = Dash(__name__)

app.layout = html.Div(
[
html.Div("Folder Upload Test", id="title"),
dcc.Upload(
id="upload-folder",
children=html.Div(
["Drag and Drop or ", html.A("Select Folder")]
),
style={
"width": "100%",
"height": "60px",
"lineHeight": "60px",
"borderWidth": "1px",
"borderStyle": "dashed",
"borderRadius": "5px",
"textAlign": "center",
},
multiple=True,
enable_folder_selection=True, # Enables folder selection
accept=".txt,.csv", # Test accept filtering
),
html.Div(id="output"),
]
)

@app.callback(
Output("output", "children"),
[Input("upload-folder", "contents")],
)
def update_output(contents_list):
if contents_list is not None:
return html.Div(f"Uploaded {len(contents_list)} file(s)", id="file-count")
return html.Div("No files uploaded")

dash_dcc.start_server(app)

# Verify the component renders
dash_dcc.wait_for_text_to_equal("#title", "Folder Upload Test")

# Verify the upload component and input are present
dash_dcc.wait_for_element("#upload-folder")

# Verify the input has folder selection attributes when enable_folder_selection=True
upload_input = dash_dcc.wait_for_element("#upload-folder input[type=file]")
webkitdir_attr = upload_input.get_attribute("webkitdirectory")

assert webkitdir_attr == "true", (
f"webkitdirectory attribute should be 'true' when enable_folder_selection=True, "
f"but got '{webkitdir_attr}'"
)

assert dash_dcc.get_logs() == [], "browser console should contain no error"


def test_upfd002_multiple_files_without_folder_selection(dash_dcc):
"""
Test that multiple file upload does NOT enable folder selection
when enable_folder_selection=False (default).
"""
app = Dash(__name__)

app.layout = html.Div(
[
html.Div("Multiple Files Test", id="title"),
dcc.Upload(
id="upload-multiple",
children=html.Div(["Drag and Drop or ", html.A("Select Multiple Files")]),
style={
"width": "100%",
"height": "60px",
"lineHeight": "60px",
"borderWidth": "1px",
"borderStyle": "dashed",
"borderRadius": "5px",
"textAlign": "center",
},
multiple=True, # Allows multiple files
enable_folder_selection=False, # But NOT folder selection
accept=".txt,.csv", # Accept should work with file picker
),
html.Div(id="output", children="Upload ready"),
]
)

dash_dcc.start_server(app)

# Wait for the component to render
dash_dcc.wait_for_text_to_equal("#title", "Multiple Files Test")
dash_dcc.wait_for_text_to_equal("#output", "Upload ready")

# Verify the input does NOT have folder selection attributes
upload_input = dash_dcc.wait_for_element("#upload-multiple input[type=file]")
webkitdir_attr = upload_input.get_attribute("webkitdirectory")

# webkitdirectory should not be set when enable_folder_selection=False
assert webkitdir_attr in [None, "", "false"], (
f"webkitdirectory attribute should not be 'true' when enable_folder_selection=False, "
f"but got '{webkitdir_attr}'"
)

# Verify multiple attribute is set
multiple_attr = upload_input.get_attribute("multiple")
assert multiple_attr == "true", (
f"multiple attribute should be 'true' when multiple=True, "
f"but got '{multiple_attr}'"
)

assert dash_dcc.get_logs() == [], "browser console should contain no error"


def test_upfd003_single_file_upload(dash_dcc):
"""
Test that single file upload does NOT enable folder selection.
"""
app = Dash(__name__)

app.layout = html.Div(
[
html.Div("Single File Test", id="title"),
dcc.Upload(
id="upload-single",
children=html.Div(["Drag and Drop or ", html.A("Select File")]),
style={
"width": "100%",
"height": "60px",
"lineHeight": "60px",
"borderWidth": "1px",
"borderStyle": "dashed",
"borderRadius": "5px",
"textAlign": "center",
},
multiple=False, # Single file only
accept="application/pdf",
),
html.Div(id="output", children="Upload ready"),
]
)

dash_dcc.start_server(app)

# Wait for the component to render
dash_dcc.wait_for_text_to_equal("#title", "Single File Test")
dash_dcc.wait_for_text_to_equal("#output", "Upload ready")

# Verify the input does NOT have folder selection attributes when multiple=False
upload_input = dash_dcc.wait_for_element("#upload-single input[type=file]")
webkitdir_attr = upload_input.get_attribute("webkitdirectory")

# webkitdirectory should not be set when multiple=False
assert webkitdir_attr in [None, "", "false"], (
f"webkitdirectory attribute should not be 'true' when multiple=False, "
f"but got '{webkitdir_attr}'"
)

assert dash_dcc.get_logs() == [], "browser console should contain no error"