From dca28b36400c81c8799a52528823cc6432e5b221 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 7 May 2021 07:59:33 -0400 Subject: [PATCH 1/3] feat: file upload input --- src/examples/src/config.tsx | 39 ++++ .../src/widgets/file-upload-input/Basic.tsx | 21 +++ .../widgets/file-upload-input/Disabled.tsx | 17 ++ .../widgets/file-upload-input/Labelled.tsx | 25 +++ .../widgets/file-upload-input/Multiple.tsx | 49 +++++ .../src/widgets/file-upload-input/NoDrop.tsx | 21 +++ .../src/widgets/file-upload-input/README.md | 0 .../widgets/file-upload-input/multiple.m.css | 3 + .../file-upload-input/multiple.m.css.d.ts | 1 + src/file-upload-input/index.tsx | 151 +++++++++++++++ src/file-upload-input/nls/FileUploadInput.ts | 6 + .../styles/file-upload-input.m.css | 20 ++ .../styles/file-upload-input.m.css.d.ts | 2 + .../tests/unit/FileUploadInput.spec.tsx | 175 ++++++++++++++++++ src/theme/default/file-upload-input.m.css | 29 +++ .../default/file-upload-input.m.css.d.ts | 6 + src/theme/default/index.ts | 2 + src/theme/dojo/file-input-button.m.css | 23 +++ src/theme/dojo/file-input-button.m.css.d.ts | 5 + src/theme/dojo/index.ts | 2 + src/theme/material/file-upload-input.m.css | 26 +++ .../material/file-upload-input.m.css.d.ts | 5 + src/theme/material/index.ts | 2 + 23 files changed, 630 insertions(+) create mode 100644 src/examples/src/widgets/file-upload-input/Basic.tsx create mode 100644 src/examples/src/widgets/file-upload-input/Disabled.tsx create mode 100644 src/examples/src/widgets/file-upload-input/Labelled.tsx create mode 100644 src/examples/src/widgets/file-upload-input/Multiple.tsx create mode 100644 src/examples/src/widgets/file-upload-input/NoDrop.tsx create mode 100644 src/examples/src/widgets/file-upload-input/README.md create mode 100644 src/examples/src/widgets/file-upload-input/multiple.m.css create mode 100644 src/examples/src/widgets/file-upload-input/multiple.m.css.d.ts create mode 100644 src/file-upload-input/index.tsx create mode 100644 src/file-upload-input/nls/FileUploadInput.ts create mode 100644 src/file-upload-input/styles/file-upload-input.m.css create mode 100644 src/file-upload-input/styles/file-upload-input.m.css.d.ts create mode 100644 src/file-upload-input/tests/unit/FileUploadInput.spec.tsx create mode 100644 src/theme/default/file-upload-input.m.css create mode 100644 src/theme/default/file-upload-input.m.css.d.ts create mode 100644 src/theme/dojo/file-input-button.m.css create mode 100644 src/theme/dojo/file-input-button.m.css.d.ts create mode 100644 src/theme/material/file-upload-input.m.css create mode 100644 src/theme/material/file-upload-input.m.css.d.ts diff --git a/src/examples/src/config.tsx b/src/examples/src/config.tsx index 267e84a67a..87782fabe5 100644 --- a/src/examples/src/config.tsx +++ b/src/examples/src/config.tsx @@ -284,6 +284,11 @@ import InitialStateTree from './widgets/tree/InitialState'; import PopupConfirmation from './widgets/popup-confirmation/Basic'; import PopupConfirmationUnderlay from './widgets/popup-confirmation/Underlay'; import ClickableCard from './widgets/card/ClickableCard'; +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 * as dojoDarkVariant from '@dojo/widgets/theme/dojo/variants/dark.m.css'; import * as materialDarkVariant from '@dojo/widgets/theme/material/variants/dark.m.css'; @@ -810,6 +815,40 @@ 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 + } + ] + }, '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/README.md b/src/examples/src/widgets/file-upload-input/README.md new file mode 100644 index 0000000000..e69de29bb2 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/file-upload-input/index.tsx b/src/file-upload-input/index.tsx new file mode 100644 index 0000000000..63e55c0a38 --- /dev/null +++ b/src/file-upload-input/index.tsx @@ -0,0 +1,151 @@ +import { DojoEvent, RenderResult } from '@dojo/framework/core/interfaces'; +import i18n from '@dojo/framework/core/middleware/i18n'; +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 FileValidation { + message?: string; + valid?: boolean; +} + +export interface FileUploadInputChildren { + /** Label displayed above the input */ + label?: RenderResult; + + /** Content rendered within the upload area */ + content?: RenderResult; +} + +export interface FileUploadInputProperties { + /** Determines what file types this input accepts */ + accept?: string; + + /** Custom aria attributes */ + aria?: { [key: string]: string | null }; + + /** Determines if this input is disabled */ + disabled?: boolean; + + /** Hides the label for a11y purposes */ + labelHidden?: boolean; + + /** Allows multiple files to be uploaded at once */ + multiple?: boolean; + + /** Name given to this attribute within a form */ + name?: string; + + /** Callback called when the user selects files */ + onValue(value: File[]): void; + + /** Determines if a value is required for this input */ + required?: boolean; + + /** Represents if the selected files passed internal validation */ + valid?: boolean | FileValidation; +} + +const factory = create({ i18n, node, theme }) + .properties() + .children(); + +export const FileUploadInput = factory(function FileUploadInput({ + children, + id, + middleware: { i18n, node, theme }, + properties +}) { + const { + accept, + aria = {}, + disabled = false, + labelHidden = false, + multiple = false, + name, + onValue, + required = false, + valid = true + } = properties(); + + const { messages } = i18n.localize(bundle); + const themeCss = theme.classes(css); + const { content = undefined, label = undefined } = children()[0] || {}; + + function clickNativeButton() { + // Certain browsers such as Firefox 80 require direct DOM access for fileInputNode.click() to fire. + 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 && ( + + )} + +
+ + +
+ + {content} +
+ ); +}); + +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..b43163f08f --- /dev/null +++ b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx @@ -0,0 +1,175 @@ +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 { messages } = bundle; + +describe('FileUploadInput', function() { + const WrappedRoot = wrap('div'); + const WrappedWrapper = wrap('div'); + const WrappedInput = wrap('input'); + const WrappedButton = wrap(Button); + + 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 disabled', function() { + const r = renderer(function() { + return ; + }); + + r.expect( + baseAssertion + .setProperty(WrappedRoot, 'aria-disabled', 'true') + .setProperty(WrappedRoot, 'classes', [null, fixedCss.root, css.root, css.disabled]) + .setProperty(WrappedInput, 'disabled', true) + .setProperty(WrappedButton, 'disabled', true) + ); + }); + + 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/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/index.ts b/src/theme/default/index.ts index 780ccf2d80..0a8fd9c0ad 100644 --- a/src/theme/default/index.ts +++ b/src/theme/default/index.ts @@ -58,6 +58,7 @@ import * as tooltip from './tooltip.m.css'; import * as twoColumnLayout from './two-column-layout.m.css'; import * as typeahead from './typeahead.m.css'; import * as defaultVariant from './variants/default.m.css'; +import * as fileUploadInput from './file-upload-input.m.css'; export default { theme: { @@ -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-input-button.m.css b/src/theme/dojo/file-input-button.m.css new file mode 100644 index 0000000000..f6c10fde82 --- /dev/null +++ b/src/theme/dojo/file-input-button.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-input-button.m.css.d.ts b/src/theme/dojo/file-input-button.m.css.d.ts new file mode 100644 index 0000000000..2368aac56a --- /dev/null +++ b/src/theme/dojo/file-input-button.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/index.ts b/src/theme/dojo/index.ts index 2ad293ad17..34aa92ce65 100644 --- a/src/theme/dojo/index.ts +++ b/src/theme/dojo/index.ts @@ -61,6 +61,7 @@ import * as twoColumnLayout from './two-column-layout.m.css'; import * as typeahead from './typeahead.m.css'; import * as darkVariant from './variants/dark.m.css'; import * as defaultVariant from './variants/default.m.css'; +import * as fileUploadInput from './file-input-button.m.css'; export default { theme: { @@ -75,6 +76,7 @@ export default { '@dojo/widgets/chip': chip, '@dojo/widgets/date-input': dateInput, '@dojo/widgets/dialog': dialog, + '@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/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/index.ts b/src/theme/material/index.ts index f30767e268..362325f6b6 100644 --- a/src/theme/material/index.ts +++ b/src/theme/material/index.ts @@ -61,6 +61,7 @@ import * as twoColumnLayout from './two-column-layout.m.css'; import * as typeahead from './typeahead.m.css'; import * as defaultVariant from './variants/default.m.css'; import * as darkVariant from './variants/dark.m.css'; +import * as fileUploadInput from './file-upload-input.m.css'; export default { theme: { @@ -75,6 +76,7 @@ export default { '@dojo/widgets/chip': chip, '@dojo/widgets/date-input': dateInput, '@dojo/widgets/dialog': dialog, + '@dojo/widgets/file-upload-input': fileUploadInput, '@dojo/widgets/floating-action-button': floatingActionButton, '@dojo/widgets/form': form, '@dojo/widgets/grid-body': gridBody, From 06c97fb778f4e8bd91e1c6c257cb99ed32d78e61 Mon Sep 17 00:00:00 2001 From: bitpshr Date: Fri, 7 May 2021 09:04:50 -0400 Subject: [PATCH 2/3] feat: file uploader --- src/examples/src/config.tsx | 42 +++ .../src/widgets/file-upload-input/README.md | 0 .../src/widgets/file-uploader/Basic.tsx | 17 + .../src/widgets/file-uploader/Controlled.tsx | 46 +++ .../widgets/file-uploader/CustomValidator.tsx | 32 ++ .../src/widgets/file-uploader/Disabled.tsx | 17 + .../src/widgets/file-uploader/Multiple.tsx | 17 + .../src/widgets/file-uploader/Validated.tsx | 20 ++ src/file-upload-input/README.md | 16 + src/file-upload-input/index.tsx | 3 +- .../styles/file-upload-input.m.css | 20 -- .../styles/file-upload-input.m.css.d.ts | 2 - .../tests/unit/FileUploadInput.spec.tsx | 11 +- src/file-uploader/README.md | 20 ++ src/file-uploader/index.tsx | 230 ++++++++++++ src/file-uploader/nls/FileUploader.ts | 6 + .../tests/unit/FileUploader.spec.tsx | 332 ++++++++++++++++++ src/theme/default/file-upload-input.m.css | 16 +- .../default/file-upload-input.m.css.d.ts | 4 +- src/theme/default/file-uploader.m.css | 51 +++ src/theme/default/file-uploader.m.css.d.ts | 9 + src/theme/default/index.ts | 2 + src/theme/dojo/file-input-button.m.css | 23 -- src/theme/dojo/file-input-button.m.css.d.ts | 5 - src/theme/dojo/file-upload-input.m.css | 8 + src/theme/dojo/file-upload-input.m.css.d.ts | 2 + src/theme/dojo/file-uploader.m.css | 44 +++ src/theme/dojo/file-uploader.m.css.d.ts | 8 + src/theme/dojo/index.ts | 4 +- src/theme/material/file-upload-input.m.css | 18 +- .../material/file-upload-input.m.css.d.ts | 4 +- src/theme/material/file-uploader.m.css | 39 ++ src/theme/material/file-uploader.m.css.d.ts | 8 + src/theme/material/index.ts | 2 + 34 files changed, 981 insertions(+), 97 deletions(-) delete mode 100644 src/examples/src/widgets/file-upload-input/README.md create mode 100644 src/examples/src/widgets/file-uploader/Basic.tsx create mode 100644 src/examples/src/widgets/file-uploader/Controlled.tsx create mode 100644 src/examples/src/widgets/file-uploader/CustomValidator.tsx create mode 100644 src/examples/src/widgets/file-uploader/Disabled.tsx create mode 100644 src/examples/src/widgets/file-uploader/Multiple.tsx create mode 100644 src/examples/src/widgets/file-uploader/Validated.tsx create mode 100644 src/file-upload-input/README.md delete mode 100644 src/file-upload-input/styles/file-upload-input.m.css delete mode 100644 src/file-upload-input/styles/file-upload-input.m.css.d.ts create mode 100644 src/file-uploader/README.md create mode 100644 src/file-uploader/index.tsx create mode 100644 src/file-uploader/nls/FileUploader.ts create mode 100644 src/file-uploader/tests/unit/FileUploader.spec.tsx create mode 100644 src/theme/default/file-uploader.m.css create mode 100644 src/theme/default/file-uploader.m.css.d.ts delete mode 100644 src/theme/dojo/file-input-button.m.css delete mode 100644 src/theme/dojo/file-input-button.m.css.d.ts create mode 100644 src/theme/dojo/file-upload-input.m.css create mode 100644 src/theme/dojo/file-upload-input.m.css.d.ts create mode 100644 src/theme/dojo/file-uploader.m.css create mode 100644 src/theme/dojo/file-uploader.m.css.d.ts create mode 100644 src/theme/material/file-uploader.m.css create mode 100644 src/theme/material/file-uploader.m.css.d.ts diff --git a/src/examples/src/config.tsx b/src/examples/src/config.tsx index 87782fabe5..75b634fb98 100644 --- a/src/examples/src/config.tsx +++ b/src/examples/src/config.tsx @@ -289,6 +289,12 @@ 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 * as dojoDarkVariant from '@dojo/widgets/theme/dojo/variants/dark.m.css'; import * as materialDarkVariant from '@dojo/widgets/theme/material/variants/dark.m.css'; @@ -849,6 +855,42 @@ export const config = { } ] }, + '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/README.md b/src/examples/src/widgets/file-upload-input/README.md deleted file mode 100644 index e69de29bb2..0000000000 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..def04d414f --- /dev/null +++ b/src/file-upload-input/README.md @@ -0,0 +1,16 @@ +# @dojo/widgets/file-upload-input + +Dojo's `FileUploadInput` provides an interface for managing file uploads supporting both ``. 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 + +### 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 diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index 63e55c0a38..7aa45a3398 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -10,7 +10,6 @@ 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 FileValidation { @@ -98,7 +97,7 @@ export const FileUploadInput = factory(function FileUploadInput({ key="root" {...formatAriaProperties(aria)} aria-disabled={disabled ? 'true' : 'false'} - classes={[theme.variant(), fixedCss.root, themeCss.root, disabled && themeCss.disabled]} + classes={[theme.variant(), themeCss.root, disabled && themeCss.disabled]} > {label && (