+
+ );
+});
diff --git a/src/examples/src/widgets/file-upload-input/multiple.m.css b/src/examples/src/widgets/file-upload-input/multiple.m.css
new file mode 100644
index 0000000000..b76e63c8c2
--- /dev/null
+++ b/src/examples/src/widgets/file-upload-input/multiple.m.css
@@ -0,0 +1,3 @@
+.table {
+ width: 100%;
+}
diff --git a/src/examples/src/widgets/file-upload-input/multiple.m.css.d.ts b/src/examples/src/widgets/file-upload-input/multiple.m.css.d.ts
new file mode 100644
index 0000000000..a6f2a84d49
--- /dev/null
+++ b/src/examples/src/widgets/file-upload-input/multiple.m.css.d.ts
@@ -0,0 +1 @@
+export const table: string;
diff --git a/src/examples/src/widgets/file-uploader/Basic.tsx b/src/examples/src/widgets/file-uploader/Basic.tsx
new file mode 100644
index 0000000000..3bdb400a26
--- /dev/null
+++ b/src/examples/src/widgets/file-uploader/Basic.tsx
@@ -0,0 +1,17 @@
+import { create, tsx } from '@dojo/framework/core/vdom';
+import FileUploader from '@dojo/widgets/file-uploader';
+import Example from '../../Example';
+
+const factory = create();
+
+export default factory(function Basic() {
+ function onValue() {
+ // do something with files
+ }
+
+ return (
+
+
+
+ );
+});
diff --git a/src/examples/src/widgets/file-uploader/Controlled.tsx b/src/examples/src/widgets/file-uploader/Controlled.tsx
new file mode 100644
index 0000000000..5e8e0f1936
--- /dev/null
+++ b/src/examples/src/widgets/file-uploader/Controlled.tsx
@@ -0,0 +1,46 @@
+import { create, tsx } from '@dojo/framework/core/vdom';
+import icache from '@dojo/framework/core/middleware/icache';
+import FileUploader, { FileWithValidation } from '@dojo/widgets/file-uploader';
+import Example from '../../Example';
+
+const factory = create({ icache });
+
+export default factory(function Controlled({ middleware: { icache } }) {
+ function validateFiles(files: FileWithValidation[]) {
+ return files.map(function(file) {
+ // Files bigger than 100KB are marked invalid
+ const valid = file.size <= 100 * 1024;
+ file.valid = valid;
+ // Each file can include a message for the valid state as well as invalid
+ file.message = valid ? 'File is valid' : 'File is too big';
+
+ return file;
+ });
+ }
+
+ // onValue receives any files selected from the file dialog or
+ // dragged and dropped from the OS
+ function onValue(files: File[]) {
+ // Validation and manipulation of the selected files is done
+ // entirely external to the FileUploader widget.
+ // This line both validates the files and truncates the total count to 4.
+ const validatedFiles = validateFiles(files).slice(0, 4);
+
+ icache.set('files', validatedFiles);
+ }
+
+ // If FileUploader receives a value for `files` then it will only render that.
+ // If it receives a falsy value then it will render whatever files the user selects.
+ // To ensure no files are rendered pass an empty array.
+ const files = icache.getOrSet('files', []);
+
+ return (
+
+
+ {{
+ label: 'Controlled FileUploader'
+ }}
+
+
+ );
+});
diff --git a/src/examples/src/widgets/file-uploader/CustomValidator.tsx b/src/examples/src/widgets/file-uploader/CustomValidator.tsx
new file mode 100644
index 0000000000..43fd555a34
--- /dev/null
+++ b/src/examples/src/widgets/file-uploader/CustomValidator.tsx
@@ -0,0 +1,32 @@
+import { create, tsx } from '@dojo/framework/core/vdom';
+import FileUploader from '@dojo/widgets/file-uploader';
+import Example from '../../Example';
+
+const factory = create();
+
+export default factory(function CustomValidator() {
+ function onValue() {
+ // do something with files
+ }
+
+ function validateName(file: File) {
+ if (file.name === 'validfile.txt') {
+ return { valid: true };
+ } else {
+ return {
+ message: 'File name must be "validfile.txt"',
+ valid: false
+ };
+ }
+ }
+
+ return (
+
+
+ {{
+ label: 'Upload a file named "validfile.txt"'
+ }}
+
+
+ );
+});
diff --git a/src/examples/src/widgets/file-uploader/Disabled.tsx b/src/examples/src/widgets/file-uploader/Disabled.tsx
new file mode 100644
index 0000000000..074886a640
--- /dev/null
+++ b/src/examples/src/widgets/file-uploader/Disabled.tsx
@@ -0,0 +1,17 @@
+import { create, tsx } from '@dojo/framework/core/vdom';
+import FileUploader from '@dojo/widgets/file-uploader';
+import Example from '../../Example';
+
+const factory = create();
+
+export default factory(function Disabled() {
+ function onValue() {
+ // do something with files
+ }
+
+ return (
+
+
+
+ );
+});
diff --git a/src/examples/src/widgets/file-uploader/Multiple.tsx b/src/examples/src/widgets/file-uploader/Multiple.tsx
new file mode 100644
index 0000000000..6e889c2453
--- /dev/null
+++ b/src/examples/src/widgets/file-uploader/Multiple.tsx
@@ -0,0 +1,17 @@
+import { create, tsx } from '@dojo/framework/core/vdom';
+import FileUploader from '@dojo/widgets/file-uploader';
+import Example from '../../Example';
+
+const factory = create();
+
+export default factory(function Multiple() {
+ function onValue() {
+ // do something with files
+ }
+
+ return (
+
+
+
+ );
+});
diff --git a/src/examples/src/widgets/file-uploader/Validated.tsx b/src/examples/src/widgets/file-uploader/Validated.tsx
new file mode 100644
index 0000000000..2070d0df64
--- /dev/null
+++ b/src/examples/src/widgets/file-uploader/Validated.tsx
@@ -0,0 +1,20 @@
+import { create, tsx } from '@dojo/framework/core/vdom';
+import FileUploader from '@dojo/widgets/file-uploader';
+import Example from '../../Example';
+
+const factory = create();
+
+export default factory(function Validated() {
+ const accept = 'image/jpeg,image/png';
+ const maxSize = 50000;
+
+ function onValue() {
+ // do something with files
+ }
+
+ return (
+
+
+
+ );
+});
diff --git a/src/file-upload-input/README.md b/src/file-upload-input/README.md
new file mode 100644
index 0000000000..186a272b63
--- /dev/null
+++ b/src/file-upload-input/README.md
@@ -0,0 +1,30 @@
+# @dojo/widgets/file-upload-input
+
+Dojo's `FileUploadInput` provides an interface for managing file uploads supporting both `` and the
+HTML Drag and Drop API. This is a controlled widget that only provides file selection. The `FileUploader` widget
+provides more full-featured file upload functionality. If you require more customization than `FileUploader` provides
+you can build a custom file uploader widget based on `FileUploadInput`. You can provide a callback function to the
+`onValue` property to receive a `File` array whenever files are selected.
+
+## Features
+
+- Single or multiple file upload
+- Add files from OS-provided file selection dialog
+- Add files with drag and drop
+
+### Keyboard features
+
+- Trigger file selection dialog with keyboard
+
+### i18n features
+
+- Localized version of labels for the button and DnD can be provided in nls resources
+
+## Drag and Drop
+
+This widget uses the [HTML Drag and Drop API](https://developer.mozilla.org/docs/Web/API/HTML_Drag_and_Drop_API).
+
+The entire widget is enabled as a drag and drop target for files from the OS. In order to provide a visual indicator
+that the widget is ready to receive a drop, and to handle
+[DragEvents](https://developer.mozilla.org/docs/Web/API/DragEvent) more smoothly as soon as a file is dragged over the
+widget an overlay is displayed.
diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx
new file mode 100644
index 0000000000..2518588763
--- /dev/null
+++ b/src/file-upload-input/index.tsx
@@ -0,0 +1,277 @@
+import { DojoEvent, RenderResult } from '@dojo/framework/core/interfaces';
+import i18n from '@dojo/framework/core/middleware/i18n';
+import { createICacheMiddleware } from '@dojo/framework/core/middleware/icache';
+import { create, node, tsx } from '@dojo/framework/core/vdom';
+import { Button } from '../button';
+import { formatAriaProperties } from '../common/util';
+import { Label } from '../label';
+import theme from '../middleware/theme';
+import bundle from './nls/FileUploadInput';
+
+import * as css from '../theme/default/file-upload-input.m.css';
+import * as baseCss from '../theme/default/base.m.css';
+import * as buttonCss from '../theme/default/button.m.css';
+import * as fixedCss from './styles/file-upload-input.m.css';
+import * as labelCss from '../theme/default/label.m.css';
+
+export interface ValidationInfo {
+ message?: string;
+ valid?: boolean;
+}
+
+export interface FileUploadInputChildren {
+ /** The label to be displayed above the input */
+ label?: RenderResult;
+
+ /**
+ * Content to be rendered within the widget area. This content will be obscured by the overlay during drag and drop.
+ */
+ content?: RenderResult;
+}
+
+export interface FileUploadInputProperties {
+ /** The `accept` attribute of the input */
+ accept?: string;
+
+ /** If `true` file drag-n-drop is allowed. Default is `true` */
+ allowDnd?: boolean;
+
+ /** Custom aria attributes */
+ aria?: { [key: string]: string | null };
+
+ /** The `disabled` attribute of the input */
+ disabled?: boolean;
+
+ /** Hides the label for a11y purposes */
+ labelHidden?: boolean;
+
+ /** The `multiple` attribute of the input */
+ multiple?: boolean;
+
+ /** The `name` attribute of the input */
+ name?: string;
+
+ /** Callback called when the user selects files */
+ onValue(value: File[]): void;
+
+ /** The `required` attribute of the input */
+ required?: boolean;
+
+ /** Represents if the selected files passed validation */
+ valid?: boolean | ValidationInfo;
+
+ /** The id to be applied to the input */
+ widgetId?: string;
+}
+
+/**
+ * Filter files based on file types specified by `accept`. This is handled automatically by the OS file selection
+ * dialog, but must be done manually for files from drag and drop.
+ * @param files
+ * @param accept file type specifiers (https://developer.mozilla.org/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers)
+ * @returns the files that match a type in `accept`
+ */
+function filterValidFiles(files: File[], accept: FileUploadInputProperties['accept']) {
+ if (!accept) {
+ return files;
+ }
+
+ const { extensions, types } = accept.split(',').reduce(
+ function(sum, acceptPattern) {
+ if (acceptPattern.startsWith('.')) {
+ sum.extensions.push(new RegExp(`\\${acceptPattern}$`, 'i'));
+ } else {
+ const wildcardIndex = acceptPattern.indexOf('/*');
+ if (wildcardIndex > 0) {
+ sum.types.push(
+ new RegExp(`^${acceptPattern.substr(0, wildcardIndex)}/.+`, 'i')
+ );
+ } else {
+ sum.types.push(new RegExp(acceptPattern, 'i'));
+ }
+ }
+
+ return sum;
+ },
+ { extensions: [], types: [] } as { extensions: RegExp[]; types: RegExp[] }
+ );
+
+ const validFiles = files.filter(function(file) {
+ if (
+ extensions.some((extensionRegex) => extensionRegex.test(file.name)) ||
+ types.some((typeRegex) => typeRegex.test(file.type))
+ ) {
+ return true;
+ }
+ });
+
+ return validFiles;
+}
+
+interface FileUploadInputIcache {
+ isDndActive?: boolean;
+}
+const icache = createICacheMiddleware();
+
+const factory = create({ i18n, icache, node, theme })
+ .properties()
+ .children();
+
+export const FileUploadInput = factory(function FileUploadInput({
+ children,
+ id,
+ middleware: { i18n, icache, node, theme },
+ properties
+}) {
+ const {
+ accept,
+ allowDnd = true,
+ aria = {},
+ disabled = false,
+ labelHidden = false,
+ multiple = false,
+ name,
+ onValue,
+ required = false,
+ valid = true,
+ widgetId = `file-upload-input-${id}`
+ } = properties();
+ const { messages } = i18n.localize(bundle);
+ const themeCss = theme.classes(css);
+ const { content = undefined, label = undefined } = children()[0] || {};
+ let isDndActive = icache.getOrSet('isDndActive', false);
+
+ // DOM events are used directly because writing reactive middleware for this use-case ends up either very
+ // specific and not widely useful, or if attempts are made to provide a general-purpose API the logic becomes
+ // very convoluted (especially for dealing with the conditionally rendered overlay).
+ // dragenter is listened for on the root node and sets `isDndActive` to `true` at which point
+ // the overlay node is displayed
+ function onDragEnter(event: DragEvent) {
+ event.preventDefault();
+ icache.set('isDndActive', true);
+ }
+
+ // dragleave is listened for on the overlay since it fires spuriously on the root node
+ // as the cursor moves over children
+ function onDragLeave(event: DragEvent) {
+ event.preventDefault();
+ icache.set('isDndActive', false);
+ }
+
+ // As long as `event.stopPropagation` is not called drag events will bubble from the overlay
+ // to the root node and can be handled there.
+ // This event must be handled, but all that needs to be done is prevent the default action:
+ // the default action is to cancel the drag operation
+ function onDragOver(event: DragEvent) {
+ event.preventDefault();
+ }
+
+ function onDrop(event: DragEvent) {
+ event.preventDefault();
+ icache.set('isDndActive', false);
+
+ if (event.dataTransfer && event.dataTransfer.files.length) {
+ const fileArray = multiple
+ ? Array.from(event.dataTransfer.files)
+ : [event.dataTransfer.files[0]];
+ const validFiles = filterValidFiles(fileArray, accept);
+ if (validFiles.length) {
+ onValue(validFiles);
+ }
+ }
+ }
+
+ function onClickButton() {
+ // It is necessary to get a direct reference to the DOM node for this due to security restrictions some
+ // browsers (e.g. Firefox 80) place on `fileInputNode.click()`. The method will only be invoked if the code
+ // calling it can directly be traced to a user action. If the call is queued in a scheduler it will not be
+ // executed.
+ const nativeInputNode = node.get('nativeInput');
+ nativeInputNode && nativeInputNode.click();
+ }
+
+ function onChange(event: DojoEvent) {
+ if (event.target.files && event.target.files.length) {
+ onValue(Array.from(event.target.files));
+ }
+ }
+
+ return (
+