Browse Source

Merge branch 'master' of github.com:transloadit/uppy

Ifedapo Olarewaju 7 years ago
parent
commit
ae389ae1c4

+ 3 - 2
CHANGELOG.md

@@ -107,15 +107,16 @@ To be released: 2018-02-01.
 - [ ] core: Move limiting to different point, to fix StatusBar and other UI issues #468 (@goto-bus-stop)
 - [ ] s3: rename `AWS S3` to something more general if it works with Google Cloud Storage too? See #460
 - [ ] dashboard: try adding optional whitelabel “powered by uppy.io”, maybe muted small uppy logo that gains color on hover (@nqst, @arturi)
-- [ ] dashboard: restore focus after modal has been closed (@arturi)
+- [x] dashboard: restore focus after modal has been closed (@arturi)
 - [ ] dashboard: option for Boolean metadata #454 (@arturi)
-- [ ] dashboard: use more accessible tip lib: https://github.com/ghosh/microtip
+- [x] dashboard: use more accessible tip lib: https://github.com/ghosh/microtip
 - [ ] core: queue preview generation #431
 - [ ] core: warn, not error, when file cannot be added due to restrictions? (@arturi)
 - [ ] goldenretriever: warn, not error, when files cannot be saved by goldenretriever (@goto-bus-stop)
 - [ ] look into text-based file type icons to save space, or more icons for file types? (@nqst, @arturi)
 - [ ] docs: quick start guide: https://community.transloadit.com/t/quick-start-guide-would-be-really-helpful/14605 (@arturi)
 - [ ] docs: on writing plugins (@goto-bus-stop)
+- [ ] docs: all useful events (@arturi)
 - [ ] core: warn, not error, when files don’t pass restrictions: Unhandled Promise Rejection when file doesn't pass restrictions #492
 - [ ] webcam: URL.createObjectURL(MediaStream) is deprecated and will be removed soon: https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/srcObject
 - [ ] xhrupload: add bundle option to send multiple files in one request (#442 / @goto-bus-stop)

+ 2 - 33
src/core/Core.js

@@ -48,8 +48,7 @@ class Uppy {
       onBeforeFileAdded: (currentFile, files) => Promise.resolve(),
       onBeforeUpload: (files, done) => Promise.resolve(),
       locale: defaultLocale,
-      store: new DefaultStore(),
-      thumbnailGeneration: true
+      store: new DefaultStore()
     }
 
     // Merge default options with the ones set by user
@@ -447,32 +446,6 @@ class Uppy {
     this.log(`Removed file: ${fileID}`)
   }
 
-  /**
-   * Generate a preview image for the given file, if possible.
-   */
-  generatePreview (file) {
-    if (Utils.isPreviewSupported(file.type) && !file.isRemote) {
-      let previewPromise
-      if (this.opts.thumbnailGeneration === true) {
-        previewPromise = Utils.createThumbnail(file, 280)
-      } else {
-        previewPromise = Promise.resolve(URL.createObjectURL(file.data))
-      }
-      previewPromise.then((preview) => {
-        this.setPreviewURL(file.id, preview)
-      }).catch((err) => {
-        console.warn(err.stack || err.message)
-      })
-    }
-  }
-
-  /**
-   * Set the preview URL for a file.
-   */
-  setPreviewURL (fileID, preview) {
-    this.setFileState(fileID, { preview: preview })
-  }
-
   pauseResume (fileID) {
     const updatedFiles = Object.assign({}, this.getState().files)
 
@@ -622,7 +595,7 @@ class Uppy {
 
   /**
    * Registers listeners for all global actions, like:
-   * `error`, `file-added`, `file-removed`, `upload-progress`
+   * `error`, `file-removed`, `upload-progress`
    *
    */
   actions () {
@@ -661,10 +634,6 @@ class Uppy {
     //   this.addFile(data)
     // })
 
-    this.on('file-added', (file) => {
-      this.generatePreview(file)
-    })
-
     this.on('file-remove', (fileID) => {
       this.removeFile(fileID)
     })

+ 1 - 28
src/core/Core.test.js

@@ -549,7 +549,7 @@ describe('src/Core', () => {
             isRemote: false,
             meta: { name: 'foo.jpg', type: 'image/jpeg' },
             name: 'foo.jpg',
-            preview: sampleImageDataURI,
+            preview: undefined,
             data: fileData,
             progress: {
               bytesTotal: 17175,
@@ -564,7 +564,6 @@ describe('src/Core', () => {
             type: 'image/jpeg'
           }
           expect(core.state.files[fileId]).toEqual(newFile)
-          newFile.preview = undefined // not sure why this happens.. needs further investigation
           expect(fileAddedEventMock.mock.calls[0][0]).toEqual(newFile)
         })
     })
@@ -603,32 +602,6 @@ describe('src/Core', () => {
         data: null
       })).rejects.toMatchObject(new Error('onBeforeFileAdded: a plain string'))
     })
-
-    it('should call utils.generatePreview when file-added is triggered and thumbnail generation is allowed', () => {
-      const core = new Core({
-      }).run()
-      const file = {
-        type: 'image/jpeg',
-        isRemote: false
-      }
-      core.emit('file-added', file)
-      expect(utils.createThumbnail).toHaveBeenCalledTimes(1)
-      expect(utils.createThumbnail.mock.calls[0][1]).toEqual(280)
-    })
-
-    it('should return an object url of the image when file-added is triggered and thumbnail generation is disabled', () => {
-      const core = new Core({
-        thumbnailGeneration: false
-      }).run()
-      const file = {
-        type: 'image/jpeg',
-        isRemote: false,
-        data: 'foo'
-      }
-      core.emit('file-added', file)
-      expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
-      expect(URL.createObjectURL).toHaveBeenCalledWith('foo')
-    })
   })
 
   describe('uploading a file', () => {

+ 0 - 17
src/core/Utils.test.js

@@ -260,23 +260,6 @@ describe('core/utils', () => {
     })
   })
 
-  describe('createThumbnail', () => {
-    const RealCreateObjectUrl = global.URL.createObjectURL
-
-    beforeEach(() => {
-      global.URL.createObjectURL = jest.fn().mockReturnValue('newUrl')
-    })
-
-    afterEach(() => {
-      global.URL.createObjectURL = RealCreateObjectUrl
-    })
-
-    xit(
-      'should create a thumbnail of the specified image at the specified width',
-      () => {}
-    )
-  })
-
   describe('dataURItoBlob', () => {
     it('should convert a data uri to a blob', () => {
       const blob = utils.dataURItoBlob(sampleImageDataURI, {})

+ 29 - 9
src/plugins/Dashboard/index.js

@@ -4,11 +4,12 @@ const dragDrop = require('drag-drop')
 const DashboardUI = require('./Dashboard')
 const StatusBar = require('../StatusBar')
 const Informer = require('../Informer')
+const ThumbnailGenerator = require('../ThumbnailGenerator')
 const { findAllDOMElements, toArray } = require('../../core/Utils')
 const prettyBytes = require('prettier-bytes')
 const { defaultTabIcon } = require('./icons')
 
-// some code for managing focus was adopted from https://github.com/ghosh/micromodal
+// Some code for managing focus was adopted from https://github.com/ghosh/micromodal
 // MIT licence, https://github.com/ghosh/micromodal/blob/master/LICENSE.md
 // Copyright (c) 2017 Indrashish Ghosh
 const FOCUSABLE_ELEMENTS = [
@@ -71,6 +72,7 @@ module.exports = class Dashboard extends Plugin {
       inline: false,
       width: 750,
       height: 550,
+      thumbnailWidth: 280,
       semiTransparent: false,
       defaultTabIcon: defaultTabIcon,
       showProgressDetails: false,
@@ -78,8 +80,11 @@ module.exports = class Dashboard extends Plugin {
       hideProgressAfterFinish: false,
       note: null,
       closeModalOnClickOutside: false,
-      locale: defaultLocale,
-      onRequestCloseModal: () => this.closeModal()
+      disableStatusBar: false,
+      disableInformer: false,
+      disableThumbnailGenerator: false,
+      onRequestCloseModal: () => this.closeModal(),
+      locale: defaultLocale
     }
 
     // merge default options with the ones set by user
@@ -91,9 +96,9 @@ module.exports = class Dashboard extends Plugin {
     this.translator = new Translator({locale: this.locale})
     this.i18n = this.translator.translate.bind(this.translator)
 
+    this.openModal = this.openModal.bind(this)
     this.closeModal = this.closeModal.bind(this)
     this.requestCloseModal = this.requestCloseModal.bind(this)
-    this.openModal = this.openModal.bind(this)
     this.isModalOpen = this.isModalOpen.bind(this)
 
     this.addTarget = this.addTarget.bind(this)
@@ -202,12 +207,14 @@ module.exports = class Dashboard extends Plugin {
     })
 
     // save scroll position
-    this.savedDocumentScrollPosition = window.scrollY
+    this.savedScrollPosition = window.scrollY
+    // save active element, so we can restore focus when modal is closed
+    this.savedActiveElement = document.activeElement
 
     // add class to body that sets position fixed, move everything back
     // to scroll position
     document.body.classList.add('uppy-Dashboard-isOpen')
-    document.body.style.top = `-${this.savedDocumentScrollPosition}px`
+    document.body.style.top = `-${this.savedScrollPosition}px`
 
     this.updateDashboardElWidth()
     this.setFocusToFirstNode()
@@ -220,7 +227,9 @@ module.exports = class Dashboard extends Plugin {
 
     document.body.classList.remove('uppy-Dashboard-isOpen')
 
-    window.scrollTo(0, this.savedDocumentScrollPosition)
+    this.savedActiveElement.focus()
+
+    window.scrollTo(0, this.savedScrollPosition)
   }
 
   isModalOpen () {
@@ -505,6 +514,12 @@ module.exports = class Dashboard extends Plugin {
       })
     }
 
+    if (!this.opts.disableThumbnailGenerator) {
+      this.uppy.use(ThumbnailGenerator, {
+        thumbnailWidth: this.opts.thumbnailWidth
+      })
+    }
+
     this.discoverProviderPlugins()
 
     this.initEvents()
@@ -513,16 +528,21 @@ module.exports = class Dashboard extends Plugin {
   uninstall () {
     if (!this.opts.disableInformer) {
       const informer = this.uppy.getPlugin('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('StatusBar')
-      // Checking if this plugin exists, in case it was removed by uppy-core
-      // before the Dashboard was.
       if (statusBar) this.uppy.removePlugin(statusBar)
     }
 
+    if (!this.opts.disableThumbnailGenerator) {
+      const thumbnail = this.uppy.getPlugin('ThumbnailGenerator')
+      if (thumbnail) this.uppy.removePlugin(thumbnail)
+    }
+
     const plugins = this.opts.plugins || []
     plugins.forEach((pluginID) => {
       const plugin = this.uppy.getPlugin(pluginID)

+ 4 - 3
src/plugins/Informer.js

@@ -58,9 +58,10 @@ module.exports = class Informer extends Plugin {
           {message}
           {' '}
           {details && <span style={{ color: this.opts.typeColors[type].bg }}
-            data-balloon={details}
-            data-balloon-pos="up"
-            data-balloon-length="large">?</span>
+            aria-label={details}
+            data-microtip-position="top"
+            data-microtip-size="large"
+            role="tooltip">?</span>
           }
         </p>
       </div>

+ 4 - 3
src/plugins/StatusBar/StatusBar.js

@@ -199,9 +199,10 @@ const ProgressBarError = ({ error, retryAll, i18n }) => {
     <div class="uppy-StatusBar-content" role="alert">
       <strong>{i18n('uploadFailed')}.</strong> <span>{i18n('pleasePressRetry')}</span>
       <span class="uppy-StatusBar-details"
-        data-balloon={error}
-        data-balloon-pos="up"
-        data-balloon-length="large">?</span>
+        aria-label={error}
+        data-microtip-position="top"
+        data-microtip-size="large"
+        role="tooltip">?</span>
     </div>
   )
 }

+ 212 - 0
src/plugins/ThumbnailGenerator/index.js

@@ -0,0 +1,212 @@
+const Plugin = require('../../core/Plugin')
+const Utils = require('../../core/Utils')
+/**
+ * The Thumbnail Generator plugin
+ *
+ */
+
+module.exports = class ThumbnailGenerator extends Plugin {
+  constructor (uppy, opts) {
+    super(uppy, opts)
+    this.type = 'thumbnail'
+    this.id = 'ThumbnailGenerator'
+    this.title = 'Thumbnail Generator'
+    this.queue = []
+    this.queueProcessing = false
+
+    const defaultOptions = {
+      thumbnailWidth: 200
+    }
+
+    this.opts = Object.assign({}, defaultOptions, opts)
+
+    this.addToQueue = this.addToQueue.bind(this)
+  }
+
+  /**
+   * Create a thumbnail for the given Uppy file object.
+   *
+   * @param {{data: Blob}} file
+   * @param {number} width
+   * @return {Promise}
+   */
+  createThumbnail (file, targetWidth) {
+    const originalUrl = URL.createObjectURL(file.data)
+    const onload = new Promise((resolve, reject) => {
+      const image = new Image()
+      image.src = originalUrl
+      image.onload = () => {
+        URL.revokeObjectURL(originalUrl)
+        resolve(image)
+      }
+      image.onerror = () => {
+        // The onerror event is totally useless unfortunately, as far as I know
+        URL.revokeObjectURL(originalUrl)
+        reject(new Error('Could not create thumbnail'))
+      }
+    })
+
+    return onload
+      .then(image => {
+        const targetHeight = this.getProportionalHeight(image, targetWidth)
+        const canvas = this.resizeImage(image, targetWidth, targetHeight)
+        return this.canvasToBlob(canvas, 'image/png')
+      })
+      .then(blob => {
+        return URL.createObjectURL(blob)
+      })
+  }
+
+  /**
+   * Resize an image to the target `width` and `height`.
+   *
+   * Returns a Canvas with the resized image on it.
+   */
+  resizeImage (image, targetWidth, targetHeight) {
+    let sourceWidth = image.width
+    let sourceHeight = image.height
+
+    if (targetHeight < image.height / 2) {
+      const steps = Math.floor(
+        Math.log(image.width / targetWidth) / Math.log(2)
+      )
+      const stepScaled = this.downScaleInSteps(image, steps)
+      image = stepScaled.image
+      sourceWidth = stepScaled.sourceWidth
+      sourceHeight = stepScaled.sourceHeight
+    }
+
+    const canvas = document.createElement('canvas')
+    canvas.width = targetWidth
+    canvas.height = targetHeight
+
+    const context = canvas.getContext('2d')
+    context.drawImage(
+      image,
+      0,
+      0,
+      sourceWidth,
+      sourceHeight,
+      0,
+      0,
+      targetWidth,
+      targetHeight
+    )
+    return canvas
+  }
+
+  /**
+   * Downscale an image by 50% `steps` times.
+   */
+  downScaleInSteps (image, steps) {
+    let source = image
+    let currentWidth = source.width
+    let currentHeight = source.height
+
+    for (let i = 0; i < steps; i += 1) {
+      const canvas = document.createElement('canvas')
+      const context = canvas.getContext('2d')
+      canvas.width = currentWidth / 2
+      canvas.height = currentHeight / 2
+      context.drawImage(
+        source,
+        // The entire source image. We pass width and height here,
+        // because we reuse this canvas, and should only scale down
+        // the part of the canvas that contains the previous scale step.
+        0,
+        0,
+        currentWidth,
+        currentHeight,
+        // Draw to 50% size
+        0,
+        0,
+        currentWidth / 2,
+        currentHeight / 2
+      )
+      currentWidth /= 2
+      currentHeight /= 2
+      source = canvas
+    }
+
+    return {
+      image: source,
+      sourceWidth: currentWidth,
+      sourceHeight: currentHeight
+    }
+  }
+
+  /**
+   * Save a <canvas> element's content to a Blob object.
+   *
+   * @param {HTMLCanvasElement} canvas
+   * @return {Promise}
+   */
+  canvasToBlob (canvas, type, quality) {
+    if (canvas.toBlob) {
+      return new Promise(resolve => {
+        canvas.toBlob(resolve, type, quality)
+      })
+    }
+    return Promise.resolve().then(() => {
+      return Utils.dataURItoBlob(canvas.toDataURL(type, quality), {})
+    })
+  }
+
+  getProportionalHeight (img, width) {
+    const aspect = img.width / img.height
+    return Math.round(width / aspect)
+  }
+
+  /**
+   * Set the preview URL for a file.
+   */
+  setPreviewURL (fileID, preview) {
+    const { files } = this.uppy.state
+    this.uppy.setState({
+      files: Object.assign({}, files, {
+        [fileID]: Object.assign({}, files[fileID], {
+          preview: preview
+        })
+      })
+    })
+  }
+
+  addToQueue (item) {
+    this.queue.push(item)
+    if (this.queueProcessing === false) {
+      this.processQueue()
+    }
+  }
+
+  processQueue () {
+    this.queueProcessing = true
+    if (this.queue.length > 0) {
+      const current = this.queue.shift()
+      return this.requestThumbnail(current)
+        .catch(err => {}) // eslint-disable-line handle-callback-err
+        .then(() => this.processQueue())
+    } else {
+      this.queueProcessing = false
+    }
+  }
+
+  requestThumbnail (file) {
+    if (Utils.isPreviewSupported(file.type) && !file.isRemote) {
+      return this.createThumbnail(file, this.opts.thumbnailWidth)
+        .then(preview => {
+          this.setPreviewURL(file.id, preview)
+        })
+        .catch(err => {
+          console.warn(err.stack || err.message)
+        })
+    }
+    return Promise.resolve()
+  }
+
+  install () {
+    this.uppy.on('file-added', this.addToQueue)
+  }
+  uninstall () {
+    this.uppy.off('file-added', this.addToQueue)
+  }
+}

+ 333 - 0
src/plugins/ThumbnailGenerator/index.test.js

@@ -0,0 +1,333 @@
+import ThumbnailGeneratorPlugin from './index'
+import Plugin from '../../core/Plugin'
+
+const delay = duration => new Promise(resolve => setTimeout(resolve, duration))
+
+describe('uploader/ThumbnailGeneratorPlugin', () => {
+  it('should initialise successfully', () => {
+    const plugin = new ThumbnailGeneratorPlugin(null, {})
+    expect(plugin instanceof Plugin).toEqual(true)
+  })
+
+  it('should accept the thumbnailWidth option and override the default', () => {
+    const plugin1 = new ThumbnailGeneratorPlugin(null) // eslint-disable-line no-new
+    expect(plugin1.opts.thumbnailWidth).toEqual(200)
+
+    const plugin2 = new ThumbnailGeneratorPlugin(null, { thumbnailWidth: 100 }) // eslint-disable-line no-new
+    expect(plugin2.opts.thumbnailWidth).toEqual(100)
+  })
+
+  describe('install', () => {
+    it('should subscribe to uppy file-added event', () => {
+      const core = {
+        on: jest.fn()
+      }
+
+      const plugin = new ThumbnailGeneratorPlugin(core)
+      plugin.addToQueue = jest.fn()
+      plugin.install()
+
+      expect(core.on).toHaveBeenCalledTimes(1)
+      expect(core.on).toHaveBeenCalledWith('file-added', plugin.addToQueue)
+    })
+  })
+
+  describe('uninstall', () => {
+    it('should unsubscribe from uppy file-added event', () => {
+      const core = {
+        on: jest.fn(),
+        off: jest.fn()
+      }
+
+      const plugin = new ThumbnailGeneratorPlugin(core)
+      plugin.addToQueue = jest.fn()
+      plugin.install()
+
+      expect(core.on).toHaveBeenCalledTimes(1)
+
+      plugin.uninstall()
+
+      expect(core.off).toHaveBeenCalledTimes(1)
+      expect(core.off).toHaveBeenCalledWith('file-added', plugin.addToQueue)
+    })
+  })
+
+  describe('queue', () => {
+    it('should add a new file to the queue and start processing the queue when queueProcessing is false', () => {
+      const core = {}
+      const plugin = new ThumbnailGeneratorPlugin(core)
+      plugin.processQueue = jest.fn()
+
+      const file = { foo: 'bar' }
+      plugin.queueProcessing = false
+      plugin.addToQueue(file)
+      expect(plugin.queue).toEqual([{ foo: 'bar' }])
+      expect(plugin.processQueue).toHaveBeenCalledTimes(1)
+
+      const file2 = { foo: 'bar2' }
+      plugin.queueProcessing = true
+      plugin.addToQueue(file2)
+      expect(plugin.queue).toEqual([{ foo: 'bar' }, { foo: 'bar2' }])
+      expect(plugin.processQueue).toHaveBeenCalledTimes(1)
+    })
+
+    it('should process items in the queue one by one', () => {
+      const core = {}
+      const plugin = new ThumbnailGeneratorPlugin(core)
+
+      plugin.requestThumbnail = jest.fn(() => delay(100))
+
+      const file1 = { foo: 'bar' }
+      const file2 = { foo: 'bar2' }
+      const file3 = { foo: 'bar3' }
+      plugin.addToQueue(file1)
+      plugin.addToQueue(file2)
+      plugin.addToQueue(file3)
+
+      expect(plugin.requestThumbnail).toHaveBeenCalledTimes(1)
+      expect(plugin.requestThumbnail).toHaveBeenCalledWith(file1)
+
+      return delay(110)
+        .then(() => {
+          expect(plugin.requestThumbnail).toHaveBeenCalledTimes(2)
+          expect(plugin.requestThumbnail).toHaveBeenCalledWith(file2)
+          return delay(110)
+        })
+        .then(() => {
+          expect(plugin.requestThumbnail).toHaveBeenCalledTimes(3)
+          expect(plugin.requestThumbnail).toHaveBeenCalledWith(file3)
+          return delay(110)
+        })
+        .then(() => {
+          expect(plugin.queue).toEqual([])
+          expect(plugin.queueProcessing).toEqual(false)
+        })
+    })
+  })
+
+  describe('requestThumbnail', () => {
+    it('should call createThumbnail if it is a supported filetype', () => {
+      const core = {}
+      const plugin = new ThumbnailGeneratorPlugin(core)
+
+      plugin.createThumbnail = jest
+        .fn()
+        .mockReturnValue(Promise.resolve('preview'))
+      plugin.setPreviewURL = jest.fn()
+
+      const file = { id: 'file1', type: 'image/png', isRemote: false }
+      return plugin.requestThumbnail(file).then(() => {
+        expect(plugin.createThumbnail).toHaveBeenCalledTimes(1)
+        expect(plugin.createThumbnail).toHaveBeenCalledWith(
+          file,
+          plugin.opts.thumbnailWidth
+        )
+      })
+    })
+
+    it('should not call createThumbnail if it is not a supported filetype', () => {
+      const core = {}
+      const plugin = new ThumbnailGeneratorPlugin(core)
+
+      plugin.createThumbnail = jest
+        .fn()
+        .mockReturnValue(Promise.resolve('preview'))
+      plugin.setPreviewURL = jest.fn()
+
+      const file = { id: 'file1', type: 'text/html', isRemote: false }
+      return plugin.requestThumbnail(file).then(() => {
+        expect(plugin.createThumbnail).toHaveBeenCalledTimes(0)
+      })
+    })
+
+    it('should not call createThumbnail if the file is remote', () => {
+      const core = {}
+      const plugin = new ThumbnailGeneratorPlugin(core)
+
+      plugin.createThumbnail = jest
+        .fn()
+        .mockReturnValue(Promise.resolve('preview'))
+      plugin.setPreviewURL = jest.fn()
+
+      const file = { id: 'file1', type: 'image/png', isRemote: true }
+      return plugin.requestThumbnail(file).then(() => {
+        expect(plugin.createThumbnail).toHaveBeenCalledTimes(0)
+      })
+    })
+
+    it('should call setPreviewURL with the thumbnail image', () => {
+      const core = {}
+      const plugin = new ThumbnailGeneratorPlugin(core)
+
+      plugin.createThumbnail = jest
+        .fn()
+        .mockReturnValue(Promise.resolve('preview'))
+      plugin.setPreviewURL = jest.fn()
+
+      const file = { id: 'file1', type: 'image/png', isRemote: false }
+      return plugin.requestThumbnail(file).then(() => {
+        expect(plugin.setPreviewURL).toHaveBeenCalledTimes(1)
+        expect(plugin.setPreviewURL).toHaveBeenCalledWith('file1', 'preview')
+      })
+    })
+  })
+
+  describe('setPreviewURL', () => {
+    it('should update the preview url for the specified image', () => {
+      const core = {
+        state: {
+          files: {
+            file1: {
+              preview: 'foo'
+            },
+            file2: {
+              preview: 'boo'
+            }
+          }
+        },
+        setState: jest.fn()
+      }
+      const plugin = new ThumbnailGeneratorPlugin(core)
+      plugin.setPreviewURL('file1', 'moo')
+      expect(core.setState).toHaveBeenCalledTimes(1)
+      expect(core.setState).toHaveBeenCalledWith({
+        files: { file1: { preview: 'moo' }, file2: { preview: 'boo' } }
+      })
+    })
+  })
+
+  describe('getProportionalHeight', () => {
+    it('should calculate the resized height based on the specified width of the image whilst keeping aspect ratio', () => {
+      const core = {}
+      const plugin = new ThumbnailGeneratorPlugin(core)
+      expect(
+        plugin.getProportionalHeight({ width: 200, height: 100 }, 50)
+      ).toEqual(25)
+      expect(
+        plugin.getProportionalHeight({ width: 66, height: 66 }, 33)
+      ).toEqual(33)
+      expect(
+        plugin.getProportionalHeight({ width: 201.2, height: 198.2 }, 47)
+      ).toEqual(46)
+    })
+  })
+
+  describe('canvasToBlob', () => {
+    it('should use canvas.toBlob if available', () => {
+      const core = {}
+      const plugin = new ThumbnailGeneratorPlugin(core)
+      const canvas = {
+        toBlob: jest.fn()
+      }
+      plugin.canvasToBlob(canvas, 'type', 90)
+      expect(canvas.toBlob).toHaveBeenCalledTimes(1)
+      expect(canvas.toBlob.mock.calls[0][1]).toEqual('type')
+      expect(canvas.toBlob.mock.calls[0][2]).toEqual(90)
+    })
+  })
+
+  describe('downScaleInSteps', () => {
+    let originalDocumentCreateElement
+    let originalURLCreateObjectURL
+
+    beforeEach(() => {
+      originalDocumentCreateElement = document.createElement
+      originalURLCreateObjectURL = URL.createObjectURL
+    })
+
+    afterEach(() => {
+      document.createElement = originalDocumentCreateElement
+      URL.createObjectURL = originalURLCreateObjectURL
+    })
+
+    it('should scale down the image by the specified number of steps', () => {
+      const core = {}
+      const plugin = new ThumbnailGeneratorPlugin(core)
+      const image = {
+        width: 1000,
+        height: 800
+      }
+      const context = {
+        drawImage: jest.fn()
+      }
+      const canvas = {
+        width: 0,
+        height: 0,
+        getContext: jest.fn().mockReturnValue(context)
+      }
+      document.createElement = jest.fn().mockReturnValue(canvas)
+      const result = plugin.downScaleInSteps(image, 3)
+      const newImage = {
+        getContext: canvas.getContext,
+        height: 100,
+        width: 125
+      }
+      expect(result).toEqual({
+        image: newImage,
+        sourceWidth: 125,
+        sourceHeight: 100
+      })
+      expect(context.drawImage).toHaveBeenCalledTimes(3)
+      expect(context.drawImage.mock.calls).toEqual([
+        [{ width: 1000, height: 800 }, 0, 0, 1000, 800, 0, 0, 500, 400],
+        [
+          { width: 125, height: 100, getContext: canvas.getContext },
+          0,
+          0,
+          500,
+          400,
+          0,
+          0,
+          250,
+          200
+        ],
+        [
+          { width: 125, height: 100, getContext: canvas.getContext },
+          0,
+          0,
+          250,
+          200,
+          0,
+          0,
+          125,
+          100
+        ]
+      ])
+    })
+  })
+
+  describe('resizeImage', () => {
+    it('should return a canvas with the resized image on it', () => {
+      const core = {}
+      const plugin = new ThumbnailGeneratorPlugin(core)
+      const image = {
+        width: 1000,
+        height: 800
+      }
+      plugin.downScaleInSteps = jest.fn().mockReturnValue({
+        image: {
+          height: 160,
+          width: 200
+        },
+        sourceWidth: 200,
+        sourceHeight: 160
+      })
+      const context = {
+        drawImage: jest.fn()
+      }
+      const canvas = {
+        width: 0,
+        height: 0,
+        getContext: jest.fn().mockReturnValue(context)
+      }
+      document.createElement = jest.fn().mockReturnValue(canvas)
+
+      const result = plugin.resizeImage(image, 200, 160)
+      expect(result).toEqual({
+        width: 200,
+        height: 160,
+        getContext: canvas.getContext
+      })
+    })
+  })
+})

+ 1 - 334
src/scss/_common.scss

@@ -105,337 +105,4 @@
 .UppyButton--sizeS {
   width: 45px;
   height: 45px;
-}
-
-// Balloon tooltip
-// https://kazzkiq.github.io/balloon.css/
-
-button[data-balloon] {
-  overflow: visible; }
-
-[data-balloon] {
-  position: relative;
-  cursor: pointer; }
-  [data-balloon]:after {
-    filter: alpha(opactiy=0);
-    -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)";
-    -moz-opacity: 0;
-    -khtml-opacity: 0;
-    opacity: 0;
-    pointer-events: none;
-    -webkit-transition: all 0.18s ease-out 0.18s;
-    -moz-transition: all 0.18s ease-out 0.18s;
-    -ms-transition: all 0.18s ease-out 0.18s;
-    -o-transition: all 0.18s ease-out 0.18s;
-    transition: all 0.18s ease-out 0.18s;
-    font-family: sans-serif !important;
-    font-weight: normal !important;
-    font-style: normal !important;
-    text-shadow: none !important;
-    font-size: 12px !important;
-    background: rgba(17, 17, 17, 0.9);
-    border-radius: 4px;
-    color: #fff;
-    content: attr(data-balloon);
-    padding: .5em 1em;
-    position: absolute;
-    white-space: nowrap;
-    z-index: 10; }
-  [data-balloon]:before {
-    background: no-repeat url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http://www.w3.org/2000/svg%22%20width%3D%2236px%22%20height%3D%2212px%22%3E%3Cpath%20fill%3D%22rgba(17, 17, 17, 0.9)%22%20transform%3D%22rotate(0)%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E");
-    background-size: 100% auto;
-    width: 18px;
-    height: 6px;
-    filter: alpha(opactiy=0);
-    -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)";
-    -moz-opacity: 0;
-    -khtml-opacity: 0;
-    opacity: 0;
-    pointer-events: none;
-    -webkit-transition: all 0.18s ease-out 0.18s;
-    -moz-transition: all 0.18s ease-out 0.18s;
-    -ms-transition: all 0.18s ease-out 0.18s;
-    -o-transition: all 0.18s ease-out 0.18s;
-    transition: all 0.18s ease-out 0.18s;
-    content: '';
-    position: absolute;
-    z-index: 10; }
-  [data-balloon]:hover:before, [data-balloon]:hover:after, [data-balloon][data-balloon-visible]:before, [data-balloon][data-balloon-visible]:after {
-    filter: alpha(opactiy=100);
-    -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=100)";
-    -moz-opacity: 1;
-    -khtml-opacity: 1;
-    opacity: 1;
-    pointer-events: auto; }
-
-  [data-balloon][data-balloon-pos="up"]:after {
-    bottom: 100%;
-    left: 50%;
-    margin-bottom: 11px;
-    -webkit-transform: translate(-50%, 10px);
-    -moz-transform: translate(-50%, 10px);
-    -ms-transform: translate(-50%, 10px);
-    transform: translate(-50%, 10px);
-    -webkit-transform-origin: top;
-    -moz-transform-origin: top;
-    -ms-transform-origin: top;
-    transform-origin: top; }
-  [data-balloon][data-balloon-pos="up"]:before {
-    bottom: 100%;
-    left: 50%;
-    margin-bottom: 5px;
-    -webkit-transform: translate(-50%, 10px);
-    -moz-transform: translate(-50%, 10px);
-    -ms-transform: translate(-50%, 10px);
-    transform: translate(-50%, 10px);
-    -webkit-transform-origin: top;
-    -moz-transform-origin: top;
-    -ms-transform-origin: top;
-    transform-origin: top; }
-  [data-balloon][data-balloon-pos="up"]:hover:after, [data-balloon][data-balloon-pos="up"][data-balloon-visible]:after {
-    -webkit-transform: translate(-50%, 0);
-    -moz-transform: translate(-50%, 0);
-    -ms-transform: translate(-50%, 0);
-    transform: translate(-50%, 0); }
-  [data-balloon][data-balloon-pos="up"]:hover:before, [data-balloon][data-balloon-pos="up"][data-balloon-visible]:before {
-    -webkit-transform: translate(-50%, 0);
-    -moz-transform: translate(-50%, 0);
-    -ms-transform: translate(-50%, 0);
-    transform: translate(-50%, 0); }
-  [data-balloon][data-balloon-pos="up-left"]:after {
-    bottom: 100%;
-    left: 0;
-    margin-bottom: 11px;
-    -webkit-transform: translate(0, 10px);
-    -moz-transform: translate(0, 10px);
-    -ms-transform: translate(0, 10px);
-    transform: translate(0, 10px);
-    -webkit-transform-origin: top;
-    -moz-transform-origin: top;
-    -ms-transform-origin: top;
-    transform-origin: top; }
-  [data-balloon][data-balloon-pos="up-left"]:before {
-    bottom: 100%;
-    left: 5px;
-    margin-bottom: 5px;
-    -webkit-transform: translate(0, 10px);
-    -moz-transform: translate(0, 10px);
-    -ms-transform: translate(0, 10px);
-    transform: translate(0, 10px);
-    -webkit-transform-origin: top;
-    -moz-transform-origin: top;
-    -ms-transform-origin: top;
-    transform-origin: top; }
-  [data-balloon][data-balloon-pos="up-left"]:hover:after, [data-balloon][data-balloon-pos="up-left"][data-balloon-visible]:after {
-    -webkit-transform: translate(0, 0);
-    -moz-transform: translate(0, 0);
-    -ms-transform: translate(0, 0);
-    transform: translate(0, 0); }
-  [data-balloon][data-balloon-pos="up-left"]:hover:before, [data-balloon][data-balloon-pos="up-left"][data-balloon-visible]:before {
-    -webkit-transform: translate(0, 0);
-    -moz-transform: translate(0, 0);
-    -ms-transform: translate(0, 0);
-    transform: translate(0, 0); }
-  [data-balloon][data-balloon-pos="up-right"]:after {
-    bottom: 100%;
-    right: 0;
-    margin-bottom: 11px;
-    -webkit-transform: translate(0, 10px);
-    -moz-transform: translate(0, 10px);
-    -ms-transform: translate(0, 10px);
-    transform: translate(0, 10px);
-    -webkit-transform-origin: top;
-    -moz-transform-origin: top;
-    -ms-transform-origin: top;
-    transform-origin: top; }
-  [data-balloon][data-balloon-pos="up-right"]:before {
-    bottom: 100%;
-    right: 5px;
-    margin-bottom: 5px;
-    -webkit-transform: translate(0, 10px);
-    -moz-transform: translate(0, 10px);
-    -ms-transform: translate(0, 10px);
-    transform: translate(0, 10px);
-    -webkit-transform-origin: top;
-    -moz-transform-origin: top;
-    -ms-transform-origin: top;
-    transform-origin: top; }
-  [data-balloon][data-balloon-pos="up-right"]:hover:after, [data-balloon][data-balloon-pos="up-right"][data-balloon-visible]:after {
-    -webkit-transform: translate(0, 0);
-    -moz-transform: translate(0, 0);
-    -ms-transform: translate(0, 0);
-    transform: translate(0, 0); }
-  [data-balloon][data-balloon-pos="up-right"]:hover:before, [data-balloon][data-balloon-pos="up-right"][data-balloon-visible]:before {
-    -webkit-transform: translate(0, 0);
-    -moz-transform: translate(0, 0);
-    -ms-transform: translate(0, 0);
-    transform: translate(0, 0); }
-  [data-balloon][data-balloon-pos='down']:after {
-    left: 50%;
-    margin-top: 11px;
-    top: 100%;
-    -webkit-transform: translate(-50%, -10px);
-    -moz-transform: translate(-50%, -10px);
-    -ms-transform: translate(-50%, -10px);
-    transform: translate(-50%, -10px); }
-  [data-balloon][data-balloon-pos='down']:before {
-    background: no-repeat url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http://www.w3.org/2000/svg%22%20width%3D%2236px%22%20height%3D%2212px%22%3E%3Cpath%20fill%3D%22rgba(17, 17, 17, 0.9)%22%20transform%3D%22rotate(180 18 6)%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E");
-    background-size: 100% auto;
-    width: 18px;
-    height: 6px;
-    left: 50%;
-    margin-top: 5px;
-    top: 100%;
-    -webkit-transform: translate(-50%, -10px);
-    -moz-transform: translate(-50%, -10px);
-    -ms-transform: translate(-50%, -10px);
-    transform: translate(-50%, -10px); }
-  [data-balloon][data-balloon-pos='down']:hover:after, [data-balloon][data-balloon-pos='down'][data-balloon-visible]:after {
-    -webkit-transform: translate(-50%, 0);
-    -moz-transform: translate(-50%, 0);
-    -ms-transform: translate(-50%, 0);
-    transform: translate(-50%, 0); }
-  [data-balloon][data-balloon-pos='down']:hover:before, [data-balloon][data-balloon-pos='down'][data-balloon-visible]:before {
-    -webkit-transform: translate(-50%, 0);
-    -moz-transform: translate(-50%, 0);
-    -ms-transform: translate(-50%, 0);
-    transform: translate(-50%, 0); }
-  [data-balloon][data-balloon-pos='down-left']:after {
-    left: 0;
-    margin-top: 11px;
-    top: 100%;
-    -webkit-transform: translate(0, -10px);
-    -moz-transform: translate(0, -10px);
-    -ms-transform: translate(0, -10px);
-    transform: translate(0, -10px); }
-  [data-balloon][data-balloon-pos='down-left']:before {
-    background: no-repeat url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http://www.w3.org/2000/svg%22%20width%3D%2236px%22%20height%3D%2212px%22%3E%3Cpath%20fill%3D%22rgba(17, 17, 17, 0.9)%22%20transform%3D%22rotate(180 18 6)%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E");
-    background-size: 100% auto;
-    width: 18px;
-    height: 6px;
-    left: 5px;
-    margin-top: 5px;
-    top: 100%;
-    -webkit-transform: translate(0, -10px);
-    -moz-transform: translate(0, -10px);
-    -ms-transform: translate(0, -10px);
-    transform: translate(0, -10px); }
-  [data-balloon][data-balloon-pos='down-left']:hover:after, [data-balloon][data-balloon-pos='down-left'][data-balloon-visible]:after {
-    -webkit-transform: translate(0, 0);
-    -moz-transform: translate(0, 0);
-    -ms-transform: translate(0, 0);
-    transform: translate(0, 0); }
-  [data-balloon][data-balloon-pos='down-left']:hover:before, [data-balloon][data-balloon-pos='down-left'][data-balloon-visible]:before {
-    -webkit-transform: translate(0, 0);
-    -moz-transform: translate(0, 0);
-    -ms-transform: translate(0, 0);
-    transform: translate(0, 0); }
-  [data-balloon][data-balloon-pos='down-right']:after {
-    right: 0;
-    margin-top: 11px;
-    top: 100%;
-    -webkit-transform: translate(0, -10px);
-    -moz-transform: translate(0, -10px);
-    -ms-transform: translate(0, -10px);
-    transform: translate(0, -10px); }
-  [data-balloon][data-balloon-pos='down-right']:before {
-    background: no-repeat url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http://www.w3.org/2000/svg%22%20width%3D%2236px%22%20height%3D%2212px%22%3E%3Cpath%20fill%3D%22rgba(17, 17, 17, 0.9)%22%20transform%3D%22rotate(180 18 6)%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E");
-    background-size: 100% auto;
-    width: 18px;
-    height: 6px;
-    right: 5px;
-    margin-top: 5px;
-    top: 100%;
-    -webkit-transform: translate(0, -10px);
-    -moz-transform: translate(0, -10px);
-    -ms-transform: translate(0, -10px);
-    transform: translate(0, -10px); }
-  [data-balloon][data-balloon-pos='down-right']:hover:after, [data-balloon][data-balloon-pos='down-right'][data-balloon-visible]:after {
-    -webkit-transform: translate(0, 0);
-    -moz-transform: translate(0, 0);
-    -ms-transform: translate(0, 0);
-    transform: translate(0, 0); }
-  [data-balloon][data-balloon-pos='down-right']:hover:before, [data-balloon][data-balloon-pos='down-right'][data-balloon-visible]:before {
-    -webkit-transform: translate(0, 0);
-    -moz-transform: translate(0, 0);
-    -ms-transform: translate(0, 0);
-    transform: translate(0, 0); }
-  [data-balloon][data-balloon-pos='left']:after {
-    margin-right: 11px;
-    right: 100%;
-    top: 50%;
-    -webkit-transform: translate(10px, -50%);
-    -moz-transform: translate(10px, -50%);
-    -ms-transform: translate(10px, -50%);
-    transform: translate(10px, -50%); }
-  [data-balloon][data-balloon-pos='left']:before {
-    background: no-repeat url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http://www.w3.org/2000/svg%22%20width%3D%2212px%22%20height%3D%2236px%22%3E%3Cpath%20fill%3D%22rgba(17, 17, 17, 0.9)%22%20transform%3D%22rotate(-90 18 18)%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E");
-    background-size: 100% auto;
-    width: 6px;
-    height: 18px;
-    margin-right: 5px;
-    right: 100%;
-    top: 50%;
-    -webkit-transform: translate(10px, -50%);
-    -moz-transform: translate(10px, -50%);
-    -ms-transform: translate(10px, -50%);
-    transform: translate(10px, -50%); }
-  [data-balloon][data-balloon-pos='left']:hover:after, [data-balloon][data-balloon-pos='left'][data-balloon-visible]:after {
-    -webkit-transform: translate(0, -50%);
-    -moz-transform: translate(0, -50%);
-    -ms-transform: translate(0, -50%);
-    transform: translate(0, -50%); }
-  [data-balloon][data-balloon-pos='left']:hover:before, [data-balloon][data-balloon-pos='left'][data-balloon-visible]:before {
-    -webkit-transform: translate(0, -50%);
-    -moz-transform: translate(0, -50%);
-    -ms-transform: translate(0, -50%);
-    transform: translate(0, -50%); }
-  [data-balloon][data-balloon-pos='right']:after {
-    left: 100%;
-    margin-left: 11px;
-    top: 50%;
-    -webkit-transform: translate(-10px, -50%);
-    -moz-transform: translate(-10px, -50%);
-    -ms-transform: translate(-10px, -50%);
-    transform: translate(-10px, -50%); }
-  [data-balloon][data-balloon-pos='right']:before {
-    background: no-repeat url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http://www.w3.org/2000/svg%22%20width%3D%2212px%22%20height%3D%2236px%22%3E%3Cpath%20fill%3D%22rgba(17, 17, 17, 0.9)%22%20transform%3D%22rotate(90 6 6)%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E");
-    background-size: 100% auto;
-    width: 6px;
-    height: 18px;
-    left: 100%;
-    margin-left: 5px;
-    top: 50%;
-    -webkit-transform: translate(-10px, -50%);
-    -moz-transform: translate(-10px, -50%);
-    -ms-transform: translate(-10px, -50%);
-    transform: translate(-10px, -50%); }
-  [data-balloon][data-balloon-pos='right']:hover:after, [data-balloon][data-balloon-pos='right'][data-balloon-visible]:after {
-    -webkit-transform: translate(0, -50%);
-    -moz-transform: translate(0, -50%);
-    -ms-transform: translate(0, -50%);
-    transform: translate(0, -50%); }
-  [data-balloon][data-balloon-pos='right']:hover:before, [data-balloon][data-balloon-pos='right'][data-balloon-visible]:before {
-    -webkit-transform: translate(0, -50%);
-    -moz-transform: translate(0, -50%);
-    -ms-transform: translate(0, -50%);
-    transform: translate(0, -50%); }
-  [data-balloon][data-balloon-length='small']:after {
-    white-space: normal;
-    width: 80px; }
-  [data-balloon][data-balloon-length='medium']:after {
-    white-space: normal;
-    width: 150px; }
-  [data-balloon][data-balloon-length='large']:after {
-    white-space: normal;
-    width: 260px; }
-  [data-balloon][data-balloon-length='xlarge']:after {
-    white-space: normal;
-    width: 380px; }
-    @media screen and (max-width: 768px) {
-      [data-balloon][data-balloon-length='xlarge']:after {
-        white-space: normal;
-        width: 90vw; } }
-  [data-balloon][data-balloon-length='fit']:after {
-    white-space: normal;
-    width: 100%; }
+}

+ 266 - 0
src/scss/_microtip.scss

@@ -0,0 +1,266 @@
+/* -------------------------------------------------------------------
+  Microtip
+
+  Modern, lightweight css-only tooltips
+  Just 1kb minified and gzipped
+
+  @author Ghosh
+  @package Microtip
+
+----------------------------------------------------------------------
+  1. Base Styles
+  2. Direction Modifiers
+  3. Position Modifiers
+--------------------------------------------------------------------*/
+
+
+/* ------------------------------------------------
+  [1] Base Styles
+-------------------------------------------------*/
+
+[aria-label][role~="tooltip"] {
+  position: relative;
+}
+
+[aria-label][role~="tooltip"]::before,
+[aria-label][role~="tooltip"]::after {
+  transform: translate3d(0, 0, 0);
+  -webkit-backface-visibility: hidden;
+  backface-visibility: hidden;
+  will-change: transform;
+  opacity: 0;
+  pointer-events: none;
+  transition: all var(--microtip-transition-duration, .18s) var(--microtip-transition-easing, ease-in-out) var(--microtip-transition-delay, 0s);
+  position: absolute;
+  box-sizing: border-box;
+  z-index: 10;
+  transform-origin: top;
+}
+
+[aria-label][role~="tooltip"]::before {
+  background-size: 100% auto !important;
+  content: "";
+}
+
+[aria-label][role~="tooltip"]::after {
+  background: rgba(17, 17, 17, .9);
+  border-radius: 4px;
+  color: #ffffff;
+  content: attr(aria-label);
+  font-size: var(--microtip-font-size, 13px);
+  font-weight: var(--microtip-font-weight, normal);
+  text-transform: var(--microtip-text-transform, none);
+  padding: .5em 1em;
+  white-space: nowrap;
+  box-sizing: content-box;
+}
+
+[aria-label][role~="tooltip"]:hover::before,
+[aria-label][role~="tooltip"]:hover::after,
+[aria-label][role~="tooltip"]:focus::before,
+[aria-label][role~="tooltip"]:focus::after {
+  opacity: 1;
+  pointer-events: auto;
+}
+
+
+
+/* ------------------------------------------------
+  [2] Position Modifiers
+-------------------------------------------------*/
+
+[role~="tooltip"][data-microtip-position|="top"]::before {
+  background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2236px%22%20height%3D%2212px%22%3E%3Cpath%20fill%3D%22rgba%2817,%2017,%2017,%200.9%29%22%20transform%3D%22rotate%280%29%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E") no-repeat;
+  height: 6px;
+  width: 18px;
+  margin-bottom: 5px;
+}
+
+[role~="tooltip"][data-microtip-position|="top"]::after {
+  margin-bottom: 11px;
+}
+
+[role~="tooltip"][data-microtip-position|="top"]::before {
+  transform: translate3d(-50%, 0, 0);
+  bottom: 100%;
+  left: 50%;
+}
+
+[role~="tooltip"][data-microtip-position|="top"]:hover::before {
+  transform: translate3d(-50%, -5px, 0);
+}
+
+[role~="tooltip"][data-microtip-position|="top"]::after {
+  transform: translate3d(-50%, 0, 0);
+  bottom: 100%;
+  left: 50%;
+}
+
+[role~="tooltip"][data-microtip-position="top"]:hover::after {
+  transform: translate3d(-50%, -5px, 0);
+}
+
+/* ------------------------------------------------
+  [2.1] Top Left
+-------------------------------------------------*/
+[role~="tooltip"][data-microtip-position="top-left"]::after {
+  transform: translate3d(calc(-100% + 16px), 0, 0);
+  bottom: 100%;
+}
+
+[role~="tooltip"][data-microtip-position="top-left"]:hover::after {
+  transform: translate3d(calc(-100% + 16px), -5px, 0);
+}
+
+
+/* ------------------------------------------------
+  [2.2] Top Right
+-------------------------------------------------*/
+[role~="tooltip"][data-microtip-position="top-right"]::after {
+  transform: translate3d(calc(0% + -16px), 0, 0);
+  bottom: 100%;
+}
+
+[role~="tooltip"][data-microtip-position="top-right"]:hover::after {
+  transform: translate3d(calc(0% + -16px), -5px, 0);
+}
+
+
+/* ------------------------------------------------
+  [2.3] Bottom
+-------------------------------------------------*/
+[role~="tooltip"][data-microtip-position|="bottom"]::before {
+  background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2236px%22%20height%3D%2212px%22%3E%3Cpath%20fill%3D%22rgba%2817,%2017,%2017,%200.9%29%22%20transform%3D%22rotate%28180%2018%206%29%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E") no-repeat;
+  height: 6px;
+  width: 18px;
+  margin-top: 5px;
+  margin-bottom: 0;
+}
+
+[role~="tooltip"][data-microtip-position|="bottom"]::after {
+  margin-top: 11px;
+}
+
+[role~="tooltip"][data-microtip-position|="bottom"]::before {
+  transform: translate3d(-50%, -10px, 0);
+  bottom: auto;
+  left: 50%;
+  top: 100%;
+}
+
+[role~="tooltip"][data-microtip-position|="bottom"]:hover::before {
+  transform: translate3d(-50%, 0, 0);
+}
+
+[role~="tooltip"][data-microtip-position|="bottom"]::after {
+  transform: translate3d(-50%, -10px, 0);
+  top: 100%;
+  left: 50%;
+}
+
+[role~="tooltip"][data-microtip-position="bottom"]:hover::after {
+  transform: translate3d(-50%, 0, 0);
+}
+
+
+/* ------------------------------------------------
+  [2.4] Bottom Left
+-------------------------------------------------*/
+[role~="tooltip"][data-microtip-position="bottom-left"]::after {
+  transform: translate3d(calc(-100% + 16px), -10px, 0);
+  top: 100%;
+}
+
+[role~="tooltip"][data-microtip-position="bottom-left"]:hover::after {
+  transform: translate3d(calc(-100% + 16px), 0, 0);
+}
+
+
+/* ------------------------------------------------
+  [2.5] Bottom Right
+-------------------------------------------------*/
+[role~="tooltip"][data-microtip-position="bottom-right"]::after {
+  transform: translate3d(calc(0% + -16px), -10px, 0);
+  top: 100%;
+}
+
+[role~="tooltip"][data-microtip-position="bottom-right"]:hover::after {
+  transform: translate3d(calc(0% + -16px), 0, 0);
+}
+
+
+/* ------------------------------------------------
+  [2.6] Left
+-------------------------------------------------*/
+[role~="tooltip"][data-microtip-position="left"]::before,
+[role~="tooltip"][data-microtip-position="left"]::after {
+  bottom: auto;
+  left: auto;
+  right: 100%;
+  top: 50%;
+  transform: translate3d(10px, -50%, 0);
+}
+
+[role~="tooltip"][data-microtip-position="left"]::before {
+  background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2212px%22%20height%3D%2236px%22%3E%3Cpath%20fill%3D%22rgba%2817,%2017,%2017,%200.9%29%22%20transform%3D%22rotate%28-90%2018%2018%29%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E") no-repeat;
+  height: 18px;
+  width: 6px;
+  margin-right: 5px;
+  margin-bottom: 0;
+}
+
+[role~="tooltip"][data-microtip-position="left"]::after {
+  margin-right: 11px;
+}
+
+[role~="tooltip"][data-microtip-position="left"]:hover::before,
+[role~="tooltip"][data-microtip-position="left"]:hover::after {
+  transform: translate3d(0, -50%, 0);
+}
+
+
+/* ------------------------------------------------
+  [2.7] Right
+-------------------------------------------------*/
+[role~="tooltip"][data-microtip-position="right"]::before,
+[role~="tooltip"][data-microtip-position="right"]::after {
+  bottom: auto;
+  left: 100%;
+  top: 50%;
+  transform: translate3d(-10px, -50%, 0);
+}
+
+[role~="tooltip"][data-microtip-position="right"]::before {
+  background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2212px%22%20height%3D%2236px%22%3E%3Cpath%20fill%3D%22rgba%2817,%2017,%2017,%200.9%29%22%20transform%3D%22rotate%2890%206%206%29%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E") no-repeat;
+  height: 18px;
+  width: 6px;
+  margin-bottom: 0;
+  margin-left: 5px;
+}
+
+[role~="tooltip"][data-microtip-position="right"]::after {
+  margin-left: 11px;
+}
+
+[role~="tooltip"][data-microtip-position="right"]:hover::before,
+[role~="tooltip"][data-microtip-position="right"]:hover::after {
+  transform: translate3d(0, -50%, 0);
+}
+
+/* ------------------------------------------------
+  [3] Size
+-------------------------------------------------*/
+[role~="tooltip"][data-microtip-size="small"]::after {
+  white-space: initial;
+  width: 80px;
+}
+
+[role~="tooltip"][data-microtip-size="medium"]::after {
+  white-space: initial;
+  width: 150px;
+}
+
+[role~="tooltip"][data-microtip-size="large"]::after {
+  white-space: initial;
+  width: 260px;
+}

+ 1 - 0
src/scss/uppy.scss

@@ -5,6 +5,7 @@
 @import '_variables.scss';
 @import '_utils.scss';
 @import '_animation.scss';
+@import '_microtip.scss';
 @import '_common.scss';
 @import '_fileinput.scss';
 @import '_informer.scss';