diff --git a/.dojorc b/.dojorc index 83069fdc0d..5b63468166 100644 --- a/.dojorc +++ b/.dojorc @@ -29,6 +29,8 @@ "src/date-input", "src/dialog", "src/email-input", + "src/file-upload-input", + "src/file-uploader", "src/floating-action-button", "src/form", "src/grid", diff --git a/src/examples/src/config.tsx b/src/examples/src/config.tsx index 0c22c9baba..ebf9a389ff 100644 --- a/src/examples/src/config.tsx +++ b/src/examples/src/config.tsx @@ -60,6 +60,17 @@ import AnimatedDialog from './widgets/dialog/AnimatedDialog'; import FocusTrappedDialog from './widgets/dialog/FocusTrappedDialog'; import ActionsDialog from './widgets/dialog/ActionsDialog'; import BasicEmailInput from './widgets/email-input/Basic'; +import BasicFileUploadInput from './widgets/file-upload-input/Basic'; +import DisabledFileUploadInput from './widgets/file-upload-input/Disabled'; +import LabelledFileUploadInput from './widgets/file-upload-input/Labelled'; +import MultipleFileUploadInput from './widgets/file-upload-input/Multiple'; +import NoDropFileUploadInput from './widgets/file-upload-input/NoDrop'; +import BasicFileUploader from './widgets/file-uploader/Basic'; +import ControlledFileUploader from './widgets/file-uploader/Controlled'; +import CustomValidatorFileUploader from './widgets/file-uploader/CustomValidator'; +import DisabledFileUploader from './widgets/file-uploader/Disabled'; +import MultipleFileUploader from './widgets/file-uploader/Multiple'; +import ValidatedFileUploader from './widgets/file-uploader/Validated'; import Advanced from './widgets/grid/Advanced'; import BasicFab from './widgets/floating-action-button/Basic'; import ExtendedFab from './widgets/floating-action-button/Extended'; @@ -731,6 +742,76 @@ export const config = { } } }, + 'file-upload-input': { + filename: 'index', + overview: { + example: { + title: 'Basic FileUploadInput', + filename: 'Basic', + module: BasicFileUploadInput + } + }, + examples: [ + { + title: 'Disabled FileUploadInput', + filename: 'Disabled', + module: DisabledFileUploadInput + }, + { + title: 'Multiple FileUploadInput', + filename: 'Multiple', + module: MultipleFileUploadInput, + description: + 'Demonstrates using child `content` property to render information about the uploaded files that is available to the `onValue` callback.' + }, + { + title: 'FileUploadInput with label', + filename: 'Labelled', + module: LabelledFileUploadInput + }, + { + title: 'FileUploadInput with no DnD', + filename: 'NoDrop', + module: NoDropFileUploadInput + } + ] + }, + 'file-uploader': { + filename: 'index', + overview: { + example: { + filename: 'Basic', + module: BasicFileUploader + } + }, + examples: [ + { + title: 'Disabled FileUploader', + filename: 'Disabled', + module: DisabledFileUploader + }, + { + title: 'Multiple FileUploader', + filename: 'Multiple', + module: MultipleFileUploader + }, + { + title: 'Validated FileUploader', + filename: 'Validated', + module: ValidatedFileUploader + }, + { + title: 'FileUploader with custom validator', + filename: 'CustomValidator', + module: CustomValidatorFileUploader + }, + { + title: 'Controlled FileUploader', + filename: 'Controlled', + module: ControlledFileUploader + } + ] + }, 'floating-action-button': { overview: { example: { diff --git a/src/examples/src/widgets/file-upload-input/Basic.tsx b/src/examples/src/widgets/file-upload-input/Basic.tsx new file mode 100644 index 0000000000..ea6e88afa2 --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/Basic.tsx @@ -0,0 +1,21 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import icache from '@dojo/framework/core/middleware/icache'; +import { FileUploadInput } from '@dojo/widgets/file-upload-input'; +import Example from '../../Example'; + +const factory = create({ icache }); + +export default factory(function Basic({ middleware: { icache } }) { + const selectedFiles: File[] = icache.getOrSet('selectedFiles', []); + + function onValue(files: File[]) { + icache.set('selectedFiles', files); + } + + return ( + + +
Selected file: {selectedFiles.length ? selectedFiles[0].name : 'none'}
+
+ ); +}); diff --git a/src/examples/src/widgets/file-upload-input/Disabled.tsx b/src/examples/src/widgets/file-upload-input/Disabled.tsx new file mode 100644 index 0000000000..d141db4700 --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/Disabled.tsx @@ -0,0 +1,17 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import { FileUploadInput } from '@dojo/widgets/file-upload-input'; +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-upload-input/Labelled.tsx b/src/examples/src/widgets/file-upload-input/Labelled.tsx new file mode 100644 index 0000000000..b8bd81fac3 --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/Labelled.tsx @@ -0,0 +1,25 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import icache from '@dojo/framework/core/middleware/icache'; +import { FileUploadInput } from '@dojo/widgets/file-upload-input'; +import Example from '../../Example'; + +const factory = create({ icache }); + +export default factory(function Labelled({ middleware: { icache } }) { + const selectedFiles: File[] = icache.getOrSet('selectedFiles', []); + + function onValue(files: File[]) { + icache.set('selectedFiles', files); + } + + return ( + + + {{ + label: 'Upload a profile image' + }} + +
Selected file: {selectedFiles.length ? selectedFiles[0].name : 'none'}
+
+ ); +}); diff --git a/src/examples/src/widgets/file-upload-input/Multiple.tsx b/src/examples/src/widgets/file-upload-input/Multiple.tsx new file mode 100644 index 0000000000..0fd64b3921 --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/Multiple.tsx @@ -0,0 +1,49 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import icache from '@dojo/framework/core/middleware/icache'; +import { FileUploadInput } from '@dojo/widgets/file-upload-input'; +import Example from '../../Example'; + +import * as css from './multiple.m.css'; + +const factory = create({ icache }); + +export default factory(function Multiple({ middleware: { icache } }) { + const selectedFiles: File[] = icache.getOrSet('selectedFiles', []); + + function onValue(files: File[]) { + icache.set('selectedFiles', files); + } + + return ( + + + {{ + content: selectedFiles.length ? ( + + + + + + + + + {selectedFiles.map(function(file) { + return ( + + + + + + + ); + })} + +
NameModifiedTypeBytes
{file.name}{new Date(file.lastModified).toLocaleString()}{file.type}{String(file.size)}
+ ) : ( + '' + ) + }} +
+
+ ); +}); diff --git a/src/examples/src/widgets/file-upload-input/NoDrop.tsx b/src/examples/src/widgets/file-upload-input/NoDrop.tsx new file mode 100644 index 0000000000..c28f5bc39e --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/NoDrop.tsx @@ -0,0 +1,21 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import icache from '@dojo/framework/core/middleware/icache'; +import { FileUploadInput } from '@dojo/widgets/file-upload-input'; +import Example from '../../Example'; + +const factory = create({ icache }); + +export default factory(function NoDrop({ middleware: { icache } }) { + const selectedFiles: File[] = icache.getOrSet('selectedFiles', []); + + function onValue(files: File[]) { + icache.set('selectedFiles', files); + } + + return ( + + +
Selected file: {selectedFiles.length ? selectedFiles[0].name : 'none'}
+
+ ); +}); 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 ( +
+ {label && ( + + )} + +
+ + + + {allowDnd && {messages.orDropFilesHere}} +
+ + {content} + + {isDndActive && ( +
+ )} +
+ ); +}); + +export default FileUploadInput; diff --git a/src/file-upload-input/nls/FileUploadInput.ts b/src/file-upload-input/nls/FileUploadInput.ts new file mode 100644 index 0000000000..b76e20a32c --- /dev/null +++ b/src/file-upload-input/nls/FileUploadInput.ts @@ -0,0 +1,6 @@ +const messages = { + chooseFiles: 'Choose files…', + orDropFilesHere: 'Or drop files here' +}; + +export default { messages }; diff --git a/src/file-upload-input/styles/file-upload-input.m.css b/src/file-upload-input/styles/file-upload-input.m.css new file mode 100644 index 0000000000..e7a21ed8b9 --- /dev/null +++ b/src/file-upload-input/styles/file-upload-input.m.css @@ -0,0 +1,20 @@ +/* +DnD `dragenter` and `dragleave` events are triggered on children which makes it challenging to keep track of when +a drag operation has truly left a target node - `dragleave` is triggered for the node when the cursor moves over +a child of the node. For this reason it is ideal to provide as a drag target an overlay node that has no children. +As soon as the target node receives a `dragenter` event it displays an overlay that obscures everything below it. +The overlay node then receives any further DnD events and leave and enter can be accurately tracked. + */ + +.root { + position: relative; +} + +.dndOverlay { + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + z-index: 1; +} diff --git a/src/file-upload-input/styles/file-upload-input.m.css.d.ts b/src/file-upload-input/styles/file-upload-input.m.css.d.ts new file mode 100644 index 0000000000..b4b6bfcd5f --- /dev/null +++ b/src/file-upload-input/styles/file-upload-input.m.css.d.ts @@ -0,0 +1,2 @@ +export const root: string; +export const dndOverlay: string; diff --git a/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx new file mode 100644 index 0000000000..208e356017 --- /dev/null +++ b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx @@ -0,0 +1,314 @@ +import { tsx } from '@dojo/framework/core/vdom'; +import { assertion, renderer, wrap } from '@dojo/framework/testing/renderer'; +import * as sinon from 'sinon'; +import { Button } from '../../../button'; +import { FileUploadInput } from '../../index'; +import { Label } from '../../../label'; +import { noop, stubEvent } from '../../../common/tests/support/test-helpers'; + +import bundle from '../../nls/FileUploadInput'; +import * as baseCss from '../../../theme/default/base.m.css'; +import * as buttonCss from '../../../theme/default/button.m.css'; +import * as css from '../../../theme/default/file-upload-input.m.css'; +import * as fixedCss from '../../styles/file-upload-input.m.css'; +import * as labelCss from '../../../theme/default/label.m.css'; + +const { after, afterEach, describe, it } = intern.getInterface('bdd'); +const { assert } = intern.getPlugin('chai'); +const { messages } = bundle; + +describe('FileUploadInput', function() { + const WrappedRoot = wrap('div'); + const WrappedWrapper = wrap('div'); + const WrappedInput = wrap('input'); + const WrappedButton = wrap(Button); + const WrappedLabel = wrap('span'); + + const preventDefaultSpy = sinon.spy(stubEvent, 'preventDefault'); + + const baseAssertion = assertion(function() { + return ( + + + + + ); + }); + + after(function() { + preventDefaultSpy.restore(); + }); + + afterEach(function() { + preventDefaultSpy.resetHistory(); + }); + + it('renders', function() { + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion); + }); + + it('renders content', function() { + const content =
some content
; + + const r = renderer(function() { + return ( + + {{ + content + }} + + ); + }); + + r.expect(baseAssertion.insertAfter(WrappedWrapper, () => [content])); + }); + + it('renders label', function() { + const label = 'Widget label'; + + const r = renderer(function() { + return ( + + {{ + label + }} + + ); + }); + + r.expect( + baseAssertion.prepend(WrappedRoot, function() { + return [ + + ]; + }) + ); + }); + + it('renders allowDnd=false', function() { + const r = renderer(function() { + return ; + }); + + r.expect( + baseAssertion + .setProperty(WrappedRoot, 'ondragenter', false) + .setProperty(WrappedRoot, 'ondragover', false) + .setProperty(WrappedRoot, 'ondrop', false) + .remove(WrappedLabel) + ); + }); + + it('renders disabled', function() { + const r = renderer(function() { + return ; + }); + + r.expect( + baseAssertion + .setProperty(WrappedRoot, 'aria-disabled', 'true') + .setProperty(WrappedRoot, 'classes', [ + null, + fixedCss.root, + css.root, + false, + css.disabled + ]) + .setProperty(WrappedRoot, 'ondragenter', false) + .setProperty(WrappedRoot, 'ondragover', false) + .setProperty(WrappedRoot, 'ondrop', false) + .setProperty(WrappedInput, 'disabled', true) + .setProperty(WrappedButton, 'disabled', true) + ); + }); + + it('handles dragenter, dragleave, and the overlay', function() { + const r = renderer(function() { + return ; + }); + const WrappedOverlay = wrap('div'); + + r.expect(baseAssertion); + r.property(WrappedRoot, 'ondragenter', stubEvent); + + r.expect( + baseAssertion + .setProperty(WrappedRoot, 'classes', [ + null, + fixedCss.root, + css.root, + css.dndActive, + false + ]) + .append(WrappedRoot, function() { + return [ + + ]; + }) + ); + assert(preventDefaultSpy.called, 'dragenter handler should call event.preventDefault()'); + preventDefaultSpy.resetHistory(); + + // TODO: enable when https://github.com/dojo/framework/pull/840 is merged + // r.property(WrappedOverlay, 'ondragleave', stubEvent); + // r.expect(baseAssertion); + // assert(preventDefaultSpy.called, 'dragleave handler should call event.preventDefault()'); + }); + + it('handles file drop event', function() { + const testValues = [1, 2, 3]; + const onValue = sinon.stub(); + + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion.setProperty(WrappedInput, 'multiple', true)); + r.property(WrappedRoot, 'ondrop', { + ...stubEvent, + dataTransfer: { + files: testValues + } + }); + r.expect(baseAssertion.setProperty(WrappedInput, 'multiple', true)); + + assert(preventDefaultSpy.called, 'drop handler should call event.preventDefault()'); + assert.sameOrderedMembers(onValue.firstCall.args[0], testValues); + }); + + it('restricts file drop to one file if multiple is not true', function() { + const testValues = [1, 2, 3]; + const expected = testValues.slice(0, 1); + const onValue = sinon.stub(); + + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion); + r.property(WrappedRoot, 'ondrop', { + ...stubEvent, + dataTransfer: { + files: testValues + } + }); + r.expect(baseAssertion); + + assert.sameMembers(onValue.firstCall.args[0], expected); + }); + + it('validates files based on "accept"', function() { + const accept = 'application/pdf,image/*,.gif'; + const testFiles = [ + { name: 'file1.pdf', type: 'application/pdf' }, // test direct match: application/pdf + { name: 'file2.png', type: 'image/png' }, // test wildcard match: image/* + { name: 'file3.gif', type: 'bad/type' }, // test extension match: .gif + { name: 'file4.doc', type: 'application/msword' } // test match failure + ]; + const validFiles = testFiles.slice(0, 3); + const onValue = sinon.stub(); + + const r = renderer(function() { + return ; + }); + const acceptAssertion = baseAssertion + .setProperty(WrappedInput, 'accept', accept) + .setProperty(WrappedInput, 'multiple', true); + + r.expect(acceptAssertion); + r.property(WrappedRoot, 'ondrop', { + preventDefault: noop, + dataTransfer: { + files: testFiles + } + }); + r.expect(acceptAssertion); + + assert.sameOrderedMembers(onValue.firstCall.args[0], validFiles); + }); + + it('calls onValue when files are selected from input', function() { + const testValues = [1, 2, 3]; + const onValue = sinon.stub(); + + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion.setProperty(WrappedInput, 'multiple', true)); + r.property(WrappedInput, 'onchange', { + target: { + files: testValues + } + }); + r.expect(baseAssertion.setProperty(WrappedInput, 'multiple', true)); + + // TODO: enable when https://github.com/dojo/framework/pull/840 is merged + // assert.sameOrderedMembers(onValue.firstCall.args[0], testValues); + }); +}); diff --git a/src/file-uploader/README.md b/src/file-uploader/README.md new file mode 100644 index 0000000000..1525f69df1 --- /dev/null +++ b/src/file-uploader/README.md @@ -0,0 +1,22 @@ +# @dojo/widgets/file-uploader + +Dojo's `FileUploader` provides an interface for managing file uploads using the `FileUploadInput` widget internally. +Building on `FileUploadInput's` basic file uploading functionality this widget adds state management and visual +display of selected files as well as validation. + +## Features + +- Single or multiple file upload +- Add files from OS-provided file selection dialog +- Add files with drag and drop +- View and remove added files +- Validation + +### Keyboard features + +- Trigger file selection dialog with keyboard +- TODO Navigate file list with keyboard + +### i18n features + +- Localized version of default labels for the button and DnD can be provided in nls resources diff --git a/src/file-uploader/index.tsx b/src/file-uploader/index.tsx new file mode 100644 index 0000000000..ae38266207 --- /dev/null +++ b/src/file-uploader/index.tsx @@ -0,0 +1,247 @@ +import { RenderResult } from '@dojo/framework/core/interfaces'; +import i18n from '@dojo/framework/core/middleware/i18n'; +import { createICacheMiddleware } from '@dojo/framework/core/middleware/icache'; +import { create, tsx } from '@dojo/framework/core/vdom'; +import { + FileUploadInput, + FileUploadInputChildren, + FileUploadInputProperties, + ValidationInfo +} from '../file-upload-input'; +import { Icon } from '../icon'; +import theme from '../middleware/theme'; +import bundle from './nls/FileUploader'; + +import * as css from '../theme/default/file-uploader.m.css'; +import * as fileUploadInputCss from '../theme/default/file-upload-input.m.css'; +import * as fileUploadInputFixedCss from '../file-upload-input/styles/file-upload-input.m.css'; + +export interface FileUploaderChildren { + label?: RenderResult; +} + +export interface FileUploaderProperties extends FileUploadInputProperties { + /** Custom validator used to validate each file */ + customValidator?: (file: File) => ValidationInfo | void; + + /** The files to render in the widget (controlled scenario) */ + files?: FileWithValidation[]; + + /** + * The maximum size in bytes of a file. Files larger than this will be included in the file list but their + * `valid` property will be `false` and their `message` property will be set to the validation message. + */ + maxSize?: number; + + /** + * Callback fired when the input validation changes. The `valid` parameter is an aggregate of the `valid` value + * of all files. The `message` parameter is empty; each file has a `message` property. + */ + onValidate?: (valid: boolean | undefined, message: string) => void; + + /** Callback called when the user selects files */ + onValue(value: FileWithValidation[]): void; + + /** Show the file size in the file list. Default is `true` */ + showFileSize?: boolean; +} + +export type FileWithValidation = File & ValidationInfo; + +const factorNames = ['', 'B', 'KB', 'MB', 'GB', 'TB', 'PB']; +export function formatBytes(byteCount: number) { + if (isNaN(byteCount)) { + return ''; + } + + let formattedValue = ''; + for (let i = 1; i < factorNames.length; i++) { + if (byteCount < Math.pow(1024, i) || i === factorNames.length - 1) { + formattedValue = `${(byteCount / Math.pow(1024, i - 1)).toFixed(i > 1 ? 2 : 0)} ${ + factorNames[i] + }`; + // values below the next factor up but greater than 1023.99 will round up to 1024.00 - push them down + if (formattedValue.startsWith('1024.00') && i < factorNames.length - 1) { + formattedValue = `1023.99 ${factorNames[i]}`; + } + break; + } + } + + return formattedValue; +} + +export interface FileUploaderIcache { + previousValidationState?: boolean; + value: FileWithValidation[]; +} + +const icache = createICacheMiddleware(); +const factory = create({ i18n, icache, theme }) + .properties() + .children(); + +export const FileUploader = factory(function FileUploader({ + children, + middleware: { i18n, icache, theme }, + properties +}) { + const { + accept, + allowDnd = true, + customValidator, + disabled = false, + files: initialFiles, + maxSize, + multiple = false, + name, + onValidate, + onValue, + required = false, + showFileSize = true + } = properties(); + const { messages } = i18n.localize(bundle); + const themeCss = theme.classes(css); + const inputChild = (children()[0] || {}) as FileUploadInputChildren; + let files = initialFiles || icache.getOrSet('value', []); + + function validateFiles(files: Array): FileWithValidation[] { + const previousValidationState = icache.get('previousValidationState'); + let currentValidationState = true; + + const validatedFiles = files.map(function(file) { + const validatedFile: FileWithValidation = file; + let message = ''; + let valid = maxSize ? file.size <= maxSize : true; + + if (valid) { + if (customValidator) { + const customValid = customValidator(file); + if (customValid) { + valid = customValid.valid !== false; + message = customValid.message || ''; + } + } + } else { + message = messages.invalidFileSize; + } + + currentValidationState = currentValidationState && valid; + + // It is important to use the original File object - creating a new object and assigning file's + // properties to it won't work for File's special methods. Even setting the File instance as the + // prototype of another object will result in failure when attempting to invoke File methods. + validatedFile.valid = valid; + validatedFile.message = message; + + return validatedFile; + }); + + if (currentValidationState !== previousValidationState) { + onValidate && onValidate(currentValidationState, ''); + } + + return validatedFiles; + } + + function updateFiles(newFiles: Array) { + const validatedFiles = validateFiles(newFiles); + // only update the cache if the widget is not controlled + if (!initialFiles) { + icache.set('value', validatedFiles); + } + onValue(validatedFiles); + } + + function onInputValue(newFiles: File[]) { + const newValue = multiple ? [...files, ...newFiles] : newFiles.slice(0, 1); + updateFiles(newValue); + } + + function remove(file: FileWithValidation) { + const fileIndex = files.indexOf(file); + /* istanbul ignore if (type-safety check; should never happen) */ + if (fileIndex === -1) { + return; + } else { + const updatedFiles = [...files]; + updatedFiles.splice(fileIndex, 1); + updateFiles(updatedFiles); + } + } + + function renderFiles(files: FileWithValidation[]) { + return files.map(function(file) { + let validationInfo: ValidationInfo; + if ('valid' in file) { + validationInfo = { + valid: file.valid, + message: file.message + }; + } else { + validationInfo = { + valid: true, + message: '' + }; + } + + const { message, valid } = validationInfo; + + return ( +
+
+
{file.name}
+ {showFileSize && ( +
{formatBytes(file.size)}
+ )} + +
+ {message &&
{message}
} +
+ ); + }); + } + + inputChild.content = files.length ? [
{renderFiles(files)}
] : null; + + return ( +
+ + {inputChild} + +
+ ); +}); + +export default FileUploader; diff --git a/src/file-uploader/nls/FileUploader.ts b/src/file-uploader/nls/FileUploader.ts new file mode 100644 index 0000000000..7079a71d34 --- /dev/null +++ b/src/file-uploader/nls/FileUploader.ts @@ -0,0 +1,6 @@ +const messages = { + invalidFileSize: 'Invalid file size', + remove: 'Remove' +}; + +export default { messages }; diff --git a/src/file-uploader/tests/unit/FileUploader.spec.tsx b/src/file-uploader/tests/unit/FileUploader.spec.tsx new file mode 100644 index 0000000000..c17195cca9 --- /dev/null +++ b/src/file-uploader/tests/unit/FileUploader.spec.tsx @@ -0,0 +1,358 @@ +import { tsx } from '@dojo/framework/core/vdom'; +import { assertion, renderer, wrap } from '@dojo/framework/testing/renderer'; +import { noop } from '../../../common/tests/support/test-helpers'; +import * as sinon from 'sinon'; +import FileUploader, { formatBytes } from '../../index'; +import FileUploadInput from '../../../file-upload-input'; +import Icon from '../../../icon'; + +import bundle from '../../nls/FileUploader'; +import * as css from '../../../theme/default/file-uploader.m.css'; +import * as fileUploadInputCss from '../../../theme/default/file-upload-input.m.css'; +import * as fileUploadInputFixedCss from '../../../file-upload-input/styles/file-upload-input.m.css'; + +const { describe, it } = intern.getInterface('bdd'); +const { assert } = intern.getPlugin('chai'); +const { messages } = bundle; + +describe('FileUploader', function() { + const WrappedRoot = wrap('div'); + const WrappedFileUploadInput = wrap(FileUploadInput); + const WrappedButton = wrap('button'); + + const inputThemeProp = { + '@dojo/widgets/file-upload-input': { + disabled: fileUploadInputCss.disabled, + dndActive: fileUploadInputCss.dndActive, + dndLabel: fileUploadInputCss.dndLabel, + dndOverlay: fileUploadInputCss.dndOverlay, + root: fileUploadInputCss.root, + wrapper: fileUploadInputCss.wrapper + } + }; + + const baseAssertion = assertion(function() { + return ( + + + {{ + content: null + }} + + + ); + }); + + function getTestFiles() { + return [ + { + name: 'file1.jpg', + size: 55383, + formattedSize: '54.08 KB', + type: 'image/jpeg' + }, + { + name: 'file2.png', + size: 180240, + formattedSize: '176.02 KB', + type: 'image/png', + valid: false, + message: 'File is too big' + }, + { + name: 'file3.png', + size: 4001220, + formattedSize: '3.82 MB', + type: 'image/png', + valid: true, + message: 'File is great' + } + ]; + } + + function getRenderedFiles(files: ReturnType) { + return ( +
+ {files.map(function(file, index) { + return ( +
+
+
{file.name}
+
{formatBytes(file.size)}
+ {index === 0 ? ( + + + + ) : ( + + )} +
+ {file.message && ( +
{file.message}
+ )} +
+ ); + })} +
+ ); + } + + it('renders', function() { + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion); + }); + + it('sets props on FileUploadInput', function() { + const r = renderer(function() { + return ( + + ); + }); + + const setPropsAssertionTemplate = baseAssertion + .setProperty(WrappedRoot, 'classes', [ + null, + fileUploadInputFixedCss.root, + css.root, + css.disabled + ]) + .setProperty(WrappedFileUploadInput, 'onValue', noop) + .setProperty(WrappedFileUploadInput, 'accept', 'accept') + .setProperty(WrappedFileUploadInput, 'allowDnd', false) + .setProperty(WrappedFileUploadInput, 'disabled', true) + .setProperty(WrappedFileUploadInput, 'multiple', true) + .setProperty(WrappedFileUploadInput, 'required', true) + .setProperty(WrappedFileUploadInput, 'name', 'name'); + + r.expect(setPropsAssertionTemplate); + }); + + it('renders label', function() { + const label = 'Widget label'; + + const r = renderer(function() { + return ( + + {{ + label + }} + + ); + }); + + r.expect(baseAssertion.setChildren(WrappedFileUploadInput, () => ({ label, content: '' }))); + }); + + it('renders files from property', function() { + const files = getTestFiles(); + const r = renderer(function() { + return ; + }); + const content = getRenderedFiles(files); + + r.expect(baseAssertion.setChildren(WrappedFileUploadInput, () => ({ content }))); + }); + + it('renders files from FileUploadInput', function() { + const files = getTestFiles(); + const r = renderer(function() { + return ; + }); + const content = getRenderedFiles([files[0]]); + + r.expect(baseAssertion); + r.property(WrappedFileUploadInput, 'onValue', files as any); + r.expect(baseAssertion.setChildren(WrappedFileUploadInput, () => ({ content }))); + }); + + it('renders and validates multiple files from FileUploadInput', function() { + // This is implemented to produce the same output that getRenderedFiles(getTestFiles()) creates + function customValidator(file: ReturnType[number]) { + if (file.name === 'file2.png') { + return { + valid: false, + message: 'File is too big' + }; + } else if (file.name === 'file3.png') { + return { + message: 'File is great' + }; + } + } + + const files = getTestFiles(); + const r = renderer(function() { + return ( + + ); + }); + const multipleAssertion = baseAssertion.setProperty( + WrappedFileUploadInput, + 'multiple', + true + ); + + r.expect(multipleAssertion); + r.property(WrappedFileUploadInput, 'onValue', files as any); + + const content = getRenderedFiles(files); + r.expect(multipleAssertion.setChildren(WrappedFileUploadInput, () => ({ content }))); + }); + + it('renders added files cumulatively when multiple=true', function() { + const r = renderer(function() { + return ; + }); + const multipleAssertion = baseAssertion.setProperty( + WrappedFileUploadInput, + 'multiple', + true + ); + + r.expect(multipleAssertion); + const files = [{ name: 'file1', size: 100 }]; + r.property(WrappedFileUploadInput, 'onValue', files as any); + r.expect( + multipleAssertion.setChildren(WrappedFileUploadInput, () => ({ + content: getRenderedFiles(files as any) + })) + ); + + const moreFiles = [{ name: 'file2', size: 200 }]; + r.property(WrappedFileUploadInput, 'onValue', moreFiles as any); + r.expect( + multipleAssertion.setChildren(WrappedFileUploadInput, () => ({ + content: getRenderedFiles([...(files as any), ...moreFiles]) + })) + ); + }); + + it('renders only a single file when multiple is not true', function() { + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion); + const files = [{ name: 'file1', size: 100 }, { name: 'file2', size: 200 }]; + r.property(WrappedFileUploadInput, 'onValue', files as any); + r.expect( + baseAssertion.setChildren(WrappedFileUploadInput, () => ({ + content: getRenderedFiles([files[0] as any]) + })) + ); + const moreFiles = [{ name: 'file3', size: 300 }, { name: 'file4', size: 400 }]; + r.property(WrappedFileUploadInput, 'onValue', moreFiles as any); + r.expect( + baseAssertion.setChildren(WrappedFileUploadInput, () => ({ + content: getRenderedFiles([moreFiles[0] as any]) + })) + ); + }); + + it('validates files on maxSize', function() { + const maxSize = 500; + const onValue = sinon.spy(); + const r = renderer(function() { + return ; + }); + const multipleAssertion = baseAssertion.setProperty( + WrappedFileUploadInput, + 'multiple', + true + ); + + r.expect(multipleAssertion); + + const files = [ + { name: 'file1', size: 100 }, + { name: 'file2', size: 0 }, + { name: 'file3', size: 499 }, + { name: 'file4', size: 500 }, + { name: 'file5', size: 501 }, + { name: 'file6', size: Math.pow(10, 10) } + ]; + const expectedFiles = (files as any).map(function(file: any) { + const expectedFile = { ...file, message: '', valid: true }; + if (file.size > maxSize) { + expectedFile.valid = false; + expectedFile.message = messages.invalidFileSize; + } + + return expectedFile; + }); + r.property(WrappedFileUploadInput, 'onValue', files as any); + r.expect( + multipleAssertion.setChildren(WrappedFileUploadInput, () => ({ + content: getRenderedFiles(expectedFiles) + })) + ); + assert.deepEqual(onValue.firstCall.args[0], expectedFiles); + }); + + it('removes files', function() { + const files = getTestFiles(); + const r = renderer(function() { + return ; + }); + const content = getRenderedFiles(files); + + r.expect(baseAssertion.setChildren(WrappedFileUploadInput, () => ({ content }))); + // TODO: the click event is not firing + // r.property(WrappedButton, 'onclick'); + // r.expect(baseAssertion.setChildren(WrappedFileUploadInput, () => [{ content: getRenderedFiles(files.slice(1)) }])); + }); + + it('formats bytes up to PB', function() { + assert.strictEqual(formatBytes(0), '0 B'); + assert.strictEqual(formatBytes(1), '1 B'); + assert.strictEqual(formatBytes(1023), '1023 B'); + assert.strictEqual(formatBytes(1024), '1.00 KB'); + assert.strictEqual(formatBytes(1025), '1.00 KB'); + assert.strictEqual(formatBytes(1034), '1.01 KB'); + assert.strictEqual(formatBytes(Math.pow(1024, 2) - 1), '1023.99 KB'); + assert.strictEqual(formatBytes(Math.pow(1024, 2)), '1.00 MB'); + assert.strictEqual(formatBytes(Math.pow(1024, 2) + 1), '1.00 MB'); + assert.strictEqual(formatBytes(Math.pow(1024, 2) + 10485), '1.01 MB'); + assert.strictEqual(formatBytes(Math.pow(1024, 3) - 1), '1023.99 MB'); + assert.strictEqual(formatBytes(Math.pow(1024, 3)), '1.00 GB'); + assert.strictEqual(formatBytes(Math.pow(1024, 3) + 1), '1.00 GB'); + assert.strictEqual(formatBytes(Math.pow(1024, 3) + 10737418), '1.01 GB'); + assert.strictEqual(formatBytes(Math.pow(1024, 4) - 1), '1023.99 GB'); + assert.strictEqual(formatBytes(Math.pow(1024, 4)), '1.00 TB'); + assert.strictEqual(formatBytes(Math.pow(1024, 4) + 1), '1.00 TB'); + assert.strictEqual(formatBytes(Math.pow(1024, 4) + 10995116277), '1.01 TB'); + assert.strictEqual(formatBytes(Math.pow(1024, 5) - 1), '1023.99 TB'); + assert.strictEqual(formatBytes(Math.pow(1024, 5)), '1.00 PB'); + assert.strictEqual(formatBytes(Math.pow(1024, 5) + 1), '1.00 PB'); + assert.strictEqual(formatBytes(Math.pow(1024, 5) + 11258999068426), '1.01 PB'); + // with no tier above PB there should be no rounding down just below 1024 PB + assert.strictEqual(formatBytes(Math.pow(1024, 6) - 1), '1024.00 PB'); + // still PB + assert.strictEqual(formatBytes(Math.pow(1024, 7)), '1048576.00 PB'); + }); +}); diff --git a/src/theme/default/file-upload-input.m.css b/src/theme/default/file-upload-input.m.css new file mode 100644 index 0000000000..c2e19019cd --- /dev/null +++ b/src/theme/default/file-upload-input.m.css @@ -0,0 +1,29 @@ +/* The root class for FileUploadInput */ +.root { + border: 2px dashed transparent; +} + +/* Applied to the root node if a DnD operation is in progress */ +.dndActive { + border-color: hsl(210, 29%, 60%); +} + +/* Applied to the root node if the widget is disabled */ +.disabled { + cursor: no-drop; +} + +/* The node containing the button and dnd label */ +.wrapper { +} + +/* The text label in the DnD area */ +.dndLabel { + margin-left: 8px; +} + +/* The overlay node that is displayed when DnD is active */ +.dndOverlay { + background-color: var(--selected-background); + opacity: 0.25; +} diff --git a/src/theme/default/file-upload-input.m.css.d.ts b/src/theme/default/file-upload-input.m.css.d.ts new file mode 100644 index 0000000000..0fef57969e --- /dev/null +++ b/src/theme/default/file-upload-input.m.css.d.ts @@ -0,0 +1,6 @@ +export const root: string; +export const dndActive: string; +export const disabled: string; +export const wrapper: string; +export const dndLabel: string; +export const dndOverlay: string; diff --git a/src/theme/default/file-uploader.m.css b/src/theme/default/file-uploader.m.css new file mode 100644 index 0000000000..fa7df4bea2 --- /dev/null +++ b/src/theme/default/file-uploader.m.css @@ -0,0 +1,51 @@ +/* The root class for FileUploader */ +.root { +} + +/* Applied to the root node if the widget is disabled */ +.disabled { +} + +/* Container for each item representing an added file */ +.fileItem { + display: flex; + flex-direction: column; +} + +/* Applied to a file item if it is invalid */ +.invalid { +} + +/* The file information node within a file item node */ +.fileInfo { + display: flex; + flex-direction: row; +} + +/* The name of each added file */ +.fileItemName { + flex-grow: 1; +} + +/* The size of each added file */ +.fileItemSize { + flex-basis: 8em; + text-align: right; +} + +/* Close icon button rendered for each file */ +.closeButton { + border: none; + background: transparent; + cursor: pointer; + padding-top: 2px; +} + +/* Applied to the node containing the validation message */ +.validationMessage { + color: var(--success-color); +} + +.invalid .validationMessage { + color: var(--error-color); +} diff --git a/src/theme/default/file-uploader.m.css.d.ts b/src/theme/default/file-uploader.m.css.d.ts new file mode 100644 index 0000000000..fda39b1629 --- /dev/null +++ b/src/theme/default/file-uploader.m.css.d.ts @@ -0,0 +1,9 @@ +export const root: string; +export const disabled: string; +export const fileItem: string; +export const invalid: string; +export const fileInfo: string; +export const fileItemName: string; +export const fileItemSize: string; +export const closeButton: string; +export const validationMessage: string; diff --git a/src/theme/default/index.ts b/src/theme/default/index.ts index 780ccf2d80..7cb12e3c74 100644 --- a/src/theme/default/index.ts +++ b/src/theme/default/index.ts @@ -11,6 +11,7 @@ import * as constrainedInput from './constrained-input.m.css'; import * as dateInput from './date-input.m.css'; import * as dialog from './dialog.m.css'; import * as emailInput from './email-input.m.css'; +import * as fileUploadInput from './file-upload-input.m.css'; import * as floatingActionButton from './floating-action-button.m.css'; import * as form from './form.m.css'; import * as gridBody from './grid-body.m.css'; @@ -74,6 +75,7 @@ export default { '@dojo/widgets/date-input': dateInput, '@dojo/widgets/dialog': dialog, '@dojo/widgets/email-input': emailInput, + '@dojo/widgets/file-upload-input': fileUploadInput, '@dojo/widgets/floating-action-button': floatingActionButton, '@dojo/widgets/form': form, '@dojo/widgets/grid-body': gridBody, diff --git a/src/theme/dojo/file-upload-input.m.css b/src/theme/dojo/file-upload-input.m.css new file mode 100644 index 0000000000..f6c10fde82 --- /dev/null +++ b/src/theme/dojo/file-upload-input.m.css @@ -0,0 +1,23 @@ +.root { + background-color: var(--color-background); + border: 2px dashed transparent; +} + +.dndActive { + border-color: var(--color-highlight); + box-shadow: var(--box-shadow-dimensions-small) var(--color-box-shadow-highlight); +} + +.disabled { + cursor: no-drop; +} + +.dndOverlay { + background-color: var(--color-background-faded); + opacity: 0.55; +} + +.dndLabel { + color: var(--color-text-primary); + margin-left: var(--grid-base); +} diff --git a/src/theme/dojo/file-upload-input.m.css.d.ts b/src/theme/dojo/file-upload-input.m.css.d.ts new file mode 100644 index 0000000000..2368aac56a --- /dev/null +++ b/src/theme/dojo/file-upload-input.m.css.d.ts @@ -0,0 +1,5 @@ +export const root: string; +export const dndActive: string; +export const disabled: string; +export const dndOverlay: string; +export const dndLabel: string; diff --git a/src/theme/dojo/file-uploader.m.css b/src/theme/dojo/file-uploader.m.css new file mode 100644 index 0000000000..eedd8e732f --- /dev/null +++ b/src/theme/dojo/file-uploader.m.css @@ -0,0 +1,44 @@ +.root { + background-color: var(--color-background); +} + +.disabled { + cursor: no-drop; +} + +.fileItem { + color: var(--color-text-primary); + display: flex; + flex-direction: column; + line-height: var(--line-height-base); + padding: calc(var(--spacing-regular) / 2); +} + +.fileItem:hover { + background-color: var(--color-background-faded); +} + +.fileInfo { + display: flex; + flex-direction: row; +} + +.fileItemName { + flex-grow: 1; +} + +.closeButton { + border: none; + background: transparent; + color: var(--color-text-primary); + cursor: pointer; + padding-top: 2px; +} + +.validationMessage { + color: var(--color-success); +} + +.invalid .validationMessage { + color: var(--color-error); +} diff --git a/src/theme/dojo/file-uploader.m.css.d.ts b/src/theme/dojo/file-uploader.m.css.d.ts new file mode 100644 index 0000000000..b5de0806cb --- /dev/null +++ b/src/theme/dojo/file-uploader.m.css.d.ts @@ -0,0 +1,8 @@ +export const root: string; +export const disabled: string; +export const fileItem: string; +export const fileInfo: string; +export const fileItemName: string; +export const closeButton: string; +export const validationMessage: string; +export const invalid: string; diff --git a/src/theme/dojo/index.ts b/src/theme/dojo/index.ts index 2ad293ad17..4dd5c93320 100644 --- a/src/theme/dojo/index.ts +++ b/src/theme/dojo/index.ts @@ -10,6 +10,8 @@ import * as chipTypeahead from './chip-typeahead.m.css'; import * as chip from './chip.m.css'; import * as dateInput from './date-input.m.css'; import * as dialog from './dialog.m.css'; +import * as fileUploadInput from './file-upload-input.m.css'; +import * as fileUploader from './file-uploader.m.css'; import * as floatingActionButton from './floating-action-button.m.css'; import * as form from './form.m.css'; import * as gridBody from './grid-body.m.css'; @@ -75,6 +77,8 @@ export default { '@dojo/widgets/chip': chip, '@dojo/widgets/date-input': dateInput, '@dojo/widgets/dialog': dialog, + '@dojo/widgets/file-upload-input': fileUploadInput, + '@dojo/widgets/file-uploader': fileUploader, '@dojo/widgets/floating-action-button': floatingActionButton, '@dojo/widgets/form': form, '@dojo/widgets/grid-body': gridBody, diff --git a/src/theme/material/file-upload-input.m.css b/src/theme/material/file-upload-input.m.css new file mode 100644 index 0000000000..9f89e6c0bd --- /dev/null +++ b/src/theme/material/file-upload-input.m.css @@ -0,0 +1,26 @@ +.root { + background-color: var(--mdc-theme-background); + border: 2px dashed transparent; +} + +.dndActive { + border-color: var(--mdc-solid-border-color-hover); +} + +.disabled { + cursor: no-drop; +} + +.dndOverlay { + background-color: var(--mdc-theme-on-surface); + opacity: 0.55; +} + +.dndLabel { + color: var(--mdc-text-color); + margin-left: var(--mdc-theme-grid-base); +} + +.disabled .dndLabel { + color: var(--mdc-disabled-text-color); +} diff --git a/src/theme/material/file-upload-input.m.css.d.ts b/src/theme/material/file-upload-input.m.css.d.ts new file mode 100644 index 0000000000..2368aac56a --- /dev/null +++ b/src/theme/material/file-upload-input.m.css.d.ts @@ -0,0 +1,5 @@ +export const root: string; +export const dndActive: string; +export const disabled: string; +export const dndOverlay: string; +export const dndLabel: string; diff --git a/src/theme/material/file-uploader.m.css b/src/theme/material/file-uploader.m.css new file mode 100644 index 0000000000..c5d0b7f1cf --- /dev/null +++ b/src/theme/material/file-uploader.m.css @@ -0,0 +1,39 @@ +.root { + background-color: var(--mdc-theme-background); +} + +.disabled { + cursor: no-drop; +} + +.fileItem { + color: var(--mdc-text-color); + display: flex; + flex-direction: column; + padding: calc(var(--mdc-theme-grid-base) / 2); +} + +.fileInfo { + display: flex; + flex-direction: row; +} + +.fileItemName { + flex-grow: 1; +} + +.closeButton { + border: none; + background: transparent; + color: var(--mdc-text-color); + cursor: pointer; + padding-top: 2px; +} + +.validationMessage { + color: var(--mdc-secondary-text-color); +} + +.invalid .validationMessage { + color: var(--mdc-theme-error); +} diff --git a/src/theme/material/file-uploader.m.css.d.ts b/src/theme/material/file-uploader.m.css.d.ts new file mode 100644 index 0000000000..b5de0806cb --- /dev/null +++ b/src/theme/material/file-uploader.m.css.d.ts @@ -0,0 +1,8 @@ +export const root: string; +export const disabled: string; +export const fileItem: string; +export const fileInfo: string; +export const fileItemName: string; +export const closeButton: string; +export const validationMessage: string; +export const invalid: string; diff --git a/src/theme/material/index.ts b/src/theme/material/index.ts index f30767e268..a8161a763d 100644 --- a/src/theme/material/index.ts +++ b/src/theme/material/index.ts @@ -10,6 +10,8 @@ import * as chipTypeahead from './chip-typeahead.m.css'; import * as chip from './chip.m.css'; import * as dateInput from './date-input.m.css'; import * as dialog from './dialog.m.css'; +import * as fileUploadInput from './file-upload-input.m.css'; +import * as fileUploader from './file-uploader.m.css'; import * as floatingActionButton from './floating-action-button.m.css'; import * as form from './form.m.css'; import * as gridBody from './grid-body.m.css'; @@ -75,6 +77,8 @@ export default { '@dojo/widgets/chip': chip, '@dojo/widgets/date-input': dateInput, '@dojo/widgets/dialog': dialog, + '@dojo/widgets/file-upload-input': fileUploadInput, + '@dojo/widgets/file-uploader': fileUploader, '@dojo/widgets/floating-action-button': floatingActionButton, '@dojo/widgets/form': form, '@dojo/widgets/grid-body': gridBody,