1#![doc = include_str!("../docs/api/file-drops.md")]
2
3use dioxus::html::{FileData, HasFileData};
4use dioxus::prelude::*;
5
6use crate::core::{client_point, element_point, Point};
7
8#[derive(Clone, PartialEq)]
10pub struct FileDrop {
11 pub files: Vec<FileData>,
12 pub client: Point,
16 pub element: Point,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum FileRejection {
29 Extension,
31 ContentType,
33 TooLarge,
35 TooMany,
37}
38
39#[derive(Debug, Clone, PartialEq, Default)]
48pub struct FileFilter {
49 extensions: Vec<String>,
50 content_types: Vec<String>,
51 max_size: Option<u64>,
52 max_files: Option<usize>,
53}
54
55impl FileFilter {
56 pub fn new() -> Self {
57 Self::default()
58 }
59
60 pub fn extensions<I, S>(mut self, exts: I) -> Self
62 where
63 I: IntoIterator<Item = S>,
64 S: Into<String>,
65 {
66 self.extensions = exts
67 .into_iter()
68 .map(|s| normalize_extension(&s.into()))
69 .filter(|s| !s.is_empty())
70 .collect();
71 self
72 }
73
74 pub fn content_types<I, S>(mut self, types: I) -> Self
83 where
84 I: IntoIterator<Item = S>,
85 S: Into<String>,
86 {
87 self.content_types = types
88 .into_iter()
89 .map(|s| normalize_content_type(&s.into()))
90 .filter(|s| !s.is_empty())
91 .collect();
92 self
93 }
94
95 pub fn max_size(mut self, bytes: u64) -> Self {
97 self.max_size = Some(bytes);
98 self
99 }
100
101 pub fn max_files(mut self, n: usize) -> Self {
103 self.max_files = Some(n);
104 self
105 }
106
107 pub fn check(&self, file: &FileData) -> Result<(), FileRejection> {
109 if !self.extensions.is_empty() {
110 let name = file.name().to_ascii_lowercase();
115 let ok = self
116 .extensions
117 .iter()
118 .any(|ext| name.ends_with(&format!(".{ext}")));
119 if !ok {
120 return Err(FileRejection::Extension);
121 }
122 }
123 if !self.content_types.is_empty() {
124 let ct = file
125 .content_type()
126 .map(|s| normalize_content_type(&s))
127 .unwrap_or_default();
128 let ok = self
129 .content_types
130 .iter()
131 .any(|allowed| content_type_matches(allowed, &ct));
132 if !ok {
133 return Err(FileRejection::ContentType);
134 }
135 }
136 if let Some(max) = self.max_size {
137 if file.size() > max {
138 return Err(FileRejection::TooLarge);
139 }
140 }
141 Ok(())
142 }
143
144 pub fn partition(
146 &self,
147 files: Vec<FileData>,
148 ) -> (Vec<FileData>, Vec<(FileData, FileRejection)>) {
149 let mut ok = Vec::new();
150 let mut bad = Vec::new();
151 for file in files {
152 if let Some(max) = self.max_files {
153 if ok.len() >= max {
154 bad.push((file, FileRejection::TooMany));
155 continue;
156 }
157 }
158 match self.check(&file) {
159 Ok(()) => ok.push(file),
160 Err(why) => bad.push((file, why)),
161 }
162 }
163 (ok, bad)
164 }
165
166 fn picker_accept(&self) -> Option<String> {
172 let mut hints = self
173 .extensions
174 .iter()
175 .map(|extension| format!(".{extension}"))
176 .collect::<Vec<_>>();
177
178 for content_type in &self.content_types {
179 let Some((ty, subtype)) = split_content_type(content_type) else {
180 continue;
181 };
182 if ty == "*" || ty.contains('*') || (subtype != "*" && subtype.contains('*')) {
187 continue;
188 }
189 if !hints.contains(content_type) {
190 hints.push(content_type.clone());
191 }
192 }
193
194 (!hints.is_empty()).then(|| hints.join(","))
195 }
196}
197
198fn normalize_extension(ext: &str) -> String {
199 ext.trim().trim_start_matches('.').to_ascii_lowercase()
200}
201
202fn normalize_content_type(content_type: &str) -> String {
203 content_type
204 .split_once(';')
205 .map(|(base, _)| base)
206 .unwrap_or(content_type)
207 .trim()
208 .to_ascii_lowercase()
209}
210
211fn split_content_type(content_type: &str) -> Option<(&str, &str)> {
212 let (ty, subtype) = content_type.split_once('/')?;
213 if ty.is_empty() || subtype.is_empty() || subtype.contains('/') {
214 return None;
215 }
216 Some((ty, subtype))
217}
218
219fn content_type_matches(pattern: &str, content_type: &str) -> bool {
220 let Some((pattern_type, pattern_subtype)) = split_content_type(pattern) else {
221 return false;
222 };
223 let Some((actual_type, actual_subtype)) = split_content_type(content_type) else {
224 return false;
225 };
226
227 if pattern_type == "*" && pattern_subtype == "*" {
228 return true;
229 }
230 if pattern_type == "*" && !pattern_subtype.starts_with("*+") {
231 return false;
232 }
233 if pattern_type != "*" && pattern_type != actual_type {
234 return false;
235 }
236 if pattern_subtype == "*" {
237 return true;
238 }
239 if let Some(suffix) = pattern_subtype.strip_prefix("*+") {
240 return actual_subtype
241 .rsplit_once('+')
242 .map(|(_, actual_suffix)| actual_suffix == suffix)
243 .unwrap_or(false);
244 }
245
246 pattern_subtype == actual_subtype
247}
248
249fn deliver_files(
250 files: Vec<FileData>,
251 filter: Option<&FileFilter>,
252 on_files: &EventHandler<FileDrop>,
253 on_rejected: Option<&EventHandler<Vec<(FileData, FileRejection)>>>,
254 client: Point,
255 element: Point,
256) {
257 if files.is_empty() {
258 return;
259 }
260 let (accepted, rejected) = match filter {
261 Some(filter) => filter.partition(files),
262 None => (files, Vec::new()),
263 };
264 if !rejected.is_empty() {
265 if let Some(handler) = on_rejected {
266 handler.call(rejected);
267 }
268 }
269 if !accepted.is_empty() {
270 on_files.call(FileDrop {
271 files: accepted,
272 client,
273 element,
274 });
275 }
276}
277
278#[component]
289pub fn FileDropZone(
290 #[props(default)]
292 filter: Option<FileFilter>,
293 on_files: EventHandler<FileDrop>,
295 #[props(default)]
297 on_rejected: Option<EventHandler<Vec<(FileData, FileRejection)>>>,
298 #[props(default)]
300 on_hover: Option<EventHandler<bool>>,
301 #[props(default = true)]
303 multiple: bool,
304 #[props(default)]
306 disabled: bool,
307 #[props(default = "Choose or drop files".to_string())]
309 label: String,
310 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
311 children: Element,
312) -> Element {
313 let mut depth = use_signal(|| 0u32);
314 let input_id = use_hook(|| {
315 format!(
316 "dioxus-dnd-file-input-{}",
317 dioxus::core::current_scope_id().0
318 )
319 });
320 let picker_input_id = input_id.clone();
321 let picker_accept = filter.as_ref().and_then(FileFilter::picker_accept);
322 let picker_filter = filter.clone();
323 let picker_on_files = on_files;
324 let picker_on_rejected = on_rejected;
325 let open_picker = use_callback(move |_: ()| {
326 if disabled {
327 return;
328 }
329 let script = format!(
332 "const input = document.getElementById({picker_input_id:?}); \
333 if (input) {{ input.value = ''; input.click(); }}"
334 );
335 let _ = dioxus::document::eval(&script);
336 });
337 let mut attributes = attributes;
338 crate::core::components::protect_attributes(
339 &mut attributes,
340 &[
341 "data-over",
342 "data-disabled",
343 "role",
344 "tabindex",
345 "aria-label",
346 "aria-disabled",
347 "onclick",
348 "onkeydown",
349 "ondragover",
350 "ondragenter",
351 "ondragleave",
352 "ondrop",
353 ],
354 );
355
356 rsx! {
357 div {
358 "data-over": if !disabled && depth() > 0 { "true" },
359 "data-disabled": if disabled { "true" },
360 role: "button",
361 tabindex: if disabled { -1_i64 } else { 0 },
362 aria_label: label,
363 aria_disabled: disabled,
364 onclick: move |_| open_picker.call(()),
365 onkeydown: move |event: KeyboardEvent| {
366 if disabled {
367 return;
368 }
369 let key = event.key();
370 if matches!(key, Key::Enter)
371 || matches!(&key, Key::Character(value) if value == " ")
372 {
373 event.prevent_default();
374 open_picker.call(());
375 }
376 },
377 ondragover: move |evt: DragEvent| {
378 evt.prevent_default();
381 },
382 ondragenter: move |evt: DragEvent| {
383 evt.prevent_default();
384 if disabled {
385 return;
386 }
387 let d = depth() + 1;
388 depth.set(d);
389 if d == 1 {
390 if let Some(h) = &on_hover {
391 h.call(true);
392 }
393 }
394 },
395 ondragleave: move |_| {
396 if disabled {
397 depth.set(0);
398 return;
399 }
400 let d = depth().saturating_sub(1);
401 depth.set(d);
402 if d == 0 {
403 if let Some(h) = &on_hover {
404 h.call(false);
405 }
406 }
407 },
408 ondrop: move |evt: DragEvent| {
409 evt.prevent_default();
410 depth.set(0);
411 if let Some(h) = &on_hover {
412 h.call(false);
413 }
414 if disabled {
415 return;
416 }
417 deliver_files(
418 evt.files(),
419 filter.as_ref(),
420 &on_files,
421 on_rejected.as_ref(),
422 client_point(&evt),
423 element_point(&evt),
424 );
425 },
426 ..attributes,
427 {children}
428 input {
429 id: input_id,
430 type: "file",
431 accept: picker_accept,
432 multiple,
433 disabled,
434 hidden: true,
435 onclick: move |evt: MouseEvent| evt.stop_propagation(),
438 onchange: move |evt: FormEvent| {
439 if disabled {
440 return;
441 }
442 deliver_files(
443 evt.files(),
444 picker_filter.as_ref(),
445 &picker_on_files,
446 picker_on_rejected.as_ref(),
447 Point::default(),
448 Point::default(),
449 );
450 },
451 }
452 }
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use dioxus::html::NativeFileData;
460 use std::path::PathBuf;
461 use std::pin::Pin;
462
463 struct MockFile {
465 name: &'static str,
466 size: u64,
467 content_type: Option<&'static str>,
468 }
469
470 impl NativeFileData for MockFile {
471 fn name(&self) -> String {
472 self.name.to_string()
473 }
474 fn size(&self) -> u64 {
475 self.size
476 }
477 fn last_modified(&self) -> u64 {
478 0
479 }
480 fn path(&self) -> PathBuf {
481 PathBuf::new()
482 }
483 fn content_type(&self) -> Option<String> {
484 self.content_type.map(str::to_string)
485 }
486 fn read_bytes(
487 &self,
488 ) -> Pin<Box<dyn std::future::Future<Output = Result<bytes::Bytes, dioxus::CapturedError>>>>
489 {
490 Box::pin(std::future::ready(Ok(bytes::Bytes::new())))
491 }
492 fn byte_stream(
493 &self,
494 ) -> Pin<
495 Box<
496 dyn futures_util::Stream<Item = Result<bytes::Bytes, dioxus::CapturedError>>
497 + Send
498 + 'static,
499 >,
500 > {
501 Box::pin(futures_util::stream::empty())
502 }
503 fn read_string(
504 &self,
505 ) -> Pin<Box<dyn std::future::Future<Output = Result<String, dioxus::CapturedError>>>>
506 {
507 Box::pin(std::future::ready(Ok(String::new())))
508 }
509 fn inner(&self) -> &dyn std::any::Any {
510 self
511 }
512 }
513
514 fn file(name: &'static str, size: u64, ct: Option<&'static str>) -> FileData {
515 FileData::new(MockFile {
516 name,
517 size,
518 content_type: ct,
519 })
520 }
521
522 #[test]
523 fn extension_filter_is_case_insensitive() {
524 let f = FileFilter::new().extensions(["png", ".JPG", " gif "]);
525 assert!(f.check(&file("Photo.PNG", 10, None)).is_ok());
526 assert!(f.check(&file("photo.jpg", 10, None)).is_ok());
527 assert!(f.check(&file("clip.GIF", 10, None)).is_ok());
528 assert_eq!(
529 f.check(&file("notes.txt", 10, None)),
530 Err(FileRejection::Extension)
531 );
532 assert_eq!(
534 f.check(&file("png.txt", 10, None)),
535 Err(FileRejection::Extension)
536 );
537 }
538
539 #[test]
540 fn content_type_wildcards() {
541 let f = FileFilter::new().content_types(["image/*", "application/pdf"]);
542 assert!(f.check(&file("a", 1, Some("image/webp"))).is_ok());
543 assert!(f.check(&file("b", 1, Some("application/pdf"))).is_ok());
544 assert_eq!(
545 f.check(&file("c", 1, Some("text/plain"))),
546 Err(FileRejection::ContentType)
547 );
548 assert_eq!(
550 f.check(&file("d", 1, None)),
551 Err(FileRejection::ContentType)
552 );
553 }
554
555 #[test]
556 fn content_type_wildcards_match_whole_type_only() {
557 let f = FileFilter::new().content_types(["image/*"]);
558 assert!(f.check(&file("a", 1, Some("image/svg+xml"))).is_ok());
559 assert_eq!(
560 f.check(&file("b", 1, Some("imageevil/png"))),
561 Err(FileRejection::ContentType)
562 );
563 assert_eq!(
564 f.check(&file("c", 1, Some("application/image"))),
565 Err(FileRejection::ContentType)
566 );
567 assert_eq!(
568 f.check(&file("d", 1, Some("image/png/extra"))),
569 Err(FileRejection::ContentType)
570 );
571 }
572
573 #[test]
574 fn content_type_matching_normalizes_case_whitespace_and_parameters() {
575 let f = FileFilter::new().content_types([" Application/PDF ", "text/plain"]);
576 assert!(f.check(&file("a", 1, Some("application/pdf"))).is_ok());
577 assert!(f
578 .check(&file("b", 1, Some("TEXT/PLAIN; charset=utf-8")))
579 .is_ok());
580 }
581
582 #[test]
583 fn content_type_all_wildcard_accepts_any_typed_file() {
584 let f = FileFilter::new().content_types(["*/*"]);
585 assert!(f.check(&file("a", 1, Some("image/png"))).is_ok());
586 assert!(f
587 .check(&file("b", 1, Some("application/octet-stream")))
588 .is_ok());
589 assert_eq!(
590 f.check(&file("c", 1, None)),
591 Err(FileRejection::ContentType)
592 );
593 }
594
595 #[test]
596 fn content_type_structured_suffix_wildcards() {
597 let app_json = FileFilter::new().content_types(["application/*+json"]);
598 assert!(app_json
599 .check(&file("a", 1, Some("application/ld+json")))
600 .is_ok());
601 assert!(app_json
602 .check(&file("b", 1, Some("application/vnd.api+json")))
603 .is_ok());
604 assert_eq!(
605 app_json.check(&file("c", 1, Some("text/ld+json"))),
606 Err(FileRejection::ContentType)
607 );
608 assert_eq!(
609 app_json.check(&file("d", 1, Some("application/json"))),
610 Err(FileRejection::ContentType)
611 );
612
613 let any_json = FileFilter::new().content_types(["*/*+json"]);
614 assert!(any_json
615 .check(&file("e", 1, Some("application/problem+json")))
616 .is_ok());
617 assert!(any_json
618 .check(&file("f", 1, Some("model/gltf+json")))
619 .is_ok());
620 assert_eq!(
621 any_json.check(&file("g", 1, Some("application/json"))),
622 Err(FileRejection::ContentType)
623 );
624 }
625
626 #[test]
627 fn malformed_content_type_patterns_do_not_match() {
628 let f = FileFilter::new().content_types(["image", "image/", "/png", "image/png/extra"]);
629 assert_eq!(
630 f.check(&file("a", 1, Some("image/png"))),
631 Err(FileRejection::ContentType)
632 );
633 }
634
635 #[test]
636 fn unsupported_subtype_only_wildcard_does_not_match() {
637 let f = FileFilter::new().content_types(["*/json"]);
638 assert_eq!(
639 f.check(&file("a", 1, Some("application/json"))),
640 Err(FileRejection::ContentType)
641 );
642 assert_eq!(
643 f.check(&file("b", 1, Some("text/json"))),
644 Err(FileRejection::ContentType)
645 );
646 }
647
648 #[test]
649 fn size_limit() {
650 let f = FileFilter::new().max_size(100);
651 assert!(f.check(&file("ok", 100, None)).is_ok());
652 assert_eq!(
653 f.check(&file("big", 101, None)),
654 Err(FileRejection::TooLarge)
655 );
656 }
657
658 #[test]
659 fn partition_applies_count_after_other_rules() {
660 let f = FileFilter::new().extensions(["png"]).max_files(2);
661 let batch = vec![
662 file("a.png", 1, None),
663 file("b.txt", 1, None), file("c.png", 1, None),
665 file("d.png", 1, None), ];
667 let (ok, bad) = f.partition(batch);
668 assert_eq!(
669 ok.iter().map(|f| f.name()).collect::<Vec<_>>(),
670 vec!["a.png", "c.png"]
671 );
672 assert_eq!(bad.len(), 2);
673 assert_eq!(bad[0].1, FileRejection::Extension);
674 assert_eq!(bad[1].1, FileRejection::TooMany);
675 }
676
677 #[test]
678 fn picker_accept_uses_only_rules_the_dialog_can_represent() {
679 let filter = FileFilter::new()
680 .extensions(["png", ".JPG"])
681 .content_types(["image/*", "application/pdf", "application/*+json", "*/*"]);
682
683 assert_eq!(
684 filter.picker_accept().as_deref(),
685 Some(".png,.jpg,image/*,application/pdf")
686 );
687 assert_eq!(
688 FileFilter::new().max_size(10).picker_accept(),
689 None,
690 "non-picker rules must not create an empty accept restriction"
691 );
692 }
693}