This is an automated email from the ASF dual-hosted git repository.

reiern70 pushed a commit to branch upload-to-resource
in repository https://gitbox.apache.org/repos/asf/wicket.git

commit 2439b8122591cf106c997d9763582410b28257f6
Author: reiern70 <[email protected]>
AuthorDate: Thu Oct 15 11:08:00 2026 -0500

    Add a drop zone and a file list to uploads to a resource
    
    GitHub issue #1669
    
    FileUploadToResourceField uploaded the files of its input as the
    browser's dialog left them: each choice replaced the one before, files
    could not be dropped, and a file could not be taken out again before the
    upload.
    
    Two behaviors add that:
    
    - FileDropZoneBehavior makes a component a zone files are dropped on.
      The input receives the dropped files it accepts by its accept
      attribute, all of them if it is multiple, else the first; folders are
      left out. It then fires its input and change events, as for files
      chosen in the dialog. While files are dragged over the zone, it has the
      CSS class wicket-upload-dragover.
    
    - FileListBehavior, on a <ul> or <ol>, lists the files chosen for the
      next upload, each with a button removing it, whose text is the resource
      uploadToResourceRemove. With a list the field collects files: if its
      input is multiple, files chosen or dropped are added to the listed ones
      instead of replacing them. The files an upload sent leave the list once
      it succeeded, while files chosen meanwhile stay; during an upload the
      remove buttons are disabled. A file name is written as text, never as
      markup.
    
    The script keeps the chosen files and writes them back into the input
    through a DataTransfer, so the upload, the progress bar and other
    listeners of the input see all of them. It hears of a choice in the
    capture phase, before the input's other change listeners run.
    
    A field works with the input it was created for, and draws the lists
    only while that input is in the page. The running upload of a field is
    kept by the script, not by the field: an Ajax rendering of the field
    creates a new field, which takes the upload over. It refuses a second
    upload until the first one ended, cancelUpload() cancels it, and when it
    ends, the files chosen in the new input stay and the remove buttons are
    enabled again. Before, the new field knew nothing of the running upload:
    cancelUpload() did not reach it, and a second upload could run next to
    it.
    
    A field without these behaviors works as before: a choice replaces the
    files of the input, and the input keeps them after the upload.
    
    The script's guard against being evaluated twice tested for an object,
    but Wicket.FileUploadToResourceField is a function, so a second
    evaluation replaced it. It tests for a function now, as the registries
    of file lists and of running uploads live in the script. A test runs the
    script a second time and checks that the field and its lists survive it.
    
    The upload-to-resource example shows a drop zone and a file list. Its
    styles never applied: they were in a <head> the page ignores, since it
    extends WicketExamplePage, and inline, which the Content Security Policy
    of the examples blocks. They are in UploadToResourcePage.css now, under
    a class name that does not clash with the drop-zone of the examples'
    style.css. Its guard against leaving the page during an upload used
    jQuery, which the plain JavaScript engine does not load; it uses
    Wicket.Event now. Its explanation also says that the limits of the field
    are checked before anything is sent, and that files of the same name are
    kept apart, each stored under a name of its own.
    
    migration-to-11.md lists the two behaviors among the changes of Wicket
    11 that need no migration, and says that a field rendered anew during an
    upload refuses a second one and that cancelUpload() cancels it.
---
 migration-to-11.md                                 |  17 +-
 .../upload/resource/FileDropZoneBehaviorTest.java  |  70 ++++
 .../form/upload/resource/FileListBehaviorTest.java |  90 ++++++
 .../form/upload/resource/FileDropZoneBehavior.java |  84 +++++
 .../form/upload/resource/FileListBehavior.java     | 107 +++++++
 .../upload/resource/FileUploadToResourceField.java |   5 +-
 .../upload/resource/FileUploadToResourceField.js   | 355 +++++++++++++++++++--
 .../resource/FileUploadToResourceField.properties  |   1 +
 wicket-core/src/test/js/all.html                   |   3 +
 wicket-core/src/test/js/upload-to-resource.js      | 320 ++++++++++++++++++-
 .../examples/upload/UploadToResourcePage.css       |  22 ++
 .../examples/upload/UploadToResourcePage.html      |  18 +-
 .../examples/upload/UploadToResourcePage.java      |  22 +-
 .../upload/UploadToResourcePage.properties         |   2 +-
 14 files changed, 1062 insertions(+), 54 deletions(-)

diff --git a/migration-to-11.md b/migration-to-11.md
index d80ab57180..78b4bf9e31 100644
--- a/migration-to-11.md
+++ b/migration-to-11.md
@@ -11,8 +11,9 @@ of the migration. Its comment lists the other Wicket 11 
changes that need manual
 
 ## Uploads to a resource (`FileUploadToResourceField`)
 
-Pull request [#1671](https://github.com/apache/wicket/pull/1671), issue
-[#1670](https://github.com/apache/wicket/issues/1670). All classes are in
+Pull request [#1671](https://github.com/apache/wicket/pull/1671), issues
+[#1670](https://github.com/apache/wicket/issues/1670) and
+[#1669](https://github.com/apache/wicket/issues/1669). All classes are in
 `org.apache.wicket.markup.html.form.upload.resource`. None of these changes is 
mechanical, so
 there is no OpenRewrite recipe for them.
 
@@ -200,6 +201,7 @@ New resource keys, in 
`FileUploadToResourceField.properties`:
 |---|---|
 | `uploadToResourceFailed` | The upload failed. |
 | `uploadConnectionLost` | The upload failed: the connection to the server was 
lost. |
+| `uploadToResourceRemove` | Remove |
 
 ### `FileUploadToResourceField`: browser side
 
@@ -207,7 +209,8 @@ New resource keys, in 
`FileUploadToResourceField.properties`:
 |---|---|---|
 | `getClientBeforeSendCallBack()` default | `function (xhr, settings) { return 
true; }`. The return value was ignored and `settings` never passed | `function 
(xhr) { return true; }`. Returning `false` keeps the upload from being sent and 
calls `onUploadCanceled()` |
 | `errorMessage` given to `getClientSideUploadErrorCallBack()` | A resource 
key, an HTTP status text, or an exception message | Only keys: 
`uploadTooLarge`, `uploadSingleFileTooLarge`, `uploadTooManyFiles`, 
`wicket.no.files.selected`, `uploadFailed`, `uploadConnectionLost` |
-| Upload started while one runs | A second request was sent, and the two broke 
each other | Refused: `upload()` returns `false` and logs it |
+| Upload started while one runs | A second request was sent, and the two broke 
each other | Refused: `upload()` returns `false` and logs it. This holds as 
well for a field rendered anew by Ajax during the upload, which takes the 
upload over |
+| `cancelUpload()` after an Ajax rendering of the field during an upload | Did 
nothing: the upload went on | Cancels the upload |
 | Files beyond a limit of the field | Sent, then refused by the server, often 
with a reset connection | Not sent. The error callback gets the key of the 
limit, `onUploadFailure()` its text |
 | Telling the field which files arrived | `GET` with the files in the URL. 
Beyond about 65 files the server answered 414, and `onUploadSuccess()` was 
never called | `POST` |
 | `cancel()` after the upload ended | Called the cancel callback and logged 
"canceled" | Only logs that there is nothing to cancel |
@@ -216,3 +219,11 @@ New resource keys, in 
`FileUploadToResourceField.properties`:
 
 To migrate, check a before-send callback that returns `false`: it used to be 
ignored, and now
 cancels the upload. Also check an error callback that expects HTTP status 
texts.
+
+### New in 11, without migration
+
+- `FileDropZoneBehavior` makes any component a drop zone for the files of a 
field.
+- `FileListBehavior`, on a `<ul>` or `<ol>`, lists the files chosen for the 
next upload, each with
+  a remove button. With it, a `multiple` input collects files over several 
choices.
+
+Without these behaviors, the field works as before: a new choice replaces the 
files of the input.
diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileDropZoneBehaviorTest.java
 
b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileDropZoneBehaviorTest.java
new file mode 100644
index 0000000000..1a90e6795d
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileDropZoneBehaviorTest.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.markup.html.form.upload.resource;
+
+import java.util.regex.Pattern;
+import org.apache.wicket.MarkupContainer;
+import org.apache.wicket.markup.IMarkupResourceStreamProvider;
+import org.apache.wicket.markup.html.WebMarkupContainer;
+import org.apache.wicket.markup.html.WebPage;
+import 
org.apache.wicket.markup.html.form.upload.resource.FileUploadToResourceFieldTest.TestField;
+import 
org.apache.wicket.markup.html.form.upload.resource.FileUploadToResourceFieldTest.TestFileUploadResourceReference;
+import org.apache.wicket.util.file.File;
+import org.apache.wicket.util.resource.IResourceStream;
+import org.apache.wicket.util.resource.StringResourceStream;
+import org.apache.wicket.util.tester.WicketTestCase;
+import org.junit.jupiter.api.Test;
+
+class FileDropZoneBehaviorTest extends WicketTestCase
+{
+       @Test
+       void makesTheComponentADropZoneOfTheField()
+       {
+               DropZonePage page = tester.startPage(new DropZonePage());
+
+               
tester.assertContains(Pattern.quote("Wicket.FileUploadToResourceField.addDropZone(\""
 +
+                       page.zone.getMarkupId() + "\", \"" + 
page.field.getMarkupId() + "\", \"" +
+                       FileDropZoneBehavior.DRAG_OVER_CSS_CLASS + "\");"));
+       }
+
+       static class DropZonePage extends WebPage implements 
IMarkupResourceStreamProvider
+       {
+               private static final long serialVersionUID = 1L;
+
+               final TestField field;
+
+               final WebMarkupContainer zone;
+
+               DropZonePage()
+               {
+                       zone = new WebMarkupContainer("zone");
+                       add(zone);
+                       field = new TestField("upload", new 
TestFileUploadResourceReference(
+                               new FolderUploadsFileManager(new 
File(System.getProperty("java.io.tmpdir")))));
+                       zone.add(field);
+                       zone.add(new FileDropZoneBehavior(field));
+               }
+
+               @Override
+               public IResourceStream getMarkupResourceStream(MarkupContainer 
container,
+                       Class<?> containerClass)
+               {
+                       return new StringResourceStream("<html><body><div 
wicket:id=\"zone\">" +
+                               "<input type=\"file\" wicket:id=\"upload\" 
multiple/></div></body></html>");
+               }
+       }
+}
diff --git 
a/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileListBehaviorTest.java
 
b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileListBehaviorTest.java
new file mode 100644
index 0000000000..6707725e5e
--- /dev/null
+++ 
b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileListBehaviorTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.markup.html.form.upload.resource;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.regex.Pattern;
+import org.apache.wicket.MarkupContainer;
+import org.apache.wicket.markup.IMarkupResourceStreamProvider;
+import org.apache.wicket.markup.MarkupException;
+import org.apache.wicket.markup.html.WebMarkupContainer;
+import org.apache.wicket.markup.html.WebPage;
+import 
org.apache.wicket.markup.html.form.upload.resource.FileUploadToResourceFieldTest.TestField;
+import 
org.apache.wicket.markup.html.form.upload.resource.FileUploadToResourceFieldTest.TestFileUploadResourceReference;
+import org.apache.wicket.util.file.File;
+import org.apache.wicket.util.resource.IResourceStream;
+import org.apache.wicket.util.resource.StringResourceStream;
+import org.apache.wicket.util.tester.WicketTestCase;
+import org.junit.jupiter.api.Test;
+
+class FileListBehaviorTest extends WicketTestCase
+{
+       @Test
+       void listsTheFilesOfTheField()
+       {
+               ListPage page = tester.startPage(new ListPage("ul"));
+
+               
tester.assertContains(Pattern.quote("Wicket.FileUploadToResourceField.addList(\""
 +
+                       page.field.getMarkupId() + "\", \"" + 
page.list.getMarkupId() + "\", \"Remove\");"));
+       }
+
+       @Test
+       void canBeAttachedToAnOrderedList()
+       {
+               ListPage page = tester.startPage(new ListPage("ol"));
+
+               
tester.assertContains(Pattern.quote("Wicket.FileUploadToResourceField.addList(\""
 +
+                       page.field.getMarkupId() + "\", \"" + 
page.list.getMarkupId() + "\", \"Remove\");"));
+       }
+
+       @Test
+       void canOnlyBeAttachedToAList()
+       {
+               assertThrows(MarkupException.class, () -> tester.startPage(new 
ListPage("div")));
+       }
+
+       static class ListPage extends WebPage implements 
IMarkupResourceStreamProvider
+       {
+               private static final long serialVersionUID = 1L;
+
+               private final String listTag;
+
+               final TestField field;
+
+               final WebMarkupContainer list;
+
+               ListPage(String listTag)
+               {
+                       this.listTag = listTag;
+                       field = new TestField("upload", new 
TestFileUploadResourceReference(
+                               new FolderUploadsFileManager(new 
File(System.getProperty("java.io.tmpdir")))));
+                       add(field);
+                       list = new WebMarkupContainer("list");
+                       list.add(new FileListBehavior(field));
+                       add(list);
+               }
+
+               @Override
+               public IResourceStream getMarkupResourceStream(MarkupContainer 
container,
+                       Class<?> containerClass)
+               {
+                       return new StringResourceStream("<html><body><input 
type=\"file\" wicket:id=\"upload\"/><" +
+                               listTag + " wicket:id=\"list\"></" + listTag + 
"></body></html>");
+               }
+       }
+}
diff --git 
a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileDropZoneBehavior.java
 
b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileDropZoneBehavior.java
new file mode 100644
index 0000000000..686e79a87a
--- /dev/null
+++ 
b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileDropZoneBehavior.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.markup.html.form.upload.resource;
+
+import org.apache.wicket.Component;
+import org.apache.wicket.behavior.Behavior;
+import org.apache.wicket.markup.head.IHeaderResponse;
+import org.apache.wicket.markup.head.JavaScriptHeaderItem;
+import org.apache.wicket.markup.head.OnDomReadyHeaderItem;
+import org.apache.wicket.resource.CoreLibrariesContributor;
+import org.apache.wicket.util.lang.Args;
+import com.github.openjson.JSONObject;
+
+/**
+ * Makes a component a zone files are dropped on to choose them for the next 
upload of a
+ * {@link FileUploadToResourceField}, as if they were chosen in the browser's 
dialog of its input.
+ * The input receives the dropped files it accepts by its {@code accept} 
attribute: all of them if
+ * it is {@code multiple}, else the first. Folders are left out. The input 
then fires its
+ * {@code input} and {@code change} events, as it does for files chosen in the 
dialog.
+ * <p>
+ * With a {@link FileListBehavior}, dropped files are added to those chosen 
before. While files are
+ * dragged over the component, it has the CSS class {@value 
#DRAG_OVER_CSS_CLASS}.
+ *
+ * <pre>
+ * FileUploadToResourceField field = ...;
+ * add(new WebMarkupContainer("dropZone").add(new 
FileDropZoneBehavior(field)));
+ * </pre>
+ *
+ * @since 11.0.0
+ */
+public class FileDropZoneBehavior extends Behavior
+{
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * The CSS class of the component while files are dragged over it.
+        */
+       public static final String DRAG_OVER_CSS_CLASS = 
"wicket-upload-dragover";
+
+       private final FileUploadToResourceField field;
+
+       /**
+        * @param field
+        *            the field the dropped files are chosen for
+        */
+       public FileDropZoneBehavior(FileUploadToResourceField field)
+       {
+               this.field = Args.notNull(field, "field");
+       }
+
+       @Override
+       public void bind(Component component)
+       {
+               super.bind(component);
+               component.setOutputMarkupId(true);
+       }
+
+       @Override
+       public void renderHead(Component component, IHeaderResponse response)
+       {
+               super.renderHead(component, response);
+               
CoreLibrariesContributor.contributeAjax(component.getApplication(), response);
+               
response.render(JavaScriptHeaderItem.forReference(FileUploadToResourceField.JS));
+               response.render(OnDomReadyHeaderItem.forScript(
+                       "Wicket.FileUploadToResourceField.addDropZone(" +
+                               JSONObject.quote(component.getMarkupId()) + ", 
" +
+                               JSONObject.quote(field.getMarkupId()) + ", " +
+                               JSONObject.quote(DRAG_OVER_CSS_CLASS) + ");"));
+       }
+}
diff --git 
a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileListBehavior.java
 
b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileListBehavior.java
new file mode 100644
index 0000000000..34139e0f4a
--- /dev/null
+++ 
b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileListBehavior.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.wicket.markup.html.form.upload.resource;
+
+import org.apache.wicket.Component;
+import org.apache.wicket.behavior.Behavior;
+import org.apache.wicket.markup.ComponentTag;
+import org.apache.wicket.markup.MarkupException;
+import org.apache.wicket.markup.head.IHeaderResponse;
+import org.apache.wicket.markup.head.JavaScriptHeaderItem;
+import org.apache.wicket.markup.head.OnDomReadyHeaderItem;
+import org.apache.wicket.resource.CoreLibrariesContributor;
+import org.apache.wicket.util.lang.Args;
+import com.github.openjson.JSONObject;
+
+/**
+ * Lists the files chosen for the next upload of a {@link 
FileUploadToResourceField}, each with a
+ * button removing it. It is attached to a {@code <ul>} or an {@code <ol>}, 
whose items it replaces
+ * in the browser with one for each file:
+ *
+ * <pre>
+ * &lt;li class="wicket-upload-file"&gt;
+ *   &lt;span class="wicket-upload-file-name"&gt;report.pdf&lt;/span&gt;
+ *   &lt;button type="button" 
class="wicket-upload-file-remove"&gt;Remove&lt;/button&gt;
+ * &lt;/li&gt;
+ * </pre>
+ *
+ * The name of a file is written as text, never as markup. The text of the 
buttons is the resource
+ * {@value #REMOVE_RESOURCE_KEY} of the field.
+ * <p>
+ * With a list, the field collects the files of several choices: if its input 
is
+ * {@code multiple}, files chosen in the browser's dialog or dropped on a
+ * {@link FileDropZoneBehavior drop zone} are added to the listed ones instead 
of replacing them.
+ * Once an upload succeeded, the files it sent leave the list. While an upload 
runs, no file can be
+ * removed.
+ *
+ * <pre>
+ * FileUploadToResourceField field = ...;
+ * add(new WebMarkupContainer("files").add(new FileListBehavior(field)));
+ * </pre>
+ *
+ * @since 11.0.0
+ */
+public class FileListBehavior extends Behavior
+{
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * The resource key of the text of the buttons removing a file.
+        */
+       public static final String REMOVE_RESOURCE_KEY = 
"uploadToResourceRemove";
+
+       private final FileUploadToResourceField field;
+
+       /**
+        * @param field
+        *            the field whose files are listed
+        */
+       public FileListBehavior(FileUploadToResourceField field)
+       {
+               this.field = Args.notNull(field, "field");
+       }
+
+       @Override
+       public void bind(Component component)
+       {
+               super.bind(component);
+               component.setOutputMarkupId(true);
+       }
+
+       @Override
+       public void onComponentTag(Component component, ComponentTag tag)
+       {
+               super.onComponentTag(component, tag);
+               if (!tag.getName().equalsIgnoreCase("ul") && 
!tag.getName().equalsIgnoreCase("ol"))
+               {
+                       throw new MarkupException("A FileListBehavior can only 
be attached to <ul> or <ol>, " +
+                               "not to <" + tag.getName() + "> of " + 
component);
+               }
+       }
+
+       @Override
+       public void renderHead(Component component, IHeaderResponse response)
+       {
+               super.renderHead(component, response);
+               
CoreLibrariesContributor.contributeAjax(component.getApplication(), response);
+               
response.render(JavaScriptHeaderItem.forReference(FileUploadToResourceField.JS));
+               
response.render(OnDomReadyHeaderItem.forScript("Wicket.FileUploadToResourceField.addList("
 +
+                       JSONObject.quote(field.getMarkupId()) + ", " +
+                       JSONObject.quote(component.getMarkupId()) + ", " +
+                       JSONObject.quote(field.getString(REMOVE_RESOURCE_KEY)) 
+ ");"));
+       }
+}
diff --git 
a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.java
 
b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.java
index 5ce7eec0cd..ea002d8231 100644
--- 
a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.java
+++ 
b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.java
@@ -55,6 +55,9 @@ import com.github.openjson.JSONObject;
  * Implementation of FileUploadField capable to uploading a file into a wicket 
mounted resource.
  * This field does not require a {@link 
org.apache.wicket.markup.html.form.Form}!
  * The upload of the file id done via the {@link 
#startUpload(IPartialPageRequestHandler)} method.
+ * <p>
+ * A {@link FileDropZoneBehavior} makes a component a zone the files to upload 
can be dropped on,
+ * and a {@link FileListBehavior} lists the files chosen, which then add up 
over several choices.
  */
 public abstract class FileUploadToResourceField extends FileUploadField
 {
@@ -199,7 +202,7 @@ public abstract class FileUploadToResourceField extends 
FileUploadField
         }
     }
 
-    private static final JavaScriptResourceReference JS = new 
JavaScriptResourceReference(FileUploadToResourceField.class, 
"FileUploadToResourceField.js");
+    static final JavaScriptResourceReference JS = new 
JavaScriptResourceReference(FileUploadToResourceField.class, 
"FileUploadToResourceField.js");
 
     public static String UPLOAD_CANCELED = "upload.canceled";
 
diff --git 
a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js
 
b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js
index 4ae7b50823..276fc56dd1 100644
--- 
a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js
+++ 
b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js
@@ -19,10 +19,25 @@
 
     'use strict';
 
-    if (typeof(Wicket.FileUploadToResourceField) === 'object') {
+    if (typeof(Wicket.FileUploadToResourceField) === 'function') {
         return;
     }
 
+    // the keys of the errors, as the field knows them
+    const UPLOAD_TOO_LARGE = 'uploadTooLarge';
+    const UPLOAD_SINGLE_FILE_TOO_LARGE = 'uploadSingleFileTooLarge';
+    const UPLOAD_TOO_MANY_FILES = 'uploadTooManyFiles';
+    const UPLOAD_FAILED = 'uploadFailed';
+    const CONNECTION_LOST = 'uploadConnectionLost';
+    const UPLOAD_CANCELED = 'upload.canceled';
+
+    // the file lists of the fields, by field id: the text of the remove 
buttons, by list id
+    const lists = {};
+
+    // the running uploads, by field id: the request, the files it sends and 
the field it belongs
+    // to, which a new rendering of the field takes over
+    const uploads = {};
+
     Wicket.FileUploadToResourceField = function (settings, 
clientBeforeSendCallBack, clientSideSuccessCallBack, clientSideCancelCallBack, 
uploadErrorCallBack)
     {
         this.settings = settings;
@@ -41,15 +56,268 @@
         this.clientSideSuccessCallBack = clientSideSuccessCallBack;
         this.clientSideCancelCallBack = clientSideCancelCallBack;
         this.uploadErrorCallBack = uploadErrorCallBack;
+        // the files chosen for the next upload, which the input holds as well
+        this.files = Array.from(this.input.files);
+        if (uploads[this.inputName]) {
+            uploads[this.inputName].field = this;
+        }
+        const self = this;
+        // in the capture phase, so the other listeners of the input see all 
the chosen files
+        this.input.addEventListener('change', function () {
+            if (!self.dispatching) {
+                self.choose(Array.from(self.input.files));
+            }
+        }, true);
+        this.renderLists();
     };
 
-    // the keys of the errors, as the field knows them
-    const UPLOAD_TOO_LARGE = 'uploadTooLarge';
-    const UPLOAD_SINGLE_FILE_TOO_LARGE = 'uploadSingleFileTooLarge';
-    const UPLOAD_TOO_MANY_FILES = 'uploadTooManyFiles';
-    const UPLOAD_FAILED = 'uploadFailed';
-    const CONNECTION_LOST = 'uploadConnectionLost';
-    const UPLOAD_CANCELED = 'upload.canceled';
+    /**
+     * Makes a file list of a field show the files chosen for its next upload. 
With a list, the
+     * files chosen are added to those chosen before if the input is multiple, 
and leave the list
+     * once they are uploaded.
+     *
+     * @param fieldId the id of the field
+     * @param listId the id of the list, a ul or an ol
+     * @param removeText the text of the buttons removing a file
+     */
+    Wicket.FileUploadToResourceField.addList = function (fieldId, listId, 
removeText)
+    {
+        lists[fieldId] = lists[fieldId] || {};
+        lists[fieldId][listId] = removeText;
+        const field = Wicket.Timer[fieldId];
+        if (field) {
+            field.renderLists();
+        }
+    };
+
+    /**
+     * Makes an element a zone files are dropped on to choose them for the 
upload of a field.
+     *
+     * @param zoneId the id of the element
+     * @param fieldId the id of the field
+     * @param dragOverClass the CSS class of the element while files are 
dragged over it
+     */
+    Wicket.FileUploadToResourceField.addDropZone = function (zoneId, fieldId, 
dragOverClass)
+    {
+        const zone = document.getElementById(zoneId);
+        if (!zone || zone.wicketUploadDropZone) {
+            return;
+        }
+        zone.wicketUploadDropZone = true;
+        // dragenter and dragleave fire for every element within the zone the 
files pass over
+        let depth = 0;
+        const draggingFiles = function (event) {
+            return event.dataTransfer && 
Array.prototype.indexOf.call(event.dataTransfer.types, 'Files') >= 0;
+        };
+        const leave = function () {
+            depth = 0;
+            zone.classList.remove(dragOverClass);
+        };
+        zone.addEventListener('dragenter', function (event) {
+            if (draggingFiles(event)) {
+                event.preventDefault();
+                depth++;
+                zone.classList.add(dragOverClass);
+            }
+        });
+        zone.addEventListener('dragover', function (event) {
+            if (draggingFiles(event)) {
+                event.preventDefault();
+                event.dataTransfer.dropEffect = 'copy';
+            }
+        });
+        zone.addEventListener('dragleave', function (event) {
+            if (draggingFiles(event) && --depth <= 0) {
+                leave();
+            }
+        });
+        zone.addEventListener('drop', function (event) {
+            if (draggingFiles(event)) {
+                event.preventDefault();
+                leave();
+                const field = Wicket.Timer[fieldId];
+                if (field) {
+                    field.drop(event.dataTransfer);
+                }
+            }
+        });
+    };
+
+    /**
+     * @param dataTransfer the data of a drop
+     * @returns {File[]} the files dropped, without folders
+     */
+    function droppedFiles(dataTransfer)
+    {
+        if (!dataTransfer.items) {
+            return Array.from(dataTransfer.files);
+        }
+        const files = [];
+        for (let index = 0; index < dataTransfer.items.length; index++) {
+            const item = dataTransfer.items[index];
+            const entry = item.kind === 'file' && item.webkitGetAsEntry ? 
item.webkitGetAsEntry() : null;
+            const file = item.kind === 'file' && !(entry && entry.isDirectory) 
? item.getAsFile() : null;
+            if (file) {
+                files.push(file);
+            }
+        }
+        return files;
+    }
+
+    /**
+     * @param accept the accept attribute of an input
+     * @param file a file
+     * @returns {boolean} whether the input accepts the file, by its extension 
or its type
+     */
+    function accepts(accept, file)
+    {
+        if (!accept) {
+            return true;
+        }
+        const name = file.name.toLowerCase();
+        const type = (file.type || '').toLowerCase();
+        return accept.split(',').some(function (token) {
+            token = token.trim().toLowerCase();
+            if (token.charAt(0) === '.') {
+                return name.endsWith(token);
+            }
+            if (token.endsWith('/*')) {
+                return type.startsWith(token.slice(0, -1));
+            }
+            return token !== '' && type === token;
+        });
+    }
+
+    /**
+     * Chooses files for the next upload: in addition to those chosen before 
if the field lists its
+     * files and its input is multiple, else instead of them.
+     *
+     * @param files the files
+     */
+    Wicket.FileUploadToResourceField.prototype.choose = function (files)
+    {
+        this.files = this.hasLists() && this.input.multiple ? 
this.files.concat(files) : files;
+        this.updateInput();
+        this.renderLists();
+    };
+
+    /**
+     * Chooses the files dropped on a drop zone of the field as if they were 
chosen in the
+     * browser's dialog, and fires the input and change events of the input: 
those the input
+     * accepts, and only the first of them unless the input is multiple.
+     *
+     * @param dataTransfer the data of the drop
+     */
+    Wicket.FileUploadToResourceField.prototype.drop = function (dataTransfer)
+    {
+        const input = this.input;
+        let files = droppedFiles(dataTransfer).filter(function (file) {
+            return accepts(input.accept, file);
+        });
+        if (!input.multiple) {
+            files = files.slice(0, 1);
+        }
+        if (files.length === 0) {
+            return;
+        }
+        this.choose(files);
+        this.dispatching = true;
+        try {
+            input.dispatchEvent(new Event('input', {'bubbles': true}));
+            input.dispatchEvent(new Event('change', {'bubbles': true}));
+        } finally {
+            this.dispatching = false;
+        }
+    };
+
+    /**
+     * Takes a file out of those chosen for the next upload.
+     *
+     * @param file the file
+     */
+    Wicket.FileUploadToResourceField.prototype.remove = function (file)
+    {
+        this.files = this.files.filter(function (chosen) {
+            return chosen !== file;
+        });
+        this.updateInput();
+        this.renderLists();
+    };
+
+    Wicket.FileUploadToResourceField.prototype.updateInput = function ()
+    {
+        const dataTransfer = new window.DataTransfer();
+        this.files.forEach(function (file) {
+            dataTransfer.items.add(file);
+        });
+        this.input.files = dataTransfer.files;
+    };
+
+    /**
+     * @returns {boolean} whether a file list of the field is in the page
+     */
+    Wicket.FileUploadToResourceField.prototype.hasLists = function ()
+    {
+        return Object.keys(lists[this.inputName] || {}).some(function (listId) 
{
+            return document.getElementById(listId) !== null;
+        });
+    };
+
+    /**
+     * @returns {boolean} whether the input of the field is still in the page: 
once a new rendering
+     *          replaced it, its lists belong to the field created for the new 
input
+     */
+    Wicket.FileUploadToResourceField.prototype.isCurrent = function ()
+    {
+        return document.getElementById(this.inputName) === this.input;
+    };
+
+    Wicket.FileUploadToResourceField.prototype.renderLists = function ()
+    {
+        if (!this.isCurrent()) {
+            return;
+        }
+        const fieldLists = lists[this.inputName] || {};
+        Object.keys(fieldLists).forEach(function (listId) {
+            const list = document.getElementById(listId);
+            if (list) {
+                this.renderList(list, fieldLists[listId]);
+            }
+        }, this);
+    };
+
+    /**
+     * Renders an item for each chosen file into a list: its name, as text, 
and a button removing
+     * it, which is disabled while an upload runs.
+     *
+     * @param list the ul or ol
+     * @param removeText the text of the buttons
+     */
+    Wicket.FileUploadToResourceField.prototype.renderList = function (list, 
removeText)
+    {
+        const self = this;
+        list.replaceChildren();
+        this.files.forEach(function (file, index) {
+            const name = document.createElement('span');
+            name.className = 'wicket-upload-file-name';
+            name.id = list.id + '-' + index + '-name';
+            name.textContent = file.name;
+            const remove = document.createElement('button');
+            remove.type = 'button';
+            remove.className = 'wicket-upload-file-remove';
+            remove.id = list.id + '-' + index + '-remove';
+            remove.textContent = removeText;
+            remove.setAttribute('aria-labelledby', remove.id + ' ' + name.id);
+            remove.disabled = self.isUploading();
+            remove.addEventListener('click', function () {
+                self.remove(file);
+            });
+            const item = document.createElement('li');
+            item.className = 'wicket-upload-file';
+            item.append(name, ' ', remove);
+            list.appendChild(item);
+        });
+    };
 
     /**
      * Uploads the files of the input, unless an upload of the field is 
running already, or the
@@ -60,12 +328,10 @@
      */
     Wicket.FileUploadToResourceField.prototype.upload = function()
     {
-        if (this.xhr) {
+        if (this.isUploading()) {
             Wicket.Log.log("An upload of field '" + this.inputName + "' is 
running: it has to end before another one starts.");
             return false;
         }
-        // get a fresh reference to input
-        this.input = document.getElementById(this.inputName);
         const files = this.input.files;
         const exceededLimit = this.exceededLimit(files);
         if (exceededLimit) {
@@ -77,47 +343,48 @@
         for (let index = 0; index < files.length; index++) {
             formData.append("WICKET-FILE-UPLOAD", files[index]);
         }
-        const self = this;
         // we post the files to the resource (this.resourceUrl) with a plain 
XMLHttpRequest
         // and keep a reference to it in order to be able to cancel the upload
         const xhr = new XMLHttpRequest();
+        const upload = {'xhr': xhr, 'files': this.files.slice(), 'field': 
this};
 
         xhr.open("POST", this.resourceUrl);
 
         xhr.onload = function () {
+            const field = upload.field;
             if (xhr.status < 200 || xhr.status >= 300) {
-                self.failed(UPLOAD_FAILED);
+                field.failed(UPLOAD_FAILED);
                 return;
             }
             let res;
             try {
                 res = xhr.responseText ? JSON.parse(xhr.responseText) : {};
             } catch (parseError) {
-                Wicket.Log.error("The answer to the upload of field '" + 
self.inputName + "' is not JSON: " + xhr.responseText);
-                self.failed(UPLOAD_FAILED);
+                Wicket.Log.error("The answer to the upload of field '" + 
field.inputName + "' is not JSON: " + xhr.responseText);
+                field.failed(UPLOAD_FAILED);
                 return;
             }
             if (res.error) {
-                self.failed(res.errorMessage);
+                field.failed(res.errorMessage);
             } else {
-                delete self.xhr;
-                self.clientSideSuccessCallBack();
-                self.report({'error': false, 'filesInfo': 
JSON.stringify(res)});
+                field.uploaded(upload.files);
+                field.clientSideSuccessCallBack();
+                field.report({'error': false, 'filesInfo': 
JSON.stringify(res)});
             }
         };
 
         xhr.onerror = function () {
-            self.failed(CONNECTION_LOST);
+            upload.field.failed(CONNECTION_LOST);
         };
 
         xhr.ontimeout = function () {
-            self.failed(UPLOAD_FAILED);
+            upload.field.failed(UPLOAD_FAILED);
         };
 
         xhr.onabort = function () {
             // user aborted the upload.
-            delete self.xhr;
-            self.report({'error': true, 'errorMessage': UPLOAD_CANCELED});
+            upload.field.ended();
+            upload.field.report({'error': true, 'errorMessage': 
UPLOAD_CANCELED});
         };
 
         if (this.clientBeforeSendCallBack(xhr) === false) {
@@ -126,11 +393,43 @@
             return false;
         }
 
-        this.xhr = xhr;
+        uploads[this.inputName] = upload;
+        this.renderLists();
         xhr.send(formData);
         return true;
     };
 
+    /**
+     * @returns {boolean} whether an upload of the field runs, which it may 
have taken over from a
+     *          rendering of the field before
+     */
+    Wicket.FileUploadToResourceField.prototype.isUploading = function ()
+    {
+        return uploads[this.inputName] !== undefined;
+    };
+
+    Wicket.FileUploadToResourceField.prototype.ended = function ()
+    {
+        delete uploads[this.inputName];
+        this.renderLists();
+    };
+
+    /**
+     * Ends an upload that succeeded: the files it sent leave the lists of the 
field.
+     *
+     * @param sent the files the upload sent
+     */
+    Wicket.FileUploadToResourceField.prototype.uploaded = function (sent)
+    {
+        if (this.hasLists()) {
+            this.files = this.files.filter(function (file) {
+                return sent.indexOf(file) < 0;
+            });
+            this.updateInput();
+        }
+        this.ended();
+    };
+
     /**
      * @param files the files to upload
      * @returns {string|null} the key of the first limit of the field the 
files break, if any
@@ -163,7 +462,7 @@
      */
     Wicket.FileUploadToResourceField.prototype.failed = function (errorMessage)
     {
-        delete this.xhr;
+        this.ended();
         const ep = {'error': true, 'errorMessage': errorMessage};
         this.uploadErrorCallBack(ep);
         this.report(ep);
@@ -182,10 +481,8 @@
 
     // cancel the upload
     Wicket.FileUploadToResourceField.prototype.cancel = function () {
-        // we have a reference to the request we can cancel it.
-        if (this.xhr) {
-            this.xhr.abort();
-            delete (this.xhr);
+        if (this.isUploading()) {
+            uploads[this.inputName].xhr.abort();
             this.clientSideCancelCallBack();
             Wicket.Log.log("The upload associated with field '" + 
this.inputName + "' has been canceled!");
         } else {
diff --git 
a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.properties
 
b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.properties
index c614ece583..8242a41596 100644
--- 
a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.properties
+++ 
b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.properties
@@ -15,3 +15,4 @@
 wicket.no.files.selected = Nothing to upload: no files were selected.
 uploadToResourceFailed = The upload failed.
 uploadConnectionLost = The upload failed: the connection to the server was 
lost.
+uploadToResourceRemove = Remove
diff --git a/wicket-core/src/test/js/all.html b/wicket-core/src/test/js/all.html
index 83f4085202..ef2f3ab0e1 100644
--- a/wicket-core/src/test/js/all.html
+++ b/wicket-core/src/test/js/all.html
@@ -160,6 +160,9 @@
                        FileUploadToResourceField
                -->
                <input type="file" id="uploadToResourceInput" 
multiple="multiple"/>
+               <div id="uploadToResourceDropZone"><input type="file" 
id="uploadToResourceListedInput" multiple="multiple"/></div>
+               <ul id="uploadToResourceList"></ul>
+               <input type="file" id="uploadToResourceSingleInput" 
accept=".txt,image/*"/>
 
                <div id="ajaxIndicator"></div>
        </div>
diff --git a/wicket-core/src/test/js/upload-to-resource.js 
b/wicket-core/src/test/js/upload-to-resource.js
index baac2feb0e..4b9b784b05 100644
--- a/wicket-core/src/test/js/upload-to-resource.js
+++ b/wicket-core/src/test/js/upload-to-resource.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-/*global QUnit: true, DataTransfer: true */
+/*global QUnit: true, DataTransfer: true, DragEvent: true */
 
 ;(function (undefined) {
        "use strict";
@@ -37,20 +37,35 @@
                };
                FakeXhr.prototype.send = function (body) {
                        this.body = body;
+                       this.running = true;
+               };
+               FakeXhr.prototype.end = function (handler) {
+                       this.running = false;
+                       this[handler]();
                };
                FakeXhr.prototype.abort = function () {
-                       this.aborted = true;
-                       this.onabort();
+                       if (this.running) {
+                               this.aborted = true;
+                               this.end('onabort');
+                       }
                };
                FakeXhr.prototype.respond = function (status, responseText) {
                        this.status = status;
                        this.responseText = responseText;
-                       this.onload();
+                       this.end('onload');
+               };
+               FakeXhr.prototype.fail = function () {
+                       this.end('onerror');
                };
                FakeXhr.prototype.timeOut = function () {
-                       this.ontimeout();
+                       this.end('ontimeout');
                };
 
+               // the field keeps a running upload beyond the test that 
started it
+               function abortUploads() {
+                       FakeXhr.instances.forEach(xhr => xhr.abort());
+               }
+
                let originalXhr, originalPost, reports, calls;
 
                function chooseFiles(files) {
@@ -59,6 +74,14 @@
                        document.getElementById(INPUT_ID).files = 
dataTransfer.files;
                }
 
+               // replaces an input with an empty copy, as an Ajax rendering 
of its field does
+               function rerender(inputId) {
+                       const input = document.getElementById(inputId);
+                       const copy = input.cloneNode(false);
+                       copy.files = new DataTransfer().files;
+                       input.replaceWith(copy);
+               }
+
                function newFile(name, size) {
                        return new File([new Uint8Array(size)], name, {type: 
'text/plain'});
                }
@@ -91,6 +114,7 @@
                                };
                        },
                        afterEach: function () {
+                               abortUploads();
                                window.XMLHttpRequest = originalXhr;
                                Wicket.Ajax.post = originalPost;
                        }
@@ -144,7 +168,7 @@
                        chooseFiles([newFile('a.txt', 3)]);
                        newField().upload();
 
-                       FakeXhr.instances[0].onerror();
+                       FakeXhr.instances[0].fail();
 
                        assert.deepEqual(calls, ['error:uploadConnectionLost']);
                        assert.deepEqual(reports[0].ep, {'error': true, 
'errorMessage': 'uploadConnectionLost'});
@@ -236,5 +260,289 @@
                        field.upload();
                        assert.equal(FakeXhr.instances.length, 2, 'the field is 
not left busy');
                });
+
+               test('a field rendered anew refuses a second upload while the 
first one runs', assert => {
+                       chooseFiles([newFile('a.txt', 3)]);
+                       newField().upload();
+
+                       rerender(INPUT_ID);
+                       const field = newField();
+                       chooseFiles([newFile('b.txt', 3)]);
+
+                       assert.false(field.upload(), 'refused while the first 
one runs');
+                       assert.equal(FakeXhr.instances.length, 1);
+                       FakeXhr.instances[0].respond(200, '[]');
+                       assert.true(field.upload(), 'sent once the first one 
ended');
+               });
+
+               test('a field rendered anew cancels the running upload', assert 
=> {
+                       chooseFiles([newFile('a.txt', 3)]);
+                       newField().upload();
+
+                       rerender(INPUT_ID);
+                       newField({ajaxCallBackUrl: 'newCallback'}).cancel();
+
+                       assert.true(FakeXhr.instances[0].aborted);
+                       assert.deepEqual(calls, ['cancel']);
+                       assert.deepEqual(reports.map(report => [report.u, 
report.ep.errorMessage]),
+                               [['newCallback', 'upload.canceled']]);
+               });
+
+               test('a field rendered anew reports the end of the running 
upload', assert => {
+                       chooseFiles([newFile('a.txt', 3)]);
+                       newField().upload();
+
+                       rerender(INPUT_ID);
+                       newField({ajaxCallBackUrl: 'newCallback'});
+                       FakeXhr.instances[0].respond(200, '[]');
+
+                       assert.deepEqual(calls, ['success']);
+                       assert.deepEqual(reports.map(report => [report.u, 
report.ep.error]), [['newCallback', false]]);
+               });
+
+               const LISTED_ID = 'uploadToResourceListedInput';
+               const SINGLE_ID = 'uploadToResourceSingleInput';
+               const LIST_ID = 'uploadToResourceList';
+               const ZONE_ID = 'uploadToResourceDropZone';
+
+               function newFieldOf(inputId) {
+                       const field = newField({inputName: inputId});
+                       Wicket.Timer[inputId] = field;
+                       return field;
+               }
+
+               function pick(inputId, files) {
+                       const dataTransfer = new DataTransfer();
+                       files.forEach(file => dataTransfer.items.add(file));
+                       const input = document.getElementById(inputId);
+                       input.files = dataTransfer.files;
+                       input.dispatchEvent(new Event('change', {bubbles: 
true}));
+               }
+
+               function drop(files) {
+                       const dataTransfer = new DataTransfer();
+                       files.forEach(file => dataTransfer.items.add(file));
+                       document.getElementById(ZONE_ID).dispatchEvent(new 
DragEvent('drop',
+                               {dataTransfer: dataTransfer, bubbles: true, 
cancelable: true}));
+               }
+
+               function listed() {
+                       return Array.from(document.querySelectorAll('#' + 
LIST_ID + ' .wicket-upload-file-name'))
+                               .map(name => name.textContent);
+               }
+
+               function inputFiles(inputId) {
+                       return 
Array.from(document.getElementById(inputId).files).map(file => file.name);
+               }
+
+               function removeButtons() {
+                       return Array.from(document.querySelectorAll('#' + 
LIST_ID + ' .wicket-upload-file-remove'));
+               }
+
+               module('Wicket.FileUploadToResourceField file lists and drop 
zones', {
+                       beforeEach: function () {
+                               originalXhr = window.XMLHttpRequest;
+                               originalPost = Wicket.Ajax.post;
+                               FakeXhr.instances = [];
+                               window.XMLHttpRequest = FakeXhr;
+                               reports = [];
+                               calls = [];
+                               Wicket.Ajax.post = function (attrs) {
+                                       reports.push(attrs);
+                               };
+                       },
+                       afterEach: function () {
+                               abortUploads();
+                               window.XMLHttpRequest = originalXhr;
+                               Wicket.Ajax.post = originalPost;
+                               delete Wicket.Timer[LISTED_ID];
+                               delete Wicket.Timer[SINGLE_ID];
+                       }
+               });
+
+               test('without a list, a choice replaces the files chosen 
before', assert => {
+                       newFieldOf(LISTED_ID);
+
+                       pick(LISTED_ID, [newFile('a.txt', 1)]);
+                       pick(LISTED_ID, [newFile('b.txt', 1)]);
+
+                       assert.deepEqual(inputFiles(LISTED_ID), ['b.txt']);
+               });
+
+               test('a list shows the files of several choices, which add up', 
assert => {
+                       newFieldOf(LISTED_ID);
+                       Wicket.FileUploadToResourceField.addList(LISTED_ID, 
LIST_ID, 'Remove');
+
+                       pick(LISTED_ID, [newFile('a.txt', 1)]);
+                       pick(LISTED_ID, [newFile('b.txt', 1), newFile('c.txt', 
1)]);
+
+                       assert.deepEqual(listed(), ['a.txt', 'b.txt', 'c.txt']);
+                       assert.deepEqual(inputFiles(LISTED_ID), ['a.txt', 
'b.txt', 'c.txt']);
+               });
+
+               test('a list added after the field shows the files chosen 
before', assert => {
+                       newFieldOf(LISTED_ID);
+                       pick(LISTED_ID, [newFile('a.txt', 1)]);
+
+                       Wicket.FileUploadToResourceField.addList(LISTED_ID, 
LIST_ID, 'Remove');
+
+                       assert.deepEqual(listed(), ['a.txt']);
+               });
+
+               test('the name of a file is listed as text', assert => {
+                       newFieldOf(LISTED_ID);
+                       Wicket.FileUploadToResourceField.addList(LISTED_ID, 
LIST_ID, 'Remove');
+
+                       pick(LISTED_ID, [newFile('<b>bold</b>.txt', 1)]);
+
+                       assert.deepEqual(listed(), ['<b>bold</b>.txt']);
+                       assert.equal(document.querySelectorAll('#' + LIST_ID + 
' b').length, 0);
+               });
+
+               test('a button removes its file', assert => {
+                       newFieldOf(LISTED_ID);
+                       Wicket.FileUploadToResourceField.addList(LISTED_ID, 
LIST_ID, 'Remove');
+                       pick(LISTED_ID, [newFile('a.txt', 1), newFile('b.txt', 
1)]);
+
+                       const button = removeButtons()[1];
+                       assert.equal(button.textContent, 'Remove');
+                       assert.equal(button.getAttribute('aria-labelledby'),
+                               button.id + ' ' + document.querySelectorAll('#' 
+ LIST_ID + ' .wicket-upload-file-name')[1].id);
+                       button.click();
+
+                       assert.deepEqual(listed(), ['a.txt']);
+                       assert.deepEqual(inputFiles(LISTED_ID), ['a.txt']);
+               });
+
+               test('the files sent leave the list once uploaded, those chosen 
meanwhile stay', assert => {
+                       const field = newFieldOf(LISTED_ID);
+                       Wicket.FileUploadToResourceField.addList(LISTED_ID, 
LIST_ID, 'Remove');
+                       pick(LISTED_ID, [newFile('a.txt', 1)]);
+
+                       field.upload();
+                       assert.true(removeButtons().every(button => 
button.disabled), 'no removing while uploading');
+                       pick(LISTED_ID, [newFile('b.txt', 1)]);
+                       FakeXhr.instances[0].respond(200, '[]');
+
+                       assert.deepEqual(listed(), ['b.txt']);
+                       assert.deepEqual(inputFiles(LISTED_ID), ['b.txt']);
+                       assert.true(removeButtons().every(button => 
!button.disabled), 'removing once the upload ended');
+               });
+
+               test('a failed upload keeps its files in the list', assert => {
+                       const field = newFieldOf(LISTED_ID);
+                       Wicket.FileUploadToResourceField.addList(LISTED_ID, 
LIST_ID, 'Remove');
+                       pick(LISTED_ID, [newFile('a.txt', 1)]);
+
+                       field.upload();
+                       FakeXhr.instances[0].respond(500, 'Internal Server 
Error');
+
+                       assert.deepEqual(listed(), ['a.txt']);
+               });
+
+               test('a timed-out upload keeps its files in the list, and they 
can be removed again', assert => {
+                       const field = newFieldOf(LISTED_ID);
+                       Wicket.FileUploadToResourceField.addList(LISTED_ID, 
LIST_ID, 'Remove');
+                       pick(LISTED_ID, [newFile('a.txt', 1)]);
+                       field.upload();
+
+                       FakeXhr.instances[0].timeOut();
+
+                       assert.deepEqual(listed(), ['a.txt']);
+                       assert.true(removeButtons().every(button => 
!button.disabled));
+               });
+
+               test('a field rendered anew during an upload keeps its own 
files when the upload ends', assert => {
+                       const field = newFieldOf(LISTED_ID);
+                       Wicket.FileUploadToResourceField.addList(LISTED_ID, 
LIST_ID, 'Remove');
+                       pick(LISTED_ID, [newFile('a.txt', 1)]);
+                       field.upload();
+
+                       rerender(LISTED_ID);
+                       newFieldOf(LISTED_ID);
+                       pick(LISTED_ID, [newFile('b.txt', 1)]);
+                       assert.true(removeButtons().every(button => 
button.disabled), 'no removing while the upload runs');
+                       FakeXhr.instances[0].respond(200, '[]');
+
+                       assert.deepEqual(inputFiles(LISTED_ID), ['b.txt']);
+                       assert.deepEqual(listed(), ['b.txt']);
+                       assert.true(removeButtons().every(button => 
!button.disabled), 'removing once it ended');
+                       assert.deepEqual(calls, ['success'], 'the upload is 
reported');
+                       assert.deepEqual(reports.map(report => 
report.ep.error), [false]);
+               });
+
+               test('an upload ending after its field was removed still 
reports', assert => {
+                       const field = newFieldOf(LISTED_ID);
+                       Wicket.FileUploadToResourceField.addList(LISTED_ID, 
LIST_ID, 'Remove');
+                       pick(LISTED_ID, [newFile('a.txt', 1)]);
+                       field.upload();
+
+                       document.getElementById(LISTED_ID).remove();
+                       FakeXhr.instances[0].respond(200, '[]');
+
+                       assert.deepEqual(calls, ['success']);
+                       assert.deepEqual(reports.map(report => 
report.ep.error), [false]);
+               });
+
+               test('dropped files are chosen, and the input fires its 
events', assert => {
+                       newFieldOf(LISTED_ID);
+                       Wicket.FileUploadToResourceField.addList(LISTED_ID, 
LIST_ID, 'Remove');
+                       Wicket.FileUploadToResourceField.addDropZone(ZONE_ID, 
LISTED_ID, 'dragging');
+                       pick(LISTED_ID, [newFile('a.txt', 1)]);
+                       const events = [];
+                       const input = document.getElementById(LISTED_ID);
+                       input.addEventListener('input', () => 
events.push('input:' + input.files.length));
+                       input.addEventListener('change', () => 
events.push('change:' + input.files.length));
+
+                       drop([newFile('b.txt', 1), newFile('c.txt', 1)]);
+
+                       assert.deepEqual(listed(), ['a.txt', 'b.txt', 'c.txt']);
+                       assert.deepEqual(events, ['input:3', 'change:3']);
+               });
+
+               test('a drop zone chooses only the files the input accepts, one 
for a single input', assert => {
+                       newFieldOf(SINGLE_ID);
+                       Wicket.FileUploadToResourceField.addDropZone(ZONE_ID, 
SINGLE_ID, 'dragging');
+
+                       drop([new File(['x'], 'a.pdf', {type: 
'application/pdf'}),
+                               new File(['x'], 'b.png', {type: 'image/png'}),
+                               new File(['x'], 'c.txt', {type: 
'text/plain'})]);
+
+                       assert.deepEqual(inputFiles(SINGLE_ID), ['b.png']);
+               });
+
+               test('a drop zone is marked while files are dragged over it', 
assert => {
+                       newFieldOf(LISTED_ID);
+                       Wicket.FileUploadToResourceField.addDropZone(ZONE_ID, 
LISTED_ID, 'dragging');
+                       const zone = document.getElementById(ZONE_ID);
+                       const dataTransfer = new DataTransfer();
+                       dataTransfer.items.add(newFile('a.txt', 1));
+
+                       zone.dispatchEvent(new DragEvent('dragenter', 
{dataTransfer: dataTransfer, bubbles: true, cancelable: true}));
+                       assert.true(zone.classList.contains('dragging'), 
'marked');
+                       zone.dispatchEvent(new DragEvent('dragleave', 
{dataTransfer: dataTransfer, bubbles: true, cancelable: true}));
+                       assert.false(zone.classList.contains('dragging'), 
'unmarked');
+               });
+
+               test('a second run of the script keeps the field and its 
lists', assert => {
+                       const done = assert.async();
+                       const constructor = Wicket.FileUploadToResourceField;
+                       Wicket.FileUploadToResourceField.addList(LISTED_ID, 
LIST_ID, 'Remove');
+                       const script = document.createElement('script');
+                       script.src = 
document.querySelector('script[src$="FileUploadToResourceField.js"]').src;
+                       script.onload = function () {
+                               
assert.strictEqual(Wicket.FileUploadToResourceField, constructor, 'not 
replaced');
+
+                               newFieldOf(LISTED_ID);
+                               pick(LISTED_ID, [newFile('a.txt', 1)]);
+                               pick(LISTED_ID, [newFile('b.txt', 1)]);
+
+                               assert.deepEqual(inputFiles(LISTED_ID), 
['a.txt', 'b.txt'], 'the files add up');
+                               assert.deepEqual(listed(), ['a.txt', 'b.txt'], 
'the list shows them');
+                               script.remove();
+                               done();
+                       };
+                       document.head.appendChild(script);
+               });
        });
 })();
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.css
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.css
new file mode 100644
index 0000000000..e6ee56bf73
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.css
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+legend { border: 1px solid #e9601a; background-color: #bbb; color: #fff; 
padding: 4px; }
+fieldset { border: 1px solid #e9601a; padding: 10px; margin-top: 10px; }
+.upload-drop-zone { border: 2px dashed #bbb; padding: 10px; margin-bottom: 
10px; }
+.upload-drop-zone.wicket-upload-dragover { border-color: #e9601a; 
background-color: #fdf0e8; }
+.file-list:empty { display: none; }
+.wicket-upload-file-remove { margin-left: 0.5em; }
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.html
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.html
index bcccdf6a5b..b3536c16b5 100644
--- 
a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.html
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.html
@@ -1,20 +1,18 @@
 <html xmlns:wicket="http://wicket.apache.org";>
-<head>
-       <style>
-               legend { border: 1px solid #e9601a; background-color: #bbb; 
color: #fff; padding: 4px;}
-               fieldset { border: 1px solid #e9601a; padding: 10px; 
margin-top: 10px;}
-       </style>
-</head>
 <body>
     <wicket:extend>
 
                <div>
                 <fieldset>
                         <legend>Upload a file(s) to a <em>mounted 
resource</em></legend>
-                       <p>
-                               <label wicket:for="fileInput">File</label>
-                               <input wicket:id="fileInput" type="file" 
multiple="multiple"/>
-                       </p>
+                       <div wicket:id="dropZone" class="upload-drop-zone">
+                               <p>Drop files here, or choose them, as often as 
you like:</p>
+                               <p>
+                                       <label 
wicket:for="fileInput">Files</label>
+                                       <input wicket:id="fileInput" 
type="file" multiple="multiple"/>
+                               </p>
+                               <ul wicket:id="fileList" class="file-list"></ul>
+                       </div>
                        <form wicket:id="form">
                                <p>
                                        <input 
wicket:id="allowToLeavePageWhileUploading" type="checkbox"/>
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.java
index f929b17401..fc5f4af59e 100644
--- 
a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.java
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.java
@@ -26,17 +26,21 @@ import 
org.apache.wicket.core.request.handler.IPartialPageRequestHandler;
 import org.apache.wicket.event.IEvent;
 import org.apache.wicket.examples.WicketExamplePage;
 import 
org.apache.wicket.extensions.ajax.markup.html.form.upload.UploadProgressBar;
+import org.apache.wicket.markup.head.CssHeaderItem;
 import org.apache.wicket.markup.head.IHeaderResponse;
 import org.apache.wicket.markup.head.OnDomReadyHeaderItem;
 import org.apache.wicket.markup.head.OnEventHeaderItem;
 import org.apache.wicket.markup.html.WebMarkupContainer;
 import org.apache.wicket.markup.html.form.Form;
-import org.apache.wicket.markup.html.form.upload.resource.IUploadsFileManager;
+import org.apache.wicket.markup.html.form.upload.resource.FileDropZoneBehavior;
+import org.apache.wicket.markup.html.form.upload.resource.FileListBehavior;
 import 
org.apache.wicket.markup.html.form.upload.resource.FileUploadToResourceField;
+import org.apache.wicket.markup.html.form.upload.resource.IUploadsFileManager;
 import org.apache.wicket.markup.html.panel.FeedbackPanel;
 import org.apache.wicket.model.IModel;
 import org.apache.wicket.model.Model;
 import org.apache.wicket.request.mapper.parameter.PageParameters;
+import org.apache.wicket.request.resource.CssResourceReference;
 import org.apache.wicket.util.lang.Bytes;
 
 /**
@@ -156,7 +160,13 @@ public class UploadToResourcePage extends WicketExamplePage
                fileUploadToResourceField.setMaxSize(Bytes.kilobytes(100));
                fileUploadToResourceField.setFileMaxSize(Bytes.kilobytes(90));
                fileUploadToResourceField.setFileCountMax(5L);
-               add(fileUploadToResourceField);
+
+               WebMarkupContainer dropZone = new 
WebMarkupContainer("dropZone");
+               dropZone.add(new 
FileDropZoneBehavior(fileUploadToResourceField));
+               add(dropZone);
+               dropZone.add(fileUploadToResourceField);
+               dropZone.add(new WebMarkupContainer("fileList")
+                       .add(new FileListBehavior(fileUploadToResourceField)));
 
                Form<Void> form = new Form<>("form");
                add(form);
@@ -254,8 +264,12 @@ public class UploadToResourcePage extends WicketExamplePage
        @Override
        public void renderHead(IHeaderResponse response) {
                super.renderHead(response);
-               response.render(
-                               
OnDomReadyHeaderItem.forScript("$(window).bind('beforeunload', function(){ if 
(Wicket.CurrentUpload != null) { return 'leave?'; }});"));
+               response.render(CssHeaderItem.forReference(
+                       new CssResourceReference(UploadToResourcePage.class, 
"UploadToResourcePage.css")));
+               response.render(OnDomReadyHeaderItem.forScript(
+                       "Wicket.Event.add(window, 'beforeunload', function 
(event) {" +
+                               " if (Wicket.CurrentUpload != null) { 
event.preventDefault(); event.returnValue = 'leave?'; }" +
+                               "});"));
        }
 
        @Override
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.properties
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.properties
index 25e21b65ea..6d1d7b0388 100644
--- 
a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.properties
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.properties
@@ -13,5 +13,5 @@
 #  See the License for the specific language governing permissions and
 #  limitations under the License.
 title=Multiple (AJAX) Upload to a Resource
-explanation=<p>Wicket can upload to a <em>mounted resource.</em></p> <p>This 
upload is done via jQuery AJAX and <em>does not</em> block Wicket AJAX</p> 
<p><em>Upload progressbar</em> has been updated to work with this component</p>
+explanation=<p>Wicket can upload to a <em>mounted resource.</em></p> <p>This 
upload is done with a plain XMLHttpRequest and <em>does not</em> block Wicket 
AJAX</p> <p>Files chosen several times or dropped on the zone add up in a list, 
from which each can be removed before the upload</p> <p>The limits of the 
field, 100 KB in all, 90 KB a file and 5 files, are checked before anything is 
sent: files beyond them are refused without being uploaded</p> <p>Files of the 
same name are kept apart: [...]
 order=3

Reply via email to