@uppy/dashboard
Version:
Universal UI plugin for Uppy.
1,069 lines (1,068 loc) • 45.4 kB
JavaScript
import { UIPlugin } from '@uppy/core';
import Informer from '@uppy/informer';
import { defaultPickerIcon } from '@uppy/provider-views';
import StatusBar from '@uppy/status-bar';
import ThumbnailGenerator from '@uppy/thumbnail-generator';
import findAllDOMElements from '@uppy/utils/lib/findAllDOMElements';
import getDroppedFiles from '@uppy/utils/lib/getDroppedFiles';
import toArray from '@uppy/utils/lib/toArray';
import { nanoid } from 'nanoid/non-secure';
import { h } from 'preact';
import packageJson from '../package.json' with { type: 'json' };
import DashboardUI from './components/Dashboard.js';
import locale from './locale.js';
import createSuperFocus from './utils/createSuperFocus.js';
import * as trapFocus from './utils/trapFocus.js';
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;
}
const defaultOptions = {
target: 'body',
metaFields: [],
thumbnailWidth: 280,
thumbnailType: 'image/jpeg',
waitForThumbnailsBeforeUpload: false,
defaultPickerIcon,
showLinkToFileUploadResult: false,
showProgressDetails: false,
hideUploadButton: false,
hideCancelButton: false,
hideRetryButton: false,
hidePauseResumeButton: false,
hideProgressAfterFinish: false,
note: null,
singleFileFullScreen: true,
disableStatusBar: false,
disableInformer: false,
disableThumbnailGenerator: false,
fileManagerSelectionType: 'files',
proudlyDisplayPoweredByUppy: true,
showSelectedFiles: true,
showRemoveButtonAfterComplete: false,
showNativePhotoCameraButton: false,
showNativeVideoCameraButton: false,
theme: 'light',
autoOpen: null,
disabled: false,
disableLocalFiles: false,
nativeCameraFacingMode: '',
onDragLeave: () => { },
onDragOver: () => { },
onDrop: () => { },
plugins: [],
// Dynamic default options, they have to be defined in the constructor (because
// they require access to the `this` keyword), but we still want them to
// appear in the default options so TS knows they'll be defined.
doneButtonHandler: undefined,
onRequestCloseModal: null,
// defaultModalOptions
inline: false,
animateOpenClose: true,
browserBackButtonClose: false,
closeAfterFinish: false,
closeModalOnClickOutside: false,
disablePageScrollWhenModalOpen: true,
trigger: null,
// defaultInlineOptions
width: 750,
height: 550,
};
/**
* Dashboard UI with previews, metadata editing, tabs for various services and more
*/
export default class Dashboard extends UIPlugin {
static VERSION = packageJson.version;
#disabledNodes;
modalName = `uppy-Dashboard-${nanoid()}`;
superFocus = createSuperFocus();
ifFocusedOnUppyRecently = false;
dashboardIsDisabled;
savedScrollPosition;
savedActiveElement;
resizeObserver;
darkModeMediaQuery;
// Timeouts
makeDashboardInsidesVisibleAnywayTimeout;
constructor(uppy, opts) {
const autoOpen = opts?.autoOpen ?? null;
super(uppy, { ...defaultOptions, ...opts, autoOpen });
this.id = this.opts.id || 'Dashboard';
this.title = 'Dashboard';
this.type = 'orchestrator';
this.defaultLocale = locale;
// Dynamic default options:
if (this.opts.doneButtonHandler === undefined) {
// `null` means "do not display a Done button", while `undefined` means
// "I want the default behavior". For this reason, we need to differentiate `null` and `undefined`.
this.opts.doneButtonHandler = () => {
this.uppy.clear();
this.requestCloseModal();
};
}
this.opts.onRequestCloseModal ??= () => this.closeModal();
this.i18nInit();
}
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 null;
}
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: undefined,
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);
this.uppy.emit('dashboard:close-panel', state.activePickerPanel?.id);
};
showPanel = (id) => {
const { targets } = this.getPluginState();
const activePickerPanel = targets.find((target) => {
return target.type === 'acquirer' && target.id === id;
});
this.setPluginState({
activePickerPanel,
activeOverlayType: 'PickerPanel',
});
this.uppy.emit('dashboard:show-panel', id);
};
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);
});
};
closeFileEditor = () => {
const { metaFields } = this.getPluginState();
const isMetaEditorEnabled = metaFields && metaFields.length > 0;
if (isMetaEditorEnabled) {
this.setPluginState({
showFileEditor: false,
activeOverlayType: 'FileCard',
});
}
else {
this.setPluginState({
showFileEditor: false,
fileCardFor: null,
activeOverlayType: 'AddFiles',
});
}
};
saveFileEditor = () => {
const { targets } = this.getPluginState();
const editors = this.#getEditors(targets);
editors.forEach((editor) => {
;
this.uppy.getPlugin(editor.id).save();
});
this.closeFileEditor();
};
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) => {
// Whether the modal is being closed by the user (`true`) or by other means (e.g. browser back button)
const manualClose = opts?.manualClose ?? true;
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
if (history.state?.[this.modalName]) {
// Go back in history to clear out the entry we created (ultimately closing the modal)
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 || file.webkitRelativePath || 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.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 (
// We might want to enable this in the future
// 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', 'warning');
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();
}
};
disableInteractiveElements = (disable) => {
const NODES_TO_DISABLE = [
'a[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'[role="button"]:not([disabled])',
];
const nodesToDisable = this.#disabledNodes ??
toArray(this.el.querySelectorAll(NODES_TO_DISABLE)).filter((node) => !node.classList.contains('uppy-Dashboard-close'));
for (const node of nodesToDisable) {
// Links can’t have `disabled` attr, so we use `aria-disabled` for a11y
if (node.tagName === 'A') {
node.setAttribute('aria-disabled', disable);
}
else {
node.disabled = disable;
}
}
if (disable) {
this.#disabledNodes = nodesToDisable;
}
else {
this.#disabledNodes = null;
}
this.dashboardIsDisabled = disable;
};
updateBrowserHistory = () => {
// Ensure history state does not already contain our modal name to avoid double-pushing
if (!history.state?.[this.modalName]) {
// Push to history so that the page is not lost on browser back button press
history.pushState({
...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]) {
history.back();
}
};
handleKeyDownInModal = (event) => {
// close modal on esc key press
if (event.keyCode === ESC_KEY)
this.requestCloseModal();
// 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.currentTarget.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();
const hasFiles = doesEventHaveFiles();
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';
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';
this.setPluginState({ isDraggingOver: true });
this.opts.onDragOver(event);
};
handleDragLeave = (event) => {
event.preventDefault();
event.stopPropagation();
this.setPluginState({ isDraggingOver: false });
this.opts.onDragLeave(event);
};
handleDrop = async (event) => {
event.preventDefault();
event.stopPropagation();
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;
}
};
this.uppy.log('[Dashboard] Processing dropped files');
// 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) {
// All uploads are done
this.requestCloseModal();
}
};
handleCancelRestore = () => {
this.uppy.emit('restore-canceled');
};
#generateLargeThumbnailIfSingleFile = () => {
if (this.opts.disableThumbnailGenerator) {
return;
}
const LARGE_THUMBNAIL = 600;
const files = this.uppy.getFiles();
if (files.length === 1) {
const thumbnailGenerator = this.uppy.getPlugin(`${this.id}:ThumbnailGenerator`);
thumbnailGenerator?.setOptions({ thumbnailWidth: LARGE_THUMBNAIL });
const fileForThumbnail = { ...files[0], preview: undefined };
thumbnailGenerator?.requestThumbnail(fileForThumbnail).then(() => {
thumbnailGenerator?.setOptions({
thumbnailWidth: this.opts.thumbnailWidth,
});
});
}
};
#openFileEditorWhenFilesAdded = (files) => {
const firstFile = files[0];
const { metaFields } = this.getPluginState();
const isMetaEditorEnabled = metaFields && metaFields.length > 0;
const isImageEditorEnabled = this.canEditFile(firstFile);
if (isMetaEditorEnabled && this.opts.autoOpen === 'metaEditor') {
this.toggleFileCard(true, firstFile.id);
}
else if (isImageEditorEnabled && this.opts.autoOpen === 'imageEditor') {
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-added', this.#addSupportedPluginIfNoTarget);
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('complete', this.handleComplete);
this.uppy.on('files-added', this.#generateLargeThumbnailIfSingleFile);
this.uppy.on('file-removed', this.#generateLargeThumbnailIfSingleFile);
// ___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.autoOpen) {
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-added', this.#addSupportedPluginIfNoTarget);
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('complete', this.handleComplete);
this.uppy.off('files-added', this.#generateLargeThumbnailIfSingleFile);
this.uppy.off('file-removed', this.#generateLargeThumbnailIfSingleFile);
document.removeEventListener('focus', this.recordIfFocusedOnUppyRecently);
document.removeEventListener('click', this.recordIfFocusedOnUppyRecently);
if (this.opts.inline) {
this.el.removeEventListener('keydown', this.handleKeyDownInInline);
}
if (this.opts.autoOpen) {
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.disableInteractiveElements(true);
return;
}
if (!this.opts.disabled && this.dashboardIsDisabled) {
this.disableInteractiveElements(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 = (targets) => {
return targets
.filter((target) => target.type === 'acquirer' && this.#isTargetSupported(target))
.map(this.#attachRenderFunctionToTarget);
};
#getProgressIndicators = (targets) => {
return targets
.filter((target) => target.type === 'progressindicator')
.map(this.#attachRenderFunctionToTarget);
};
#getEditors = (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, 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';
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,
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,
closeFileEditor: this.closeFileEditor,
disableInteractiveElements: this.disableInteractiveElements,
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,
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,
showNativePhotoCameraButton: this.opts.showNativePhotoCameraButton,
showNativeVideoCameraButton: this.opts.showNativeVideoCameraButton,
nativeCameraFacingMode: this.opts.nativeCameraFacingMode,
singleFileFullScreen: this.opts.singleFileFullScreen,
handleCancelRestore: this.handleCancelRestore,
handleRequestThumbnail: this.handleRequestThumbnail,
handleCancelThumbnail: this.handleCancelThumbnail,
// drag props
isDraggingOver: pluginState.isDraggingOver,
handleDragOver: this.handleDragOver,
handleDragLeave: this.handleDragLeave,
handleDrop: this.handleDrop,
});
};
#addSpecifiedPluginsFromOptions = () => {
const { plugins } = this.opts;
plugins.forEach((pluginID) => {
const plugin = this.uppy.getPlugin(pluginID);
if (plugin) {
;
plugin.mount(this, plugin);
}
else {
this.uppy.log(`[Uppy] Dashboard could not find plugin '${pluginID}', make sure to uppy.use() the plugins you are specifying`, 'warning');
}
});
};
#autoDiscoverPlugins = () => {
this.uppy.iteratePlugins(this.#addSupportedPluginIfNoTarget);
};
#addSupportedPluginIfNoTarget = (plugin) => {
// Only these types belong on the Dashboard,
// we wouldn’t want to try and mount Compressor or Tus, for example.
const typesAllowed = ['acquirer', 'editor'];
if (plugin && !plugin.opts?.target && typesAllowed.includes(plugin.type)) {
const pluginAlreadyAdded = this.getPluginState().targets.some((installedPlugin) => plugin.id === installedPlugin.id);
if (!pluginAlreadyAdded) {
;
plugin.mount(this, plugin);
}
}
};
#getStatusBarOpts() {
const { hideUploadButton, hideRetryButton, hidePauseResumeButton, hideCancelButton, showProgressDetails, hideProgressAfterFinish, locale: l, doneButtonHandler, } = this.opts;
return {
hideUploadButton,
hideRetryButton,
hidePauseResumeButton,
hideCancelButton,
showProgressDetails,
hideAfterFinish: hideProgressAfterFinish,
locale: l,
doneButtonHandler,
};
}
#getThumbnailGeneratorOpts() {
const { thumbnailWidth, thumbnailHeight, thumbnailType, waitForThumbnailsBeforeUpload, } = this.opts;
return {
thumbnailWidth,
thumbnailHeight,
thumbnailType,
waitForThumbnailsBeforeUpload,
// If we don't block on thumbnails, we can lazily generate them
lazy: !waitForThumbnailsBeforeUpload,
};
}
#getInformerOpts() {
return {
// currently no options
};
}
setOptions(opts) {
super.setOptions(opts);
this.uppy
.getPlugin(this.#getStatusBarId())
?.setOptions(this.#getStatusBarOpts());
this.uppy
.getPlugin(this.#getThumbnailGeneratorId())
?.setOptions(this.#getThumbnailGeneratorOpts());
}
#getStatusBarId() {
return `${this.id}:StatusBar`;
}
#getThumbnailGeneratorId() {
return `${this.id}:ThumbnailGenerator`;
}
#getInformerId() {
return `${this.id}:Informer`;
}
install = () => {
// Set default state for Dashboard
this.setPluginState({
isHidden: true,
fileCardFor: null,
activeOverlayType: null,
showAddFilesPanel: false,
activePickerPanel: undefined,
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);
}
if (!this.opts.disableStatusBar) {
this.uppy.use(StatusBar, {
id: this.#getStatusBarId(),
target: this,
...this.#getStatusBarOpts(),
});
}
if (!this.opts.disableInformer) {
this.uppy.use(Informer, {
id: this.#getInformerId(),
target: this,
...this.#getInformerOpts(),
});
}
if (!this.opts.disableThumbnailGenerator) {
this.uppy.use(ThumbnailGenerator, {
id: this.#getThumbnailGeneratorId(),
...this.#getThumbnailGeneratorOpts(),
});
}
// 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.#addSpecifiedPluginsFromOptions();
this.#autoDiscoverPlugins();
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.forEach((pluginID) => {
const plugin = this.uppy.getPlugin(pluginID);
if (plugin)
plugin.unmount();
});
if (this.opts.theme === 'auto') {
this.darkModeMediaQuery?.removeListener(this.handleSystemDarkModeChange);
}
if (this.opts.disablePageScrollWhenModalOpen) {
document.body.classList.remove('uppy-Dashboard-isFixed');
}
this.unmount();
this.removeEvents();
};
}