From d662c22741f784ffcfe19611a8fe0316b0019345 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Wed, 26 Aug 2020 22:01:41 -0700 Subject: [PATCH 01/35] Add FileUploader widget --- src/examples/src/config.tsx | 24 +++ .../src/widgets/file-uploader/Basic.tsx | 13 ++ .../src/widgets/file-uploader/Disabled.tsx | 13 ++ .../src/widgets/file-uploader/Multiple.tsx | 13 ++ src/file-uploader/README.md | 23 +++ src/file-uploader/index.tsx | 145 ++++++++++++++++++ src/file-uploader/nls/FileUploader.ts | 7 + src/theme/default/file-uploader.m.css | 32 ++++ src/theme/default/file-uploader.m.css.d.ts | 6 + src/theme/dojo/file-uploader.m.css | 29 ++++ src/theme/dojo/file-uploader.m.css.d.ts | 5 + src/theme/dojo/index.ts | 2 + 12 files changed, 312 insertions(+) create mode 100644 src/examples/src/widgets/file-uploader/Basic.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/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/theme/default/file-uploader.m.css create mode 100644 src/theme/default/file-uploader.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 diff --git a/src/examples/src/config.tsx b/src/examples/src/config.tsx index 0c22c9baba..ace3f143d7 100644 --- a/src/examples/src/config.tsx +++ b/src/examples/src/config.tsx @@ -60,6 +60,9 @@ 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 BasicFileUploader from './widgets/file-uploader/Basic'; +import DisabledFileUploader from './widgets/file-uploader/Disabled'; +import MultipleFileUploader from './widgets/file-uploader/Multiple'; import Advanced from './widgets/grid/Advanced'; import BasicFab from './widgets/floating-action-button/Basic'; import ExtendedFab from './widgets/floating-action-button/Extended'; @@ -731,6 +734,27 @@ 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 + } + ] + }, 'floating-action-button': { overview: { example: { 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..ab953ea5a7 --- /dev/null +++ b/src/examples/src/widgets/file-uploader/Basic.tsx @@ -0,0 +1,13 @@ +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() { + return ( + + + + ); +}); 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..dcd7df3596 --- /dev/null +++ b/src/examples/src/widgets/file-uploader/Disabled.tsx @@ -0,0 +1,13 @@ +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() { + 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..577feaf391 --- /dev/null +++ b/src/examples/src/widgets/file-uploader/Multiple.tsx @@ -0,0 +1,13 @@ +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() { + return ( + + + + ); +}); diff --git a/src/file-uploader/README.md b/src/file-uploader/README.md new file mode 100644 index 0000000000..e694121ab3 --- /dev/null +++ b/src/file-uploader/README.md @@ -0,0 +1,23 @@ +# @dojo/widgets/file-uploader + +Dojo's `FileUploader` provides an interface for managing file uploads using `` internally. + +## Features + +- Single or multiple file upload +- Add files from OS-provided file selection dialog +- Add files with drag and drop +- View added files +- Validation +- Custom file item renderer can be provided + +### Keyboard features + +- Trigger file selection dialog with keyboard +- Navigate file list with keyboard + +### Accessibility features + +### 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..bc6130d941 --- /dev/null +++ b/src/file-uploader/index.tsx @@ -0,0 +1,145 @@ +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, tsx } from '@dojo/framework/core/vdom'; +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 baseCss from '../theme/default/base.m.css'; +import * as buttonCss from '../theme/default/button.m.css'; + +export interface FileItemRendererProps { + files: File[]; + messages: typeof bundle; + remove(file: File): void; + themeCss: typeof css; +} + +export interface FileItemRenderer { + (props: FileItemRendererProps): RenderResult; +} + +export interface FileUploaderIcache { + files: File[]; +} + +export interface FileUploaderChildren { + buttonLabel?: string; + dndLabel?: string; + renderFiles?: FileItemRenderer; +} + +export interface FileUploaderProperties { + accept?: string | string[]; + allowDnd?: boolean; + disabled?: boolean; + multiple?: boolean; + name?: string; + onValue?(value: File[]): void; + required?: boolean; +} + +function defaultFileRenderer(props: FileItemRendererProps) { + // TODO: is this the best way to pass values to a child renderer? + const { + files, + messages: { messages }, + remove, + themeCss + } = props; + + return files.map(function(file) { + return ( +
+
{file.name}
+ +
+ ); + }); +} + +const factory = create({ i18n, icache: createICacheMiddleware(), theme }) + .properties() + .children(); + +export const FileUploader = factory(function FileUploader({ + children, + middleware: { i18n, icache, theme }, + properties +}) { + const { + accept, + allowDnd = true, + disabled = false, + multiple = false, + name, + required = false + } = properties(); + const { messages } = i18n.localize(bundle); + const { + buttonLabel = messages.chooseFiles, + dndLabel = messages.orDropFilesHere, + renderFiles = defaultFileRenderer + } = children()[0] || {}; + const files = icache.getOrSet('files', []); + const themeCss = theme.classes(css); + const buttonThemeCss = theme.classes(buttonCss); + + function onChange(event: DojoEvent) { + const newlyAddedFiles = Array.from(event.target.files || []); + if (multiple) { + icache.set('files', [...files, ...newlyAddedFiles]); + } else { + icache.set('files', Array.from(event.target.files || [])); + } + } + + function remove(file: File) { + const fileIndex = files.indexOf(file); + if (fileIndex !== -1) { + files.splice(fileIndex, 1); + icache.set('files', files); + } + } + + return ( +
+
+ {/* TODO: is a button-styled label the best approach? */} + + {allowDnd && dndLabel} +
+
{renderFiles({ files, messages: { messages }, remove, themeCss })}
+
+ ); +}); + +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..b5a9ea8008 --- /dev/null +++ b/src/file-uploader/nls/FileUploader.ts @@ -0,0 +1,7 @@ +const messages = { + chooseFiles: 'Choose files…', + orDropFilesHere: 'Or drop files here', + remove: 'Remove' +}; + +export default { messages }; diff --git a/src/theme/default/file-uploader.m.css b/src/theme/default/file-uploader.m.css new file mode 100644 index 0000000000..e332801ab6 --- /dev/null +++ b/src/theme/default/file-uploader.m.css @@ -0,0 +1,32 @@ +/* The root class for FileUploader */ +.root { +} + +/* Applied to the root node if the widget is disabled */ +.disabled { + cursor: no-drop; +} + +/* The button to press to add files */ +.button { + margin-right: 8px; +} + +/* Container for each item representing an added file */ +.fileItem { + display: flex; + flex-direction: row; +} + +/* The name of each added file (child of .fileItem) */ +.fileItemName { + flex-grow: 1; +} + +/* Close icon button rendered for each file (child of .fileItem) */ +.closeButton { + border: none; + background: transparent; + cursor: pointer; + padding-top: 2px; +} 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..5217f6f764 --- /dev/null +++ b/src/theme/default/file-uploader.m.css.d.ts @@ -0,0 +1,6 @@ +export const root: string; +export const disabled: string; +export const button: string; +export const fileItem: string; +export const fileItemName: string; +export const closeButton: 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..a5c59819ca --- /dev/null +++ b/src/theme/dojo/file-uploader.m.css @@ -0,0 +1,29 @@ +.disabled { + cursor: no-drop; +} + +.button { + /* TODO: button overrides this */ + margin-right: var(--grid-base); +} + +.fileItem { + display: flex; + flex-direction: row; + line-height: var(--line-height-base); +} + +.fileItem:hover { + background-color: var(--color-background-faded); +} + +.fileItemName { + flex-grow: 1; +} + +.closeButton { + border: none; + background: transparent; + cursor: pointer; + padding-top: 2px; +} 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..0709711ad0 --- /dev/null +++ b/src/theme/dojo/file-uploader.m.css.d.ts @@ -0,0 +1,5 @@ +export const disabled: string; +export const button: string; +export const fileItem: string; +export const fileItemName: string; +export const closeButton: string; diff --git a/src/theme/dojo/index.ts b/src/theme/dojo/index.ts index 2ad293ad17..941e704e8f 100644 --- a/src/theme/dojo/index.ts +++ b/src/theme/dojo/index.ts @@ -10,6 +10,7 @@ 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 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 +76,7 @@ export default { '@dojo/widgets/chip': chip, '@dojo/widgets/date-input': dateInput, '@dojo/widgets/dialog': dialog, + '@dojo/widgets/file-uploader': fileUploader, '@dojo/widgets/floating-action-button': floatingActionButton, '@dojo/widgets/form': form, '@dojo/widgets/grid-body': gridBody, From 65a510fe17586c9a603140be0a6c236d63fd2786 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Thu, 27 Aug 2020 17:06:49 -0700 Subject: [PATCH 02/35] Add FileUploadInput widget --- src/examples/src/config.tsx | 32 ++++++- .../src/widgets/file-upload-input/Basic.tsx | 13 +++ .../widgets/file-upload-input/Disabled.tsx | 13 +++ .../widgets/file-upload-input/Multiple.tsx | 13 +++ src/file-upload-input/README.md | 22 +++++ src/file-upload-input/index.tsx | 94 +++++++++++++++++++ src/file-upload-input/nls/FileUploadInput.ts | 6 ++ src/file-uploader/nls/FileUploader.ts | 2 - src/theme/default/file-upload-input.m.css | 13 +++ .../default/file-upload-input.m.css.d.ts | 3 + src/theme/default/file-uploader.m.css | 14 --- src/theme/default/file-uploader.m.css.d.ts | 3 - src/theme/default/index.ts | 2 + src/theme/dojo/file-upload-input.m.css | 12 +++ src/theme/dojo/file-upload-input.m.css.d.ts | 3 + src/theme/dojo/index.ts | 2 + src/theme/material/file-upload-input.m.css | 15 +++ .../material/file-upload-input.m.css.d.ts | 3 + src/theme/material/index.ts | 2 + 19 files changed, 244 insertions(+), 23 deletions(-) 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/Multiple.tsx create mode 100644 src/file-upload-input/README.md create mode 100644 src/file-upload-input/index.tsx create mode 100644 src/file-upload-input/nls/FileUploadInput.ts 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-upload-input.m.css create mode 100644 src/theme/dojo/file-upload-input.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 ace3f143d7..f58655c996 100644 --- a/src/examples/src/config.tsx +++ b/src/examples/src/config.tsx @@ -60,9 +60,12 @@ 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 BasicFileUploader from './widgets/file-uploader/Basic'; -import DisabledFileUploader from './widgets/file-uploader/Disabled'; -import MultipleFileUploader from './widgets/file-uploader/Multiple'; +import BasicFileUploadInput from './widgets/file-upload-input/Basic'; +import DisabledFileUploadInput from './widgets/file-upload-input/Disabled'; +import MultipleFileUploadInput from './widgets/file-upload-input/Multiple'; +// import BasicFileUploader from './widgets/file-uploader/Basic'; +// import DisabledFileUploader from './widgets/file-uploader/Disabled'; +// import MultipleFileUploader from './widgets/file-uploader/Multiple'; import Advanced from './widgets/grid/Advanced'; import BasicFab from './widgets/floating-action-button/Basic'; import ExtendedFab from './widgets/floating-action-button/Extended'; @@ -734,6 +737,27 @@ export const config = { } } }, + 'file-upload-input': { + filename: 'index', + overview: { + example: { + filename: 'Basic FileUploadInput', + module: BasicFileUploadInput + } + }, + examples: [ + { + title: 'Disabled FileUploadInput', + filename: 'Disabled', + module: DisabledFileUploadInput + }, + { + title: 'Multiple FileUploadInput', + filename: 'Multiple', + module: MultipleFileUploadInput + } + ] + } /* 'file-uploader': { filename: 'index', overview: { @@ -754,7 +778,7 @@ export const config = { module: MultipleFileUploader } ] - }, + },*/, '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..edb5cdf5ef --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/Basic.tsx @@ -0,0 +1,13 @@ +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 Basic() { + return ( + + + + ); +}); 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..da310982a1 --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/Disabled.tsx @@ -0,0 +1,13 @@ +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 Basic() { + return ( + + + + ); +}); 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..ec9eff979d --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/Multiple.tsx @@ -0,0 +1,13 @@ +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 Basic() { + return ( + + + + ); +}); diff --git a/src/file-upload-input/README.md b/src/file-upload-input/README.md new file mode 100644 index 0000000000..cf4b55c594 --- /dev/null +++ b/src/file-upload-input/README.md @@ -0,0 +1,22 @@ +# @dojo/widgets/file-upload-input + +Dojo's `FileUploadInput` provides an interface for managing file uploads using ``. This is a +controlled component 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 +- Validation + +### Keyboard features + +- Trigger file selection dialog 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-upload-input/index.tsx b/src/file-upload-input/index.tsx new file mode 100644 index 0000000000..9f5061cf6b --- /dev/null +++ b/src/file-upload-input/index.tsx @@ -0,0 +1,94 @@ +import { DojoEvent } 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 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'; + +export interface FileUploaderChildren { + buttonLabel?: string; + dndLabel?: string; +} + +export interface FileUploaderProperties { + /** The `accept` attribute of the input */ + accept?: string | string[]; + /** If `true` file drag-n-drop is allowed. Default is `true` */ + allowDnd?: boolean; + /** The `disabled` attribute of the input */ + disabled?: 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; +} + +const factory = create({ i18n, node, theme }) + .properties() + .children(); + +export const FileUploadInput = factory(function FileUploader({ + children, + middleware: { i18n, node, theme }, + properties +}) { + const { + accept, + allowDnd = true, + disabled = false, + multiple = false, + name, + onValue, + required = false + } = properties(); + const { messages } = i18n.localize(bundle); + const { buttonLabel = messages.chooseFiles, dndLabel = messages.orDropFilesHere } = + children()[0] || {}; + const themeCss = theme.classes(css); + + function onActivate() { + const inputNode = node.get('nativeInput'); + if (inputNode) { + inputNode.click(); + } + } + + function onChange(event: DojoEvent) { + if (!onValue) { + return; + } + const fileArray = Array.from(event.target.files || []); + onValue(fileArray); + } + + return ( +
+ + + + {allowDnd && {dndLabel}} +
+ ); +}); + +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-uploader/nls/FileUploader.ts b/src/file-uploader/nls/FileUploader.ts index b5a9ea8008..8e55eb7574 100644 --- a/src/file-uploader/nls/FileUploader.ts +++ b/src/file-uploader/nls/FileUploader.ts @@ -1,6 +1,4 @@ const messages = { - chooseFiles: 'Choose files…', - orDropFilesHere: 'Or drop files here', remove: 'Remove' }; 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..c72b5629ef --- /dev/null +++ b/src/theme/default/file-upload-input.m.css @@ -0,0 +1,13 @@ +/* The root class for FileUploader */ +.root { +} + +/* Applied to the root node if the widget is disabled */ +.disabled { + cursor: no-drop; +} + +/* The text label in the DnD area if DnD is allowed */ +.dndLabel { + margin-left: 8px; +} 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..276603170b --- /dev/null +++ b/src/theme/default/file-upload-input.m.css.d.ts @@ -0,0 +1,3 @@ +export const root: string; +export const disabled: string; +export const dndLabel: string; diff --git a/src/theme/default/file-uploader.m.css b/src/theme/default/file-uploader.m.css index e332801ab6..aaf91fb8cb 100644 --- a/src/theme/default/file-uploader.m.css +++ b/src/theme/default/file-uploader.m.css @@ -1,17 +1,3 @@ -/* The root class for FileUploader */ -.root { -} - -/* Applied to the root node if the widget is disabled */ -.disabled { - cursor: no-drop; -} - -/* The button to press to add files */ -.button { - margin-right: 8px; -} - /* Container for each item representing an added file */ .fileItem { display: flex; diff --git a/src/theme/default/file-uploader.m.css.d.ts b/src/theme/default/file-uploader.m.css.d.ts index 5217f6f764..6ca2690c1b 100644 --- a/src/theme/default/file-uploader.m.css.d.ts +++ b/src/theme/default/file-uploader.m.css.d.ts @@ -1,6 +1,3 @@ -export const root: string; -export const disabled: string; -export const button: string; export const fileItem: string; export const fileItemName: string; export const closeButton: 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..0977dd63ea --- /dev/null +++ b/src/theme/dojo/file-upload-input.m.css @@ -0,0 +1,12 @@ +.root { + background-color: var(--background-color); +} + +.disabled { + cursor: no-drop; +} + +.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..276603170b --- /dev/null +++ b/src/theme/dojo/file-upload-input.m.css.d.ts @@ -0,0 +1,3 @@ +export const root: string; +export const disabled: string; +export const dndLabel: string; diff --git a/src/theme/dojo/index.ts b/src/theme/dojo/index.ts index 941e704e8f..4dd5c93320 100644 --- a/src/theme/dojo/index.ts +++ b/src/theme/dojo/index.ts @@ -10,6 +10,7 @@ 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'; @@ -76,6 +77,7 @@ 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, 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..cbc745e409 --- /dev/null +++ b/src/theme/material/file-upload-input.m.css @@ -0,0 +1,15 @@ +.root { +} + +.disabled { + cursor: no-drop; +} + +.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..276603170b --- /dev/null +++ b/src/theme/material/file-upload-input.m.css.d.ts @@ -0,0 +1,3 @@ +export const root: string; +export const disabled: string; +export const dndLabel: string; diff --git a/src/theme/material/index.ts b/src/theme/material/index.ts index f30767e268..e7820f1fc6 100644 --- a/src/theme/material/index.ts +++ b/src/theme/material/index.ts @@ -10,6 +10,7 @@ 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 floatingActionButton from './floating-action-button.m.css'; import * as form from './form.m.css'; import * as gridBody from './grid-body.m.css'; @@ -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 dfed22e60ec9670f08cee29f5678e91012f77ea5 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Thu, 27 Aug 2020 17:30:08 -0700 Subject: [PATCH 03/35] FileUploadInput: add Label demo and update label typing --- src/examples/src/config.tsx | 6 +++++ .../src/widgets/file-upload-input/Labels.tsx | 23 +++++++++++++++++++ src/file-upload-input/index.tsx | 6 ++--- 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 src/examples/src/widgets/file-upload-input/Labels.tsx diff --git a/src/examples/src/config.tsx b/src/examples/src/config.tsx index f58655c996..6266c5a3a3 100644 --- a/src/examples/src/config.tsx +++ b/src/examples/src/config.tsx @@ -62,6 +62,7 @@ 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/Labels'; import MultipleFileUploadInput from './widgets/file-upload-input/Multiple'; // import BasicFileUploader from './widgets/file-uploader/Basic'; // import DisabledFileUploader from './widgets/file-uploader/Disabled'; @@ -755,6 +756,11 @@ export const config = { title: 'Multiple FileUploadInput', filename: 'Multiple', module: MultipleFileUploadInput + }, + { + title: 'FileUploadInput with custom labels', + filename: 'Labels', + module: LabelledFileUploadInput } ] } /* diff --git a/src/examples/src/widgets/file-upload-input/Labels.tsx b/src/examples/src/widgets/file-upload-input/Labels.tsx new file mode 100644 index 0000000000..e225373af0 --- /dev/null +++ b/src/examples/src/widgets/file-upload-input/Labels.tsx @@ -0,0 +1,23 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import { FileUploadInput } from '@dojo/widgets/file-upload-input'; +import { Icon } from '@dojo/widgets/icon'; +import Example from '../../Example'; + +const factory = create(); + +export default factory(function Basic() { + return ( + + + {{ + buttonLabel: ( +
+ Upload a file +
+ ), + dndLabel: 'Drop a file here to upload' + }} +
+
+ ); +}); diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index 9f5061cf6b..e3373b056a 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -1,4 +1,4 @@ -import { DojoEvent } from '@dojo/framework/core/interfaces'; +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'; @@ -9,8 +9,8 @@ import * as css from '../theme/default/file-upload-input.m.css'; import * as baseCss from '../theme/default/base.m.css'; export interface FileUploaderChildren { - buttonLabel?: string; - dndLabel?: string; + buttonLabel?: RenderResult; + dndLabel?: RenderResult; } export interface FileUploaderProperties { From e10e060f922cefa720378cedcc640f43ddf856d0 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Thu, 27 Aug 2020 18:02:17 -0700 Subject: [PATCH 04/35] FileUploader: update to use FileUploadInput --- src/examples/src/config.tsx | 10 +-- src/file-upload-input/index.tsx | 10 +-- src/file-uploader/README.md | 1 - src/file-uploader/index.tsx | 82 ++++++---------------- src/theme/default/file-upload-input.m.css | 2 +- src/theme/default/file-uploader.m.css | 8 +++ src/theme/default/file-uploader.m.css.d.ts | 2 + 7 files changed, 44 insertions(+), 71 deletions(-) diff --git a/src/examples/src/config.tsx b/src/examples/src/config.tsx index 6266c5a3a3..2405925676 100644 --- a/src/examples/src/config.tsx +++ b/src/examples/src/config.tsx @@ -64,9 +64,9 @@ import BasicFileUploadInput from './widgets/file-upload-input/Basic'; import DisabledFileUploadInput from './widgets/file-upload-input/Disabled'; import LabelledFileUploadInput from './widgets/file-upload-input/Labels'; import MultipleFileUploadInput from './widgets/file-upload-input/Multiple'; -// import BasicFileUploader from './widgets/file-uploader/Basic'; -// import DisabledFileUploader from './widgets/file-uploader/Disabled'; -// import MultipleFileUploader from './widgets/file-uploader/Multiple'; +import BasicFileUploader from './widgets/file-uploader/Basic'; +import DisabledFileUploader from './widgets/file-uploader/Disabled'; +import MultipleFileUploader from './widgets/file-uploader/Multiple'; import Advanced from './widgets/grid/Advanced'; import BasicFab from './widgets/floating-action-button/Basic'; import ExtendedFab from './widgets/floating-action-button/Extended'; @@ -763,7 +763,7 @@ export const config = { module: LabelledFileUploadInput } ] - } /* + }, 'file-uploader': { filename: 'index', overview: { @@ -784,7 +784,7 @@ export const config = { module: MultipleFileUploader } ] - },*/, + }, 'floating-action-button': { overview: { example: { diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index e3373b056a..1f0f643b97 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -8,12 +8,12 @@ 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'; -export interface FileUploaderChildren { +export interface FileUploadInputChildren { buttonLabel?: RenderResult; dndLabel?: RenderResult; } -export interface FileUploaderProperties { +export interface FileUploadInputProperties { /** The `accept` attribute of the input */ accept?: string | string[]; /** If `true` file drag-n-drop is allowed. Default is `true` */ @@ -31,10 +31,10 @@ export interface FileUploaderProperties { } const factory = create({ i18n, node, theme }) - .properties() - .children(); + .properties() + .children(); -export const FileUploadInput = factory(function FileUploader({ +export const FileUploadInput = factory(function FileUploadInput({ children, middleware: { i18n, node, theme }, properties diff --git a/src/file-uploader/README.md b/src/file-uploader/README.md index e694121ab3..e06a699e44 100644 --- a/src/file-uploader/README.md +++ b/src/file-uploader/README.md @@ -9,7 +9,6 @@ Dojo's `FileUploader` provides an interface for managing file uploads using `(), theme }) .properties() - .children(); + .children(); export const FileUploader = factory(function FileUploader({ children, @@ -85,21 +68,14 @@ export const FileUploader = factory(function FileUploader({ required = false } = properties(); const { messages } = i18n.localize(bundle); - const { - buttonLabel = messages.chooseFiles, - dndLabel = messages.orDropFilesHere, - renderFiles = defaultFileRenderer - } = children()[0] || {}; const files = icache.getOrSet('files', []); const themeCss = theme.classes(css); - const buttonThemeCss = theme.classes(buttonCss); - function onChange(event: DojoEvent) { - const newlyAddedFiles = Array.from(event.target.files || []); + function onValue(newFiles: File[]) { if (multiple) { - icache.set('files', [...files, ...newlyAddedFiles]); + icache.set('files', [...files, ...newFiles]); } else { - icache.set('files', Array.from(event.target.files || [])); + icache.set('files', newFiles); } } @@ -113,30 +89,18 @@ export const FileUploader = factory(function FileUploader({ return (
-
- {/* TODO: is a button-styled label the best approach? */} - - {allowDnd && dndLabel} -
+ + {children()[0]} + +
{renderFiles({ files, messages: { messages }, remove, themeCss })}
); diff --git a/src/theme/default/file-upload-input.m.css b/src/theme/default/file-upload-input.m.css index c72b5629ef..038cc5362d 100644 --- a/src/theme/default/file-upload-input.m.css +++ b/src/theme/default/file-upload-input.m.css @@ -1,4 +1,4 @@ -/* The root class for FileUploader */ +/* The root class for FileUploadInput */ .root { } diff --git a/src/theme/default/file-uploader.m.css b/src/theme/default/file-uploader.m.css index aaf91fb8cb..eb2d0eea5b 100644 --- a/src/theme/default/file-uploader.m.css +++ b/src/theme/default/file-uploader.m.css @@ -1,3 +1,11 @@ +/* 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; diff --git a/src/theme/default/file-uploader.m.css.d.ts b/src/theme/default/file-uploader.m.css.d.ts index 6ca2690c1b..d6a25a5f7e 100644 --- a/src/theme/default/file-uploader.m.css.d.ts +++ b/src/theme/default/file-uploader.m.css.d.ts @@ -1,3 +1,5 @@ +export const root: string; +export const disabled: string; export const fileItem: string; export const fileItemName: string; export const closeButton: string; From 618054bc14457dfd290c0c4b1f5908176833cbe3 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Thu, 27 Aug 2020 21:38:32 -0700 Subject: [PATCH 05/35] FileUploader: add dnd support --- src/file-upload-input/index.tsx | 6 ++ src/file-uploader/index.tsx | 86 +++++++++++++++++++--- src/middleware/dnd.ts | 44 +++++++++++ src/theme/default/file-uploader.m.css | 12 +++ src/theme/default/file-uploader.m.css.d.ts | 1 + 5 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 src/middleware/dnd.ts diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index 1f0f643b97..741d6bd197 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -16,16 +16,22 @@ export interface FileUploadInputChildren { export interface FileUploadInputProperties { /** The `accept` attribute of the input */ accept?: string | string[]; + /** If `true` file drag-n-drop is allowed. Default is `true` */ allowDnd?: boolean; + /** The `disabled` attribute of the input */ disabled?: 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; } diff --git a/src/file-uploader/index.tsx b/src/file-uploader/index.tsx index 517835ae60..09099aaccf 100644 --- a/src/file-uploader/index.tsx +++ b/src/file-uploader/index.tsx @@ -7,33 +7,51 @@ import { FileUploadInputProperties } from '../file-upload-input'; import { Icon } from '../icon'; +import dnd from '../middleware/dnd'; import theme from '../middleware/theme'; import bundle from './nls/FileUploader'; import * as css from '../theme/default/file-uploader.m.css'; +export interface FileUploaderIcache { + files: File[]; + isDndActive: boolean; +} + +export interface FileUploaderProperties extends FileUploadInputProperties { + /** Custom validator used to validate each file */ + customValidator?: (file: File) => { valid?: boolean; message?: string } | void; + + /** The maximum size in bytes of a file */ + maxSize?: number; +} + export interface FileItemRendererProps { + customValidator: FileUploaderProperties['customValidator']; files: File[]; + maxSize?: number; messages: typeof bundle; remove(file: File): void; themeCss: typeof css; } -export interface FileUploaderIcache { - files: File[]; -} - -export interface FileUploaderProperties extends FileUploadInputProperties {} - function renderFiles(props: FileItemRendererProps) { const { + customValidator, files, + maxSize, messages: { messages }, remove, themeCss } = props; return files.map(function(file) { + const isValid = customValidator + ? customValidator(file) + : maxSize + ? file.size <= maxSize + : true; + return (
{file.name}
@@ -50,25 +68,28 @@ function renderFiles(props: FileItemRendererProps) { }); } -const factory = create({ i18n, icache: createICacheMiddleware(), theme }) +const factory = create({ dnd, i18n, icache: createICacheMiddleware(), theme }) .properties() .children(); export const FileUploader = factory(function FileUploader({ children, - middleware: { i18n, icache, theme }, + middleware: { dnd, i18n, icache, theme }, properties }) { const { accept, allowDnd = true, + customValidator, disabled = false, + maxSize, multiple = false, name, required = false } = properties(); const { messages } = i18n.localize(bundle); const files = icache.getOrSet('files', []); + const isDndActive = icache.getOrSet('isDndActive', false); const themeCss = theme.classes(css); function onValue(newFiles: File[]) { @@ -87,8 +108,42 @@ export const FileUploader = factory(function FileUploader({ } } + dnd.onDragEnter('root', function(event) { + event.preventDefault(); + event.stopPropagation(); + icache.set('isDndActive', true); + }); + + dnd.onDragLeave('overlay', function(event) { + event.preventDefault(); + event.stopPropagation(); + icache.set('isDndActive', false); + }); + + dnd.onDragOver('overlay', function(event) { + event.preventDefault(); + event.stopPropagation(); + }); + + dnd.onDrop('overlay', function(event) { + event.preventDefault(); + event.stopPropagation(); + icache.set('isDndActive', false); + + if (event.dataTransfer && event.dataTransfer.files.length) { + const newFiles = Array.from(event.dataTransfer.files); + if (multiple) { + icache.set('files', [...files, ...newFiles]); + } else { + icache.set('files', newFiles.slice(0, 1)); + } + } + }); + return ( -
+
+ {isDndActive &&
} + -
{renderFiles({ files, messages: { messages }, remove, themeCss })}
+ {files.length && ( +
+ {renderFiles({ + customValidator, + files, + maxSize, + messages: { messages }, + remove, + themeCss + })} +
+ )}
); }); diff --git a/src/middleware/dnd.ts b/src/middleware/dnd.ts new file mode 100644 index 0000000000..61c4c32f59 --- /dev/null +++ b/src/middleware/dnd.ts @@ -0,0 +1,44 @@ +// TODO: should this module be in @dojo/framework? +import { create, node } from '@dojo/framework/core/vdom'; + +function stopEvent(event: Event) { + event.preventDefault(); + event.stopPropagation(); +} + +const factory = create({ node }); + +export const dnd = factory(function dnd({ middleware: { node } }) { + // TODO: remove event listeners + return { + onDragEnter(key: string | number, callback: (event: DragEvent) => void) { + const domNode = node.get(key); + if (domNode) { + domNode.addEventListener('dragenter', callback); + } + }, + + onDragLeave(key: string | number, callback: (event: DragEvent) => void) { + const domNode = node.get(key); + if (domNode) { + domNode.addEventListener('dragleave', callback); + } + }, + + onDragOver(key: string | number, callback: (event: DragEvent) => void) { + const domNode = node.get(key); + if (domNode) { + domNode.addEventListener('dragover', callback); + } + }, + + onDrop(key: string | number, callback: (event: DragEvent) => void) { + const domNode = node.get(key); + if (domNode) { + domNode.addEventListener('drop', callback); + } + } + }; +}); + +export default dnd; diff --git a/src/theme/default/file-uploader.m.css b/src/theme/default/file-uploader.m.css index eb2d0eea5b..7943217271 100644 --- a/src/theme/default/file-uploader.m.css +++ b/src/theme/default/file-uploader.m.css @@ -1,11 +1,23 @@ /* The root class for FileUploader */ .root { + position: relative; } /* Applied to the root node if the widget is disabled */ .disabled { } +.dndOverlay { + background-color: var(--selected-background); + border: 2px outset var(--selected-background); + height: 100%; + left: 0; + opacity: 0.3; + position: absolute; + top: 0; + width: 100%; +} + /* Container for each item representing an added file */ .fileItem { display: flex; diff --git a/src/theme/default/file-uploader.m.css.d.ts b/src/theme/default/file-uploader.m.css.d.ts index d6a25a5f7e..cb6f973072 100644 --- a/src/theme/default/file-uploader.m.css.d.ts +++ b/src/theme/default/file-uploader.m.css.d.ts @@ -1,5 +1,6 @@ export const root: string; export const disabled: string; +export const dndOverlay: string; export const fileItem: string; export const fileItemName: string; export const closeButton: string; From 1e358ea2c985fbe248880d064297ff79f2608ed6 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Fri, 28 Aug 2020 15:15:16 -0700 Subject: [PATCH 06/35] WIP: convert dnd middleware to reactive API --- .../src/widgets/file-upload-input/Basic.tsx | 14 +- src/file-upload-input/index.tsx | 20 ++- src/file-uploader/index.tsx | 40 +++--- src/middleware/dnd.ts | 131 ++++++++++++++---- src/theme/default/file-upload-input.m.css | 12 ++ .../default/file-upload-input.m.css.d.ts | 1 + src/theme/default/file-uploader.m.css | 11 -- src/theme/default/file-uploader.m.css.d.ts | 1 - 8 files changed, 170 insertions(+), 60 deletions(-) diff --git a/src/examples/src/widgets/file-upload-input/Basic.tsx b/src/examples/src/widgets/file-upload-input/Basic.tsx index edb5cdf5ef..c34dead316 100644 --- a/src/examples/src/widgets/file-upload-input/Basic.tsx +++ b/src/examples/src/widgets/file-upload-input/Basic.tsx @@ -1,13 +1,21 @@ import { create, tsx } from '@dojo/framework/core/vdom'; import { FileUploadInput } from '@dojo/widgets/file-upload-input'; import Example from '../../Example'; +import icache from '@dojo/framework/core/middleware/icache'; -const factory = create(); +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); + } -export default factory(function Basic() { return ( - + +
Selected file: {selectedFiles.length ? selectedFiles[0].name : 'none'}
); }); diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index 741d6bd197..730de58162 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -2,6 +2,7 @@ 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 dnd from '../middleware/dnd'; import theme from '../middleware/theme'; import bundle from './nls/FileUploadInput'; @@ -36,13 +37,13 @@ export interface FileUploadInputProperties { required?: boolean; } -const factory = create({ i18n, node, theme }) +const factory = create({ dnd, i18n, node, theme }) .properties() .children(); export const FileUploadInput = factory(function FileUploadInput({ children, - middleware: { i18n, node, theme }, + middleware: { dnd, i18n, node, theme }, properties }) { const { @@ -58,6 +59,18 @@ export const FileUploadInput = factory(function FileUploadInput({ const { buttonLabel = messages.chooseFiles, dndLabel = messages.orDropFilesHere } = children()[0] || {}; const themeCss = theme.classes(css); + let isDndActive = false; + + if (allowDnd) { + const rootDnd = dnd.get('root'); + const overlayDnd = dnd.get('overlay'); + isDndActive = rootDnd.isDragging || overlayDnd.isDragging; + + if (overlayDnd.isDropped && overlayDnd.files && overlayDnd.files.length) { + onValue && onValue(overlayDnd.files); + dnd.reset('overlay'); + } + } function onActivate() { const inputNode = node.get('nativeInput'); @@ -75,7 +88,7 @@ export const FileUploadInput = factory(function FileUploadInput({ } return ( -
+
{allowDnd && {dndLabel}} + {isDndActive &&
}
); }); diff --git a/src/file-uploader/index.tsx b/src/file-uploader/index.tsx index 09099aaccf..e5c487a65c 100644 --- a/src/file-uploader/index.tsx +++ b/src/file-uploader/index.tsx @@ -7,11 +7,12 @@ import { FileUploadInputProperties } from '../file-upload-input'; import { Icon } from '../icon'; -import dnd from '../middleware/dnd'; +// import dnd from '../middleware/dnd'; 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'; export interface FileUploaderIcache { files: File[]; @@ -37,20 +38,21 @@ export interface FileItemRendererProps { function renderFiles(props: FileItemRendererProps) { const { - customValidator, + // customValidator, files, - maxSize, + // maxSize, messages: { messages }, remove, themeCss } = props; return files.map(function(file) { + /* const isValid = customValidator ? customValidator(file) : maxSize ? file.size <= maxSize - : true; + : true;*/ return (
@@ -67,14 +69,19 @@ function renderFiles(props: FileItemRendererProps) { ); }); } - -const factory = create({ dnd, i18n, icache: createICacheMiddleware(), theme }) +/* +function stopEvent (event: Event) { + event.preventDefault(); + event.stopPropagation(); +} +*/ +const factory = create({ i18n, icache: createICacheMiddleware(), theme }) .properties() .children(); export const FileUploader = factory(function FileUploader({ children, - middleware: { dnd, i18n, icache, theme }, + middleware: { i18n, icache, theme }, properties }) { const { @@ -91,6 +98,7 @@ export const FileUploader = factory(function FileUploader({ const files = icache.getOrSet('files', []); const isDndActive = icache.getOrSet('isDndActive', false); const themeCss = theme.classes(css); + const fileUploadInputThemeCss = theme.classes(fileUploadInputCss); function onValue(newFiles: File[]) { if (multiple) { @@ -107,27 +115,23 @@ export const FileUploader = factory(function FileUploader({ icache.set('files', files); } } - + /* dnd.onDragEnter('root', function(event) { - event.preventDefault(); - event.stopPropagation(); + stopEvent(event); icache.set('isDndActive', true); }); dnd.onDragLeave('overlay', function(event) { - event.preventDefault(); - event.stopPropagation(); + stopEvent(event); icache.set('isDndActive', false); }); dnd.onDragOver('overlay', function(event) { - event.preventDefault(); - event.stopPropagation(); + stopEvent(event); }); dnd.onDrop('overlay', function(event) { - event.preventDefault(); - event.stopPropagation(); + stopEvent(event); icache.set('isDndActive', false); if (event.dataTransfer && event.dataTransfer.files.length) { @@ -139,10 +143,10 @@ export const FileUploader = factory(function FileUploader({ } } }); - +*/ return (
- {isDndActive &&
} + {isDndActive &&
} (); + + function onDragEnter(event: DragEvent) { + event.preventDefault(); + + const results = nodeMap.get(event.currentTarget as HTMLElement); + if (results && results.isDragging === false) { + results.isDragging = true; + invalidator(); + } + } + + function onDragLeave(event: DragEvent) { + event.preventDefault(); + + const results = nodeMap.get(event.currentTarget as HTMLElement); + if (results && results.isDragging === true) { + results.isDragging = false; + invalidator(); + } + } + + // The default action for this event is to reset the current drag operation so it is necessary to add a handler + // to any valid DnD target that prevents the default action. + // https://developer.mozilla.org/en-US/docs/Web/API/Document/dragover_event + function onDragOver(event: DragEvent) { + event.preventDefault(); + } + + function onDrop(event: DragEvent) { + event.preventDefault(); + + const results = nodeMap.get(event.currentTarget as HTMLElement); + if (results) { + results.isDragging = false; + results.isDropped = true; + results.files = + event.dataTransfer && event.dataTransfer.files.length + ? Array.from(event.dataTransfer.files) + : []; + invalidator(); + } + } + + function addListeners(node: HTMLElement) { + node.addEventListener('dragenter', onDragEnter); + node.addEventListener('dragover', onDragOver); + node.addEventListener('dragleave', onDragLeave); + node.addEventListener('drop', onDrop); + + handles.push(function() { + node.removeEventListener('dragenter', onDragEnter); + node.removeEventListener('dragover', onDragOver); + node.removeEventListener('dragleave', onDragLeave); + node.removeEventListener('drop', onDrop); + }); + } + + destroy(function() { + let handle: any; + while ((handle = handles.pop())) { + handle && handle(); + } + }); -export const dnd = factory(function dnd({ middleware: { node } }) { - // TODO: remove event listeners return { - onDragEnter(key: string | number, callback: (event: DragEvent) => void) { + get(key: string | number): Readonly { const domNode = node.get(key); - if (domNode) { - domNode.addEventListener('dragenter', callback); + + if (!domNode) { + return emptyResults; } - }, - onDragLeave(key: string | number, callback: (event: DragEvent) => void) { - const domNode = node.get(key); - if (domNode) { - domNode.addEventListener('dragleave', callback); + if (!nodeMap.has(domNode)) { + nodeMap.set(domNode, createResults()); + addListeners(domNode); + + return emptyResults; } + + const results = Object.assign({}, nodeMap.get(domNode)); + return results; }, - onDragOver(key: string | number, callback: (event: DragEvent) => void) { + // When dropping a file there is no event that follows `drop` to signify the DnD operation has completed. + // This method allows the consumer to reset the dnd middleware once the drop data has been received. + reset(key: string | number) { const domNode = node.get(key); - if (domNode) { - domNode.addEventListener('dragover', callback); + + if (!domNode) { + return; } - }, - onDrop(key: string | number, callback: (event: DragEvent) => void) { - const domNode = node.get(key); - if (domNode) { - domNode.addEventListener('drop', callback); + if (nodeMap.has(domNode)) { + nodeMap.set(domNode, createResults()); + invalidator(); } } }; diff --git a/src/theme/default/file-upload-input.m.css b/src/theme/default/file-upload-input.m.css index 038cc5362d..222c7276db 100644 --- a/src/theme/default/file-upload-input.m.css +++ b/src/theme/default/file-upload-input.m.css @@ -1,5 +1,6 @@ /* The root class for FileUploadInput */ .root { + position: relative; } /* Applied to the root node if the widget is disabled */ @@ -7,6 +8,17 @@ cursor: no-drop; } +.dndOverlay { + background-color: var(--selected-background); + border: 2px outset var(--selected-background); + height: 100%; + left: 0; + opacity: 0.3; + position: absolute; + top: 0; + width: 100%; +} + /* The text label in the DnD area if DnD is allowed */ .dndLabel { margin-left: 8px; diff --git a/src/theme/default/file-upload-input.m.css.d.ts b/src/theme/default/file-upload-input.m.css.d.ts index 276603170b..3b0ee7f9dc 100644 --- a/src/theme/default/file-upload-input.m.css.d.ts +++ b/src/theme/default/file-upload-input.m.css.d.ts @@ -1,3 +1,4 @@ export const root: string; export const disabled: string; +export const dndOverlay: string; export const dndLabel: string; diff --git a/src/theme/default/file-uploader.m.css b/src/theme/default/file-uploader.m.css index 7943217271..5aa53d0f52 100644 --- a/src/theme/default/file-uploader.m.css +++ b/src/theme/default/file-uploader.m.css @@ -7,17 +7,6 @@ .disabled { } -.dndOverlay { - background-color: var(--selected-background); - border: 2px outset var(--selected-background); - height: 100%; - left: 0; - opacity: 0.3; - position: absolute; - top: 0; - width: 100%; -} - /* Container for each item representing an added file */ .fileItem { display: flex; diff --git a/src/theme/default/file-uploader.m.css.d.ts b/src/theme/default/file-uploader.m.css.d.ts index cb6f973072..d6a25a5f7e 100644 --- a/src/theme/default/file-uploader.m.css.d.ts +++ b/src/theme/default/file-uploader.m.css.d.ts @@ -1,6 +1,5 @@ export const root: string; export const disabled: string; -export const dndOverlay: string; export const fileItem: string; export const fileItemName: string; export const closeButton: string; From f8775e4d527fdbdbcf2b2830fdca08d51b1738bc Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Mon, 31 Aug 2020 14:11:33 -0700 Subject: [PATCH 07/35] Fix dnd API and update examples --- src/examples/src/config.tsx | 3 ++- .../widgets/file-upload-input/Disabled.tsx | 2 +- .../src/widgets/file-upload-input/Labels.tsx | 2 +- .../widgets/file-upload-input/Multiple.tsx | 24 ++++++++++++++++--- src/file-upload-input/index.tsx | 15 ++++++++---- .../styles/file-upload-input.m.css | 20 ++++++++++++++++ .../styles/file-upload-input.m.css.d.ts | 2 ++ src/theme/default/file-upload-input.m.css | 5 ---- 8 files changed, 58 insertions(+), 15 deletions(-) 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 diff --git a/src/examples/src/config.tsx b/src/examples/src/config.tsx index 2405925676..a965228c58 100644 --- a/src/examples/src/config.tsx +++ b/src/examples/src/config.tsx @@ -742,7 +742,8 @@ export const config = { filename: 'index', overview: { example: { - filename: 'Basic FileUploadInput', + title: 'Basic FileUploadInput', + filename: 'Basic', module: BasicFileUploadInput } }, diff --git a/src/examples/src/widgets/file-upload-input/Disabled.tsx b/src/examples/src/widgets/file-upload-input/Disabled.tsx index da310982a1..be16dae51f 100644 --- a/src/examples/src/widgets/file-upload-input/Disabled.tsx +++ b/src/examples/src/widgets/file-upload-input/Disabled.tsx @@ -4,7 +4,7 @@ import Example from '../../Example'; const factory = create(); -export default factory(function Basic() { +export default factory(function Disabled() { return ( diff --git a/src/examples/src/widgets/file-upload-input/Labels.tsx b/src/examples/src/widgets/file-upload-input/Labels.tsx index e225373af0..0469b5dfaf 100644 --- a/src/examples/src/widgets/file-upload-input/Labels.tsx +++ b/src/examples/src/widgets/file-upload-input/Labels.tsx @@ -5,7 +5,7 @@ import Example from '../../Example'; const factory = create(); -export default factory(function Basic() { +export default factory(function Labels() { return ( diff --git a/src/examples/src/widgets/file-upload-input/Multiple.tsx b/src/examples/src/widgets/file-upload-input/Multiple.tsx index ec9eff979d..73ad7cd343 100644 --- a/src/examples/src/widgets/file-upload-input/Multiple.tsx +++ b/src/examples/src/widgets/file-upload-input/Multiple.tsx @@ -1,13 +1,31 @@ import { create, tsx } from '@dojo/framework/core/vdom'; import { FileUploadInput } from '@dojo/widgets/file-upload-input'; import Example from '../../Example'; +import icache from '@dojo/framework/core/middleware/icache'; -const factory = create(); +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); + } -export default factory(function Basic() { return ( - + + {selectedFiles.length > 0 && ( +
    + {selectedFiles.map(function(file) { + return ( +
  • + {file.name}: {file.size} +
  • + ); + })} +
+ )}
); }); diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index 730de58162..72e8edbe1a 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -8,6 +8,7 @@ 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 fixedCss from './styles/file-upload-input.m.css'; export interface FileUploadInputChildren { buttonLabel?: RenderResult; @@ -72,7 +73,7 @@ export const FileUploadInput = factory(function FileUploadInput({ } } - function onActivate() { + function onClickButton() { const inputNode = node.get('nativeInput'); if (inputNode) { inputNode.click(); @@ -88,7 +89,7 @@ export const FileUploadInput = factory(function FileUploadInput({ } return ( -
+
- {allowDnd && {dndLabel}} - {isDndActive &&
} + + {/* This node MUST always be rendered. If it is conditionally rendered it will receive incorrect + information from the dnd middleware. */} +
); }); 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/theme/default/file-upload-input.m.css b/src/theme/default/file-upload-input.m.css index 222c7276db..aa0205b9e5 100644 --- a/src/theme/default/file-upload-input.m.css +++ b/src/theme/default/file-upload-input.m.css @@ -11,12 +11,7 @@ .dndOverlay { background-color: var(--selected-background); border: 2px outset var(--selected-background); - height: 100%; - left: 0; opacity: 0.3; - position: absolute; - top: 0; - width: 100%; } /* The text label in the DnD area if DnD is allowed */ From c8408688baa7fc16fdb715bbebf1c26cc5ee01e8 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Mon, 31 Aug 2020 15:26:08 -0700 Subject: [PATCH 08/35] Update themes --- src/file-upload-input/index.tsx | 10 +++++++++- src/theme/default/file-upload-input.m.css | 10 +++++++--- src/theme/default/file-upload-input.m.css.d.ts | 1 + src/theme/dojo/file-upload-input.m.css | 13 ++++++++++++- src/theme/material/file-upload-input.m.css | 11 +++++++++++ 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index 72e8edbe1a..b590e79b4c 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -89,7 +89,15 @@ export const FileUploadInput = factory(function FileUploadInput({ } return ( -
+
Date: Mon, 31 Aug 2020 22:47:40 -0700 Subject: [PATCH 09/35] WIP: experiment with more focused fileDrop middleware --- src/file-upload-input/index.tsx | 37 +++++---- src/file-uploader/index.tsx | 66 ++++++--------- src/middleware/fileDrop.ts | 138 ++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 57 deletions(-) create mode 100644 src/middleware/fileDrop.ts diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index b590e79b4c..6d8b21b2e6 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -2,7 +2,7 @@ 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 dnd from '../middleware/dnd'; +import fileDrop from '../middleware/fileDrop'; import theme from '../middleware/theme'; import bundle from './nls/FileUploadInput'; @@ -38,13 +38,13 @@ export interface FileUploadInputProperties { required?: boolean; } -const factory = create({ dnd, i18n, node, theme }) +const factory = create({ fileDrop, i18n, node, theme }) .properties() .children(); export const FileUploadInput = factory(function FileUploadInput({ children, - middleware: { dnd, i18n, node, theme }, + middleware: { fileDrop, i18n, node, theme }, properties }) { const { @@ -63,13 +63,12 @@ export const FileUploadInput = factory(function FileUploadInput({ let isDndActive = false; if (allowDnd) { - const rootDnd = dnd.get('root'); - const overlayDnd = dnd.get('overlay'); - isDndActive = rootDnd.isDragging || overlayDnd.isDragging; + const dndInfo = fileDrop.get('root', 'overlay'); + isDndActive = dndInfo.isDragging; - if (overlayDnd.isDropped && overlayDnd.files && overlayDnd.files.length) { - onValue && onValue(overlayDnd.files); - dnd.reset('overlay'); + if (dndInfo.isDropped && dndInfo.files && dndInfo.files.length) { + onValue && onValue(dndInfo.files); + fileDrop.reset('root', 'overlay'); } } @@ -92,6 +91,7 @@ export const FileUploadInput = factory(function FileUploadInput({
- {allowDnd && {dndLabel}} - - {/* This node MUST always be rendered. If it is conditionally rendered it will receive incorrect - information from the dnd middleware. */} -
+ {allowDnd && [ + {dndLabel}, +
+ ]}
); }); diff --git a/src/file-uploader/index.tsx b/src/file-uploader/index.tsx index e5c487a65c..2ff7e61013 100644 --- a/src/file-uploader/index.tsx +++ b/src/file-uploader/index.tsx @@ -7,12 +7,13 @@ import { FileUploadInputProperties } from '../file-upload-input'; import { Icon } from '../icon'; -// import dnd from '../middleware/dnd'; import theme from '../middleware/theme'; import bundle from './nls/FileUploader'; import * as css from '../theme/default/file-uploader.m.css'; +import * as baseCss from '../theme/default/base.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 FileUploaderIcache { files: File[]; @@ -69,12 +70,7 @@ function renderFiles(props: FileItemRendererProps) { ); }); } -/* -function stopEvent (event: Event) { - event.preventDefault(); - event.stopPropagation(); -} -*/ + const factory = create({ i18n, icache: createICacheMiddleware(), theme }) .properties() .children(); @@ -96,7 +92,7 @@ export const FileUploader = factory(function FileUploader({ } = properties(); const { messages } = i18n.localize(bundle); const files = icache.getOrSet('files', []); - const isDndActive = icache.getOrSet('isDndActive', false); + let isDndActive = false; const themeCss = theme.classes(css); const fileUploadInputThemeCss = theme.classes(fileUploadInputCss); @@ -115,42 +111,21 @@ export const FileUploader = factory(function FileUploader({ icache.set('files', files); } } - /* - dnd.onDragEnter('root', function(event) { - stopEvent(event); - icache.set('isDndActive', true); - }); - - dnd.onDragLeave('overlay', function(event) { - stopEvent(event); - icache.set('isDndActive', false); - }); - - dnd.onDragOver('overlay', function(event) { - stopEvent(event); - }); - dnd.onDrop('overlay', function(event) { - stopEvent(event); - icache.set('isDndActive', false); - - if (event.dataTransfer && event.dataTransfer.files.length) { - const newFiles = Array.from(event.dataTransfer.files); - if (multiple) { - icache.set('files', [...files, ...newFiles]); - } else { - icache.set('files', newFiles.slice(0, 1)); - } - } - }); -*/ return ( -
- {isDndActive &&
} - +
)} + + {allowDnd && ( +
+ )}
); }); diff --git a/src/middleware/fileDrop.ts b/src/middleware/fileDrop.ts new file mode 100644 index 0000000000..cb50dad59b --- /dev/null +++ b/src/middleware/fileDrop.ts @@ -0,0 +1,138 @@ +// TODO: this module should probably be in @dojo/framework +// TODO: this is a minimal implementation suitable for +// external file DnD as implemented in @dojo/widgets/file-upload-input + +import { create, destroy, invalidator, node } from '@dojo/framework/core/vdom'; + +export interface DndResults { + files?: File[]; + isDragging: boolean; + isDropped: boolean; +} + +function createResults(): DndResults { + return { + isDragging: false, + isDropped: false + }; +} + +const emptyResults = Object.freeze(createResults()); + +const factory = create({ destroy, invalidator, node }); + +export const fileDrop = factory(function fileDrop({ middleware: { destroy, invalidator, node } }) { + const handles: Function[] = []; + let nodeMap = new WeakMap(); + + function onDragEnter(event: DragEvent) { + event.preventDefault(); + + const results = nodeMap.get(event.currentTarget as HTMLElement); + if (results && results.isDragging === false) { + results.isDragging = true; + invalidator(); + } + } + + function onDragLeave(event: DragEvent) { + event.preventDefault(); + + const results = nodeMap.get(event.currentTarget as HTMLElement); + if (results && results.isDragging === true) { + results.isDragging = false; + invalidator(); + } + } + + // The default action for this event is to reset the current drag operation so it is necessary to add a handler + // to any valid DnD target that prevents the default action. + // https://developer.mozilla.org/en-US/docs/Web/API/Document/dragover_event + function preventDefault(event: DragEvent) { + event.preventDefault(); + } + + function onDrop(event: DragEvent) { + event.preventDefault(); + + const results = nodeMap.get(event.currentTarget as HTMLElement); + if (results) { + results.isDragging = false; + results.isDropped = true; + results.files = + event.dataTransfer && event.dataTransfer.files.length + ? Array.from(event.dataTransfer.files) + : []; + invalidator(); + } + } + + function initResults(targetNode: HTMLElement, overlayNode: HTMLElement) { + // both nodes share the same results object + const newResults = createResults(); + nodeMap.set(targetNode, newResults); + nodeMap.set(overlayNode, newResults); + } + + function addListeners(targetNode: HTMLElement, overlayNode: HTMLElement) { + targetNode.addEventListener('dragenter', onDragEnter); + overlayNode.addEventListener('dragenter', preventDefault); + overlayNode.addEventListener('dragover', preventDefault); + overlayNode.addEventListener('dragleave', onDragLeave); + overlayNode.addEventListener('drop', onDrop); + + handles.push(function() { + targetNode.removeEventListener('dragenter', onDragEnter); + overlayNode.removeEventListener('dragenter', preventDefault); + overlayNode.removeEventListener('dragover', preventDefault); + overlayNode.removeEventListener('dragleave', onDragLeave); + overlayNode.removeEventListener('drop', onDrop); + }); + } + + destroy(function() { + let handle: any; + while ((handle = handles.pop())) { + handle && handle(); + } + }); + + return { + get(targetKey: string | number, overlayKey: string | number): Readonly { + const targetNode = node.get(targetKey); + const overlayNode = node.get(overlayKey); + + if (!(targetNode && overlayNode)) { + // TODO: throw error? log warning? + return emptyResults; + } + + if (!nodeMap.has(targetNode)) { + initResults(targetNode, overlayNode); + addListeners(targetNode, overlayNode); + + return emptyResults; + } + + const results = Object.assign({}, nodeMap.get(targetNode)); + return results; + }, + + // When dropping a file there is no event that follows `drop` to signify the DnD operation has completed. + // This method allows the consumer to reset the dnd middleware once the drop data has been received. + reset(targetKey: string | number, overlayKey: string | number) { + const targetNode = node.get(targetKey); + const overlayNode = node.get(overlayKey); + + if (!(targetNode && overlayNode)) { + // TODO: throw error? log warning? + return emptyResults; + } + + initResults(targetNode, overlayNode); + invalidator(); + } + }; +}); + +export default fileDrop; From 237511ed66b9405dd51a0d9b31f7e54e07fa2c6a Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Tue, 1 Sep 2020 14:28:00 -0700 Subject: [PATCH 10/35] Refactor dnd middleware to fileDrop --- src/file-upload-input/index.tsx | 35 ++-- src/middleware/dnd.ts | 127 ------------- src/middleware/fileDrop.ts | 175 +++++++++--------- src/theme/dojo/file-upload-input.m.css.d.ts | 2 + .../material/file-upload-input.m.css.d.ts | 2 + 5 files changed, 110 insertions(+), 231 deletions(-) delete mode 100644 src/middleware/dnd.ts diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index 6d8b21b2e6..71112aaa77 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -1,6 +1,7 @@ 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 { createICacheMiddleware } from '@dojo/framework/core/middleware/icache'; +import { create, tsx } from '@dojo/framework/core/vdom'; import { Button } from '../button'; import fileDrop from '../middleware/fileDrop'; import theme from '../middleware/theme'; @@ -38,13 +39,18 @@ export interface FileUploadInputProperties { required?: boolean; } -const factory = create({ fileDrop, i18n, node, theme }) +interface FileUploadInputIcache { + shouldClick?: boolean; +} +const icache = createICacheMiddleware(); + +const factory = create({ fileDrop, i18n, icache, theme }) .properties() .children(); export const FileUploadInput = factory(function FileUploadInput({ children, - middleware: { fileDrop, i18n, node, theme }, + middleware: { fileDrop, i18n, icache, theme }, properties }) { const { @@ -64,19 +70,19 @@ export const FileUploadInput = factory(function FileUploadInput({ if (allowDnd) { const dndInfo = fileDrop.get('root', 'overlay'); - isDndActive = dndInfo.isDragging; - - if (dndInfo.isDropped && dndInfo.files && dndInfo.files.length) { - onValue && onValue(dndInfo.files); - fileDrop.reset('root', 'overlay'); + if (dndInfo) { + isDndActive = dndInfo.isDragging; + + if (dndInfo.isDropped && dndInfo.files && dndInfo.files.length) { + onValue && onValue(dndInfo.files); + } + } else { + // TODO: should not happen... log warning? } } function onClickButton() { - const inputNode = node.get('nativeInput'); - if (inputNode) { - inputNode.click(); - } + icache.set('shouldClick', true); } function onChange(event: DojoEvent) { @@ -103,6 +109,11 @@ export const FileUploadInput = factory(function FileUploadInput({ accept={accept} aria="hidden" classes={[baseCss.hidden]} + click={function() { + const shouldClick = Boolean(icache.getOrSet('shouldClick', false)); + shouldClick && icache.set('shouldClick', false, false); + return shouldClick; + }} disabled={disabled} multiple={multiple} name={name} diff --git a/src/middleware/dnd.ts b/src/middleware/dnd.ts deleted file mode 100644 index 8e4747f694..0000000000 --- a/src/middleware/dnd.ts +++ /dev/null @@ -1,127 +0,0 @@ -// TODO: this module should probably be in @dojo/framework -// TODO: this is a minimal implementation suitable for -// external file DnD as implemented in @dojo/widgets/file-upload-input - -import { create, destroy, invalidator, node } from '@dojo/framework/core/vdom'; - -export interface DndResults { - files?: File[]; - isDragging: boolean; - isDropped: boolean; -} - -function createResults(): DndResults { - return { - isDragging: false, - isDropped: false - }; -} - -const emptyResults = Object.freeze(createResults()); - -const factory = create({ destroy, invalidator, node }); - -export const dnd = factory(function dnd({ middleware: { destroy, invalidator, node } }) { - const handles: Function[] = []; - let nodeMap = new WeakMap(); - - function onDragEnter(event: DragEvent) { - event.preventDefault(); - - const results = nodeMap.get(event.currentTarget as HTMLElement); - if (results && results.isDragging === false) { - results.isDragging = true; - invalidator(); - } - } - - function onDragLeave(event: DragEvent) { - event.preventDefault(); - - const results = nodeMap.get(event.currentTarget as HTMLElement); - if (results && results.isDragging === true) { - results.isDragging = false; - invalidator(); - } - } - - // The default action for this event is to reset the current drag operation so it is necessary to add a handler - // to any valid DnD target that prevents the default action. - // https://developer.mozilla.org/en-US/docs/Web/API/Document/dragover_event - function onDragOver(event: DragEvent) { - event.preventDefault(); - } - - function onDrop(event: DragEvent) { - event.preventDefault(); - - const results = nodeMap.get(event.currentTarget as HTMLElement); - if (results) { - results.isDragging = false; - results.isDropped = true; - results.files = - event.dataTransfer && event.dataTransfer.files.length - ? Array.from(event.dataTransfer.files) - : []; - invalidator(); - } - } - - function addListeners(node: HTMLElement) { - node.addEventListener('dragenter', onDragEnter); - node.addEventListener('dragover', onDragOver); - node.addEventListener('dragleave', onDragLeave); - node.addEventListener('drop', onDrop); - - handles.push(function() { - node.removeEventListener('dragenter', onDragEnter); - node.removeEventListener('dragover', onDragOver); - node.removeEventListener('dragleave', onDragLeave); - node.removeEventListener('drop', onDrop); - }); - } - - destroy(function() { - let handle: any; - while ((handle = handles.pop())) { - handle && handle(); - } - }); - - return { - get(key: string | number): Readonly { - const domNode = node.get(key); - - if (!domNode) { - return emptyResults; - } - - if (!nodeMap.has(domNode)) { - nodeMap.set(domNode, createResults()); - addListeners(domNode); - - return emptyResults; - } - - const results = Object.assign({}, nodeMap.get(domNode)); - return results; - }, - - // When dropping a file there is no event that follows `drop` to signify the DnD operation has completed. - // This method allows the consumer to reset the dnd middleware once the drop data has been received. - reset(key: string | number) { - const domNode = node.get(key); - - if (!domNode) { - return; - } - - if (nodeMap.has(domNode)) { - nodeMap.set(domNode, createResults()); - invalidator(); - } - } - }; -}); - -export default dnd; diff --git a/src/middleware/fileDrop.ts b/src/middleware/fileDrop.ts index cb50dad59b..c3599ac836 100644 --- a/src/middleware/fileDrop.ts +++ b/src/middleware/fileDrop.ts @@ -1,8 +1,7 @@ // TODO: this module should probably be in @dojo/framework -// TODO: this is a minimal implementation suitable for -// external file DnD as implemented in @dojo/widgets/file-upload-input -import { create, destroy, invalidator, node } from '@dojo/framework/core/vdom'; +import { create, destroy, node } from '@dojo/framework/core/vdom'; +import { createICacheMiddleware } from '@dojo/framework/core/middleware/icache'; export interface DndResults { files?: File[]; @@ -19,76 +18,11 @@ function createResults(): DndResults { const emptyResults = Object.freeze(createResults()); -const factory = create({ destroy, invalidator, node }); +const icache = createICacheMiddleware>(); +const factory = create({ destroy, icache, node }); -export const fileDrop = factory(function fileDrop({ middleware: { destroy, invalidator, node } }) { +export const fileDrop = factory(function fileDrop({ middleware: { destroy, icache, node } }) { const handles: Function[] = []; - let nodeMap = new WeakMap(); - - function onDragEnter(event: DragEvent) { - event.preventDefault(); - - const results = nodeMap.get(event.currentTarget as HTMLElement); - if (results && results.isDragging === false) { - results.isDragging = true; - invalidator(); - } - } - - function onDragLeave(event: DragEvent) { - event.preventDefault(); - - const results = nodeMap.get(event.currentTarget as HTMLElement); - if (results && results.isDragging === true) { - results.isDragging = false; - invalidator(); - } - } - - // The default action for this event is to reset the current drag operation so it is necessary to add a handler - // to any valid DnD target that prevents the default action. - // https://developer.mozilla.org/en-US/docs/Web/API/Document/dragover_event - function preventDefault(event: DragEvent) { - event.preventDefault(); - } - - function onDrop(event: DragEvent) { - event.preventDefault(); - - const results = nodeMap.get(event.currentTarget as HTMLElement); - if (results) { - results.isDragging = false; - results.isDropped = true; - results.files = - event.dataTransfer && event.dataTransfer.files.length - ? Array.from(event.dataTransfer.files) - : []; - invalidator(); - } - } - - function initResults(targetNode: HTMLElement, overlayNode: HTMLElement) { - // both nodes share the same results object - const newResults = createResults(); - nodeMap.set(targetNode, newResults); - nodeMap.set(overlayNode, newResults); - } - - function addListeners(targetNode: HTMLElement, overlayNode: HTMLElement) { - targetNode.addEventListener('dragenter', onDragEnter); - overlayNode.addEventListener('dragenter', preventDefault); - overlayNode.addEventListener('dragover', preventDefault); - overlayNode.addEventListener('dragleave', onDragLeave); - overlayNode.addEventListener('drop', onDrop); - - handles.push(function() { - targetNode.removeEventListener('dragenter', onDragEnter); - overlayNode.removeEventListener('dragenter', preventDefault); - overlayNode.removeEventListener('dragover', preventDefault); - overlayNode.removeEventListener('dragleave', onDragLeave); - overlayNode.removeEventListener('drop', onDrop); - }); - } destroy(function() { let handle: any; @@ -97,40 +31,97 @@ export const fileDrop = factory(function fileDrop({ middleware: { destroy, inval } }); + let hasDropBeenRead = false; + return { - get(targetKey: string | number, overlayKey: string | number): Readonly { + get( + targetKey: string | number, + overlayKey?: string | number + ): Readonly | undefined { const targetNode = node.get(targetKey); - const overlayNode = node.get(overlayKey); + const overlayNode = overlayKey && node.get(overlayKey); - if (!(targetNode && overlayNode)) { - // TODO: throw error? log warning? - return emptyResults; + if (!targetNode) { + return; } - if (!nodeMap.has(targetNode)) { - initResults(targetNode, overlayNode); - addListeners(targetNode, overlayNode); + const resultKey = String(targetKey); - return emptyResults; + function onDragEnter(event: DragEvent) { + event.preventDefault(); + + const results = icache.get(resultKey); + if (results && results.isDragging === false) { + icache.set(resultKey, { ...results, isDragging: true }); + } } - const results = Object.assign({}, nodeMap.get(targetNode)); - return results; - }, + function onDragLeave(event: DragEvent) { + event.preventDefault(); - // When dropping a file there is no event that follows `drop` to signify the DnD operation has completed. - // This method allows the consumer to reset the dnd middleware once the drop data has been received. - reset(targetKey: string | number, overlayKey: string | number) { - const targetNode = node.get(targetKey); - const overlayNode = node.get(overlayKey); + const results = icache.get(resultKey); + if (results && results.isDragging === true) { + icache.set(resultKey, { ...results, isDragging: false }); + } + } + + // The default action for this event is to reset the current drag operation so it is necessary to add + // a handler to any valid DnD target that prevents the default action. + // https://developer.mozilla.org/en-US/docs/Web/API/Document/dragover_event + function preventDefault(event: DragEvent) { + event.preventDefault(); + } + + function onDrop(event: DragEvent) { + event.preventDefault(); + + icache.set(resultKey, { + isDragging: false, + isDropped: true, + files: + event.dataTransfer && event.dataTransfer.files.length + ? Array.from(event.dataTransfer.files) + : [] + }); + } + + if (!icache.has(resultKey)) { + icache.set(resultKey, createResults()); + + targetNode.addEventListener('dragenter', onDragEnter); + if (overlayNode) { + overlayNode.addEventListener('dragenter', preventDefault); + } + const receiverNode = overlayNode || targetNode; + receiverNode.addEventListener('dragover', preventDefault); + receiverNode.addEventListener('dragleave', onDragLeave); + receiverNode.addEventListener('drop', onDrop); + + handles.push(function() { + targetNode.removeEventListener('dragenter', onDragEnter); + if (overlayNode) { + overlayNode.removeEventListener('dragenter', preventDefault); + } + receiverNode.removeEventListener('dragover', preventDefault); + receiverNode.removeEventListener('dragleave', onDragLeave); + receiverNode.removeEventListener('drop', onDrop); + }); - if (!(targetNode && overlayNode)) { - // TODO: throw error? log warning? return emptyResults; } - initResults(targetNode, overlayNode); - invalidator(); + let results = Object.assign({}, icache.get(resultKey)); + if (results.isDropped) { + if (hasDropBeenRead) { + results = createResults(); + icache.set(resultKey, results, false); + hasDropBeenRead = false; + } else { + hasDropBeenRead = true; + } + } + + return results; } }; }); diff --git a/src/theme/dojo/file-upload-input.m.css.d.ts b/src/theme/dojo/file-upload-input.m.css.d.ts index 276603170b..2368aac56a 100644 --- a/src/theme/dojo/file-upload-input.m.css.d.ts +++ b/src/theme/dojo/file-upload-input.m.css.d.ts @@ -1,3 +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-upload-input.m.css.d.ts b/src/theme/material/file-upload-input.m.css.d.ts index 276603170b..2368aac56a 100644 --- a/src/theme/material/file-upload-input.m.css.d.ts +++ b/src/theme/material/file-upload-input.m.css.d.ts @@ -1,3 +1,5 @@ export const root: string; +export const dndActive: string; export const disabled: string; +export const dndOverlay: string; export const dndLabel: string; From 75d7bd1846220618894364c2fb3c96ee8f157a43 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Tue, 1 Sep 2020 17:57:37 -0700 Subject: [PATCH 11/35] FileUploadInput: improve Multiple example --- .../widgets/file-upload-input/Multiple.tsx | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/examples/src/widgets/file-upload-input/Multiple.tsx b/src/examples/src/widgets/file-upload-input/Multiple.tsx index 73ad7cd343..893a067b54 100644 --- a/src/examples/src/widgets/file-upload-input/Multiple.tsx +++ b/src/examples/src/widgets/file-upload-input/Multiple.tsx @@ -16,15 +16,26 @@ export default factory(function Multiple({ middleware: { icache } }) { {selectedFiles.length > 0 && ( -
    - {selectedFiles.map(function(file) { - return ( -
  • - {file.name}: {file.size} -
  • - ); - })} -
+ + + + + + + + + {selectedFiles.map(function(file) { + return ( + + + + + + + ); + })} + +
NameModifiedTypeBytes
{file.name}{new Date(file.lastModified).toLocaleString()}{file.type}{String(file.size)}
)}
); From ddc5694f590602582845566df99ddfad37de6ad1 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Tue, 1 Sep 2020 17:58:10 -0700 Subject: [PATCH 12/35] fileDrop middleware: add doc comment --- src/middleware/fileDrop.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/middleware/fileDrop.ts b/src/middleware/fileDrop.ts index c3599ac836..a58e193a2b 100644 --- a/src/middleware/fileDrop.ts +++ b/src/middleware/fileDrop.ts @@ -34,6 +34,14 @@ export const fileDrop = factory(function fileDrop({ middleware: { destroy, icach let hasDropBeenRead = false; return { + /** + * Get information for file DnD + * @param targetKey key of the node that will be the DnD target + * @param overlayKey key of a node that will be the active DnD overlay. + * If `overlayKey` is provided the node it refers to must be rendered at the time `fileDrop.get` is called. + * Event listeners will immediately be registered for it if it exists. If it does not exist then all event + * listeners will be registered on targetNode and overlay functionality will not work correctly. + */ get( targetKey: string | number, overlayKey?: string | number From 52832a04ffe7f6e0531f51d1fb27cfc459a917c6 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Tue, 1 Sep 2020 21:52:57 -0700 Subject: [PATCH 13/35] FileUploader: add dnd and validation --- src/examples/src/config.tsx | 6 + .../src/widgets/file-uploader/Disabled.tsx | 2 +- .../src/widgets/file-uploader/Multiple.tsx | 2 +- .../src/widgets/file-uploader/Validated.tsx | 16 ++ src/file-uploader/index.tsx | 137 ++++++++++++++---- src/file-uploader/nls/FileUploader.ts | 2 + src/theme/default/file-uploader.m.css | 28 +++- src/theme/default/file-uploader.m.css.d.ts | 5 + src/theme/dojo/file-uploader.m.css | 29 +++- src/theme/dojo/file-uploader.m.css.d.ts | 4 +- 10 files changed, 195 insertions(+), 36 deletions(-) create mode 100644 src/examples/src/widgets/file-uploader/Validated.tsx diff --git a/src/examples/src/config.tsx b/src/examples/src/config.tsx index a965228c58..2230c121ca 100644 --- a/src/examples/src/config.tsx +++ b/src/examples/src/config.tsx @@ -67,6 +67,7 @@ import MultipleFileUploadInput from './widgets/file-upload-input/Multiple'; import BasicFileUploader from './widgets/file-uploader/Basic'; 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'; @@ -783,6 +784,11 @@ export const config = { title: 'Multiple FileUploader', filename: 'Multiple', module: MultipleFileUploader + }, + { + title: 'Validated FileUploader', + filename: 'Validated', + module: ValidatedFileUploader } ] }, diff --git a/src/examples/src/widgets/file-uploader/Disabled.tsx b/src/examples/src/widgets/file-uploader/Disabled.tsx index dcd7df3596..a451250e9d 100644 --- a/src/examples/src/widgets/file-uploader/Disabled.tsx +++ b/src/examples/src/widgets/file-uploader/Disabled.tsx @@ -4,7 +4,7 @@ import Example from '../../Example'; const factory = create(); -export default factory(function Basic() { +export default factory(function Disabled() { return ( diff --git a/src/examples/src/widgets/file-uploader/Multiple.tsx b/src/examples/src/widgets/file-uploader/Multiple.tsx index 577feaf391..cdca0c3b40 100644 --- a/src/examples/src/widgets/file-uploader/Multiple.tsx +++ b/src/examples/src/widgets/file-uploader/Multiple.tsx @@ -4,7 +4,7 @@ import Example from '../../Example'; const factory = create(); -export default factory(function Basic() { +export default factory(function Multiple() { 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..e0bd0c956c --- /dev/null +++ b/src/examples/src/widgets/file-uploader/Validated.tsx @@ -0,0 +1,16 @@ +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; + + return ( + + + + ); +}); diff --git a/src/file-uploader/index.tsx b/src/file-uploader/index.tsx index 2ff7e61013..3c65b1cb77 100644 --- a/src/file-uploader/index.tsx +++ b/src/file-uploader/index.tsx @@ -7,28 +7,69 @@ import { FileUploadInputProperties } from '../file-upload-input'; import { Icon } from '../icon'; +import fileDrop from '../middleware/fileDrop'; import theme from '../middleware/theme'; import bundle from './nls/FileUploader'; +import fileUploadInputBundle from '../file-upload-input/nls/FileUploadInput'; import * as css from '../theme/default/file-uploader.m.css'; import * as baseCss from '../theme/default/base.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 FileUploaderIcache { - files: File[]; - isDndActive: boolean; +interface ValidationInfo { + message?: string; + valid?: boolean; } export interface FileUploaderProperties extends FileUploadInputProperties { /** Custom validator used to validate each file */ - customValidator?: (file: File) => { valid?: boolean; message?: string } | void; + customValidator?: (file: File) => ValidationInfo | void; /** The maximum size in bytes of a file */ maxSize?: number; } -export interface FileItemRendererProps { +interface ValidationProps { + accept?: string | string[]; + file: File; + maxSize?: number; + messages: typeof bundle; +} + +function validateFile(props: ValidationProps): ValidationInfo { + const { + accept, + file, + maxSize, + messages: { messages } + } = props; + let valid = true; + let message = ''; + + if (maxSize) { + if (file.size > maxSize) { + valid = false; + message = messages.invalidFileSize; + } + } + + if (accept) { + const acceptTypes = Array.isArray(accept) ? accept : [accept]; + if (!acceptTypes.includes(file.type)) { + valid = false; + message = messages.invalidFileType; + } + } + + return { + message, + valid + }; +} + +interface FileItemRendererProps { + accept?: string | string[]; customValidator: FileUploaderProperties['customValidator']; files: File[]; maxSize?: number; @@ -39,45 +80,61 @@ export interface FileItemRendererProps { function renderFiles(props: FileItemRendererProps) { const { - // customValidator, + accept, + customValidator, files, - // maxSize, + maxSize, messages: { messages }, remove, themeCss } = props; return files.map(function(file) { - /* - const isValid = customValidator - ? customValidator(file) - : maxSize - ? file.size <= maxSize - : true;*/ + let validationInfo: ValidationInfo | void = validateFile({ + accept, + file, + maxSize, + messages: { messages } + }); + + if (validationInfo && validationInfo.valid && customValidator) { + validationInfo = customValidator(file); + } + const isValid = validationInfo ? validationInfo.valid : true; return ( -
-
{file.name}
- +
+
+
{file.name}
+ +
+ {validationInfo && validationInfo.message && ( +
{validationInfo.message}
+ )}
); }); } -const factory = create({ i18n, icache: createICacheMiddleware(), theme }) +export interface FileUploaderIcache { + files: File[]; +} +const icache = createICacheMiddleware(); + +const factory = create({ fileDrop, i18n, icache, theme }) .properties() .children(); export const FileUploader = factory(function FileUploader({ children, - middleware: { i18n, icache, theme }, + middleware: { fileDrop, i18n, icache, theme }, properties }) { const { @@ -91,16 +148,19 @@ export const FileUploader = factory(function FileUploader({ required = false } = properties(); const { messages } = i18n.localize(bundle); + const { messages: fileUploadInputMessages } = i18n.localize(fileUploadInputBundle); const files = icache.getOrSet('files', []); - let isDndActive = false; + const { dndLabel = fileUploadInputMessages.orDropFilesHere } = children()[0] || {}; const themeCss = theme.classes(css); const fileUploadInputThemeCss = theme.classes(fileUploadInputCss); function onValue(newFiles: File[]) { if (multiple) { + console.log('set multiple', newFiles); icache.set('files', [...files, ...newFiles]); } else { - icache.set('files', newFiles); + console.log('set single', newFiles); + icache.set('files', newFiles.slice(0, 1)); } } @@ -112,6 +172,21 @@ export const FileUploader = factory(function FileUploader({ } } + let isDndActive = false; + if (allowDnd) { + const dndInfo = fileDrop.get('root', 'overlay'); + if (dndInfo) { + isDndActive = dndInfo.isDragging; + + if (dndInfo.isDropped && dndInfo.files && dndInfo.files.length) { + // TODO: why does this not cause a rerender?? + onValue(dndInfo.files); + } + } else { + // TODO: should not happen... log warning? + } + } + return (
+ {allowDnd && {dndLabel}} + {files.length && (
{renderFiles({ + accept, customValidator, files, maxSize, diff --git a/src/file-uploader/nls/FileUploader.ts b/src/file-uploader/nls/FileUploader.ts index 8e55eb7574..f1057e7970 100644 --- a/src/file-uploader/nls/FileUploader.ts +++ b/src/file-uploader/nls/FileUploader.ts @@ -1,4 +1,6 @@ const messages = { + invalidFileSize: 'Invalid file size', + invalidFileType: 'Invalid file type', remove: 'Remove' }; diff --git a/src/theme/default/file-uploader.m.css b/src/theme/default/file-uploader.m.css index 5aa53d0f52..ebfbd8ef2d 100644 --- a/src/theme/default/file-uploader.m.css +++ b/src/theme/default/file-uploader.m.css @@ -3,25 +3,49 @@ position: relative; } +/* Applied to the root node if a DnD operation is in progress */ +.dndActive { +} + /* Applied to the root node if the widget is disabled */ .disabled { } +/* Applied to the root node of the FileUploadInput widget */ +.fileInputRoot { + display: inline-block; +} + /* 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 (child of .fileItem) */ +/* The name of each added file */ .fileItemName { flex-grow: 1; } -/* Close icon button rendered for each file (child of .fileItem) */ +/* 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(--error-color); +} diff --git a/src/theme/default/file-uploader.m.css.d.ts b/src/theme/default/file-uploader.m.css.d.ts index d6a25a5f7e..9417a7adcb 100644 --- a/src/theme/default/file-uploader.m.css.d.ts +++ b/src/theme/default/file-uploader.m.css.d.ts @@ -1,5 +1,10 @@ export const root: string; +export const dndActive: string; export const disabled: string; +export const fileInputRoot: string; export const fileItem: string; +export const invalid: string; +export const fileInfo: string; export const fileItemName: string; export const closeButton: string; +export const validationMessage: string; diff --git a/src/theme/dojo/file-uploader.m.css b/src/theme/dojo/file-uploader.m.css index a5c59819ca..d735acd74a 100644 --- a/src/theme/dojo/file-uploader.m.css +++ b/src/theme/dojo/file-uploader.m.css @@ -1,22 +1,38 @@ +.root { + background-color: var(--color-background); + border: 2px solid transparent; +} + +.dndActive { + border-color: var(--color-highlight); + box-shadow: var(--box-shadow-dimensions-small) var(--color-box-shadow-highlight); +} + .disabled { cursor: no-drop; } -.button { - /* TODO: button overrides this */ - margin-right: var(--grid-base); +.fileInputRoot { + display: inline-block; } .fileItem { + color: var(--color-text-primary); display: flex; - flex-direction: row; + 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; } @@ -24,6 +40,11 @@ .closeButton { border: none; background: transparent; + color: var(--color-text-primary); cursor: pointer; padding-top: 2px; } + +.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 index 0709711ad0..c9782e87d3 100644 --- a/src/theme/dojo/file-uploader.m.css.d.ts +++ b/src/theme/dojo/file-uploader.m.css.d.ts @@ -1,5 +1,7 @@ +export const root: string; +export const dndActive: string; export const disabled: string; -export const button: string; +export const fileInputRoot: string; export const fileItem: string; export const fileItemName: string; export const closeButton: string; From 502b1d93f919055e151d1c7d340ac3a6420a919e Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Wed, 2 Sep 2020 09:52:34 -0700 Subject: [PATCH 14/35] FileUploader: fix stale files bug --- src/file-uploader/index.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/file-uploader/index.tsx b/src/file-uploader/index.tsx index 3c65b1cb77..430e3e375c 100644 --- a/src/file-uploader/index.tsx +++ b/src/file-uploader/index.tsx @@ -149,17 +149,15 @@ export const FileUploader = factory(function FileUploader({ } = properties(); const { messages } = i18n.localize(bundle); const { messages: fileUploadInputMessages } = i18n.localize(fileUploadInputBundle); - const files = icache.getOrSet('files', []); const { dndLabel = fileUploadInputMessages.orDropFilesHere } = children()[0] || {}; const themeCss = theme.classes(css); const fileUploadInputThemeCss = theme.classes(fileUploadInputCss); + let files = icache.getOrSet('files', []); function onValue(newFiles: File[]) { if (multiple) { - console.log('set multiple', newFiles); icache.set('files', [...files, ...newFiles]); } else { - console.log('set single', newFiles); icache.set('files', newFiles.slice(0, 1)); } } @@ -179,8 +177,8 @@ export const FileUploader = factory(function FileUploader({ isDndActive = dndInfo.isDragging; if (dndInfo.isDropped && dndInfo.files && dndInfo.files.length) { - // TODO: why does this not cause a rerender?? onValue(dndInfo.files); + files = icache.get('files')!; } } else { // TODO: should not happen... log warning? From 95cb60c3325ec3acce56f5d9916791149630c40f Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Wed, 2 Sep 2020 09:53:28 -0700 Subject: [PATCH 15/35] Update themes, change dnd border to dashed --- src/theme/default/file-upload-input.m.css | 2 +- src/theme/default/file-uploader.m.css | 3 +- src/theme/dojo/file-upload-input.m.css | 2 +- src/theme/dojo/file-uploader.m.css | 2 +- src/theme/dojo/file-uploader.m.css.d.ts | 2 + src/theme/material/file-upload-input.m.css | 2 +- src/theme/material/file-uploader.m.css | 44 +++++++++++++++++++++ src/theme/material/file-uploader.m.css.d.ts | 9 +++++ src/theme/material/index.ts | 2 + 9 files changed, 63 insertions(+), 5 deletions(-) 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/theme/default/file-upload-input.m.css b/src/theme/default/file-upload-input.m.css index c52340b534..ccebfecb5d 100644 --- a/src/theme/default/file-upload-input.m.css +++ b/src/theme/default/file-upload-input.m.css @@ -1,6 +1,6 @@ /* The root class for FileUploadInput */ .root { - border: 2px solid transparent; + border: 2px dashed transparent; } /* Applied to the root node if a DnD operation is in progress */ diff --git a/src/theme/default/file-uploader.m.css b/src/theme/default/file-uploader.m.css index ebfbd8ef2d..d830416a90 100644 --- a/src/theme/default/file-uploader.m.css +++ b/src/theme/default/file-uploader.m.css @@ -1,10 +1,11 @@ /* The root class for FileUploader */ .root { - position: relative; + 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 */ diff --git a/src/theme/dojo/file-upload-input.m.css b/src/theme/dojo/file-upload-input.m.css index eded5cc2c4..f6c10fde82 100644 --- a/src/theme/dojo/file-upload-input.m.css +++ b/src/theme/dojo/file-upload-input.m.css @@ -1,6 +1,6 @@ .root { background-color: var(--color-background); - border: 2px solid transparent; + border: 2px dashed transparent; } .dndActive { diff --git a/src/theme/dojo/file-uploader.m.css b/src/theme/dojo/file-uploader.m.css index d735acd74a..67f0a71c55 100644 --- a/src/theme/dojo/file-uploader.m.css +++ b/src/theme/dojo/file-uploader.m.css @@ -1,6 +1,6 @@ .root { background-color: var(--color-background); - border: 2px solid transparent; + border: 2px dashed transparent; } .dndActive { diff --git a/src/theme/dojo/file-uploader.m.css.d.ts b/src/theme/dojo/file-uploader.m.css.d.ts index c9782e87d3..66db779360 100644 --- a/src/theme/dojo/file-uploader.m.css.d.ts +++ b/src/theme/dojo/file-uploader.m.css.d.ts @@ -3,5 +3,7 @@ export const dndActive: string; export const disabled: string; export const fileInputRoot: string; export const fileItem: string; +export const fileInfo: string; export const fileItemName: string; export const closeButton: string; +export const validationMessage: string; diff --git a/src/theme/material/file-upload-input.m.css b/src/theme/material/file-upload-input.m.css index 854dea2d3b..9f89e6c0bd 100644 --- a/src/theme/material/file-upload-input.m.css +++ b/src/theme/material/file-upload-input.m.css @@ -1,6 +1,6 @@ .root { background-color: var(--mdc-theme-background); - border: 2px solid transparent; + border: 2px dashed transparent; } .dndActive { diff --git a/src/theme/material/file-uploader.m.css b/src/theme/material/file-uploader.m.css new file mode 100644 index 0000000000..d57293673c --- /dev/null +++ b/src/theme/material/file-uploader.m.css @@ -0,0 +1,44 @@ +.root { + background-color: var(--mdc-theme-background); + border: 2px dashed transparent; +} + +.dndActive { + border-color: var(--mdc-solid-border-color-hover); +} + +.disabled { + cursor: no-drop; +} + +.fileInputRoot { + display: inline-block; +} + +.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-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..66db779360 --- /dev/null +++ b/src/theme/material/file-uploader.m.css.d.ts @@ -0,0 +1,9 @@ +export const root: string; +export const dndActive: string; +export const disabled: string; +export const fileInputRoot: string; +export const fileItem: string; +export const fileInfo: string; +export const fileItemName: string; +export const closeButton: string; +export const validationMessage: string; diff --git a/src/theme/material/index.ts b/src/theme/material/index.ts index e7820f1fc6..a8161a763d 100644 --- a/src/theme/material/index.ts +++ b/src/theme/material/index.ts @@ -11,6 +11,7 @@ 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'; @@ -77,6 +78,7 @@ export default { '@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, From 2024969bdfc73fd201b611271bc66f5770fd7acb Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Wed, 2 Sep 2020 10:27:56 -0700 Subject: [PATCH 16/35] Pass theme to child widgets --- src/examples/src/widgets/file-upload-input/Basic.tsx | 2 +- src/file-upload-input/index.tsx | 11 ++++++++++- src/file-uploader/index.tsx | 5 +++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/examples/src/widgets/file-upload-input/Basic.tsx b/src/examples/src/widgets/file-upload-input/Basic.tsx index c34dead316..ea6e88afa2 100644 --- a/src/examples/src/widgets/file-upload-input/Basic.tsx +++ b/src/examples/src/widgets/file-upload-input/Basic.tsx @@ -1,7 +1,7 @@ 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 icache from '@dojo/framework/core/middleware/icache'; const factory = create({ icache }); diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index 71112aaa77..1ca73b02fe 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -9,6 +9,7 @@ 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'; export interface FileUploadInputChildren { @@ -121,7 +122,15 @@ export const FileUploadInput = factory(function FileUploadInput({ required={required} type="file" /> - diff --git a/src/file-uploader/index.tsx b/src/file-uploader/index.tsx index 430e3e375c..19d26d4f0d 100644 --- a/src/file-uploader/index.tsx +++ b/src/file-uploader/index.tsx @@ -209,6 +209,11 @@ export const FileUploader = factory(function FileUploader({ name={name} onValue={onValue} required={required} + theme={theme.compose( + fileUploadInputCss, + css, + 'input' + )} > {children()[0]} From 8bd6cc712280e9cdef9d8bed1e3acecc937509eb Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Wed, 2 Sep 2020 15:13:06 -0700 Subject: [PATCH 17/35] Update build config, add FileUploadInput tests --- .dojorc | 2 + .../tests/unit/FileUploadInput.spec.tsx | 100 ++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/file-upload-input/tests/unit/FileUploadInput.spec.tsx 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/file-upload-input/tests/unit/FileUploadInput.spec.tsx b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx new file mode 100644 index 0000000000..2c29fc225e --- /dev/null +++ b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx @@ -0,0 +1,100 @@ +import { tsx } from '@dojo/framework/core/vdom'; +import { assertion, renderer, wrap } from '@dojo/framework/testing/renderer'; +import { Button } from '../../../button'; +import { FileUploadInput } from '../../index'; +import { noop } 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'; + +const { it, describe } = intern.getInterface('bdd'); +const { messages } = bundle; + +describe('FileUploadInput', function() { + const WrappedButton = wrap(Button); + const WrappedDndLabel = wrap('span'); + const WrappedOverlay = wrap('div'); + + const baseAssertion = assertion(function() { + return ( +
+ + + {messages.chooseFiles} + + + {messages.orDropFilesHere} + + +
+ ); + }); + + it('renders', function() { + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion); + }); + + it('renders labels', function() { + const buttonLabel = 'Button label'; + const dndLabel = 'Dnd label'; + + const r = renderer(function() { + return ( + + {{ + buttonLabel, + dndLabel + }} + + ); + }); + + r.expect( + baseAssertion + .setChildren(WrappedButton, () => [buttonLabel]) + .setChildren(WrappedDndLabel, () => [dndLabel]) + ); + }); + + it('renders allowDnd=false', function() { + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion.remove(WrappedDndLabel).remove(WrappedOverlay)); + }); +}); From 6feb6f11102f91f8984dcfe77d74806b6575614c Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Thu, 3 Sep 2020 21:39:25 -0700 Subject: [PATCH 18/35] FileUploadInput: abandon middleware, render children, update examples --- src/examples/src/config.tsx | 16 +- .../widgets/file-upload-input/Labelled.tsx | 25 +++ .../src/widgets/file-upload-input/Labels.tsx | 23 --- .../widgets/file-upload-input/Multiple.tsx | 55 +++--- .../src/widgets/file-upload-input/NoDrop.tsx | 21 +++ .../widgets/file-upload-input/multiple.m.css | 3 + .../file-upload-input/multiple.m.css.d.ts | 1 + src/file-upload-input/README.md | 13 +- src/file-upload-input/design.md | 30 ++++ src/file-upload-input/index.tsx | 165 ++++++++++++------ src/file-uploader/index.tsx | 82 +++------ src/middleware/fileDrop.ts | 78 +++++++-- src/theme/default/file-upload-input.m.css | 13 +- .../default/file-upload-input.m.css.d.ts | 3 +- src/theme/default/file-uploader.m.css | 5 - src/theme/default/file-uploader.m.css.d.ts | 1 - src/theme/dojo/file-uploader.m.css | 4 - src/theme/material/file-uploader.m.css | 4 - 18 files changed, 335 insertions(+), 207 deletions(-) create mode 100644 src/examples/src/widgets/file-upload-input/Labelled.tsx delete mode 100644 src/examples/src/widgets/file-upload-input/Labels.tsx create mode 100644 src/examples/src/widgets/file-upload-input/NoDrop.tsx 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/design.md diff --git a/src/examples/src/config.tsx b/src/examples/src/config.tsx index 2230c121ca..4afacf2e35 100644 --- a/src/examples/src/config.tsx +++ b/src/examples/src/config.tsx @@ -62,8 +62,9 @@ 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/Labels'; +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 DisabledFileUploader from './widgets/file-uploader/Disabled'; import MultipleFileUploader from './widgets/file-uploader/Multiple'; @@ -757,12 +758,19 @@ export const config = { { title: 'Multiple FileUploadInput', filename: 'Multiple', - module: MultipleFileUploadInput + 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 custom labels', - filename: 'Labels', + title: 'FileUploadInput with label', + filename: 'Labelled', module: LabelledFileUploadInput + }, + { + title: 'FileUploadInput with no DnD', + filename: 'NoDrop', + module: NoDropFileUploadInput } ] }, 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/Labels.tsx b/src/examples/src/widgets/file-upload-input/Labels.tsx deleted file mode 100644 index 0469b5dfaf..0000000000 --- a/src/examples/src/widgets/file-upload-input/Labels.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { create, tsx } from '@dojo/framework/core/vdom'; -import { FileUploadInput } from '@dojo/widgets/file-upload-input'; -import { Icon } from '@dojo/widgets/icon'; -import Example from '../../Example'; - -const factory = create(); - -export default factory(function Labels() { - return ( - - - {{ - buttonLabel: ( -
- Upload a file -
- ), - dndLabel: 'Drop a file here to upload' - }} -
-
- ); -}); diff --git a/src/examples/src/widgets/file-upload-input/Multiple.tsx b/src/examples/src/widgets/file-upload-input/Multiple.tsx index 893a067b54..0fd64b3921 100644 --- a/src/examples/src/widgets/file-upload-input/Multiple.tsx +++ b/src/examples/src/widgets/file-upload-input/Multiple.tsx @@ -1,7 +1,9 @@ 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 icache from '@dojo/framework/core/middleware/icache'; + +import * as css from './multiple.m.css'; const factory = create({ icache }); @@ -14,29 +16,34 @@ export default factory(function Multiple({ middleware: { icache } }) { return ( - - {selectedFiles.length > 0 && ( - - - - - - - - - {selectedFiles.map(function(file) { - return ( - - - - - - - ); - })} - -
NameModifiedTypeBytes
{file.name}{new Date(file.lastModified).toLocaleString()}{file.type}{String(file.size)}
- )} + + {{ + 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/file-upload-input/README.md b/src/file-upload-input/README.md index cf4b55c594..ff5281f878 100644 --- a/src/file-upload-input/README.md +++ b/src/file-upload-input/README.md @@ -1,17 +1,16 @@ # @dojo/widgets/file-upload-input -Dojo's `FileUploadInput` provides an interface for managing file uploads using ``. This is a -controlled component 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. +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 -- Validation ### Keyboard features @@ -19,4 +18,4 @@ a `File` array whenever files are selected. ### i18n features -- Localized version of default labels for the button and DnD can be provided in nls resources +- Localized version of labels for the button and DnD can be provided in nls resources diff --git a/src/file-upload-input/design.md b/src/file-upload-input/design.md new file mode 100644 index 0000000000..9b70b94b02 --- /dev/null +++ b/src/file-upload-input/design.md @@ -0,0 +1,30 @@ +# DOM structure + +The only way to launch the operating system's file selection dialog is with the native `` element. +This element renders a button in the UI which is not very customizable. A hidden input can still be used to open the +dialog but you have to call its `click` method. + +The overlay `
` provides a visual indicator that a drag operation is in progress. + +# Drag and Drop + +https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API + +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. Dealing with +a conditionally rendered overlay becomes a hassle as well. + +The `dragenter` event must be listened for to detect when a drag enters the target area. At this point the overlay +should be rendered and it must have no children and must be the highest layer. If `dragenter` and `dragleave` event +listeners are added to an element with visible children then spurious enter/leave events are constantly triggered as +the cursor moves over children (even letters in text). + +- `dragenter`: listened for on the root element at all times that DnD is allowed + - makes the overlay visible to indicate DnD is active +- `dragover`: this event must be listened for, but nothing needs to be done with it other than `event.preventDefault()` +(the default action for DragEvents is to cancel the drag operation) + - is listened for on the root since it bubbles from the overlay +- `dragleave`: listened for on the overlay since it is unreliable on the root element + - when this event fires the overlay is hidden +- `drop`: listened for on the root since it bubbles from the overlay + - get the files and update to indicate DnD is no longer active diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index 1ca73b02fe..6c6401e13d 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -3,7 +3,7 @@ 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 { Button } from '../button'; -import fileDrop from '../middleware/fileDrop'; +import { Label } from '../label'; import theme from '../middleware/theme'; import bundle from './nls/FileUploadInput'; @@ -11,10 +11,11 @@ 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 FileUploadInputChildren { - buttonLabel?: RenderResult; - dndLabel?: RenderResult; + label?: RenderResult; + content?: RenderResult; } export interface FileUploadInputProperties { @@ -27,6 +28,9 @@ export interface FileUploadInputProperties { /** The `disabled` attribute of the input */ disabled?: boolean; + /** Hides the label for a11y purposes */ + labelHidden?: boolean; + /** The `multiple` attribute of the input */ multiple?: boolean; @@ -38,47 +42,72 @@ export interface FileUploadInputProperties { /** The `required` attribute of the input */ required?: boolean; + + /** Represents if the input value is valid */ + valid?: ValidationInfo | boolean; + + /** The id to be applied to the input */ + widgetId?: string; +} + +export interface ValidationInfo { + message?: string; + valid?: boolean; } interface FileUploadInputIcache { + isDndActive?: boolean; shouldClick?: boolean; } const icache = createICacheMiddleware(); -const factory = create({ fileDrop, i18n, icache, theme }) +const factory = create({ i18n, icache, theme }) .properties() .children(); export const FileUploadInput = factory(function FileUploadInput({ children, - middleware: { fileDrop, i18n, icache, theme }, + id, + middleware: { i18n, icache, theme }, properties }) { const { accept, allowDnd = true, disabled = false, + labelHidden = false, multiple = false, name, onValue, - required = false + required = false, + valid = true, + widgetId = `file-upload-input-${id}` } = properties(); const { messages } = i18n.localize(bundle); - const { buttonLabel = messages.chooseFiles, dndLabel = messages.orDropFilesHere } = - children()[0] || {}; const themeCss = theme.classes(css); - let isDndActive = false; - - if (allowDnd) { - const dndInfo = fileDrop.get('root', 'overlay'); - if (dndInfo) { - isDndActive = dndInfo.isDragging; - - if (dndInfo.isDropped && dndInfo.files && dndInfo.files.length) { - onValue && onValue(dndInfo.files); - } - } else { - // TODO: should not happen... log warning? + const { content, label } = children()[0] || {}; + let isDndActive = icache.getOrSet('isDndActive', false); + + function onDragEnter(event: DragEvent) { + event.preventDefault(); + icache.set('isDndActive', true); + } + + function onDragLeave(event: DragEvent) { + event.preventDefault(); + icache.set('isDndActive', false); + } + + function onDragOver(event: DragEvent) { + event.preventDefault(); + } + + function onDrop(event: DragEvent) { + event.preventDefault(); + icache.set('isDndActive', false); + + if (onValue && event.dataTransfer && event.dataTransfer.files.length) { + onValue(Array.from(event.dataTransfer.files)); } } @@ -87,11 +116,9 @@ export const FileUploadInput = factory(function FileUploadInput({ } function onChange(event: DojoEvent) { - if (!onValue) { - return; + if (onValue && event.target.files && event.target.files.length) { + onValue(Array.from(event.target.files)); } - const fileArray = Array.from(event.target.files || []); - onValue(fileArray); } return ( @@ -104,38 +131,63 @@ export const FileUploadInput = factory(function FileUploadInput({ isDndActive && themeCss.dndActive, disabled && themeCss.disabled ]} + ondragenter={allowDnd && onDragEnter} + ondragover={allowDnd && onDragOver} + ondrop={allowDnd && onDrop} > - - - - {allowDnd && [ - {dndLabel}, + {label && ( + + )} + +
+ + + + {allowDnd && {messages.orDropFilesHere}} +
+ + {content} + + {isDndActive && (
- ]} + )}
); }); diff --git a/src/file-uploader/index.tsx b/src/file-uploader/index.tsx index 19d26d4f0d..6e1699edcd 100644 --- a/src/file-uploader/index.tsx +++ b/src/file-uploader/index.tsx @@ -4,24 +4,18 @@ import { create, tsx } from '@dojo/framework/core/vdom'; import { FileUploadInput, FileUploadInputChildren, - FileUploadInputProperties + FileUploadInputProperties, + ValidationInfo } from '../file-upload-input'; import { Icon } from '../icon'; import fileDrop from '../middleware/fileDrop'; import theme from '../middleware/theme'; import bundle from './nls/FileUploader'; -import fileUploadInputBundle from '../file-upload-input/nls/FileUploadInput'; import * as css from '../theme/default/file-uploader.m.css'; -import * as baseCss from '../theme/default/base.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'; -interface ValidationInfo { - message?: string; - valid?: boolean; -} - export interface FileUploaderProperties extends FileUploadInputProperties { /** Custom validator used to validate each file */ customValidator?: (file: File) => ValidationInfo | void; @@ -130,11 +124,11 @@ const icache = createICacheMiddleware(); const factory = create({ fileDrop, i18n, icache, theme }) .properties() - .children(); + .children | undefined>(); export const FileUploader = factory(function FileUploader({ children, - middleware: { fileDrop, i18n, icache, theme }, + middleware: { i18n, icache, theme }, properties }) { const { @@ -148,11 +142,9 @@ export const FileUploader = factory(function FileUploader({ required = false } = properties(); const { messages } = i18n.localize(bundle); - const { messages: fileUploadInputMessages } = i18n.localize(fileUploadInputBundle); - const { dndLabel = fileUploadInputMessages.orDropFilesHere } = children()[0] || {}; const themeCss = theme.classes(css); - const fileUploadInputThemeCss = theme.classes(fileUploadInputCss); let files = icache.getOrSet('files', []); + const inputChild = (children()[0] || {}) as FileUploadInputChildren; function onValue(newFiles: File[]) { if (multiple) { @@ -170,19 +162,20 @@ export const FileUploader = factory(function FileUploader({ } } - let isDndActive = false; - if (allowDnd) { - const dndInfo = fileDrop.get('root', 'overlay'); - if (dndInfo) { - isDndActive = dndInfo.isDragging; - - if (dndInfo.isDropped && dndInfo.files && dndInfo.files.length) { - onValue(dndInfo.files); - files = icache.get('files')!; - } - } else { - // TODO: should not happen... log warning? - } + if (files.length) { + inputChild.content = ( +
+ {renderFiles({ + accept, + customValidator, + files, + maxSize, + messages: { messages }, + remove, + themeCss + })} +
+ ); } return ( @@ -192,18 +185,12 @@ export const FileUploader = factory(function FileUploader({ theme.variant(), fileUploadInputFixedCss.root, themeCss.root, - isDndActive && themeCss.dndActive, disabled && themeCss.disabled ]} > - {children()[0]} + {inputChild} - - {allowDnd && {dndLabel}} - - {files.length && ( -
- {renderFiles({ - accept, - customValidator, - files, - maxSize, - messages: { messages }, - remove, - themeCss - })} -
- )} - - {allowDnd && ( -
- )}
); }); diff --git a/src/middleware/fileDrop.ts b/src/middleware/fileDrop.ts index a58e193a2b..51dd6b124c 100644 --- a/src/middleware/fileDrop.ts +++ b/src/middleware/fileDrop.ts @@ -32,6 +32,7 @@ export const fileDrop = factory(function fileDrop({ middleware: { destroy, icach }); let hasDropBeenRead = false; + let isOverlayVolatile = false; return { /** @@ -47,7 +48,7 @@ export const fileDrop = factory(function fileDrop({ middleware: { destroy, icach overlayKey?: string | number ): Readonly | undefined { const targetNode = node.get(targetKey); - const overlayNode = overlayKey && node.get(overlayKey); + let overlayNode = overlayKey && node.get(overlayKey); if (!targetNode) { return; @@ -69,6 +70,9 @@ export const fileDrop = factory(function fileDrop({ middleware: { destroy, icach const results = icache.get(resultKey); if (results && results.isDragging === true) { + if (isOverlayVolatile) { + overlayNode = undefined; + } icache.set(resultKey, { ...results, isDragging: false }); } } @@ -83,6 +87,10 @@ export const fileDrop = factory(function fileDrop({ middleware: { destroy, icach function onDrop(event: DragEvent) { event.preventDefault(); + if (isOverlayVolatile) { + overlayNode = undefined; + } + icache.set(resultKey, { isDragging: false, isDropped: true, @@ -97,23 +105,67 @@ export const fileDrop = factory(function fileDrop({ middleware: { destroy, icach icache.set(resultKey, createResults()); targetNode.addEventListener('dragenter', onDragEnter); - if (overlayNode) { - overlayNode.addEventListener('dragenter', preventDefault); - } - const receiverNode = overlayNode || targetNode; - receiverNode.addEventListener('dragover', preventDefault); - receiverNode.addEventListener('dragleave', onDragLeave); - receiverNode.addEventListener('drop', onDrop); + targetNode.addEventListener('drop', onDrop); handles.push(function() { targetNode.removeEventListener('dragenter', onDragEnter); + targetNode.removeEventListener('dragover', preventDefault); + targetNode.removeEventListener('drop', onDrop); + }); + + if (overlayKey) { if (overlayNode) { - overlayNode.removeEventListener('dragenter', preventDefault); + overlayNode.addEventListener('dragleave', onDragLeave); + + handles.push(function() { + if (overlayNode) { + overlayNode.removeEventListener('dragleave', onDragLeave); + } + }); + } else { + let dragoverCount = 0; + targetNode.addEventListener('dragover', function temporaryDragOver(event) { + console.count('temp dragover'); + event.preventDefault(); + + overlayNode = node.get(overlayKey); + if (overlayNode) { + isOverlayVolatile = true; + console.log('add listener to overlayNode'); + targetNode.addEventListener('dragover', preventDefault); + targetNode.removeEventListener('dragover', temporaryDragOver); + overlayNode.addEventListener('dragleave', onDragLeave); + + handles.push(function() { + if (overlayNode) { + overlayNode.removeEventListener('dragleave', onDragLeave); + } + }); + } else { + dragoverCount += 1; + console.count('dragOver'); + } + + if (dragoverCount > 10) { + console.log('bail out, add listener to targetNode'); + targetNode.addEventListener('dragover', preventDefault); + targetNode.removeEventListener('dragover', temporaryDragOver); + targetNode.addEventListener('dragleave', onDragLeave); + + handles.push(function() { + targetNode.removeEventListener('dragleave', onDragLeave); + }); + } + }); } - receiverNode.removeEventListener('dragover', preventDefault); - receiverNode.removeEventListener('dragleave', onDragLeave); - receiverNode.removeEventListener('drop', onDrop); - }); + } else { + targetNode.addEventListener('dragover', preventDefault); + targetNode.addEventListener('dragleave', onDragLeave); + + handles.push(function() { + targetNode.removeEventListener('dragleave', onDragLeave); + }); + } return emptyResults; } diff --git a/src/theme/default/file-upload-input.m.css b/src/theme/default/file-upload-input.m.css index ccebfecb5d..c2e19019cd 100644 --- a/src/theme/default/file-upload-input.m.css +++ b/src/theme/default/file-upload-input.m.css @@ -13,12 +13,17 @@ cursor: no-drop; } -.dndOverlay { - background-color: var(--selected-background); - opacity: 0.25; +/* The node containing the button and dnd label */ +.wrapper { } -/* The text label in the DnD area if DnD is allowed */ +/* 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 index 2368aac56a..0fef57969e 100644 --- a/src/theme/default/file-upload-input.m.css.d.ts +++ b/src/theme/default/file-upload-input.m.css.d.ts @@ -1,5 +1,6 @@ export const root: string; export const dndActive: string; export const disabled: string; -export const dndOverlay: 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 index d830416a90..0437437159 100644 --- a/src/theme/default/file-uploader.m.css +++ b/src/theme/default/file-uploader.m.css @@ -12,11 +12,6 @@ .disabled { } -/* Applied to the root node of the FileUploadInput widget */ -.fileInputRoot { - display: inline-block; -} - /* Container for each item representing an added file */ .fileItem { display: flex; diff --git a/src/theme/default/file-uploader.m.css.d.ts b/src/theme/default/file-uploader.m.css.d.ts index 9417a7adcb..234ad96a06 100644 --- a/src/theme/default/file-uploader.m.css.d.ts +++ b/src/theme/default/file-uploader.m.css.d.ts @@ -1,7 +1,6 @@ export const root: string; export const dndActive: string; export const disabled: string; -export const fileInputRoot: string; export const fileItem: string; export const invalid: string; export const fileInfo: string; diff --git a/src/theme/dojo/file-uploader.m.css b/src/theme/dojo/file-uploader.m.css index 67f0a71c55..8e406d19e9 100644 --- a/src/theme/dojo/file-uploader.m.css +++ b/src/theme/dojo/file-uploader.m.css @@ -12,10 +12,6 @@ cursor: no-drop; } -.fileInputRoot { - display: inline-block; -} - .fileItem { color: var(--color-text-primary); display: flex; diff --git a/src/theme/material/file-uploader.m.css b/src/theme/material/file-uploader.m.css index d57293673c..d881d65e65 100644 --- a/src/theme/material/file-uploader.m.css +++ b/src/theme/material/file-uploader.m.css @@ -11,10 +11,6 @@ cursor: no-drop; } -.fileInputRoot { - display: inline-block; -} - .fileItem { color: var(--mdc-text-color); display: flex; From 5773d2f0b23ddb82efdb05b7ec9061faf730763a Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Fri, 4 Sep 2020 21:54:13 -0700 Subject: [PATCH 19/35] * FileUploadInput: add design doc * FileUploadInput: add file type validation for DnD * FileUploadInput: update unit tests * FileUploader: add formatted file size display * FileUploader: add CustomValidator example --- src/examples/src/config.tsx | 6 + .../widgets/file-uploader/CustomValidator.tsx | 28 ++++ .../src/widgets/file-uploader/Validated.tsx | 2 +- src/file-upload-input/design.md | 8 ++ src/file-upload-input/index.tsx | 47 ++++++- .../tests/unit/FileUploadInput.spec.tsx | 131 +++++++++++------- src/file-uploader/index.tsx | 64 +++++---- src/file-uploader/nls/FileUploader.ts | 1 - src/theme/default/file-uploader.m.css | 12 +- src/theme/default/file-uploader.m.css.d.ts | 2 +- src/theme/dojo/file-uploader.m.css | 6 - src/theme/dojo/file-uploader.m.css.d.ts | 1 - src/theme/material/file-uploader.m.css | 5 - src/theme/material/file-uploader.m.css.d.ts | 1 - 14 files changed, 214 insertions(+), 100 deletions(-) create mode 100644 src/examples/src/widgets/file-uploader/CustomValidator.tsx diff --git a/src/examples/src/config.tsx b/src/examples/src/config.tsx index 4afacf2e35..551053a6ec 100644 --- a/src/examples/src/config.tsx +++ b/src/examples/src/config.tsx @@ -66,6 +66,7 @@ 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 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'; @@ -797,6 +798,11 @@ export const config = { title: 'Validated FileUploader', filename: 'Validated', module: ValidatedFileUploader + }, + { + title: 'FileUploader with custom validator', + filename: 'CustomValidator', + module: CustomValidatorFileUploader } ] }, 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..950c04557b --- /dev/null +++ b/src/examples/src/widgets/file-uploader/CustomValidator.tsx @@ -0,0 +1,28 @@ +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 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/Validated.tsx b/src/examples/src/widgets/file-uploader/Validated.tsx index e0bd0c956c..1853302eda 100644 --- a/src/examples/src/widgets/file-uploader/Validated.tsx +++ b/src/examples/src/widgets/file-uploader/Validated.tsx @@ -5,7 +5,7 @@ import Example from '../../Example'; const factory = create(); export default factory(function Validated() { - const accept = ['image/jpeg', 'image/png']; + const accept = 'image/jpeg,image/png'; const maxSize = 50000; return ( diff --git a/src/file-upload-input/design.md b/src/file-upload-input/design.md index 9b70b94b02..44383a8153 100644 --- a/src/file-upload-input/design.md +++ b/src/file-upload-input/design.md @@ -6,6 +6,9 @@ dialog but you have to call its `click` method. The overlay `
` provides a visual indicator that a drag operation is in progress. +The widget accepts children so that widgets using FileUploadInput can render file information within the bounds of +the FileUploadInput and when the overlay is displayed it will cover the children as well. + # Drag and Drop https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API @@ -28,3 +31,8 @@ the cursor moves over children (even letters in text). - when this event fires the overlay is hidden - `drop`: listened for on the root since it bubbles from the overlay - get the files and update to indicate DnD is no longer active + +# Validation + +When the `accept` parameter is set on `` the dialog restricts which files can be selected. For +Drag and Drop the validation has to be done by the widget, otherwise any file will be accepted. diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index 6c6401e13d..e2cda8f3c7 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -20,7 +20,7 @@ export interface FileUploadInputChildren { export interface FileUploadInputProperties { /** The `accept` attribute of the input */ - accept?: string | string[]; + accept?: string; /** If `true` file drag-n-drop is allowed. Default is `true` */ allowDnd?: boolean; @@ -50,6 +50,43 @@ export interface FileUploadInputProperties { widgetId?: string; } +export 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; +} + export interface ValidationInfo { message?: string; valid?: boolean; @@ -85,7 +122,7 @@ export const FileUploadInput = factory(function FileUploadInput({ } = properties(); const { messages } = i18n.localize(bundle); const themeCss = theme.classes(css); - const { content, label } = children()[0] || {}; + const { content = null, label = null } = children()[0] || {}; let isDndActive = icache.getOrSet('isDndActive', false); function onDragEnter(event: DragEvent) { @@ -107,7 +144,11 @@ export const FileUploadInput = factory(function FileUploadInput({ icache.set('isDndActive', false); if (onValue && event.dataTransfer && event.dataTransfer.files.length) { - onValue(Array.from(event.dataTransfer.files)); + const fileArray = Array.from(event.dataTransfer.files); + const validFiles = filterValidFiles(fileArray, accept); + if (validFiles.length) { + onValue(validFiles); + } } } diff --git a/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx index 2c29fc225e..a32b83b611 100644 --- a/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx +++ b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx @@ -2,6 +2,7 @@ import { tsx } from '@dojo/framework/core/vdom'; import { assertion, renderer, wrap } from '@dojo/framework/testing/renderer'; import { Button } from '../../../button'; import { FileUploadInput } from '../../index'; +import { Label } from '../../../label'; import { noop } from '../../../common/tests/support/test-helpers'; import bundle from '../../nls/FileUploadInput'; @@ -9,54 +10,58 @@ 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 { it, describe } = intern.getInterface('bdd'); const { messages } = bundle; describe('FileUploadInput', function() { - const WrappedButton = wrap(Button); - const WrappedDndLabel = wrap('span'); - const WrappedOverlay = wrap('div'); + const WrappedRoot = wrap('div'); + const WrappedLabel = wrap('span'); + const baseRootProperties = { + key: 'root', + classes: [null, fixedCss.root, css.root, false, false], + ondragenter: noop, + ondragover: noop, + ondrop: noop + }; const baseAssertion = assertion(function() { return ( -
- - - {messages.chooseFiles} - - - {messages.orDropFilesHere} - - -
+ +
+ + + + {messages.orDropFilesHere} +
+
); }); @@ -68,25 +73,44 @@ describe('FileUploadInput', function() { r.expect(baseAssertion); }); - it('renders labels', function() { - const buttonLabel = 'Button label'; - const dndLabel = 'Dnd label'; + it('renders label', function() { + const label = 'Widget label'; const r = renderer(function() { return ( {{ - buttonLabel, - dndLabel + label }} ); }); r.expect( - baseAssertion - .setChildren(WrappedButton, () => [buttonLabel]) - .setChildren(WrappedDndLabel, () => [dndLabel]) + baseAssertion.prepend(WrappedRoot, () => [ + + ]) ); }); @@ -95,6 +119,15 @@ describe('FileUploadInput', function() { return ; }); - r.expect(baseAssertion.remove(WrappedDndLabel).remove(WrappedOverlay)); + r.expect( + baseAssertion + .setProperties(WrappedRoot, { + ...baseRootProperties, + ondragenter: false, + ondragover: false, + ondrop: false + }) + .remove(WrappedLabel) + ); }); }); diff --git a/src/file-uploader/index.tsx b/src/file-uploader/index.tsx index 6e1699edcd..9706c8c294 100644 --- a/src/file-uploader/index.tsx +++ b/src/file-uploader/index.tsx @@ -22,10 +22,28 @@ export interface FileUploaderProperties extends FileUploadInputProperties { /** The maximum size in bytes of a file */ maxSize?: number; + + /** Callback fired when the input validation changes */ + onValidate?: (valid: boolean | undefined, message: string) => void; + + /** Show the file size in the file list. Default is `true` */ + showSize?: boolean; +} + +const factorNames = ['', 'B', 'KB', 'MB', 'GB', 'TB', 'PB']; +function formatBytes(byteCount: number) { + 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(2)} ${factorNames[i]}`; + break; + } + } + + return formattedValue; } interface ValidationProps { - accept?: string | string[]; file: File; maxSize?: number; messages: typeof bundle; @@ -33,7 +51,6 @@ interface ValidationProps { function validateFile(props: ValidationProps): ValidationInfo { const { - accept, file, maxSize, messages: { messages } @@ -48,44 +65,35 @@ function validateFile(props: ValidationProps): ValidationInfo { } } - if (accept) { - const acceptTypes = Array.isArray(accept) ? accept : [accept]; - if (!acceptTypes.includes(file.type)) { - valid = false; - message = messages.invalidFileType; - } - } - return { message, valid }; } -interface FileItemRendererProps { - accept?: string | string[]; - customValidator: FileUploaderProperties['customValidator']; +type FileItemRendererProps = Pick< + FileUploaderProperties, + 'customValidator' | 'maxSize' | 'showSize' +> & { files: File[]; - maxSize?: number; messages: typeof bundle; remove(file: File): void; themeCss: typeof css; -} +}; function renderFiles(props: FileItemRendererProps) { const { - accept, customValidator, files, maxSize, messages: { messages }, remove, + showSize = true, themeCss } = props; return files.map(function(file) { let validationInfo: ValidationInfo | void = validateFile({ - accept, file, maxSize, messages: { messages } @@ -100,6 +108,9 @@ function renderFiles(props: FileItemRendererProps) {
{file.name}
+ {showSize && ( +
{formatBytes(file.size)}
+ )}
@@ -194,7 +206,7 @@ export const FileUploader = factory(function FileUploader({ disabled={disabled} multiple={multiple} name={name} - onValue={onValue} + onValue={onInputValue} required={required} theme={theme.compose( fileUploadInputCss, diff --git a/src/file-uploader/nls/FileUploader.ts b/src/file-uploader/nls/FileUploader.ts index f1057e7970..7079a71d34 100644 --- a/src/file-uploader/nls/FileUploader.ts +++ b/src/file-uploader/nls/FileUploader.ts @@ -1,6 +1,5 @@ const messages = { invalidFileSize: 'Invalid file size', - invalidFileType: 'Invalid file type', remove: 'Remove' }; diff --git a/src/theme/default/file-uploader.m.css b/src/theme/default/file-uploader.m.css index 0437437159..25336d7429 100644 --- a/src/theme/default/file-uploader.m.css +++ b/src/theme/default/file-uploader.m.css @@ -1,11 +1,5 @@ /* The root class for FileUploader */ .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 */ @@ -33,6 +27,12 @@ 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; diff --git a/src/theme/default/file-uploader.m.css.d.ts b/src/theme/default/file-uploader.m.css.d.ts index 234ad96a06..fda39b1629 100644 --- a/src/theme/default/file-uploader.m.css.d.ts +++ b/src/theme/default/file-uploader.m.css.d.ts @@ -1,9 +1,9 @@ export const root: string; -export const dndActive: 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/dojo/file-uploader.m.css b/src/theme/dojo/file-uploader.m.css index 8e406d19e9..b57f572c56 100644 --- a/src/theme/dojo/file-uploader.m.css +++ b/src/theme/dojo/file-uploader.m.css @@ -1,11 +1,5 @@ .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 { diff --git a/src/theme/dojo/file-uploader.m.css.d.ts b/src/theme/dojo/file-uploader.m.css.d.ts index 66db779360..5ceed8ec85 100644 --- a/src/theme/dojo/file-uploader.m.css.d.ts +++ b/src/theme/dojo/file-uploader.m.css.d.ts @@ -1,7 +1,6 @@ export const root: string; export const dndActive: string; export const disabled: string; -export const fileInputRoot: string; export const fileItem: string; export const fileInfo: string; export const fileItemName: string; diff --git a/src/theme/material/file-uploader.m.css b/src/theme/material/file-uploader.m.css index d881d65e65..136c3103cf 100644 --- a/src/theme/material/file-uploader.m.css +++ b/src/theme/material/file-uploader.m.css @@ -1,10 +1,5 @@ .root { background-color: var(--mdc-theme-background); - border: 2px dashed transparent; -} - -.dndActive { - border-color: var(--mdc-solid-border-color-hover); } .disabled { diff --git a/src/theme/material/file-uploader.m.css.d.ts b/src/theme/material/file-uploader.m.css.d.ts index 66db779360..5ceed8ec85 100644 --- a/src/theme/material/file-uploader.m.css.d.ts +++ b/src/theme/material/file-uploader.m.css.d.ts @@ -1,7 +1,6 @@ export const root: string; export const dndActive: string; export const disabled: string; -export const fileInputRoot: string; export const fileItem: string; export const fileInfo: string; export const fileItemName: string; From 726ad1f5dcf1307e706fe9e4e54bc13308dc9a9a Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Tue, 8 Sep 2020 16:17:44 -0700 Subject: [PATCH 20/35] Update tests --- src/file-upload-input/index.tsx | 7 ++- .../tests/unit/FileUploadInput.spec.tsx | 57 ++++++++++++++++++- src/file-uploader/index.tsx | 20 ++++--- src/theme/dojo/file-uploader.m.css.d.ts | 1 - src/theme/material/file-uploader.m.css.d.ts | 1 - 5 files changed, 71 insertions(+), 15 deletions(-) diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index e2cda8f3c7..b0de4899c3 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -14,7 +14,10 @@ import * as fixedCss from './styles/file-upload-input.m.css'; import * as labelCss from '../theme/default/label.m.css'; export interface FileUploadInputChildren { + /** The label to be displayed above the input */ label?: RenderResult; + + /** Content to be rendered within the widget area */ content?: RenderResult; } @@ -43,7 +46,7 @@ export interface FileUploadInputProperties { /** The `required` attribute of the input */ required?: boolean; - /** Represents if the input value is valid */ + /** Represents if the selected files passed validation */ valid?: ValidationInfo | boolean; /** The id to be applied to the input */ @@ -122,7 +125,7 @@ export const FileUploadInput = factory(function FileUploadInput({ } = properties(); const { messages } = i18n.localize(bundle); const themeCss = theme.classes(css); - const { content = null, label = null } = children()[0] || {}; + const [{ content, label } = { content: undefined, label: undefined }] = children(); let isDndActive = icache.getOrSet('isDndActive', false); function onDragEnter(event: DragEvent) { diff --git a/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx index a32b83b611..c10ca844a7 100644 --- a/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx +++ b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx @@ -17,7 +17,9 @@ const { messages } = bundle; describe('FileUploadInput', function() { const WrappedRoot = wrap('div'); + const WrappedWrapper = wrap('div'); const WrappedLabel = wrap('span'); + const baseRootProperties = { key: 'root', classes: [null, fixedCss.root, css.root, false, false], @@ -29,7 +31,7 @@ describe('FileUploadInput', function() { const baseAssertion = assertion(function() { return ( -
+ {messages.orDropFilesHere} -
+
); }); @@ -73,6 +75,22 @@ describe('FileUploadInput', function() { 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'; @@ -130,4 +148,39 @@ describe('FileUploadInput', function() { .remove(WrappedLabel) ); }); + + it('handles dragenter, dragleave, and the overlay', function() { + const r = renderer(function() { + return ; + }); + const WrappedOverlay = wrap('div'); + + r.expect(baseAssertion); + r.property(WrappedRoot, 'ondragenter', { preventDefault: noop }); + + r.expect( + baseAssertion + .setProperty(WrappedRoot, 'classes', [ + null, + fixedCss.root, + css.root, + css.dndActive, + false + ]) + .append(WrappedRoot, function() { + return [ + + ]; + }) + ); + + // TODO: enable when testing bug is fixed + // https://github.com/dojo/framework/issues/839 + // r.property(WrappedOverlay, 'ondragleave'); + // r.expect(baseAssertion); + }); }); diff --git a/src/file-uploader/index.tsx b/src/file-uploader/index.tsx index 9706c8c294..6dcfccd942 100644 --- a/src/file-uploader/index.tsx +++ b/src/file-uploader/index.tsx @@ -177,15 +177,17 @@ export const FileUploader = factory(function FileUploader({ if (files.length) { inputChild.content = (
- {renderFiles({ - customValidator, - files, - maxSize, - messages: { messages }, - remove, - showSize, - themeCss - })} + {function() { + return renderFiles({ + customValidator, + files, + maxSize, + messages: { messages }, + remove, + showSize, + themeCss + }); + }}
); } diff --git a/src/theme/dojo/file-uploader.m.css.d.ts b/src/theme/dojo/file-uploader.m.css.d.ts index 5ceed8ec85..80a978ff0d 100644 --- a/src/theme/dojo/file-uploader.m.css.d.ts +++ b/src/theme/dojo/file-uploader.m.css.d.ts @@ -1,5 +1,4 @@ export const root: string; -export const dndActive: string; export const disabled: string; export const fileItem: string; export const fileInfo: string; diff --git a/src/theme/material/file-uploader.m.css.d.ts b/src/theme/material/file-uploader.m.css.d.ts index 5ceed8ec85..80a978ff0d 100644 --- a/src/theme/material/file-uploader.m.css.d.ts +++ b/src/theme/material/file-uploader.m.css.d.ts @@ -1,5 +1,4 @@ export const root: string; -export const dndActive: string; export const disabled: string; export const fileItem: string; export const fileInfo: string; From f2d6d8fb951aa22f1a455d83a9602602847297f8 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Tue, 8 Sep 2020 21:39:00 -0700 Subject: [PATCH 21/35] Update tests --- src/file-upload-input/index.tsx | 10 +- .../tests/unit/FileUploadInput.spec.tsx | 121 ++++++++++++++++-- 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index b0de4899c3..34cf07fc3b 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -3,6 +3,7 @@ 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 { Button } from '../button'; +import { formatAriaProperties } from '../common/util'; import { Label } from '../label'; import theme from '../middleware/theme'; import bundle from './nls/FileUploadInput'; @@ -28,6 +29,9 @@ export interface FileUploadInputProperties { /** 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; @@ -114,6 +118,7 @@ export const FileUploadInput = factory(function FileUploadInput({ const { accept, allowDnd = true, + aria = {}, disabled = false, labelHidden = false, multiple = false, @@ -160,6 +165,7 @@ export const FileUploadInput = factory(function FileUploadInput({ } function onChange(event: DojoEvent) { + console.log('onChange', event); if (onValue && event.target.files && event.target.files.length) { onValue(Array.from(event.target.files)); } @@ -168,6 +174,8 @@ export const FileUploadInput = factory(function FileUploadInput({ return (
- - + {messages.orDropFilesHere} @@ -149,6 +153,26 @@ describe('FileUploadInput', function() { ); }); + 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(WrappedInput, 'disabled', true) + .setProperty(WrappedButton, 'disabled', true) + ); + }); + it('handles dragenter, dragleave, and the overlay', function() { const r = renderer(function() { return ; @@ -156,7 +180,7 @@ describe('FileUploadInput', function() { const WrappedOverlay = wrap('div'); r.expect(baseAssertion); - r.property(WrappedRoot, 'ondragenter', { preventDefault: noop }); + r.property(WrappedRoot, 'ondragenter', stubEvent); r.expect( baseAssertion @@ -178,9 +202,88 @@ describe('FileUploadInput', function() { }) ); - // TODO: enable when testing bug is fixed - // https://github.com/dojo/framework/issues/839 - // r.property(WrappedOverlay, 'ondragleave'); + // TODO: enable when https://github.com/dojo/framework/pull/840 is merged + // r.property(WrappedOverlay, 'ondragleave', stubEvent); // r.expect(baseAssertion); }); + + it('handles file drop event', function() { + const testValues = [1, 2, 3]; + let receivedFiles: number[] = []; + + function onValue(value: any[]) { + receivedFiles = value; + } + + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion); + r.property(WrappedRoot, 'ondrop', { + preventDefault: noop, + dataTransfer: { + files: testValues + } + }); + r.expect(baseAssertion); + + assert.sameOrderedMembers(receivedFiles, testValues); + }); + + it('validates files based on "accept"', function() { + const accept = 'image/jpeg,image/*,.gif'; + const testFiles = [ + { name: 'file1.jpg', type: 'image/jpeg' }, // test direct match: image/jpeg + { 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/word' } // test match failure + ]; + const validFiles = testFiles.slice(0, 3); + let receivedFiles: Array = []; + + function onValue(value: any[]) { + receivedFiles = value; + } + + const r = renderer(function() { + return ; + }); + const acceptAssertion = baseAssertion.setProperty(WrappedInput, 'accept', accept); + + r.expect(acceptAssertion); + r.property(WrappedRoot, 'ondrop', { + preventDefault: noop, + dataTransfer: { + files: testFiles + } + }); + r.expect(acceptAssertion); + + assert.sameOrderedMembers(receivedFiles, validFiles); + }); + + it('calls onValue when files are selected from input', function() { + const testValues = [1, 2, 3]; + let receivedFiles: number[] = []; + + function onValue(value: any[]) { + receivedFiles = value; + } + + const r = renderer(function() { + return ; + }); + + r.expect(baseAssertion); + r.property(WrappedInput, 'onchange', { + target: { + files: testValues + } + }); + // TODO: the queued onchange is not triggering because it is for a node with a different id than expected + r.expect(baseAssertion); + + assert.sameOrderedMembers(receivedFiles, testValues); + }); }); From ab02f56a5bf49d25486847f7e480bb8ca785b2a9 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Wed, 9 Sep 2020 10:58:44 -0700 Subject: [PATCH 22/35] Update tests --- src/file-upload-input/index.tsx | 1 - .../tests/unit/FileUploadInput.spec.tsx | 27 ++++++------------- src/file-uploader/index.tsx | 6 ++++- 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index 34cf07fc3b..5e0d920cac 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -165,7 +165,6 @@ export const FileUploadInput = factory(function FileUploadInput({ } function onChange(event: DojoEvent) { - console.log('onChange', event); if (onValue && event.target.files && event.target.files.length) { onValue(Array.from(event.target.files)); } diff --git a/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx index 64cac8a34a..a24871733e 100644 --- a/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx +++ b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx @@ -1,5 +1,6 @@ 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'; @@ -209,11 +210,7 @@ describe('FileUploadInput', function() { it('handles file drop event', function() { const testValues = [1, 2, 3]; - let receivedFiles: number[] = []; - - function onValue(value: any[]) { - receivedFiles = value; - } + const onValue = sinon.stub(); const r = renderer(function() { return ; @@ -228,7 +225,7 @@ describe('FileUploadInput', function() { }); r.expect(baseAssertion); - assert.sameOrderedMembers(receivedFiles, testValues); + assert.sameOrderedMembers(onValue.firstCall.args[0], testValues); }); it('validates files based on "accept"', function() { @@ -240,11 +237,7 @@ describe('FileUploadInput', function() { { name: 'file4.doc', type: 'application/word' } // test match failure ]; const validFiles = testFiles.slice(0, 3); - let receivedFiles: Array = []; - - function onValue(value: any[]) { - receivedFiles = value; - } + const onValue = sinon.stub(); const r = renderer(function() { return ; @@ -260,16 +253,12 @@ describe('FileUploadInput', function() { }); r.expect(acceptAssertion); - assert.sameOrderedMembers(receivedFiles, validFiles); + assert.sameOrderedMembers(onValue.firstCall.args[0], validFiles); }); it('calls onValue when files are selected from input', function() { const testValues = [1, 2, 3]; - let receivedFiles: number[] = []; - - function onValue(value: any[]) { - receivedFiles = value; - } + const onValue = sinon.stub(); const r = renderer(function() { return ; @@ -281,9 +270,9 @@ describe('FileUploadInput', function() { files: testValues } }); - // TODO: the queued onchange is not triggering because it is for a node with a different id than expected r.expect(baseAssertion); - assert.sameOrderedMembers(receivedFiles, testValues); + // 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/index.tsx b/src/file-uploader/index.tsx index 6dcfccd942..ab3abf5058 100644 --- a/src/file-uploader/index.tsx +++ b/src/file-uploader/index.tsx @@ -16,6 +16,10 @@ 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?: FileUploadInputChildren['label']; +} + export interface FileUploaderProperties extends FileUploadInputProperties { /** Custom validator used to validate each file */ customValidator?: (file: File) => ValidationInfo | void; @@ -135,7 +139,7 @@ const icache = createICacheMiddleware(); const factory = create({ fileDrop, i18n, icache, theme }) .properties() - .children | undefined>(); + .children(); export const FileUploader = factory(function FileUploader({ children, From 76d8454ee7b78c3d9f02763e20cabfb2773319a9 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Wed, 9 Sep 2020 13:51:12 -0700 Subject: [PATCH 23/35] Update tests to check for preventDefault --- .../tests/unit/FileUploadInput.spec.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx index a24871733e..54d148694c 100644 --- a/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx +++ b/src/file-upload-input/tests/unit/FileUploadInput.spec.tsx @@ -13,7 +13,7 @@ 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 { it, describe } = intern.getInterface('bdd'); +const { after, afterEach, it, describe } = intern.getInterface('bdd'); const { assert } = intern.getPlugin('chai'); const { messages } = bundle; @@ -33,6 +33,8 @@ describe('FileUploadInput', function() { ondrop: noop }; + const preventDefaultSpy = sinon.spy(stubEvent, 'preventDefault'); + const baseAssertion = assertion(function() { return ( @@ -72,6 +74,14 @@ describe('FileUploadInput', function() { ); }); + after(function() { + preventDefaultSpy.restore(); + }); + + afterEach(function() { + preventDefaultSpy.resetHistory(); + }); + it('renders', function() { const r = renderer(function() { return ; @@ -202,10 +212,13 @@ describe('FileUploadInput', function() { ]; }) ); + 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() { @@ -218,13 +231,14 @@ describe('FileUploadInput', function() { r.expect(baseAssertion); r.property(WrappedRoot, 'ondrop', { - preventDefault: noop, + ...stubEvent, dataTransfer: { files: testValues } }); r.expect(baseAssertion); + assert(preventDefaultSpy.called, 'drop handler should call event.preventDefault()'); assert.sameOrderedMembers(onValue.firstCall.args[0], testValues); }); From 188ef5ba579f863c9b0042073304cd92781ea8a1 Mon Sep 17 00:00:00 2001 From: Mangala SSS Khalsa Date: Wed, 9 Sep 2020 14:47:44 -0700 Subject: [PATCH 24/35] FileUploadInput: don't handle drag events when allowDnd=false --- src/file-upload-input/index.tsx | 6 +++--- .../tests/unit/FileUploadInput.spec.tsx | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/file-upload-input/index.tsx b/src/file-upload-input/index.tsx index 5e0d920cac..886aa66c43 100644 --- a/src/file-upload-input/index.tsx +++ b/src/file-upload-input/index.tsx @@ -182,9 +182,9 @@ export const FileUploadInput = factory(function FileUploadInput({ isDndActive && themeCss.dndActive, disabled && themeCss.disabled ]} - ondragenter={allowDnd && onDragEnter} - ondragover={allowDnd && onDragOver} - ondrop={allowDnd && onDrop} + ondragenter={allowDnd && disabled === false && onDragEnter} + ondragover={allowDnd && disabled === false && onDragOver} + ondrop={allowDnd && disabled === false && onDrop} > {label && (