diff --git a/.eslintrc.js b/.eslintrc.js index b1a116d17d..40cbf64c3b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -201,6 +201,7 @@ module.exports = { 'packages/@uppy/aws-s3-multipart/src/**/*.js', 'packages/@uppy/box/src/**/*.js', 'packages/@uppy/compressor/src/**/*.js', + 'packages/@uppy/dashboard/src/**/*.js', 'packages/@uppy/drag-drop/src/**/*.js', 'packages/@uppy/drop-target/src/**/*.js', 'packages/@uppy/dropbox/src/**/*.js', @@ -269,6 +270,14 @@ module.exports = { 'import/extensions': ['error', 'ignorePackages'], }, }, + { + files: [ + 'packages/@uppy/dashboard/src/components/**/*.jsx', + ], + rules: { + 'react/destructuring-assignment': 'off', + }, + }, { files: [ // Those need looser rules, and cannot be made part of the stricter rules above. diff --git a/packages/@uppy/dashboard/package.json b/packages/@uppy/dashboard/package.json index deccaa065a..6ea67343e3 100644 --- a/packages/@uppy/dashboard/package.json +++ b/packages/@uppy/dashboard/package.json @@ -6,6 +6,7 @@ "main": "lib/index.js", "style": "dist/style.min.css", "types": "types/index.d.ts", + "type": "module", "keywords": [ "file uploader", "uppy", @@ -36,6 +37,7 @@ "preact": "^10.5.13" }, "devDependencies": { + "@jest/globals": "^27.4.2", "@uppy/google-drive": "workspace:^", "@uppy/status-bar": "workspace:^", "resize-observer-polyfill": "^1.5.0" diff --git a/packages/@uppy/dashboard/src/Dashboard.jsx b/packages/@uppy/dashboard/src/Dashboard.jsx new file mode 100644 index 0000000000..f369bd0572 --- /dev/null +++ b/packages/@uppy/dashboard/src/Dashboard.jsx @@ -0,0 +1,1112 @@ +import { h } from 'preact' +import { UIPlugin } from '@uppy/core' +import StatusBar from '@uppy/status-bar' +import Informer from '@uppy/informer' +import ThumbnailGenerator from '@uppy/thumbnail-generator' +import findAllDOMElements from '@uppy/utils/lib/findAllDOMElements' +import toArray from '@uppy/utils/lib/toArray' +import getDroppedFiles from '@uppy/utils/lib/getDroppedFiles' +import { nanoid } from 'nanoid/non-secure' +import memoizeOne from 'memoize-one' +import FOCUSABLE_ELEMENTS from '@uppy/utils/lib/FOCUSABLE_ELEMENTS.js' +import * as trapFocus from './utils/trapFocus.js' +import createSuperFocus from './utils/createSuperFocus.js' +import DashboardUI from './components/Dashboard.jsx' + +import packageJson from '../package.json' +import locale from './locale.js' + +const memoize = memoizeOne.default || memoizeOne + +const TAB_KEY = 9 +const ESC_KEY = 27 + +function createPromise () { + const o = {} + o.promise = new Promise((resolve, reject) => { + o.resolve = resolve + o.reject = reject + }) + return o +} + +function defaultPickerIcon () { + return ( + + ) +} + +/** + * Dashboard UI with previews, metadata editing, tabs for various services and more + */ +export default class Dashboard extends UIPlugin { + static VERSION = packageJson.version + + constructor (uppy, opts) { + super(uppy, opts) + this.id = this.opts.id || 'Dashboard' + this.title = 'Dashboard' + this.type = 'orchestrator' + this.modalName = `uppy-Dashboard-${nanoid()}` + + this.defaultLocale = locale + + // set default options + const defaultOptions = { + target: 'body', + metaFields: [], + trigger: null, + inline: false, + width: 750, + height: 550, + thumbnailWidth: 280, + thumbnailType: 'image/jpeg', + waitForThumbnailsBeforeUpload: false, + defaultPickerIcon, + showLinkToFileUploadResult: false, + showProgressDetails: false, + hideUploadButton: false, + hideCancelButton: false, + hideRetryButton: false, + hidePauseResumeButton: false, + hideProgressAfterFinish: false, + doneButtonHandler: () => { + this.uppy.reset() + this.requestCloseModal() + }, + note: null, + closeModalOnClickOutside: false, + closeAfterFinish: false, + disableStatusBar: false, + disableInformer: false, + disableThumbnailGenerator: false, + disablePageScrollWhenModalOpen: true, + animateOpenClose: true, + fileManagerSelectionType: 'files', + proudlyDisplayPoweredByUppy: true, + onRequestCloseModal: () => this.closeModal(), + showSelectedFiles: true, + showRemoveButtonAfterComplete: false, + browserBackButtonClose: false, + theme: 'light', + autoOpenFileEditor: false, + disabled: false, + disableLocalFiles: false, + } + + // merge default options with the ones set by user + this.opts = { ...defaultOptions, ...opts } + + this.i18nInit() + + this.superFocus = createSuperFocus() + this.ifFocusedOnUppyRecently = false + + // Timeouts + this.makeDashboardInsidesVisibleAnywayTimeout = null + this.removeDragOverClassTimeout = null + } + + removeTarget = (plugin) => { + const pluginState = this.getPluginState() + // filter out the one we want to remove + const newTargets = pluginState.targets.filter(target => target.id !== plugin.id) + + this.setPluginState({ + targets: newTargets, + }) + } + + addTarget = (plugin) => { + const callerPluginId = plugin.id || plugin.constructor.name + const callerPluginName = plugin.title || callerPluginId + const callerPluginType = plugin.type + + if (callerPluginType !== 'acquirer' + && callerPluginType !== 'progressindicator' + && callerPluginType !== 'editor') { + const msg = 'Dashboard: can only be targeted by plugins of types: acquirer, progressindicator, editor' + this.uppy.log(msg, 'error') + return undefined + } + + const target = { + id: callerPluginId, + name: callerPluginName, + type: callerPluginType, + } + + const state = this.getPluginState() + const newTargets = state.targets.slice() + newTargets.push(target) + + this.setPluginState({ + targets: newTargets, + }) + + return this.el + } + + hideAllPanels = () => { + const state = this.getPluginState() + const update = { + activePickerPanel: false, + showAddFilesPanel: false, + activeOverlayType: null, + fileCardFor: null, + showFileEditor: false, + } + + if (state.activePickerPanel === update.activePickerPanel + && state.showAddFilesPanel === update.showAddFilesPanel + && state.showFileEditor === update.showFileEditor + && state.activeOverlayType === update.activeOverlayType) { + // avoid doing a state update if nothing changed + return + } + + this.setPluginState(update) + } + + showPanel = (id) => { + const { targets } = this.getPluginState() + + const activePickerPanel = targets.filter((target) => { + return target.type === 'acquirer' && target.id === id + })[0] + + this.setPluginState({ + activePickerPanel, + activeOverlayType: 'PickerPanel', + }) + } + + canEditFile = (file) => { + const { targets } = this.getPluginState() + const editors = this.#getEditors(targets) + + return editors.some((target) => ( + this.uppy.getPlugin(target.id).canEditFile(file) + )) + } + + openFileEditor = (file) => { + const { targets } = this.getPluginState() + const editors = this.#getEditors(targets) + + this.setPluginState({ + showFileEditor: true, + fileCardFor: file.id || null, + activeOverlayType: 'FileEditor', + }) + + editors.forEach((editor) => { + this.uppy.getPlugin(editor.id).selectFile(file) + }) + } + + saveFileEditor = () => { + const { targets } = this.getPluginState() + const editors = this.#getEditors(targets) + + editors.forEach((editor) => { + this.uppy.getPlugin(editor.id).save() + }) + + this.hideAllPanels() + } + + openModal = () => { + const { promise, resolve } = createPromise() + // save scroll position + this.savedScrollPosition = window.pageYOffset + // save active element, so we can restore focus when modal is closed + this.savedActiveElement = document.activeElement + + if (this.opts.disablePageScrollWhenModalOpen) { + document.body.classList.add('uppy-Dashboard-isFixed') + } + + if (this.opts.animateOpenClose && this.getPluginState().isClosing) { + const handler = () => { + this.setPluginState({ + isHidden: false, + }) + this.el.removeEventListener('animationend', handler, false) + resolve() + } + this.el.addEventListener('animationend', handler, false) + } else { + this.setPluginState({ + isHidden: false, + }) + resolve() + } + + if (this.opts.browserBackButtonClose) { + this.updateBrowserHistory() + } + + // handle ESC and TAB keys in modal dialog + document.addEventListener('keydown', this.handleKeyDownInModal) + + this.uppy.emit('dashboard:modal-open') + + return promise + } + + closeModal = (opts = {}) => { + const { + // Whether the modal is being closed by the user (`true`) or by other means (e.g. browser back button) + manualClose = true, + } = opts + + const { isHidden, isClosing } = this.getPluginState() + if (isHidden || isClosing) { + // short-circuit if animation is ongoing + return undefined + } + + const { promise, resolve } = createPromise() + + if (this.opts.disablePageScrollWhenModalOpen) { + document.body.classList.remove('uppy-Dashboard-isFixed') + } + + if (this.opts.animateOpenClose) { + this.setPluginState({ + isClosing: true, + }) + const handler = () => { + this.setPluginState({ + isHidden: true, + isClosing: false, + }) + + this.superFocus.cancel() + this.savedActiveElement.focus() + + this.el.removeEventListener('animationend', handler, false) + resolve() + } + this.el.addEventListener('animationend', handler, false) + } else { + this.setPluginState({ + isHidden: true, + }) + + this.superFocus.cancel() + this.savedActiveElement.focus() + + resolve() + } + + // handle ESC and TAB keys in modal dialog + document.removeEventListener('keydown', this.handleKeyDownInModal) + + if (manualClose) { + if (this.opts.browserBackButtonClose) { + // Make sure that the latest entry in the history state is our modal name + // eslint-disable-next-line no-restricted-globals + if (history.state?.[this.modalName]) { + // Go back in history to clear out the entry we created (ultimately closing the modal) + // eslint-disable-next-line no-restricted-globals + history.back() + } + } + } + + this.uppy.emit('dashboard:modal-closed') + + return promise + } + + isModalOpen = () => { + return !this.getPluginState().isHidden || false + } + + requestCloseModal = () => { + if (this.opts.onRequestCloseModal) { + return this.opts.onRequestCloseModal() + } + return this.closeModal() + } + + setDarkModeCapability = (isDarkModeOn) => { + const { capabilities } = this.uppy.getState() + this.uppy.setState({ + capabilities: { + ...capabilities, + darkMode: isDarkModeOn, + }, + }) + } + + handleSystemDarkModeChange = (event) => { + const isDarkModeOnNow = event.matches + this.uppy.log(`[Dashboard] Dark mode is ${isDarkModeOnNow ? 'on' : 'off'}`) + this.setDarkModeCapability(isDarkModeOnNow) + } + + toggleFileCard = (show, fileID) => { + const file = this.uppy.getFile(fileID) + if (show) { + this.uppy.emit('dashboard:file-edit-start', file) + } else { + this.uppy.emit('dashboard:file-edit-complete', file) + } + + this.setPluginState({ + fileCardFor: show ? fileID : null, + activeOverlayType: show ? 'FileCard' : null, + }) + } + + toggleAddFilesPanel = (show) => { + this.setPluginState({ + showAddFilesPanel: show, + activeOverlayType: show ? 'AddFiles' : null, + }) + } + + addFiles = (files) => { + const descriptors = files.map((file) => ({ + source: this.id, + name: file.name, + type: file.type, + data: file, + meta: { + // path of the file relative to the ancestor directory the user selected. + // e.g. 'docs/Old Prague/airbnb.pdf' + relativePath: file.relativePath || null, + }, + })) + + try { + this.uppy.addFiles(descriptors) + } catch (err) { + this.uppy.log(err) + } + } + + // ___Why make insides of Dashboard invisible until first ResizeObserver event is emitted? + // ResizeOberserver doesn't emit the first resize event fast enough, users can see the jump from one .uppy-size-- to + // another (e.g. in Safari) + // ___Why not apply visibility property to .uppy-Dashboard-inner? + // Because ideally, acc to specs, ResizeObserver should see invisible elements as of width 0. So even though applying + // invisibility to .uppy-Dashboard-inner works now, it may not work in the future. + startListeningToResize = () => { + // Watch for Dashboard container (`.uppy-Dashboard-inner`) resize + // and update containerWidth/containerHeight in plugin state accordingly. + // Emits first event on initialization. + this.resizeObserver = new ResizeObserver((entries) => { + const uppyDashboardInnerEl = entries[0] + + const { width, height } = uppyDashboardInnerEl.contentRect + + this.uppy.log(`[Dashboard] resized: ${width} / ${height}`, 'debug') + + this.setPluginState({ + containerWidth: width, + containerHeight: height, + areInsidesReadyToBeVisible: true, + }) + }) + this.resizeObserver.observe(this.el.querySelector('.uppy-Dashboard-inner')) + + // If ResizeObserver fails to emit an event telling us what size to use - default to the mobile view + this.makeDashboardInsidesVisibleAnywayTimeout = setTimeout(() => { + const pluginState = this.getPluginState() + const isModalAndClosed = !this.opts.inline && pluginState.isHidden + if ( + // if ResizeObserver hasn't yet fired, + !pluginState.areInsidesReadyToBeVisible + // and it's not due to the modal being closed + && !isModalAndClosed + ) { + this.uppy.log("[Dashboard] resize event didn't fire on time: defaulted to mobile layout", 'debug') + + this.setPluginState({ + areInsidesReadyToBeVisible: true, + }) + } + }, 1000) + } + + stopListeningToResize = () => { + this.resizeObserver.disconnect() + + clearTimeout(this.makeDashboardInsidesVisibleAnywayTimeout) + } + + // Records whether we have been interacting with uppy right now, + // which is then used to determine whether state updates should trigger a refocusing. + recordIfFocusedOnUppyRecently = (event) => { + if (this.el.contains(event.target)) { + this.ifFocusedOnUppyRecently = true + } else { + this.ifFocusedOnUppyRecently = false + // ___Why run this.superFocus.cancel here when it already runs in superFocusOnEachUpdate? + // Because superFocus is debounced, when we move from Uppy to some other element on the page, + // previously run superFocus sometimes hits and moves focus back to Uppy. + this.superFocus.cancel() + } + } + + disableAllFocusableElements = (disable) => { + const focusableNodes = toArray(this.el.querySelectorAll(FOCUSABLE_ELEMENTS)) + if (disable) { + focusableNodes.forEach((node) => { + // save previous tabindex in a data-attribute, to restore when enabling + const currentTabIndex = node.getAttribute('tabindex') + if (currentTabIndex) { + node.dataset.inertTabindex = currentTabIndex // eslint-disable-line no-param-reassign + } + node.setAttribute('tabindex', '-1') + }) + } else { + focusableNodes.forEach((node) => { + if ('inertTabindex' in node.dataset) { + node.setAttribute('tabindex', node.dataset.inertTabindex) + } else { + node.removeAttribute('tabindex') + } + }) + } + this.dashboardIsDisabled = disable + } + + updateBrowserHistory = () => { + // Ensure history state does not already contain our modal name to avoid double-pushing + // eslint-disable-next-line no-restricted-globals + if (!history.state?.[this.modalName]) { + // Push to history so that the page is not lost on browser back button press + // eslint-disable-next-line no-restricted-globals + history.pushState({ + // eslint-disable-next-line no-restricted-globals + ...history.state, + [this.modalName]: true, + }, '') + } + + // Listen for back button presses + window.addEventListener('popstate', this.handlePopState, false) + } + + handlePopState = (event) => { + // Close the modal if the history state no longer contains our modal name + if (this.isModalOpen() && (!event.state || !event.state[this.modalName])) { + this.closeModal({ manualClose: false }) + } + + // When the browser back button is pressed and uppy is now the latest entry + // in the history but the modal is closed, fix the history by removing the + // uppy history entry. + // This occurs when another entry is added into the history state while the + // modal is open, and then the modal gets manually closed. + // Solves PR #575 (https://github.com/transloadit/uppy/pull/575) + if (!this.isModalOpen() && event.state?.[this.modalName]) { + // eslint-disable-next-line no-restricted-globals + history.back() + } + } + + handleKeyDownInModal = (event) => { + // close modal on esc key press + if (event.keyCode === ESC_KEY) this.requestCloseModal(event) + // trap focus on tab key press + if (event.keyCode === TAB_KEY) trapFocus.forModal(event, this.getPluginState().activeOverlayType, this.el) + } + + handleClickOutside = () => { + if (this.opts.closeModalOnClickOutside) this.requestCloseModal() + } + + handlePaste = (event) => { + // Let any acquirer plugin (Url/Webcam/etc.) handle pastes to the root + this.uppy.iteratePlugins((plugin) => { + if (plugin.type === 'acquirer') { + // Every Plugin with .type acquirer can define handleRootPaste(event) + plugin.handleRootPaste?.(event) + } + }) + + // Add all dropped files + const files = toArray(event.clipboardData.files) + if (files.length > 0) { + this.uppy.log('[Dashboard] Files pasted') + this.addFiles(files) + } + } + + handleInputChange = (event) => { + event.preventDefault() + const files = toArray(event.target.files) + if (files.length > 0) { + this.uppy.log('[Dashboard] Files selected through input') + this.addFiles(files) + } + } + + handleDragOver = (event) => { + event.preventDefault() + event.stopPropagation() + + // Check if some plugin can handle the datatransfer without files — + // for instance, the Url plugin can import a url + const canSomePluginHandleRootDrop = () => { + let somePluginCanHandleRootDrop = true + this.uppy.iteratePlugins((plugin) => { + if (plugin.canHandleRootDrop?.(event)) { + somePluginCanHandleRootDrop = true + } + }) + return somePluginCanHandleRootDrop + } + + // Check if the "type" of the datatransfer object includes files + const doesEventHaveFiles = () => { + const { types } = event.dataTransfer + return types.some(type => type === 'Files') + } + + // Deny drop, if no plugins can handle datatransfer, there are no files, + // or when opts.disabled is set, or new uploads are not allowed + const somePluginCanHandleRootDrop = canSomePluginHandleRootDrop(event) + const hasFiles = doesEventHaveFiles(event) + if ( + (!somePluginCanHandleRootDrop && !hasFiles) + || this.opts.disabled + // opts.disableLocalFiles should only be taken into account if no plugins + // can handle the datatransfer + || (this.opts.disableLocalFiles && (hasFiles || !somePluginCanHandleRootDrop)) + || !this.uppy.getState().allowNewUpload + ) { + event.dataTransfer.dropEffect = 'none' // eslint-disable-line no-param-reassign + clearTimeout(this.removeDragOverClassTimeout) + return + } + + // Add a small (+) icon on drop + // (and prevent browsers from interpreting this as files being _moved_ into the + // browser, https://github.com/transloadit/uppy/issues/1978). + event.dataTransfer.dropEffect = 'copy' // eslint-disable-line no-param-reassign + + clearTimeout(this.removeDragOverClassTimeout) + this.setPluginState({ isDraggingOver: true }) + + this.opts.onDragOver?.(event) + } + + handleDragLeave = (event) => { + event.preventDefault() + event.stopPropagation() + + clearTimeout(this.removeDragOverClassTimeout) + // Timeout against flickering, this solution is taken from drag-drop library. + // Solution with 'pointer-events: none' didn't work across browsers. + this.removeDragOverClassTimeout = setTimeout(() => { + this.setPluginState({ isDraggingOver: false }) + }, 50) + + this.opts.onDragLeave?.(event) + } + + handleDrop = async (event) => { + event.preventDefault() + event.stopPropagation() + + clearTimeout(this.removeDragOverClassTimeout) + + this.setPluginState({ isDraggingOver: false }) + + // Let any acquirer plugin (Url/Webcam/etc.) handle drops to the root + this.uppy.iteratePlugins((plugin) => { + if (plugin.type === 'acquirer') { + // Every Plugin with .type acquirer can define handleRootDrop(event) + plugin.handleRootDrop?.(event) + } + }) + + // Add all dropped files + let executedDropErrorOnce = false + const logDropError = (error) => { + this.uppy.log(error, 'error') + + // In practice all drop errors are most likely the same, + // so let's just show one to avoid overwhelming the user + if (!executedDropErrorOnce) { + this.uppy.info(error.message, 'error') + executedDropErrorOnce = true + } + } + + // Add all dropped files + const files = await getDroppedFiles(event.dataTransfer, { logDropError }) + if (files.length > 0) { + this.uppy.log('[Dashboard] Files dropped') + this.addFiles(files) + } + + this.opts.onDrop?.(event) + } + + handleRequestThumbnail = (file) => { + if (!this.opts.waitForThumbnailsBeforeUpload) { + this.uppy.emit('thumbnail:request', file) + } + } + + /** + * We cancel thumbnail requests when a file item component unmounts to avoid + * clogging up the queue when the user scrolls past many elements. + */ + handleCancelThumbnail = (file) => { + if (!this.opts.waitForThumbnailsBeforeUpload) { + this.uppy.emit('thumbnail:cancel', file) + } + } + + handleKeyDownInInline = (event) => { + // Trap focus on tab key press. + if (event.keyCode === TAB_KEY) trapFocus.forInline(event, this.getPluginState().activeOverlayType, this.el) + } + + // ___Why do we listen to the 'paste' event on a document instead of onPaste={props.handlePaste} prop, + // or this.el.addEventListener('paste')? + // Because (at least) Chrome doesn't handle paste if focus is on some button, e.g. 'My Device'. + // => Therefore, the best option is to listen to all 'paste' events, and only react to them when we are focused on our + // particular Uppy instance. + // ___Why do we still need onPaste={props.handlePaste} for the DashboardUi? + // Because if we click on the 'Drop files here' caption e.g., `document.activeElement` will be 'body'. Which means our + // standard determination of whether we're pasting into our Uppy instance won't work. + // => Therefore, we need a traditional onPaste={props.handlePaste} handler too. + handlePasteOnBody = (event) => { + const isFocusInOverlay = this.el.contains(document.activeElement) + if (isFocusInOverlay) { + this.handlePaste(event) + } + } + + handleComplete = ({ failed }) => { + if (this.opts.closeAfterFinish && failed.length === 0) { + // All uploads are done + this.requestCloseModal() + } + } + + handleCancelRestore = () => { + this.uppy.emit('restore-canceled') + } + + #openFileEditorWhenFilesAdded = (files) => { + const firstFile = files[0] + if (this.canEditFile(firstFile)) { + this.openFileEditor(firstFile) + } + } + + initEvents = () => { + // Modal open button + if (this.opts.trigger && !this.opts.inline) { + const showModalTrigger = findAllDOMElements(this.opts.trigger) + if (showModalTrigger) { + showModalTrigger.forEach(trigger => trigger.addEventListener('click', this.openModal)) + } else { + this.uppy.log('Dashboard modal trigger not found. Make sure `trigger` is set in Dashboard options, unless you are planning to call `dashboard.openModal()` method yourself', 'warning') + } + } + + this.startListeningToResize() + document.addEventListener('paste', this.handlePasteOnBody) + + this.uppy.on('plugin-remove', this.removeTarget) + this.uppy.on('file-added', this.hideAllPanels) + this.uppy.on('dashboard:modal-closed', this.hideAllPanels) + this.uppy.on('file-editor:complete', this.hideAllPanels) + this.uppy.on('complete', this.handleComplete) + + // ___Why fire on capture? + // Because this.ifFocusedOnUppyRecently needs to change before onUpdate() fires. + document.addEventListener('focus', this.recordIfFocusedOnUppyRecently, true) + document.addEventListener('click', this.recordIfFocusedOnUppyRecently, true) + + if (this.opts.inline) { + this.el.addEventListener('keydown', this.handleKeyDownInInline) + } + + if (this.opts.autoOpenFileEditor) { + this.uppy.on('files-added', this.#openFileEditorWhenFilesAdded) + } + } + + removeEvents = () => { + const showModalTrigger = findAllDOMElements(this.opts.trigger) + if (!this.opts.inline && showModalTrigger) { + showModalTrigger.forEach(trigger => trigger.removeEventListener('click', this.openModal)) + } + + this.stopListeningToResize() + document.removeEventListener('paste', this.handlePasteOnBody) + + window.removeEventListener('popstate', this.handlePopState, false) + this.uppy.off('plugin-remove', this.removeTarget) + this.uppy.off('file-added', this.hideAllPanels) + this.uppy.off('dashboard:modal-closed', this.hideAllPanels) + this.uppy.off('file-editor:complete', this.hideAllPanels) + this.uppy.off('complete', this.handleComplete) + + document.removeEventListener('focus', this.recordIfFocusedOnUppyRecently) + document.removeEventListener('click', this.recordIfFocusedOnUppyRecently) + + if (this.opts.inline) { + this.el.removeEventListener('keydown', this.handleKeyDownInInline) + } + + if (this.opts.autoOpenFileEditor) { + this.uppy.off('files-added', this.#openFileEditorWhenFilesAdded) + } + } + + superFocusOnEachUpdate = () => { + const isFocusInUppy = this.el.contains(document.activeElement) + // When focus is lost on the page (== focus is on body for most browsers, or focus is null for IE11) + const isFocusNowhere = document.activeElement === document.body || document.activeElement === null + const isInformerHidden = this.uppy.getState().info.length === 0 + const isModal = !this.opts.inline + + if ( + // If update is connected to showing the Informer - let the screen reader calmly read it. + isInformerHidden + && ( + // If we are in a modal - always superfocus without concern for other elements + // on the page (user is unlikely to want to interact with the rest of the page) + isModal + // If we are already inside of Uppy, or + || isFocusInUppy + // If we are not focused on anything BUT we have already, at least once, focused on uppy + // 1. We focus when isFocusNowhere, because when the element we were focused + // on disappears (e.g. an overlay), - focus gets lost. If user is typing + // something somewhere else on the page, - focus won't be 'nowhere'. + // 2. We only focus when focus is nowhere AND this.ifFocusedOnUppyRecently, + // to avoid focus jumps if we do something else on the page. + // [Practical check] Without '&& this.ifFocusedOnUppyRecently', in Safari, in inline mode, + // when file is uploading, - navigate via tab to the checkbox, + // try to press space multiple times. Focus will jump to Uppy. + || (isFocusNowhere && this.ifFocusedOnUppyRecently) + ) + ) { + this.superFocus(this.el, this.getPluginState().activeOverlayType) + } else { + this.superFocus.cancel() + } + } + + afterUpdate = () => { + if (this.opts.disabled && !this.dashboardIsDisabled) { + this.disableAllFocusableElements(true) + return + } + + if (!this.opts.disabled && this.dashboardIsDisabled) { + this.disableAllFocusableElements(false) + } + + this.superFocusOnEachUpdate() + } + + saveFileCard = (meta, fileID) => { + this.uppy.setFileMeta(fileID, meta) + this.toggleFileCard(false, fileID) + } + + #attachRenderFunctionToTarget = (target) => { + const plugin = this.uppy.getPlugin(target.id) + return { + ...target, + icon: plugin.icon || this.opts.defaultPickerIcon, + render: plugin.render, + } + } + + #isTargetSupported = (target) => { + const plugin = this.uppy.getPlugin(target.id) + // If the plugin does not provide a `supported` check, assume the plugin works everywhere. + if (typeof plugin.isSupported !== 'function') { + return true + } + return plugin.isSupported() + } + + #getAcquirers = memoize((targets) => { + return targets + .filter(target => target.type === 'acquirer' && this.#isTargetSupported(target)) + .map(this.#attachRenderFunctionToTarget) + }) + + #getProgressIndicators = memoize((targets) => { + return targets + .filter(target => target.type === 'progressindicator') + .map(this.#attachRenderFunctionToTarget) + }) + + #getEditors = memoize((targets) => { + return targets + .filter(target => target.type === 'editor') + .map(this.#attachRenderFunctionToTarget) + }) + + render = (state) => { + const pluginState = this.getPluginState() + const { files, capabilities, allowNewUpload } = state + const { + newFiles, + uploadStartedFiles, + completeFiles, + erroredFiles, + inProgressFiles, + inProgressNotPausedFiles, + processingFiles, + + isUploadStarted, + isAllComplete, + isAllErrored, + isAllPaused, + } = this.uppy.getObjectOfFilesPerState() + + const acquirers = this.#getAcquirers(pluginState.targets) + const progressindicators = this.#getProgressIndicators(pluginState.targets) + const editors = this.#getEditors(pluginState.targets) + + let theme + if (this.opts.theme === 'auto') { + theme = capabilities.darkMode ? 'dark' : 'light' + } else { + theme = this.opts.theme + } + + if (['files', 'folders', 'both'].indexOf(this.opts.fileManagerSelectionType) < 0) { + this.opts.fileManagerSelectionType = 'files' + // eslint-disable-next-line no-console + console.warn(`Unsupported option for "fileManagerSelectionType". Using default of "${this.opts.fileManagerSelectionType}".`) + } + + return DashboardUI({ + state, + isHidden: pluginState.isHidden, + files, + newFiles, + uploadStartedFiles, + completeFiles, + erroredFiles, + inProgressFiles, + inProgressNotPausedFiles, + processingFiles, + isUploadStarted, + isAllComplete, + isAllErrored, + isAllPaused, + totalFileCount: Object.keys(files).length, + totalProgress: state.totalProgress, + allowNewUpload, + acquirers, + theme, + disabled: this.opts.disabled, + disableLocalFiles: this.opts.disableLocalFiles, + direction: this.opts.direction, + activePickerPanel: pluginState.activePickerPanel, + showFileEditor: pluginState.showFileEditor, + saveFileEditor: this.saveFileEditor, + disableAllFocusableElements: this.disableAllFocusableElements, + animateOpenClose: this.opts.animateOpenClose, + isClosing: pluginState.isClosing, + progressindicators, + editors, + autoProceed: this.uppy.opts.autoProceed, + id: this.id, + closeModal: this.requestCloseModal, + handleClickOutside: this.handleClickOutside, + handleInputChange: this.handleInputChange, + handlePaste: this.handlePaste, + inline: this.opts.inline, + showPanel: this.showPanel, + hideAllPanels: this.hideAllPanels, + i18n: this.i18n, + i18nArray: this.i18nArray, + uppy: this.uppy, + note: this.opts.note, + recoveredState: state.recoveredState, + metaFields: pluginState.metaFields, + resumableUploads: capabilities.resumableUploads || false, + individualCancellation: capabilities.individualCancellation, + isMobileDevice: capabilities.isMobileDevice, + fileCardFor: pluginState.fileCardFor, + toggleFileCard: this.toggleFileCard, + toggleAddFilesPanel: this.toggleAddFilesPanel, + showAddFilesPanel: pluginState.showAddFilesPanel, + saveFileCard: this.saveFileCard, + openFileEditor: this.openFileEditor, + canEditFile: this.canEditFile, + width: this.opts.width, + height: this.opts.height, + showLinkToFileUploadResult: this.opts.showLinkToFileUploadResult, + fileManagerSelectionType: this.opts.fileManagerSelectionType, + proudlyDisplayPoweredByUppy: this.opts.proudlyDisplayPoweredByUppy, + hideCancelButton: this.opts.hideCancelButton, + hideRetryButton: this.opts.hideRetryButton, + hidePauseResumeButton: this.opts.hidePauseResumeButton, + showRemoveButtonAfterComplete: this.opts.showRemoveButtonAfterComplete, + containerWidth: pluginState.containerWidth, + containerHeight: pluginState.containerHeight, + areInsidesReadyToBeVisible: pluginState.areInsidesReadyToBeVisible, + isTargetDOMEl: this.isTargetDOMEl, + parentElement: this.el, + allowedFileTypes: this.uppy.opts.restrictions.allowedFileTypes, + maxNumberOfFiles: this.uppy.opts.restrictions.maxNumberOfFiles, + requiredMetaFields: this.uppy.opts.restrictions.requiredMetaFields, + showSelectedFiles: this.opts.showSelectedFiles, + handleCancelRestore: this.handleCancelRestore, + handleRequestThumbnail: this.handleRequestThumbnail, + handleCancelThumbnail: this.handleCancelThumbnail, + // drag props + isDraggingOver: pluginState.isDraggingOver, + handleDragOver: this.handleDragOver, + handleDragLeave: this.handleDragLeave, + handleDrop: this.handleDrop, + }) + } + + discoverProviderPlugins = () => { + this.uppy.iteratePlugins((plugin) => { + if (plugin && !plugin.target && plugin.opts && plugin.opts.target === this.constructor) { + this.addTarget(plugin) + } + }) + } + + install = () => { + // Set default state for Dashboard + this.setPluginState({ + isHidden: true, + fileCardFor: null, + activeOverlayType: null, + showAddFilesPanel: false, + activePickerPanel: false, + showFileEditor: false, + metaFields: this.opts.metaFields, + targets: [], + // We'll make them visible once .containerWidth is determined + areInsidesReadyToBeVisible: false, + isDraggingOver: false, + }) + + const { inline, closeAfterFinish } = this.opts + if (inline && closeAfterFinish) { + throw new Error('[Dashboard] `closeAfterFinish: true` cannot be used on an inline Dashboard, because an inline Dashboard cannot be closed at all. Either set `inline: false`, or disable the `closeAfterFinish` option.') + } + + const { allowMultipleUploads, allowMultipleUploadBatches } = this.uppy.opts + if ((allowMultipleUploads || allowMultipleUploadBatches) && closeAfterFinish) { + this.uppy.log('[Dashboard] When using `closeAfterFinish`, we recommended setting the `allowMultipleUploadBatches` option to `false` in the Uppy constructor. See https://uppy.io/docs/uppy/#allowMultipleUploads-true', 'warning') + } + + const { target } = this.opts + + if (target) { + this.mount(target, this) + } + + const plugins = this.opts.plugins || [] + + plugins.forEach((pluginID) => { + const plugin = this.uppy.getPlugin(pluginID) + if (plugin) { + plugin.mount(this, plugin) + } + }) + + if (!this.opts.disableStatusBar) { + this.uppy.use(StatusBar, { + id: `${this.id}:StatusBar`, + target: this, + hideUploadButton: this.opts.hideUploadButton, + hideRetryButton: this.opts.hideRetryButton, + hidePauseResumeButton: this.opts.hidePauseResumeButton, + hideCancelButton: this.opts.hideCancelButton, + showProgressDetails: this.opts.showProgressDetails, + hideAfterFinish: this.opts.hideProgressAfterFinish, + locale: this.opts.locale, + doneButtonHandler: this.opts.doneButtonHandler, + }) + } + + if (!this.opts.disableInformer) { + this.uppy.use(Informer, { + id: `${this.id}:Informer`, + target: this, + }) + } + + if (!this.opts.disableThumbnailGenerator) { + this.uppy.use(ThumbnailGenerator, { + id: `${this.id}:ThumbnailGenerator`, + thumbnailWidth: this.opts.thumbnailWidth, + thumbnailHeight: this.opts.thumbnailHeight, + thumbnailType: this.opts.thumbnailType, + waitForThumbnailsBeforeUpload: this.opts.waitForThumbnailsBeforeUpload, + // If we don't block on thumbnails, we can lazily generate them + lazy: !this.opts.waitForThumbnailsBeforeUpload, + }) + } + + // Dark Mode / theme + this.darkModeMediaQuery = (typeof window !== 'undefined' && window.matchMedia) + ? window.matchMedia('(prefers-color-scheme: dark)') + : null + + const isDarkModeOnFromTheStart = this.darkModeMediaQuery ? this.darkModeMediaQuery.matches : false + this.uppy.log(`[Dashboard] Dark mode is ${isDarkModeOnFromTheStart ? 'on' : 'off'}`) + this.setDarkModeCapability(isDarkModeOnFromTheStart) + + if (this.opts.theme === 'auto') { + this.darkModeMediaQuery.addListener(this.handleSystemDarkModeChange) + } + + this.discoverProviderPlugins() + this.initEvents() + } + + uninstall = () => { + if (!this.opts.disableInformer) { + const informer = this.uppy.getPlugin(`${this.id}:Informer`) + // Checking if this plugin exists, in case it was removed by uppy-core + // before the Dashboard was. + if (informer) this.uppy.removePlugin(informer) + } + + if (!this.opts.disableStatusBar) { + const statusBar = this.uppy.getPlugin(`${this.id}:StatusBar`) + if (statusBar) this.uppy.removePlugin(statusBar) + } + + if (!this.opts.disableThumbnailGenerator) { + const thumbnail = this.uppy.getPlugin(`${this.id}:ThumbnailGenerator`) + if (thumbnail) this.uppy.removePlugin(thumbnail) + } + + const plugins = this.opts.plugins || [] + plugins.forEach((pluginID) => { + const plugin = this.uppy.getPlugin(pluginID) + if (plugin) plugin.unmount() + }) + + if (this.opts.theme === 'auto') { + this.darkModeMediaQuery.removeListener(this.handleSystemDarkModeChange) + } + + this.unmount() + this.removeEvents() + } +} diff --git a/packages/@uppy/dashboard/src/components/AddFiles.js b/packages/@uppy/dashboard/src/components/AddFiles.jsx similarity index 98% rename from packages/@uppy/dashboard/src/components/AddFiles.js rename to packages/@uppy/dashboard/src/components/AddFiles.jsx index 425d5652fb..44a7160100 100644 --- a/packages/@uppy/dashboard/src/components/AddFiles.js +++ b/packages/@uppy/dashboard/src/components/AddFiles.jsx @@ -1,4 +1,4 @@ -const { h, Component } = require('preact') +import { h, Component } from 'preact' class AddFiles extends Component { triggerFileInputClick = () => { @@ -18,7 +18,7 @@ class AddFiles extends Component { // ___Why not use value="" on instead? // Because if we use that method of clearing the input, // Chrome will not trigger change if we drop the same file twice (Issue #768). - event.target.value = null + event.target.value = null // eslint-disable-line no-param-reassign } renderHiddenInput = (isFolder, refCallback) => { @@ -198,4 +198,4 @@ class AddFiles extends Component { } } -module.exports = AddFiles +export default AddFiles diff --git a/packages/@uppy/dashboard/src/components/AddFilesPanel.js b/packages/@uppy/dashboard/src/components/AddFilesPanel.jsx similarity index 77% rename from packages/@uppy/dashboard/src/components/AddFilesPanel.js rename to packages/@uppy/dashboard/src/components/AddFilesPanel.jsx index 0f2b2e25a2..0a37e35584 100644 --- a/packages/@uppy/dashboard/src/components/AddFilesPanel.js +++ b/packages/@uppy/dashboard/src/components/AddFilesPanel.jsx @@ -1,6 +1,6 @@ -const { h } = require('preact') -const classNames = require('classnames') -const AddFiles = require('./AddFiles') +import { h } from 'preact' +import classNames from 'classnames' +import AddFiles from './AddFiles.jsx' const AddFilesPanel = (props) => { return ( @@ -21,9 +21,10 @@ const AddFilesPanel = (props) => { {props.i18n('back')} + {/* eslint-disable-next-line react/jsx-props-no-spreading */} ) } -module.exports = AddFilesPanel +export default AddFilesPanel diff --git a/packages/@uppy/dashboard/src/components/Dashboard.js b/packages/@uppy/dashboard/src/components/Dashboard.jsx similarity index 84% rename from packages/@uppy/dashboard/src/components/Dashboard.js rename to packages/@uppy/dashboard/src/components/Dashboard.jsx index 243bd0f241..7a3a1420a5 100644 --- a/packages/@uppy/dashboard/src/components/Dashboard.js +++ b/packages/@uppy/dashboard/src/components/Dashboard.jsx @@ -1,14 +1,14 @@ -const { h } = require('preact') -const classNames = require('classnames') -const isDragDropSupported = require('@uppy/utils/lib/isDragDropSupported') -const FileList = require('./FileList') -const AddFiles = require('./AddFiles') -const AddFilesPanel = require('./AddFilesPanel') -const PickerPanelContent = require('./PickerPanelContent') -const EditorPanel = require('./EditorPanel') -const PanelTopBar = require('./PickerPanelTopBar') -const FileCard = require('./FileCard') -const Slide = require('./Slide') +import { h } from 'preact' +import classNames from 'classnames' +import isDragDropSupported from '@uppy/utils/lib/isDragDropSupported' +import FileList from './FileList.jsx' +import AddFiles from './AddFiles.jsx' +import AddFilesPanel from './AddFilesPanel.jsx' +import PickerPanelContent from './PickerPanelContent.jsx' +import EditorPanel from './EditorPanel.jsx' +import PanelTopBar from './PickerPanelTopBar.jsx' +import FileCard from './FileCard/index.jsx' +import Slide from './Slide.jsx' // http://dev.edenspiekermann.com/2016/02/11/introducing-accessible-modal-dialog // https://github.com/ghosh/micromodal @@ -18,7 +18,7 @@ const WIDTH_LG = 700 const WIDTH_MD = 576 const HEIGHT_MD = 400 -module.exports = function Dashboard (props) { +export default function Dashboard (props) { const noFiles = props.totalFileCount === 0 const isSizeMD = props.containerWidth > WIDTH_MD @@ -110,6 +110,7 @@ module.exports = function Dashboard (props) { {props.i18n('dropHint')} + {/* eslint-disable-next-line react/jsx-props-no-spreading */} {showFileList && } {numberOfFilesForRecovery && ( @@ -132,26 +133,32 @@ module.exports = function Dashboard (props) { {showFileList ? ( ) : ( + // eslint-disable-next-line react/jsx-props-no-spreading )} + {/* eslint-disable-next-line react/jsx-props-no-spreading */} {props.showAddFilesPanel ? : null} + {/* eslint-disable-next-line react/jsx-props-no-spreading */} {props.fileCardFor ? : null} + {/* eslint-disable-next-line react/jsx-props-no-spreading */} {props.activePickerPanel ? : null} + {/* eslint-disable-next-line react/jsx-props-no-spreading */} {props.showFileEditor ? : null} diff --git a/packages/@uppy/dashboard/src/components/EditorPanel.js b/packages/@uppy/dashboard/src/components/EditorPanel.jsx similarity index 92% rename from packages/@uppy/dashboard/src/components/EditorPanel.js rename to packages/@uppy/dashboard/src/components/EditorPanel.jsx index 28930278a2..92529c69d4 100644 --- a/packages/@uppy/dashboard/src/components/EditorPanel.js +++ b/packages/@uppy/dashboard/src/components/EditorPanel.jsx @@ -1,5 +1,5 @@ -const { h } = require('preact') -const classNames = require('classnames') +import { h } from 'preact' +import classNames from 'classnames' function EditorPanel (props) { const file = props.files[props.fileCardFor] @@ -41,4 +41,4 @@ function EditorPanel (props) { ) } -module.exports = EditorPanel +export default EditorPanel diff --git a/packages/@uppy/dashboard/src/components/FileCard/index.js b/packages/@uppy/dashboard/src/components/FileCard/index.jsx similarity index 95% rename from packages/@uppy/dashboard/src/components/FileCard/index.js rename to packages/@uppy/dashboard/src/components/FileCard/index.jsx index 5aa025fe5a..481b71a8c8 100644 --- a/packages/@uppy/dashboard/src/components/FileCard/index.js +++ b/packages/@uppy/dashboard/src/components/FileCard/index.jsx @@ -1,9 +1,9 @@ -const { h, Component } = require('preact') -const classNames = require('classnames') -const { nanoid } = require('nanoid/non-secure') -const getFileTypeIcon = require('../../utils/getFileTypeIcon') -const ignoreEvent = require('../../utils/ignoreEvent.js') -const FilePreview = require('../FilePreview') +import { h, Component } from 'preact' +import classNames from 'classnames' +import { nanoid } from 'nanoid/non-secure' +import getFileTypeIcon from '../../utils/getFileTypeIcon.jsx' +import ignoreEvent from '../../utils/ignoreEvent.js' +import FilePreview from '../FilePreview.jsx' class FileCard extends Component { form = document.createElement('form') @@ -197,4 +197,4 @@ class FileCard extends Component { } } -module.exports = FileCard +export default FileCard diff --git a/packages/@uppy/dashboard/src/components/FileItem/Buttons/index.js b/packages/@uppy/dashboard/src/components/FileItem/Buttons/index.jsx similarity index 97% rename from packages/@uppy/dashboard/src/components/FileItem/Buttons/index.js rename to packages/@uppy/dashboard/src/components/FileItem/Buttons/index.jsx index 25248db196..a609e50193 100644 --- a/packages/@uppy/dashboard/src/components/FileItem/Buttons/index.js +++ b/packages/@uppy/dashboard/src/components/FileItem/Buttons/index.jsx @@ -1,5 +1,5 @@ -const { h } = require('preact') -const copyToClipboard = require('../../../utils/copyToClipboard') +import { h } from 'preact' +import copyToClipboard from '../../../utils/copyToClipboard.js' function EditButton ({ file, @@ -80,7 +80,7 @@ function CopyLinkButton (props) { ) } -module.exports = function Buttons (props) { +export default function Buttons (props) { const { uppy, file, diff --git a/packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.js b/packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.jsx similarity index 91% rename from packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.js rename to packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.jsx index 91650ee349..d1232ef7e1 100644 --- a/packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.js +++ b/packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.jsx @@ -1,7 +1,7 @@ -const { h, Fragment } = require('preact') -const prettierBytes = require('@transloadit/prettier-bytes') -const truncateString = require('@uppy/utils/lib/truncateString') -const MetaErrorMessage = require('../MetaErrorMessage') +import { h, Fragment } from 'preact' +import prettierBytes from '@transloadit/prettier-bytes' +import truncateString from '@uppy/utils/lib/truncateString' +import MetaErrorMessage from '../MetaErrorMessage.jsx' const renderFileName = (props) => { const { author, name } = props.file.meta @@ -92,7 +92,7 @@ const ErrorButton = ({ file, onClick }) => { return null } -module.exports = function FileInfo (props) { +export default function FileInfo (props) { const { file } = props return (
-
- {props.i18n('importFrom', { name: props.activePickerPanel.name })} -
- -
-
- {props.uppy.getPlugin(props.activePickerPanel.id).render(props.state)} -
-
- ) -} - -module.exports = PickerPanelContent diff --git a/packages/@uppy/dashboard/src/components/PickerPanelContent.jsx b/packages/@uppy/dashboard/src/components/PickerPanelContent.jsx new file mode 100644 index 0000000000..2bbd95fd23 --- /dev/null +++ b/packages/@uppy/dashboard/src/components/PickerPanelContent.jsx @@ -0,0 +1,36 @@ +import { h } from 'preact' +import classNames from 'classnames' +import ignoreEvent from '../utils/ignoreEvent.js' + +function PickerPanelContent ({ activePickerPanel, className, hideAllPanels, i18n, state, uppy }) { + return ( +
+
+
+ {i18n('importFrom', { name: activePickerPanel.name })} +
+ +
+
+ {uppy.getPlugin(activePickerPanel.id).render(state)} +
+
+ ) +} + +export default PickerPanelContent diff --git a/packages/@uppy/dashboard/src/components/PickerPanelTopBar.js b/packages/@uppy/dashboard/src/components/PickerPanelTopBar.jsx similarity index 69% rename from packages/@uppy/dashboard/src/components/PickerPanelTopBar.js rename to packages/@uppy/dashboard/src/components/PickerPanelTopBar.jsx index 8fdd9cad81..e166ada57c 100644 --- a/packages/@uppy/dashboard/src/components/PickerPanelTopBar.js +++ b/packages/@uppy/dashboard/src/components/PickerPanelTopBar.jsx @@ -1,4 +1,4 @@ -const { h } = require('preact') +import { h } from 'preact' const uploadStates = { STATE_ERROR: 'error', @@ -45,51 +45,58 @@ function getUploadingState (isAllErrored, isAllComplete, isAllPaused, files = {} return state } -function UploadStatus (props) { +function UploadStatus ({ + files, i18n, isAllComplete, isAllErrored, isAllPaused, + inProgressNotPausedFiles, newFiles, processingFiles, +}) { const uploadingState = getUploadingState( - props.isAllErrored, - props.isAllComplete, - props.isAllPaused, - props.files, + isAllErrored, + isAllComplete, + isAllPaused, + files, ) switch (uploadingState) { case 'uploading': - return props.i18n('uploadingXFiles', { smart_count: props.inProgressNotPausedFiles.length }) + return i18n('uploadingXFiles', { smart_count: inProgressNotPausedFiles.length }) case 'preprocessing': case 'postprocessing': - return props.i18n('processingXFiles', { smart_count: props.processingFiles.length }) + return i18n('processingXFiles', { smart_count: processingFiles.length }) case 'paused': - return props.i18n('uploadPaused') + return i18n('uploadPaused') case 'waiting': - return props.i18n('xFilesSelected', { smart_count: props.newFiles.length }) + return i18n('xFilesSelected', { smart_count: newFiles.length }) case 'complete': - return props.i18n('uploadComplete') + return i18n('uploadComplete') + default: } } function PanelTopBar (props) { + const { i18n, isAllComplete, hideCancelButton, maxNumberOfFiles, toggleAddFilesPanel, uppy } = props let { allowNewUpload } = props - // TODO maybe this should be done in ../index.js, then just pass that down as `allowNewUpload` - if (allowNewUpload && props.maxNumberOfFiles) { + // TODO maybe this should be done in ../Dashboard.jsx, then just pass that down as `allowNewUpload` + if (allowNewUpload && maxNumberOfFiles) { + // eslint-disable-next-line react/destructuring-assignment allowNewUpload = props.totalFileCount < props.maxNumberOfFiles } return (
- {!props.isAllComplete && !props.hideCancelButton ? ( + {!isAllComplete && !hideCancelButton ? ( ) : (
)}
+ {/* eslint-disable-next-line react/jsx-props-no-spreading */}
@@ -97,14 +104,14 @@ function PanelTopBar (props) { ) : (
@@ -113,4 +120,4 @@ function PanelTopBar (props) { ) } -module.exports = PanelTopBar +export default PanelTopBar diff --git a/packages/@uppy/dashboard/src/components/Slide.js b/packages/@uppy/dashboard/src/components/Slide.jsx similarity index 94% rename from packages/@uppy/dashboard/src/components/Slide.js rename to packages/@uppy/dashboard/src/components/Slide.jsx index eeef7b096f..2b6dde38d4 100644 --- a/packages/@uppy/dashboard/src/components/Slide.js +++ b/packages/@uppy/dashboard/src/components/Slide.jsx @@ -1,5 +1,5 @@ -const { cloneElement, Component, toChildArray } = require('preact') -const classNames = require('classnames') +import { cloneElement, Component, toChildArray } from 'preact' +import classNames from 'classnames' const transitionName = 'uppy-transition-slideDownUp' const duration = 250 @@ -96,4 +96,4 @@ class Slide extends Component { } } -module.exports = Slide +export default Slide diff --git a/packages/@uppy/dashboard/src/components/VirtualList.js b/packages/@uppy/dashboard/src/components/VirtualList.jsx similarity index 97% rename from packages/@uppy/dashboard/src/components/VirtualList.js rename to packages/@uppy/dashboard/src/components/VirtualList.jsx index c6dbf2f110..0be5fe96b7 100644 --- a/packages/@uppy/dashboard/src/components/VirtualList.js +++ b/packages/@uppy/dashboard/src/components/VirtualList.jsx @@ -26,7 +26,7 @@ * - Tweaked styles for Uppy's Dashboard use case */ -const { h, Component } = require('preact') +import { h, Component } from 'preact' const STYLE_INNER = { position: 'relative', @@ -143,6 +143,7 @@ class VirtualList extends Component { // The `role="presentation"` attributes ensure that these wrapper elements are not treated as list // items by accessibility and outline tools. return ( + // eslint-disable-next-line react/jsx-props-no-spreading
@@ -154,4 +155,4 @@ class VirtualList extends Component { } } -module.exports = VirtualList +export default VirtualList diff --git a/packages/@uppy/dashboard/src/index.js b/packages/@uppy/dashboard/src/index.js index 8ef780a078..6c74a32596 100644 --- a/packages/@uppy/dashboard/src/index.js +++ b/packages/@uppy/dashboard/src/index.js @@ -1,1109 +1 @@ -const { h } = require('preact') -const { UIPlugin } = require('@uppy/core') -const StatusBar = require('@uppy/status-bar') -const Informer = require('@uppy/informer') -const ThumbnailGenerator = require('@uppy/thumbnail-generator') -const findAllDOMElements = require('@uppy/utils/lib/findAllDOMElements') -const toArray = require('@uppy/utils/lib/toArray') -const getDroppedFiles = require('@uppy/utils/lib/getDroppedFiles') -const { nanoid } = require('nanoid/non-secure') -const trapFocus = require('./utils/trapFocus') -const createSuperFocus = require('./utils/createSuperFocus') -const memoize = require('memoize-one').default || require('memoize-one') -const FOCUSABLE_ELEMENTS = require('@uppy/utils/lib/FOCUSABLE_ELEMENTS') -const DashboardUI = require('./components/Dashboard') - -const locale = require('./locale') - -const TAB_KEY = 9 -const ESC_KEY = 27 - -function createPromise () { - const o = {} - o.promise = new Promise((resolve, reject) => { - o.resolve = resolve - o.reject = reject - }) - return o -} - -function defaultPickerIcon () { - return ( - - ) -} - -/** - * Dashboard UI with previews, metadata editing, tabs for various services and more - */ -module.exports = class Dashboard extends UIPlugin { - static VERSION = require('../package.json').version - - constructor (uppy, opts) { - super(uppy, opts) - this.id = this.opts.id || 'Dashboard' - this.title = 'Dashboard' - this.type = 'orchestrator' - this.modalName = `uppy-Dashboard-${nanoid()}` - - this.defaultLocale = locale - - // set default options - const defaultOptions = { - target: 'body', - metaFields: [], - trigger: null, - inline: false, - width: 750, - height: 550, - thumbnailWidth: 280, - thumbnailType: 'image/jpeg', - waitForThumbnailsBeforeUpload: false, - defaultPickerIcon, - showLinkToFileUploadResult: false, - showProgressDetails: false, - hideUploadButton: false, - hideCancelButton: false, - hideRetryButton: false, - hidePauseResumeButton: false, - hideProgressAfterFinish: false, - doneButtonHandler: () => { - this.uppy.reset() - this.requestCloseModal() - }, - note: null, - closeModalOnClickOutside: false, - closeAfterFinish: false, - disableStatusBar: false, - disableInformer: false, - disableThumbnailGenerator: false, - disablePageScrollWhenModalOpen: true, - animateOpenClose: true, - fileManagerSelectionType: 'files', - proudlyDisplayPoweredByUppy: true, - onRequestCloseModal: () => this.closeModal(), - showSelectedFiles: true, - showRemoveButtonAfterComplete: false, - browserBackButtonClose: false, - theme: 'light', - autoOpenFileEditor: false, - disabled: false, - disableLocalFiles: false, - } - - // merge default options with the ones set by user - this.opts = { ...defaultOptions, ...opts } - - this.i18nInit() - - this.superFocus = createSuperFocus() - this.ifFocusedOnUppyRecently = false - - // Timeouts - this.makeDashboardInsidesVisibleAnywayTimeout = null - this.removeDragOverClassTimeout = null - } - - removeTarget = (plugin) => { - const pluginState = this.getPluginState() - // filter out the one we want to remove - const newTargets = pluginState.targets.filter(target => target.id !== plugin.id) - - this.setPluginState({ - targets: newTargets, - }) - } - - addTarget = (plugin) => { - const callerPluginId = plugin.id || plugin.constructor.name - const callerPluginName = plugin.title || callerPluginId - const callerPluginType = plugin.type - - if (callerPluginType !== 'acquirer' - && callerPluginType !== 'progressindicator' - && callerPluginType !== 'editor') { - const msg = 'Dashboard: can only be targeted by plugins of types: acquirer, progressindicator, editor' - this.uppy.log(msg, 'error') - return - } - - const target = { - id: callerPluginId, - name: callerPluginName, - type: callerPluginType, - } - - const state = this.getPluginState() - const newTargets = state.targets.slice() - newTargets.push(target) - - this.setPluginState({ - targets: newTargets, - }) - - return this.el - } - - hideAllPanels = () => { - const state = this.getPluginState() - const update = { - activePickerPanel: false, - showAddFilesPanel: false, - activeOverlayType: null, - fileCardFor: null, - showFileEditor: false, - } - - if (state.activePickerPanel === update.activePickerPanel - && state.showAddFilesPanel === update.showAddFilesPanel - && state.showFileEditor === update.showFileEditor - && state.activeOverlayType === update.activeOverlayType) { - // avoid doing a state update if nothing changed - return - } - - this.setPluginState(update) - } - - showPanel = (id) => { - const { targets } = this.getPluginState() - - const activePickerPanel = targets.filter((target) => { - return target.type === 'acquirer' && target.id === id - })[0] - - this.setPluginState({ - activePickerPanel, - activeOverlayType: 'PickerPanel', - }) - } - - canEditFile = (file) => { - const { targets } = this.getPluginState() - const editors = this.#getEditors(targets) - - return editors.some((target) => ( - this.uppy.getPlugin(target.id).canEditFile(file) - )) - } - - openFileEditor = (file) => { - const { targets } = this.getPluginState() - const editors = this.#getEditors(targets) - - this.setPluginState({ - showFileEditor: true, - fileCardFor: file.id || null, - activeOverlayType: 'FileEditor', - }) - - editors.forEach((editor) => { - this.uppy.getPlugin(editor.id).selectFile(file) - }) - } - - saveFileEditor = () => { - const { targets } = this.getPluginState() - const editors = this.#getEditors(targets) - - editors.forEach((editor) => { - this.uppy.getPlugin(editor.id).save() - }) - - this.hideAllPanels() - } - - openModal = () => { - const { promise, resolve } = createPromise() - // save scroll position - this.savedScrollPosition = window.pageYOffset - // save active element, so we can restore focus when modal is closed - this.savedActiveElement = document.activeElement - - if (this.opts.disablePageScrollWhenModalOpen) { - document.body.classList.add('uppy-Dashboard-isFixed') - } - - if (this.opts.animateOpenClose && this.getPluginState().isClosing) { - const handler = () => { - this.setPluginState({ - isHidden: false, - }) - this.el.removeEventListener('animationend', handler, false) - resolve() - } - this.el.addEventListener('animationend', handler, false) - } else { - this.setPluginState({ - isHidden: false, - }) - resolve() - } - - if (this.opts.browserBackButtonClose) { - this.updateBrowserHistory() - } - - // handle ESC and TAB keys in modal dialog - document.addEventListener('keydown', this.handleKeyDownInModal) - - this.uppy.emit('dashboard:modal-open') - - return promise - } - - closeModal = (opts = {}) => { - const { - // Whether the modal is being closed by the user (`true`) or by other means (e.g. browser back button) - manualClose = true, - } = opts - - const { isHidden, isClosing } = this.getPluginState() - if (isHidden || isClosing) { - // short-circuit if animation is ongoing - return - } - - const { promise, resolve } = createPromise() - - if (this.opts.disablePageScrollWhenModalOpen) { - document.body.classList.remove('uppy-Dashboard-isFixed') - } - - if (this.opts.animateOpenClose) { - this.setPluginState({ - isClosing: true, - }) - const handler = () => { - this.setPluginState({ - isHidden: true, - isClosing: false, - }) - - this.superFocus.cancel() - this.savedActiveElement.focus() - - this.el.removeEventListener('animationend', handler, false) - resolve() - } - this.el.addEventListener('animationend', handler, false) - } else { - this.setPluginState({ - isHidden: true, - }) - - this.superFocus.cancel() - this.savedActiveElement.focus() - - resolve() - } - - // handle ESC and TAB keys in modal dialog - document.removeEventListener('keydown', this.handleKeyDownInModal) - - if (manualClose) { - if (this.opts.browserBackButtonClose) { - // Make sure that the latest entry in the history state is our modal name - // eslint-disable-next-line no-restricted-globals - if (history.state?.[this.modalName]) { - // Go back in history to clear out the entry we created (ultimately closing the modal) - // eslint-disable-next-line no-restricted-globals - history.back() - } - } - } - - this.uppy.emit('dashboard:modal-closed') - - return promise - } - - isModalOpen = () => { - return !this.getPluginState().isHidden || false - } - - requestCloseModal = () => { - if (this.opts.onRequestCloseModal) { - return this.opts.onRequestCloseModal() - } - return this.closeModal() - } - - setDarkModeCapability = (isDarkModeOn) => { - const { capabilities } = this.uppy.getState() - this.uppy.setState({ - capabilities: { - ...capabilities, - darkMode: isDarkModeOn, - }, - }) - } - - handleSystemDarkModeChange = (event) => { - const isDarkModeOnNow = event.matches - this.uppy.log(`[Dashboard] Dark mode is ${isDarkModeOnNow ? 'on' : 'off'}`) - this.setDarkModeCapability(isDarkModeOnNow) - } - - toggleFileCard = (show, fileID) => { - const file = this.uppy.getFile(fileID) - if (show) { - this.uppy.emit('dashboard:file-edit-start', file) - } else { - this.uppy.emit('dashboard:file-edit-complete', file) - } - - this.setPluginState({ - fileCardFor: show ? fileID : null, - activeOverlayType: show ? 'FileCard' : null, - }) - } - - toggleAddFilesPanel = (show) => { - this.setPluginState({ - showAddFilesPanel: show, - activeOverlayType: show ? 'AddFiles' : null, - }) - } - - addFiles = (files) => { - const descriptors = files.map((file) => ({ - source: this.id, - name: file.name, - type: file.type, - data: file, - meta: { - // path of the file relative to the ancestor directory the user selected. - // e.g. 'docs/Old Prague/airbnb.pdf' - relativePath: file.relativePath || null, - }, - })) - - try { - this.uppy.addFiles(descriptors) - } catch (err) { - this.uppy.log(err) - } - } - - // ___Why make insides of Dashboard invisible until first ResizeObserver event is emitted? - // ResizeOberserver doesn't emit the first resize event fast enough, users can see the jump from one .uppy-size-- to - // another (e.g. in Safari) - // ___Why not apply visibility property to .uppy-Dashboard-inner? - // Because ideally, acc to specs, ResizeObserver should see invisible elements as of width 0. So even though applying - // invisibility to .uppy-Dashboard-inner works now, it may not work in the future. - startListeningToResize = () => { - // Watch for Dashboard container (`.uppy-Dashboard-inner`) resize - // and update containerWidth/containerHeight in plugin state accordingly. - // Emits first event on initialization. - this.resizeObserver = new ResizeObserver((entries) => { - const uppyDashboardInnerEl = entries[0] - - const { width, height } = uppyDashboardInnerEl.contentRect - - this.uppy.log(`[Dashboard] resized: ${width} / ${height}`, 'debug') - - this.setPluginState({ - containerWidth: width, - containerHeight: height, - areInsidesReadyToBeVisible: true, - }) - }) - this.resizeObserver.observe(this.el.querySelector('.uppy-Dashboard-inner')) - - // If ResizeObserver fails to emit an event telling us what size to use - default to the mobile view - this.makeDashboardInsidesVisibleAnywayTimeout = setTimeout(() => { - const pluginState = this.getPluginState() - const isModalAndClosed = !this.opts.inline && pluginState.isHidden - if ( - // if ResizeObserver hasn't yet fired, - !pluginState.areInsidesReadyToBeVisible - // and it's not due to the modal being closed - && !isModalAndClosed - ) { - this.uppy.log("[Dashboard] resize event didn't fire on time: defaulted to mobile layout", 'debug') - - this.setPluginState({ - areInsidesReadyToBeVisible: true, - }) - } - }, 1000) - } - - stopListeningToResize = () => { - this.resizeObserver.disconnect() - - clearTimeout(this.makeDashboardInsidesVisibleAnywayTimeout) - } - - // Records whether we have been interacting with uppy right now, - // which is then used to determine whether state updates should trigger a refocusing. - recordIfFocusedOnUppyRecently = (event) => { - if (this.el.contains(event.target)) { - this.ifFocusedOnUppyRecently = true - } else { - this.ifFocusedOnUppyRecently = false - // ___Why run this.superFocus.cancel here when it already runs in superFocusOnEachUpdate? - // Because superFocus is debounced, when we move from Uppy to some other element on the page, - // previously run superFocus sometimes hits and moves focus back to Uppy. - this.superFocus.cancel() - } - } - - disableAllFocusableElements = (disable) => { - const focusableNodes = toArray(this.el.querySelectorAll(FOCUSABLE_ELEMENTS)) - if (disable) { - focusableNodes.forEach((node) => { - // save previous tabindex in a data-attribute, to restore when enabling - const currentTabIndex = node.getAttribute('tabindex') - if (currentTabIndex) { - node.dataset.inertTabindex = currentTabIndex - } - node.setAttribute('tabindex', '-1') - }) - } else { - focusableNodes.forEach((node) => { - if ('inertTabindex' in node.dataset) { - node.setAttribute('tabindex', node.dataset.inertTabindex) - } else { - node.removeAttribute('tabindex') - } - }) - } - this.dashboardIsDisabled = disable - } - - updateBrowserHistory = () => { - // Ensure history state does not already contain our modal name to avoid double-pushing - // eslint-disable-next-line no-restricted-globals - if (!history.state?.[this.modalName]) { - // Push to history so that the page is not lost on browser back button press - // eslint-disable-next-line no-restricted-globals - history.pushState({ - // eslint-disable-next-line no-restricted-globals - ...history.state, - [this.modalName]: true, - }, '') - } - - // Listen for back button presses - window.addEventListener('popstate', this.handlePopState, false) - } - - handlePopState = (event) => { - // Close the modal if the history state no longer contains our modal name - if (this.isModalOpen() && (!event.state || !event.state[this.modalName])) { - this.closeModal({ manualClose: false }) - } - - // When the browser back button is pressed and uppy is now the latest entry - // in the history but the modal is closed, fix the history by removing the - // uppy history entry. - // This occurs when another entry is added into the history state while the - // modal is open, and then the modal gets manually closed. - // Solves PR #575 (https://github.com/transloadit/uppy/pull/575) - if (!this.isModalOpen() && event.state?.[this.modalName]) { - // eslint-disable-next-line no-restricted-globals - history.back() - } - } - - handleKeyDownInModal = (event) => { - // close modal on esc key press - if (event.keyCode === ESC_KEY) this.requestCloseModal(event) - // trap focus on tab key press - if (event.keyCode === TAB_KEY) trapFocus.forModal(event, this.getPluginState().activeOverlayType, this.el) - } - - handleClickOutside = () => { - if (this.opts.closeModalOnClickOutside) this.requestCloseModal() - } - - handlePaste = (event) => { - // Let any acquirer plugin (Url/Webcam/etc.) handle pastes to the root - this.uppy.iteratePlugins((plugin) => { - if (plugin.type === 'acquirer') { - // Every Plugin with .type acquirer can define handleRootPaste(event) - plugin.handleRootPaste?.(event) - } - }) - - // Add all dropped files - const files = toArray(event.clipboardData.files) - if (files.length > 0) { - this.uppy.log('[Dashboard] Files pasted') - this.addFiles(files) - } - } - - handleInputChange = (event) => { - event.preventDefault() - const files = toArray(event.target.files) - if (files.length > 0) { - this.uppy.log('[Dashboard] Files selected through input') - this.addFiles(files) - } - } - - handleDragOver = (event) => { - event.preventDefault() - event.stopPropagation() - - // Check if some plugin can handle the datatransfer without files — - // for instance, the Url plugin can import a url - const canSomePluginHandleRootDrop = () => { - let somePluginCanHandleRootDrop = true - this.uppy.iteratePlugins((plugin) => { - if (plugin.canHandleRootDrop?.(event)) { - somePluginCanHandleRootDrop = true - } - }) - return somePluginCanHandleRootDrop - } - - // Check if the "type" of the datatransfer object includes files - const doesEventHaveFiles = () => { - const { types } = event.dataTransfer - return types.some(type => type === 'Files') - } - - // Deny drop, if no plugins can handle datatransfer, there are no files, - // or when opts.disabled is set, or new uploads are not allowed - const somePluginCanHandleRootDrop = canSomePluginHandleRootDrop(event) - const hasFiles = doesEventHaveFiles(event) - if ( - (!somePluginCanHandleRootDrop && !hasFiles) - || this.opts.disabled - // opts.disableLocalFiles should only be taken into account if no plugins - // can handle the datatransfer - || (this.opts.disableLocalFiles && (hasFiles || !somePluginCanHandleRootDrop)) - || !this.uppy.getState().allowNewUpload - ) { - event.dataTransfer.dropEffect = 'none' - clearTimeout(this.removeDragOverClassTimeout) - return - } - - // Add a small (+) icon on drop - // (and prevent browsers from interpreting this as files being _moved_ into the - // browser, https://github.com/transloadit/uppy/issues/1978). - event.dataTransfer.dropEffect = 'copy' - - clearTimeout(this.removeDragOverClassTimeout) - this.setPluginState({ isDraggingOver: true }) - - this.opts.onDragOver?.(event) - } - - handleDragLeave = (event) => { - event.preventDefault() - event.stopPropagation() - - clearTimeout(this.removeDragOverClassTimeout) - // Timeout against flickering, this solution is taken from drag-drop library. - // Solution with 'pointer-events: none' didn't work across browsers. - this.removeDragOverClassTimeout = setTimeout(() => { - this.setPluginState({ isDraggingOver: false }) - }, 50) - - this.opts.onDragLeave?.(event) - } - - handleDrop = async (event) => { - event.preventDefault() - event.stopPropagation() - - clearTimeout(this.removeDragOverClassTimeout) - - this.setPluginState({ isDraggingOver: false }) - - // Let any acquirer plugin (Url/Webcam/etc.) handle drops to the root - this.uppy.iteratePlugins((plugin) => { - if (plugin.type === 'acquirer') { - // Every Plugin with .type acquirer can define handleRootDrop(event) - plugin.handleRootDrop?.(event) - } - }) - - // Add all dropped files - let executedDropErrorOnce = false - const logDropError = (error) => { - this.uppy.log(error, 'error') - - // In practice all drop errors are most likely the same, - // so let's just show one to avoid overwhelming the user - if (!executedDropErrorOnce) { - this.uppy.info(error.message, 'error') - executedDropErrorOnce = true - } - } - - // Add all dropped files - const files = await getDroppedFiles(event.dataTransfer, { logDropError }) - if (files.length > 0) { - this.uppy.log('[Dashboard] Files dropped') - this.addFiles(files) - } - - this.opts.onDrop?.(event) - } - - handleRequestThumbnail = (file) => { - if (!this.opts.waitForThumbnailsBeforeUpload) { - this.uppy.emit('thumbnail:request', file) - } - } - - /** - * We cancel thumbnail requests when a file item component unmounts to avoid - * clogging up the queue when the user scrolls past many elements. - */ - handleCancelThumbnail = (file) => { - if (!this.opts.waitForThumbnailsBeforeUpload) { - this.uppy.emit('thumbnail:cancel', file) - } - } - - handleKeyDownInInline = (event) => { - // Trap focus on tab key press. - if (event.keyCode === TAB_KEY) trapFocus.forInline(event, this.getPluginState().activeOverlayType, this.el) - } - - // ___Why do we listen to the 'paste' event on a document instead of onPaste={props.handlePaste} prop, - // or this.el.addEventListener('paste')? - // Because (at least) Chrome doesn't handle paste if focus is on some button, e.g. 'My Device'. - // => Therefore, the best option is to listen to all 'paste' events, and only react to them when we are focused on our - // particular Uppy instance. - // ___Why do we still need onPaste={props.handlePaste} for the DashboardUi? - // Because if we click on the 'Drop files here' caption e.g., `document.activeElement` will be 'body'. Which means our - // standard determination of whether we're pasting into our Uppy instance won't work. - // => Therefore, we need a traditional onPaste={props.handlePaste} handler too. - handlePasteOnBody = (event) => { - const isFocusInOverlay = this.el.contains(document.activeElement) - if (isFocusInOverlay) { - this.handlePaste(event) - } - } - - handleComplete = ({ failed }) => { - if (this.opts.closeAfterFinish && failed.length === 0) { - // All uploads are done - this.requestCloseModal() - } - } - - handleCancelRestore = () => { - this.uppy.emit('restore-canceled') - } - - #openFileEditorWhenFilesAdded = (files) => { - const firstFile = files[0] - if (this.canEditFile(firstFile)) { - this.openFileEditor(firstFile) - } - } - - initEvents = () => { - // Modal open button - if (this.opts.trigger && !this.opts.inline) { - const showModalTrigger = findAllDOMElements(this.opts.trigger) - if (showModalTrigger) { - showModalTrigger.forEach(trigger => trigger.addEventListener('click', this.openModal)) - } else { - this.uppy.log('Dashboard modal trigger not found. Make sure `trigger` is set in Dashboard options, unless you are planning to call `dashboard.openModal()` method yourself', 'warning') - } - } - - this.startListeningToResize() - document.addEventListener('paste', this.handlePasteOnBody) - - this.uppy.on('plugin-remove', this.removeTarget) - this.uppy.on('file-added', this.hideAllPanels) - this.uppy.on('dashboard:modal-closed', this.hideAllPanels) - this.uppy.on('file-editor:complete', this.hideAllPanels) - this.uppy.on('complete', this.handleComplete) - - // ___Why fire on capture? - // Because this.ifFocusedOnUppyRecently needs to change before onUpdate() fires. - document.addEventListener('focus', this.recordIfFocusedOnUppyRecently, true) - document.addEventListener('click', this.recordIfFocusedOnUppyRecently, true) - - if (this.opts.inline) { - this.el.addEventListener('keydown', this.handleKeyDownInInline) - } - - if (this.opts.autoOpenFileEditor) { - this.uppy.on('files-added', this.#openFileEditorWhenFilesAdded) - } - } - - removeEvents = () => { - const showModalTrigger = findAllDOMElements(this.opts.trigger) - if (!this.opts.inline && showModalTrigger) { - showModalTrigger.forEach(trigger => trigger.removeEventListener('click', this.openModal)) - } - - this.stopListeningToResize() - document.removeEventListener('paste', this.handlePasteOnBody) - - window.removeEventListener('popstate', this.handlePopState, false) - this.uppy.off('plugin-remove', this.removeTarget) - this.uppy.off('file-added', this.hideAllPanels) - this.uppy.off('dashboard:modal-closed', this.hideAllPanels) - this.uppy.off('file-editor:complete', this.hideAllPanels) - this.uppy.off('complete', this.handleComplete) - - document.removeEventListener('focus', this.recordIfFocusedOnUppyRecently) - document.removeEventListener('click', this.recordIfFocusedOnUppyRecently) - - if (this.opts.inline) { - this.el.removeEventListener('keydown', this.handleKeyDownInInline) - } - - if (this.opts.autoOpenFileEditor) { - this.uppy.off('files-added', this.#openFileEditorWhenFilesAdded) - } - } - - superFocusOnEachUpdate = () => { - const isFocusInUppy = this.el.contains(document.activeElement) - // When focus is lost on the page (== focus is on body for most browsers, or focus is null for IE11) - const isFocusNowhere = document.activeElement === document.body || document.activeElement === null - const isInformerHidden = this.uppy.getState().info.length === 0 - const isModal = !this.opts.inline - - if ( - // If update is connected to showing the Informer - let the screen reader calmly read it. - isInformerHidden - && ( - // If we are in a modal - always superfocus without concern for other elements - // on the page (user is unlikely to want to interact with the rest of the page) - isModal - // If we are already inside of Uppy, or - || isFocusInUppy - // If we are not focused on anything BUT we have already, at least once, focused on uppy - // 1. We focus when isFocusNowhere, because when the element we were focused - // on disappears (e.g. an overlay), - focus gets lost. If user is typing - // something somewhere else on the page, - focus won't be 'nowhere'. - // 2. We only focus when focus is nowhere AND this.ifFocusedOnUppyRecently, - // to avoid focus jumps if we do something else on the page. - // [Practical check] Without '&& this.ifFocusedOnUppyRecently', in Safari, in inline mode, - // when file is uploading, - navigate via tab to the checkbox, - // try to press space multiple times. Focus will jump to Uppy. - || (isFocusNowhere && this.ifFocusedOnUppyRecently) - ) - ) { - this.superFocus(this.el, this.getPluginState().activeOverlayType) - } else { - this.superFocus.cancel() - } - } - - afterUpdate = () => { - if (this.opts.disabled && !this.dashboardIsDisabled) { - this.disableAllFocusableElements(true) - return - } - - if (!this.opts.disabled && this.dashboardIsDisabled) { - this.disableAllFocusableElements(false) - } - - this.superFocusOnEachUpdate() - } - - saveFileCard = (meta, fileID) => { - this.uppy.setFileMeta(fileID, meta) - this.toggleFileCard(false, fileID) - } - - #attachRenderFunctionToTarget = (target) => { - const plugin = this.uppy.getPlugin(target.id) - return { - ...target, - icon: plugin.icon || this.opts.defaultPickerIcon, - render: plugin.render, - } - } - - #isTargetSupported = (target) => { - const plugin = this.uppy.getPlugin(target.id) - // If the plugin does not provide a `supported` check, assume the plugin works everywhere. - if (typeof plugin.isSupported !== 'function') { - return true - } - return plugin.isSupported() - } - - #getAcquirers = memoize((targets) => { - return targets - .filter(target => target.type === 'acquirer' && this.#isTargetSupported(target)) - .map(this.#attachRenderFunctionToTarget) - }) - - #getProgressIndicators = memoize((targets) => { - return targets - .filter(target => target.type === 'progressindicator') - .map(this.#attachRenderFunctionToTarget) - }) - - #getEditors = memoize((targets) => { - return targets - .filter(target => target.type === 'editor') - .map(this.#attachRenderFunctionToTarget) - }) - - render = (state) => { - const pluginState = this.getPluginState() - const { files, capabilities, allowNewUpload } = state - const { - newFiles, - uploadStartedFiles, - completeFiles, - erroredFiles, - inProgressFiles, - inProgressNotPausedFiles, - processingFiles, - - isUploadStarted, - isAllComplete, - isAllErrored, - isAllPaused, - } = this.uppy.getObjectOfFilesPerState() - - const acquirers = this.#getAcquirers(pluginState.targets) - const progressindicators = this.#getProgressIndicators(pluginState.targets) - const editors = this.#getEditors(pluginState.targets) - - let theme - if (this.opts.theme === 'auto') { - theme = capabilities.darkMode ? 'dark' : 'light' - } else { - theme = this.opts.theme - } - - if (['files', 'folders', 'both'].indexOf(this.opts.fileManagerSelectionType) < 0) { - this.opts.fileManagerSelectionType = 'files' - // eslint-disable-next-line no-console - console.warn(`Unsupported option for "fileManagerSelectionType". Using default of "${this.opts.fileManagerSelectionType}".`) - } - - return DashboardUI({ - state, - isHidden: pluginState.isHidden, - files, - newFiles, - uploadStartedFiles, - completeFiles, - erroredFiles, - inProgressFiles, - inProgressNotPausedFiles, - processingFiles, - isUploadStarted, - isAllComplete, - isAllErrored, - isAllPaused, - totalFileCount: Object.keys(files).length, - totalProgress: state.totalProgress, - allowNewUpload, - acquirers, - theme, - disabled: this.opts.disabled, - disableLocalFiles: this.opts.disableLocalFiles, - direction: this.opts.direction, - activePickerPanel: pluginState.activePickerPanel, - showFileEditor: pluginState.showFileEditor, - saveFileEditor: this.saveFileEditor, - disableAllFocusableElements: this.disableAllFocusableElements, - animateOpenClose: this.opts.animateOpenClose, - isClosing: pluginState.isClosing, - progressindicators, - editors, - autoProceed: this.uppy.opts.autoProceed, - id: this.id, - closeModal: this.requestCloseModal, - handleClickOutside: this.handleClickOutside, - handleInputChange: this.handleInputChange, - handlePaste: this.handlePaste, - inline: this.opts.inline, - showPanel: this.showPanel, - hideAllPanels: this.hideAllPanels, - i18n: this.i18n, - i18nArray: this.i18nArray, - uppy: this.uppy, - note: this.opts.note, - recoveredState: state.recoveredState, - metaFields: pluginState.metaFields, - resumableUploads: capabilities.resumableUploads || false, - individualCancellation: capabilities.individualCancellation, - isMobileDevice: capabilities.isMobileDevice, - fileCardFor: pluginState.fileCardFor, - toggleFileCard: this.toggleFileCard, - toggleAddFilesPanel: this.toggleAddFilesPanel, - showAddFilesPanel: pluginState.showAddFilesPanel, - saveFileCard: this.saveFileCard, - openFileEditor: this.openFileEditor, - canEditFile: this.canEditFile, - width: this.opts.width, - height: this.opts.height, - showLinkToFileUploadResult: this.opts.showLinkToFileUploadResult, - fileManagerSelectionType: this.opts.fileManagerSelectionType, - proudlyDisplayPoweredByUppy: this.opts.proudlyDisplayPoweredByUppy, - hideCancelButton: this.opts.hideCancelButton, - hideRetryButton: this.opts.hideRetryButton, - hidePauseResumeButton: this.opts.hidePauseResumeButton, - showRemoveButtonAfterComplete: this.opts.showRemoveButtonAfterComplete, - containerWidth: pluginState.containerWidth, - containerHeight: pluginState.containerHeight, - areInsidesReadyToBeVisible: pluginState.areInsidesReadyToBeVisible, - isTargetDOMEl: this.isTargetDOMEl, - parentElement: this.el, - allowedFileTypes: this.uppy.opts.restrictions.allowedFileTypes, - maxNumberOfFiles: this.uppy.opts.restrictions.maxNumberOfFiles, - requiredMetaFields: this.uppy.opts.restrictions.requiredMetaFields, - showSelectedFiles: this.opts.showSelectedFiles, - handleCancelRestore: this.handleCancelRestore, - handleRequestThumbnail: this.handleRequestThumbnail, - handleCancelThumbnail: this.handleCancelThumbnail, - // drag props - isDraggingOver: pluginState.isDraggingOver, - handleDragOver: this.handleDragOver, - handleDragLeave: this.handleDragLeave, - handleDrop: this.handleDrop, - }) - } - - discoverProviderPlugins = () => { - this.uppy.iteratePlugins((plugin) => { - if (plugin && !plugin.target && plugin.opts && plugin.opts.target === this.constructor) { - this.addTarget(plugin) - } - }) - } - - install = () => { - // Set default state for Dashboard - this.setPluginState({ - isHidden: true, - fileCardFor: null, - activeOverlayType: null, - showAddFilesPanel: false, - activePickerPanel: false, - showFileEditor: false, - metaFields: this.opts.metaFields, - targets: [], - // We'll make them visible once .containerWidth is determined - areInsidesReadyToBeVisible: false, - isDraggingOver: false, - }) - - const { inline, closeAfterFinish } = this.opts - if (inline && closeAfterFinish) { - throw new Error('[Dashboard] `closeAfterFinish: true` cannot be used on an inline Dashboard, because an inline Dashboard cannot be closed at all. Either set `inline: false`, or disable the `closeAfterFinish` option.') - } - - const { allowMultipleUploads, allowMultipleUploadBatches } = this.uppy.opts - if ((allowMultipleUploads || allowMultipleUploadBatches) && closeAfterFinish) { - this.uppy.log('[Dashboard] When using `closeAfterFinish`, we recommended setting the `allowMultipleUploadBatches` option to `false` in the Uppy constructor. See https://uppy.io/docs/uppy/#allowMultipleUploads-true', 'warning') - } - - const { target } = this.opts - - if (target) { - this.mount(target, this) - } - - const plugins = this.opts.plugins || [] - - plugins.forEach((pluginID) => { - const plugin = this.uppy.getPlugin(pluginID) - if (plugin) { - plugin.mount(this, plugin) - } - }) - - if (!this.opts.disableStatusBar) { - this.uppy.use(StatusBar, { - id: `${this.id}:StatusBar`, - target: this, - hideUploadButton: this.opts.hideUploadButton, - hideRetryButton: this.opts.hideRetryButton, - hidePauseResumeButton: this.opts.hidePauseResumeButton, - hideCancelButton: this.opts.hideCancelButton, - showProgressDetails: this.opts.showProgressDetails, - hideAfterFinish: this.opts.hideProgressAfterFinish, - locale: this.opts.locale, - doneButtonHandler: this.opts.doneButtonHandler, - }) - } - - if (!this.opts.disableInformer) { - this.uppy.use(Informer, { - id: `${this.id}:Informer`, - target: this, - }) - } - - if (!this.opts.disableThumbnailGenerator) { - this.uppy.use(ThumbnailGenerator, { - id: `${this.id}:ThumbnailGenerator`, - thumbnailWidth: this.opts.thumbnailWidth, - thumbnailHeight: this.opts.thumbnailHeight, - thumbnailType: this.opts.thumbnailType, - waitForThumbnailsBeforeUpload: this.opts.waitForThumbnailsBeforeUpload, - // If we don't block on thumbnails, we can lazily generate them - lazy: !this.opts.waitForThumbnailsBeforeUpload, - }) - } - - // Dark Mode / theme - this.darkModeMediaQuery = (typeof window !== 'undefined' && window.matchMedia) - ? window.matchMedia('(prefers-color-scheme: dark)') - : null - - const isDarkModeOnFromTheStart = this.darkModeMediaQuery ? this.darkModeMediaQuery.matches : false - this.uppy.log(`[Dashboard] Dark mode is ${isDarkModeOnFromTheStart ? 'on' : 'off'}`) - this.setDarkModeCapability(isDarkModeOnFromTheStart) - - if (this.opts.theme === 'auto') { - this.darkModeMediaQuery.addListener(this.handleSystemDarkModeChange) - } - - this.discoverProviderPlugins() - this.initEvents() - } - - uninstall = () => { - if (!this.opts.disableInformer) { - const informer = this.uppy.getPlugin(`${this.id}:Informer`) - // Checking if this plugin exists, in case it was removed by uppy-core - // before the Dashboard was. - if (informer) this.uppy.removePlugin(informer) - } - - if (!this.opts.disableStatusBar) { - const statusBar = this.uppy.getPlugin(`${this.id}:StatusBar`) - if (statusBar) this.uppy.removePlugin(statusBar) - } - - if (!this.opts.disableThumbnailGenerator) { - const thumbnail = this.uppy.getPlugin(`${this.id}:ThumbnailGenerator`) - if (thumbnail) this.uppy.removePlugin(thumbnail) - } - - const plugins = this.opts.plugins || [] - plugins.forEach((pluginID) => { - const plugin = this.uppy.getPlugin(pluginID) - if (plugin) plugin.unmount() - }) - - if (this.opts.theme === 'auto') { - this.darkModeMediaQuery.removeListener(this.handleSystemDarkModeChange) - } - - this.unmount() - this.removeEvents() - } -} +export { default } from './Dashboard.jsx' diff --git a/packages/@uppy/dashboard/src/index.test.js b/packages/@uppy/dashboard/src/index.test.js index 9016bf47d9..169422b54a 100644 --- a/packages/@uppy/dashboard/src/index.test.js +++ b/packages/@uppy/dashboard/src/index.test.js @@ -1,11 +1,14 @@ -const Core = require('@uppy/core') -const StatusBarPlugin = require('@uppy/status-bar') -const GoogleDrivePlugin = require('@uppy/google-drive') // eslint-disable-line -const DashboardPlugin = require('./index') +import { describe, it, expect } from '@jest/globals' + +import Core from '@uppy/core' +import StatusBarPlugin from '@uppy/status-bar' +import GoogleDrivePlugin from '@uppy/google-drive' +import resizeObserverPolyfill from 'resize-observer-polyfill' +import DashboardPlugin from '../lib/index.js' describe('Dashboard', () => { beforeAll(() => { - globalThis.ResizeObserver = require('resize-observer-polyfill').default || require('resize-observer-polyfill') + globalThis.ResizeObserver = resizeObserverPolyfill.default || resizeObserverPolyfill }) afterAll(() => { delete globalThis.ResizeObserver diff --git a/packages/@uppy/dashboard/src/locale.js b/packages/@uppy/dashboard/src/locale.js index 8030311d36..69e32ff5cc 100644 --- a/packages/@uppy/dashboard/src/locale.js +++ b/packages/@uppy/dashboard/src/locale.js @@ -1,4 +1,4 @@ -module.exports = { +export default { strings: { // When `inline: false`, used as the screen reader label for the button that closes the modal. closeModal: 'Close Modal', diff --git a/packages/@uppy/dashboard/src/utils/copyToClipboard.js b/packages/@uppy/dashboard/src/utils/copyToClipboard.js index a95920ba81..301dd612e6 100644 --- a/packages/@uppy/dashboard/src/utils/copyToClipboard.js +++ b/packages/@uppy/dashboard/src/utils/copyToClipboard.js @@ -8,8 +8,9 @@ * @param {string} fallbackString * @returns {Promise} */ -module.exports = function copyToClipboard (textToCopy, fallbackString) { - fallbackString = fallbackString || 'Copy the URL below' +export default function copyToClipboard (textToCopy, fallbackString) { + // TODO: make `fallbackString` an optional parameter instead. + fallbackString ||= 'Copy the URL below' // eslint-disable-line no-param-reassign return new Promise((resolve) => { const textArea = document.createElement('textarea') diff --git a/packages/@uppy/dashboard/src/utils/copyToClipboard.test.js b/packages/@uppy/dashboard/src/utils/copyToClipboard.test.js index c025c85e3b..f77c972e59 100644 --- a/packages/@uppy/dashboard/src/utils/copyToClipboard.test.js +++ b/packages/@uppy/dashboard/src/utils/copyToClipboard.test.js @@ -1,4 +1,5 @@ -const copyToClipboard = require('./copyToClipboard') +import { describe, xit, expect } from '@jest/globals' +import copyToClipboard from './copyToClipboard.js' describe('copyToClipboard', () => { xit('should copy the specified text to the clipboard', () => { diff --git a/packages/@uppy/dashboard/src/utils/createSuperFocus.js b/packages/@uppy/dashboard/src/utils/createSuperFocus.js index 62b30dd176..ee76eeb1a5 100644 --- a/packages/@uppy/dashboard/src/utils/createSuperFocus.js +++ b/packages/@uppy/dashboard/src/utils/createSuperFocus.js @@ -1,6 +1,6 @@ -const debounce = require('lodash.debounce') -const FOCUSABLE_ELEMENTS = require('@uppy/utils/lib/FOCUSABLE_ELEMENTS') -const getActiveOverlayEl = require('./getActiveOverlayEl') +import debounce from 'lodash.debounce' +import FOCUSABLE_ELEMENTS from '@uppy/utils/lib/FOCUSABLE_ELEMENTS' +import getActiveOverlayEl from './getActiveOverlayEl.js' /* Focuses on some element in the currently topmost overlay. @@ -12,7 +12,7 @@ const getActiveOverlayEl = require('./getActiveOverlayEl') 2. If there are no [data-uppy-super-focusable] elements yet (or ever) - focuses on the first focusable element, but switches focus if superfocusable elements appear on next render. */ -module.exports = function createSuperFocus () { +export default function createSuperFocus () { let lastFocusWasOnSuperFocusableEl = false const superFocus = (dashboardEl, activeOverlayType) => { diff --git a/packages/@uppy/dashboard/src/utils/createSuperFocus.test.js b/packages/@uppy/dashboard/src/utils/createSuperFocus.test.js index 640363ff30..bcebfac49f 100644 --- a/packages/@uppy/dashboard/src/utils/createSuperFocus.test.js +++ b/packages/@uppy/dashboard/src/utils/createSuperFocus.test.js @@ -1,4 +1,5 @@ -const createSuperFocus = require('./createSuperFocus') +import { describe, it, expect } from '@jest/globals' +import createSuperFocus from './createSuperFocus.js' describe('createSuperFocus', () => { // superFocus.cancel() is used in dashboard diff --git a/packages/@uppy/dashboard/src/utils/getActiveOverlayEl.js b/packages/@uppy/dashboard/src/utils/getActiveOverlayEl.js index 9b82f22268..7a73ee5b25 100644 --- a/packages/@uppy/dashboard/src/utils/getActiveOverlayEl.js +++ b/packages/@uppy/dashboard/src/utils/getActiveOverlayEl.js @@ -1,7 +1,7 @@ /** * @returns {HTMLElement} - either dashboard element, or the overlay that's most on top */ -module.exports = function getActiveOverlayEl (dashboardEl, activeOverlayType) { +export default function getActiveOverlayEl (dashboardEl, activeOverlayType) { if (activeOverlayType) { const overlayEl = dashboardEl.querySelector(`[data-uppy-paneltype="${activeOverlayType}"]`) // if an overlay is already mounted diff --git a/packages/@uppy/dashboard/src/utils/getFileTypeIcon.js b/packages/@uppy/dashboard/src/utils/getFileTypeIcon.jsx similarity index 98% rename from packages/@uppy/dashboard/src/utils/getFileTypeIcon.js rename to packages/@uppy/dashboard/src/utils/getFileTypeIcon.jsx index 18634ad747..ad5a9445c1 100644 --- a/packages/@uppy/dashboard/src/utils/getFileTypeIcon.js +++ b/packages/@uppy/dashboard/src/utils/getFileTypeIcon.jsx @@ -1,4 +1,4 @@ -const { h } = require('preact') +import { h } from 'preact' function iconImage () { return ( @@ -63,7 +63,7 @@ function iconText () { ) } -module.exports = function getIconByMime (fileType) { +export default function getIconByMime (fileType) { const defaultChoice = { color: '#838999', icon: iconFile(), diff --git a/packages/@uppy/dashboard/src/utils/ignoreEvent.js b/packages/@uppy/dashboard/src/utils/ignoreEvent.js index 8d02424aea..85d7bd8456 100644 --- a/packages/@uppy/dashboard/src/utils/ignoreEvent.js +++ b/packages/@uppy/dashboard/src/utils/ignoreEvent.js @@ -14,4 +14,4 @@ function ignoreEvent (ev) { ev.stopPropagation() } -module.exports = ignoreEvent +export default ignoreEvent diff --git a/packages/@uppy/dashboard/src/utils/trapFocus.js b/packages/@uppy/dashboard/src/utils/trapFocus.js index 3167fc104a..66353c22cd 100644 --- a/packages/@uppy/dashboard/src/utils/trapFocus.js +++ b/packages/@uppy/dashboard/src/utils/trapFocus.js @@ -1,6 +1,6 @@ -const toArray = require('@uppy/utils/lib/toArray') -const FOCUSABLE_ELEMENTS = require('@uppy/utils/lib/FOCUSABLE_ELEMENTS') -const getActiveOverlayEl = require('./getActiveOverlayEl') +import toArray from '@uppy/utils/lib/toArray' +import FOCUSABLE_ELEMENTS from '@uppy/utils/lib/FOCUSABLE_ELEMENTS' +import getActiveOverlayEl from './getActiveOverlayEl.js' function focusOnFirstNode (event, nodes) { const node = nodes[0] @@ -49,23 +49,19 @@ function trapFocus (event, activeOverlayType, dashboardEl) { } } -module.exports = { - // Traps focus inside of the currently open overlay (e.g. Dashboard, or e.g. Instagram), - // never lets focus disappear from the modal. - forModal: (event, activeOverlayType, dashboardEl) => { - trapFocus(event, activeOverlayType, dashboardEl) - }, +// Traps focus inside of the currently open overlay (e.g. Dashboard, or e.g. Instagram), +// never lets focus disappear from the modal. +export { trapFocus as forModal } - // Traps focus inside of the currently open overlay, unless overlay is null - then let the user tab away. - forInline: (event, activeOverlayType, dashboardEl) => { - // ___When we're in the bare 'Drop files here, paste, browse or import from' screen - if (activeOverlayType === null) { - // Do nothing and let the browser handle it, user can tab away from Uppy to other elements on the page +// Traps focus inside of the currently open overlay, unless overlay is null - then let the user tab away. +export function forInline (event, activeOverlayType, dashboardEl) { + // ___When we're in the bare 'Drop files here, paste, browse or import from' screen + if (activeOverlayType === null) { + // Do nothing and let the browser handle it, user can tab away from Uppy to other elements on the page // ___When there is some overlay with 'Done' button - } else { - // Trap the focus inside this overlay! - // User can close the overlay (click 'Done') if they want to travel away from Uppy. - trapFocus(event, activeOverlayType, dashboardEl) - } - }, + } else { + // Trap the focus inside this overlay! + // User can close the overlay (click 'Done') if they want to travel away from Uppy. + trapFocus(event, activeOverlayType, dashboardEl) + } } diff --git a/website/src/docs/dashboard.md b/website/src/docs/dashboard.md index cd451f4272..a7537f8962 100644 --- a/website/src/docs/dashboard.md +++ b/website/src/docs/dashboard.md @@ -337,7 +337,7 @@ Dashboard ships with the `ThumbnailGenerator` plugin that adds small resized ima ```js -module.exports = { +export default { strings: { // When `inline: false`, used as the screen reader label for the button that closes the modal. closeModal: 'Close Modal', diff --git a/yarn.lock b/yarn.lock index be3eeeae60..faa5c62fb5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9822,6 +9822,7 @@ __metadata: version: 0.0.0-use.local resolution: "@uppy/dashboard@workspace:packages/@uppy/dashboard" dependencies: + "@jest/globals": ^27.4.2 "@transloadit/prettier-bytes": 0.0.7 "@uppy/google-drive": "workspace:^" "@uppy/informer": "workspace:^"