Kaynağa Gözat

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

Kevin van Zonneveld 5 yıl önce
ebeveyn
işleme
9731431913
45 değiştirilmiş dosya ile 884 ekleme ve 617 silme
  1. 12 2
      .eslintrc.json
  2. 8 18
      .github/CONTRIBUTING.md
  3. 69 47
      package-lock.json
  4. 1 0
      package.json
  5. 8 2
      packages/@uppy/core/src/Plugin.js
  6. 9 8
      packages/@uppy/core/src/index.js
  7. 6 0
      packages/@uppy/core/src/index.test.js
  8. 8 6
      packages/@uppy/core/types/index.d.ts
  9. 2 0
      packages/@uppy/dashboard/package.json
  10. 3 1
      packages/@uppy/dashboard/src/components/AddFiles.js
  11. 2 10
      packages/@uppy/dashboard/src/components/FileCard.js
  12. 40 30
      packages/@uppy/dashboard/src/components/FileItem.js
  13. 4 1
      packages/@uppy/dashboard/src/components/FileList.js
  14. 3 3
      packages/@uppy/dashboard/src/components/PickerPanelContent.js
  15. 88 69
      packages/@uppy/dashboard/src/index.js
  16. 30 15
      packages/@uppy/dashboard/src/style.scss
  17. 43 0
      packages/@uppy/dashboard/src/utils/createSuperFocus.js
  18. 10 0
      packages/@uppy/dashboard/src/utils/createSuperFocus.test.js
  19. 11 0
      packages/@uppy/dashboard/src/utils/getActiveOverlayEl.js
  20. 65 0
      packages/@uppy/dashboard/src/utils/trapFocus.js
  21. 2 15
      packages/@uppy/provider-views/src/AuthView.js
  22. 3 2
      packages/@uppy/provider-views/src/Browser.js
  23. 7 9
      packages/@uppy/provider-views/src/Filter.js
  24. 22 0
      packages/@uppy/provider-views/src/Item/components/GridLi.js
  25. 4 54
      packages/@uppy/provider-views/src/Item/components/ItemIcon.js
  26. 48 0
      packages/@uppy/provider-views/src/Item/components/ListLi.js
  27. 26 0
      packages/@uppy/provider-views/src/Item/index.js
  28. 27 32
      packages/@uppy/provider-views/src/ItemList.js
  29. 1 0
      packages/@uppy/provider-views/src/index.js
  30. 4 235
      packages/@uppy/provider-views/src/style.scss
  31. 92 0
      packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--grid.scss
  32. 66 0
      packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--list.scss
  33. 14 0
      packages/@uppy/provider-views/src/style/uppy-ProviderBrowserItem-fakeCheckbox.scss
  34. 8 4
      packages/@uppy/status-bar/src/StatusBar.js
  35. 2 8
      packages/@uppy/url/src/UrlUI.js
  36. 7 4
      packages/@uppy/url/src/utils/forEachDroppedOrPastedUrl.js
  37. 13 0
      packages/@uppy/utils/src/FOCUSABLE_ELEMENTS.js
  38. 7 5
      packages/@uppy/utils/src/getDroppedFiles/index.js
  39. 22 11
      packages/@uppy/utils/src/getDroppedFiles/utils/webkitGetAsEntryApi.js
  40. 1 2
      packages/@uppy/webcam/src/CameraScreen.js
  41. 4 2
      packages/@uppy/webcam/src/RecordButton.js
  42. 2 1
      packages/@uppy/webcam/src/SnapshotButton.js
  43. 8 18
      website/src/_template/contributing.md
  44. 6 2
      website/src/docs/uppy.md
  45. 66 1
      website/src/docs/writing-plugins.md

+ 12 - 2
.eslintrc.json

@@ -9,10 +9,20 @@
     "window": true,
     "window": true,
     "hexo": true
     "hexo": true
   },
   },
-  "plugins": ["jest", "compat"],
+  "plugins": ["jest", "compat", "jsdoc"],
   "rules": {
   "rules": {
     "jsx-quotes": ["error", "prefer-double"],
     "jsx-quotes": ["error", "prefer-double"],
-    "compat/compat": ["error"]
+    "compat/compat": ["error"],
+
+    "jsdoc/check-alignment": ["warn"],
+    "jsdoc/check-examples": ["warn"],
+    "jsdoc/check-indentation": ["warn"],
+    "jsdoc/check-param-names": ["warn"],
+    "jsdoc/check-syntax": ["warn"],
+    "jsdoc/check-tag-names": ["warn"],
+    "jsdoc/check-types": ["warn"],
+    "jsdoc/newline-after-description": ["warn"],
+    "jsdoc/valid-types": ["warn"]
   },
   },
   "settings": {
   "settings": {
     "polyfills": [
     "polyfills": [

+ 8 - 18
.github/CONTRIBUTING.md

@@ -122,28 +122,18 @@ Even though bundled in this repo, the website is regarded as a separate project.
 
 
 ### Local previews
 ### Local previews
 
 
-It is recommended to exclude `./website/public/` from your editor if you want efficient searches.
-
-To install the required node modules, type:
-
-```bash
-npm install && cd website && npm install && cd ..
-```
-
-For local previews on http://localhost:4000, type:
-
-```bash
-npm run web:start # that gets you just the website. if you need companion, etc. you can use `npm start` instead
-```
-
-This will watch the website, as well as Uppy, as the examples, and rebuild everything and refresh your browser as files change.
+1. `npm install`
+2. `npm run bootstrap`
+3. `cd website && npm install && cd ..`
+4. `npm start`
+5. Go to http://localhost:4000. Your changes in `/website` and `/packages/@uppy` will be watched, your browser will refresh as files change.
 
 
 Then, to work on, for instance, the XHRUpload example, you would edit the following files:
 Then, to work on, for instance, the XHRUpload example, you would edit the following files:
 
 
 ```bash
 ```bash
-${EDITOR} src/core/Core.js \
-  src/plugins/XHRUpload.js \
-  src/plugins/Plugin.js \
+${EDITOR} packages/@uppy/core/src/index.js \
+  packages/@uppy/core/src/Plugin.js \
+  packages/@uppy/xhr-upload/src/index.js \
   website/src/examples/xhrupload/app.es6
   website/src/examples/xhrupload/app.es6
 ```
 ```
 
 

+ 69 - 47
package-lock.json

@@ -8188,6 +8188,12 @@
         "graceful-readlink": ">= 1.0.0"
         "graceful-readlink": ">= 1.0.0"
       }
       }
     },
     },
+    "comment-parser": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-0.5.4.tgz",
+      "integrity": "sha512-0h7W6Y1Kb6zKQMJqdX41C5qf9ITCVIsD2qP2RaqDF3GFkXFrmuAuv5zUOuo19YzyC9scjBNpqzuaRQ2Sy5pxMQ==",
+      "dev": true
+    },
     "common-shakeify": {
     "common-shakeify": {
       "version": "0.5.4",
       "version": "0.5.4",
       "resolved": "https://registry.npmjs.org/common-shakeify/-/common-shakeify-0.5.4.tgz",
       "resolved": "https://registry.npmjs.org/common-shakeify/-/common-shakeify-0.5.4.tgz",
@@ -11500,6 +11506,17 @@
       "integrity": "sha512-c3WjZR/HBoi4GedJRwo2OGHa8Pzo1EbSVwQ2HFzJ+4t2OoYM7Alx646EH/aaxZ+9eGcPiq0FT0UGkRuFFx2FHg==",
       "integrity": "sha512-c3WjZR/HBoi4GedJRwo2OGHa8Pzo1EbSVwQ2HFzJ+4t2OoYM7Alx646EH/aaxZ+9eGcPiq0FT0UGkRuFFx2FHg==",
       "dev": true
       "dev": true
     },
     },
+    "eslint-plugin-jsdoc": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-5.0.2.tgz",
+      "integrity": "sha512-ACSu4NEEG5KZK7liCZz9jm5f5hFHcCL29zsN0RTixIZe1kuZOVO3oVbvnpe6o/U/3h9dMLJ42Yhe6umBS6aO7A==",
+      "dev": true,
+      "requires": {
+        "comment-parser": "^0.5.4",
+        "jsdoctypeparser": "3.1.0",
+        "lodash": "^4.17.11"
+      }
+    },
     "eslint-plugin-node": {
     "eslint-plugin-node": {
       "version": "8.0.1",
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-8.0.1.tgz",
       "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-8.0.1.tgz",
@@ -12759,20 +12776,20 @@
       "dependencies": {
       "dependencies": {
         "abbrev": {
         "abbrev": {
           "version": "1.1.1",
           "version": "1.1.1",
-          "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+          "resolved": false,
           "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
           "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
         },
         },
         "ansi-regex": {
         "ansi-regex": {
           "version": "2.1.1",
           "version": "2.1.1",
-          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+          "resolved": false,
           "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
           "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
           "dev": true
           "dev": true
         },
         },
         "aproba": {
         "aproba": {
           "version": "1.2.0",
           "version": "1.2.0",
-          "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+          "resolved": false,
           "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
           "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
@@ -12790,13 +12807,13 @@
         },
         },
         "balanced-match": {
         "balanced-match": {
           "version": "1.0.0",
           "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+          "resolved": false,
           "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
           "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
           "dev": true
           "dev": true
         },
         },
         "brace-expansion": {
         "brace-expansion": {
           "version": "1.1.11",
           "version": "1.1.11",
-          "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+          "resolved": false,
           "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
           "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
           "dev": true,
           "dev": true,
           "requires": {
           "requires": {
@@ -12806,25 +12823,25 @@
         },
         },
         "code-point-at": {
         "code-point-at": {
           "version": "1.1.0",
           "version": "1.1.0",
-          "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+          "resolved": false,
           "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
           "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
           "dev": true
           "dev": true
         },
         },
         "concat-map": {
         "concat-map": {
           "version": "0.0.1",
           "version": "0.0.1",
-          "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+          "resolved": false,
           "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
           "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
           "dev": true
           "dev": true
         },
         },
         "console-control-strings": {
         "console-control-strings": {
           "version": "1.1.0",
           "version": "1.1.0",
-          "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+          "resolved": false,
           "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
           "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
           "dev": true
           "dev": true
         },
         },
         "core-util-is": {
         "core-util-is": {
           "version": "1.0.2",
           "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+          "resolved": false,
           "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
           "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
@@ -12848,28 +12865,28 @@
         },
         },
         "delegates": {
         "delegates": {
           "version": "1.0.0",
           "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+          "resolved": false,
           "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
           "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
         },
         },
         "detect-libc": {
         "detect-libc": {
           "version": "1.0.3",
           "version": "1.0.3",
-          "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+          "resolved": false,
           "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
           "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
         },
         },
         "fs.realpath": {
         "fs.realpath": {
           "version": "1.0.0",
           "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+          "resolved": false,
           "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
           "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
         },
         },
         "gauge": {
         "gauge": {
           "version": "2.7.4",
           "version": "2.7.4",
-          "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+          "resolved": false,
           "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
           "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
           "dev": true,
           "dev": true,
           "optional": true,
           "optional": true,
@@ -12901,7 +12918,7 @@
         },
         },
         "has-unicode": {
         "has-unicode": {
           "version": "2.0.1",
           "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+          "resolved": false,
           "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
           "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
@@ -12918,7 +12935,7 @@
         },
         },
         "ignore-walk": {
         "ignore-walk": {
           "version": "3.0.1",
           "version": "3.0.1",
-          "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz",
+          "resolved": false,
           "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==",
           "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==",
           "dev": true,
           "dev": true,
           "optional": true,
           "optional": true,
@@ -12928,7 +12945,7 @@
         },
         },
         "inflight": {
         "inflight": {
           "version": "1.0.6",
           "version": "1.0.6",
-          "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+          "resolved": false,
           "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
           "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
           "dev": true,
           "dev": true,
           "optional": true,
           "optional": true,
@@ -12939,20 +12956,20 @@
         },
         },
         "inherits": {
         "inherits": {
           "version": "2.0.3",
           "version": "2.0.3",
-          "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+          "resolved": false,
           "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
           "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
           "dev": true
           "dev": true
         },
         },
         "ini": {
         "ini": {
           "version": "1.3.5",
           "version": "1.3.5",
-          "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
+          "resolved": false,
           "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
           "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
         },
         },
         "is-fullwidth-code-point": {
         "is-fullwidth-code-point": {
           "version": "1.0.0",
           "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+          "resolved": false,
           "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
           "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
           "dev": true,
           "dev": true,
           "requires": {
           "requires": {
@@ -12961,14 +12978,14 @@
         },
         },
         "isarray": {
         "isarray": {
           "version": "1.0.0",
           "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+          "resolved": false,
           "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
           "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
         },
         },
         "minimatch": {
         "minimatch": {
           "version": "3.0.4",
           "version": "3.0.4",
-          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+          "resolved": false,
           "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
           "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
           "dev": true,
           "dev": true,
           "requires": {
           "requires": {
@@ -12977,13 +12994,13 @@
         },
         },
         "minimist": {
         "minimist": {
           "version": "0.0.8",
           "version": "0.0.8",
-          "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
+          "resolved": false,
           "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
           "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
           "dev": true
           "dev": true
         },
         },
         "mkdirp": {
         "mkdirp": {
           "version": "0.5.1",
           "version": "0.5.1",
-          "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+          "resolved": false,
           "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
           "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
           "dev": true,
           "dev": true,
           "requires": {
           "requires": {
@@ -13030,7 +13047,7 @@
         },
         },
         "nopt": {
         "nopt": {
           "version": "4.0.1",
           "version": "4.0.1",
-          "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz",
+          "resolved": false,
           "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
           "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
           "dev": true,
           "dev": true,
           "optional": true,
           "optional": true,
@@ -13059,7 +13076,7 @@
         },
         },
         "npmlog": {
         "npmlog": {
           "version": "4.1.2",
           "version": "4.1.2",
-          "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
+          "resolved": false,
           "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
           "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
           "dev": true,
           "dev": true,
           "optional": true,
           "optional": true,
@@ -13072,20 +13089,20 @@
         },
         },
         "number-is-nan": {
         "number-is-nan": {
           "version": "1.0.1",
           "version": "1.0.1",
-          "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+          "resolved": false,
           "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
           "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
           "dev": true
           "dev": true
         },
         },
         "object-assign": {
         "object-assign": {
           "version": "4.1.1",
           "version": "4.1.1",
-          "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+          "resolved": false,
           "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
           "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
         },
         },
         "once": {
         "once": {
           "version": "1.4.0",
           "version": "1.4.0",
-          "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+          "resolved": false,
           "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
           "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
           "dev": true,
           "dev": true,
           "requires": {
           "requires": {
@@ -13094,21 +13111,21 @@
         },
         },
         "os-homedir": {
         "os-homedir": {
           "version": "1.0.2",
           "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+          "resolved": false,
           "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
           "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
         },
         },
         "os-tmpdir": {
         "os-tmpdir": {
           "version": "1.0.2",
           "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+          "resolved": false,
           "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
           "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
         },
         },
         "osenv": {
         "osenv": {
           "version": "0.1.5",
           "version": "0.1.5",
-          "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz",
+          "resolved": false,
           "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
           "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
           "dev": true,
           "dev": true,
           "optional": true,
           "optional": true,
@@ -13119,14 +13136,14 @@
         },
         },
         "path-is-absolute": {
         "path-is-absolute": {
           "version": "1.0.1",
           "version": "1.0.1",
-          "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+          "resolved": false,
           "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
           "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
         },
         },
         "process-nextick-args": {
         "process-nextick-args": {
           "version": "2.0.0",
           "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz",
+          "resolved": false,
           "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
           "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
@@ -13146,7 +13163,7 @@
           "dependencies": {
           "dependencies": {
             "minimist": {
             "minimist": {
               "version": "1.2.0",
               "version": "1.2.0",
-              "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+              "resolved": false,
               "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
               "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
               "dev": true,
               "dev": true,
               "optional": true
               "optional": true
@@ -13155,7 +13172,7 @@
         },
         },
         "readable-stream": {
         "readable-stream": {
           "version": "2.3.6",
           "version": "2.3.6",
-          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
+          "resolved": false,
           "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
           "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
           "dev": true,
           "dev": true,
           "optional": true,
           "optional": true,
@@ -13187,14 +13204,14 @@
         },
         },
         "safer-buffer": {
         "safer-buffer": {
           "version": "2.1.2",
           "version": "2.1.2",
-          "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+          "resolved": false,
           "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
           "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
         },
         },
         "sax": {
         "sax": {
           "version": "1.2.4",
           "version": "1.2.4",
-          "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
+          "resolved": false,
           "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
           "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
@@ -13208,21 +13225,21 @@
         },
         },
         "set-blocking": {
         "set-blocking": {
           "version": "2.0.0",
           "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+          "resolved": false,
           "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
           "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
         },
         },
         "signal-exit": {
         "signal-exit": {
           "version": "3.0.2",
           "version": "3.0.2",
-          "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
+          "resolved": false,
           "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
           "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
         },
         },
         "string-width": {
         "string-width": {
           "version": "1.0.2",
           "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+          "resolved": false,
           "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
           "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
           "dev": true,
           "dev": true,
           "requires": {
           "requires": {
@@ -13233,7 +13250,7 @@
         },
         },
         "string_decoder": {
         "string_decoder": {
           "version": "1.1.1",
           "version": "1.1.1",
-          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+          "resolved": false,
           "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
           "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
           "dev": true,
           "dev": true,
           "optional": true,
           "optional": true,
@@ -13243,7 +13260,7 @@
         },
         },
         "strip-ansi": {
         "strip-ansi": {
           "version": "3.0.1",
           "version": "3.0.1",
-          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "resolved": false,
           "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
           "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
           "dev": true,
           "dev": true,
           "requires": {
           "requires": {
@@ -13252,7 +13269,7 @@
         },
         },
         "strip-json-comments": {
         "strip-json-comments": {
           "version": "2.0.1",
           "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+          "resolved": false,
           "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
           "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
@@ -13284,7 +13301,7 @@
         },
         },
         "util-deprecate": {
         "util-deprecate": {
           "version": "1.0.2",
           "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+          "resolved": false,
           "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
           "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
           "dev": true,
           "dev": true,
           "optional": true
           "optional": true
@@ -13301,7 +13318,7 @@
         },
         },
         "wrappy": {
         "wrappy": {
           "version": "1.0.2",
           "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+          "resolved": false,
           "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
           "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
           "dev": true
           "dev": true
         },
         },
@@ -17441,6 +17458,12 @@
       "integrity": "sha512-xYuhvQ7I9PDJIGBWev9xm0+SMSed3ZDBAmvVjbFR1ZRLAF+vlXcQu6cRI9uAlj81rzikElRVteehwV7DuX2ZmQ==",
       "integrity": "sha512-xYuhvQ7I9PDJIGBWev9xm0+SMSed3ZDBAmvVjbFR1ZRLAF+vlXcQu6cRI9uAlj81rzikElRVteehwV7DuX2ZmQ==",
       "dev": true
       "dev": true
     },
     },
+    "jsdoctypeparser": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/jsdoctypeparser/-/jsdoctypeparser-3.1.0.tgz",
+      "integrity": "sha512-JNbkKpDFqbYjg+IU3FNo7qjX7Opy7CwjHywT32zgAcz/d4lX6Umn5jOHVETUdnNNgGrMk0nEx1gvP0F4M0hzlQ==",
+      "dev": true
+    },
     "jsdom": {
     "jsdom": {
       "version": "11.12.0",
       "version": "11.12.0",
       "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-11.12.0.tgz",
       "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-11.12.0.tgz",
@@ -18632,8 +18655,7 @@
     "lodash.debounce": {
     "lodash.debounce": {
       "version": "4.0.8",
       "version": "4.0.8",
       "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
       "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
-      "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=",
-      "dev": true
+      "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
     },
     },
     "lodash.escape": {
     "lodash.escape": {
       "version": "4.0.1",
       "version": "4.0.1",

+ 1 - 0
package.json

@@ -40,6 +40,7 @@
     "eslint-plugin-compat": "^3.1.1",
     "eslint-plugin-compat": "^3.1.1",
     "eslint-plugin-import": "^2.17.2",
     "eslint-plugin-import": "^2.17.2",
     "eslint-plugin-jest": "^22.5.1",
     "eslint-plugin-jest": "^22.5.1",
+    "eslint-plugin-jsdoc": "^5.0.2",
     "eslint-plugin-node": "^8.0.1",
     "eslint-plugin-node": "^8.0.1",
     "eslint-plugin-promise": "^4.1.1",
     "eslint-plugin-promise": "^4.1.1",
     "eslint-plugin-react": "^7.12.4",
     "eslint-plugin-react": "^7.12.4",

+ 8 - 2
packages/@uppy/core/src/Plugin.js

@@ -72,6 +72,11 @@ module.exports = class Plugin {
     }
     }
   }
   }
 
 
+  // Called after every state update, after everything's mounted. Debounced.
+  afterUpdate () {
+
+  }
+
   /**
   /**
   * Called when plugin is mounted, whether in DOM or into another plugin.
   * Called when plugin is mounted, whether in DOM or into another plugin.
   * Needed because sometimes plugins are mounted separately/after `install`,
   * Needed because sometimes plugins are mounted separately/after `install`,
@@ -105,6 +110,7 @@ module.exports = class Plugin {
         // hence the check
         // hence the check
         if (!this.uppy.getPlugin(this.id)) return
         if (!this.uppy.getPlugin(this.id)) return
         this.el = preact.render(this.render(state), targetElement, this.el)
         this.el = preact.render(this.render(state), targetElement, this.el)
+        this.afterUpdate()
       }
       }
       this._updateUI = debounce(this.rerender)
       this._updateUI = debounce(this.rerender)
 
 
@@ -147,8 +153,8 @@ module.exports = class Plugin {
     }
     }
 
 
     this.uppy.log(`Not installing ${callerPluginName}`)
     this.uppy.log(`Not installing ${callerPluginName}`)
-    throw new Error(`Invalid target option given to ${callerPluginName}. Please make sure that the element 
-      exists on the page, or that the plugin you are targeting has been installed. Check that the <script> tag initializing Uppy 
+    throw new Error(`Invalid target option given to ${callerPluginName}. Please make sure that the element
+      exists on the page, or that the plugin you are targeting has been installed. Check that the <script> tag initializing Uppy
       comes at the bottom of the page, before the closing </body> tag (see https://github.com/transloadit/uppy/issues/1042).`)
       comes at the bottom of the page, before the closing </body> tag (see https://github.com/transloadit/uppy/issues/1042).`)
   }
   }
 
 

+ 9 - 8
packages/@uppy/core/src/index.js

@@ -1,7 +1,7 @@
 const Translator = require('@uppy/utils/lib/Translator')
 const Translator = require('@uppy/utils/lib/Translator')
 const ee = require('namespace-emitter')
 const ee = require('namespace-emitter')
 const cuid = require('cuid')
 const cuid = require('cuid')
-// const throttle = require('lodash.throttle')
+const throttle = require('lodash.throttle')
 const prettyBytes = require('prettier-bytes')
 const prettyBytes = require('prettier-bytes')
 const match = require('mime-match')
 const match = require('mime-match')
 const DefaultStore = require('@uppy/store-default')
 const DefaultStore = require('@uppy/store-default')
@@ -102,7 +102,14 @@ class Uppy {
     this.addFile = this.addFile.bind(this)
     this.addFile = this.addFile.bind(this)
     this.removeFile = this.removeFile.bind(this)
     this.removeFile = this.removeFile.bind(this)
     this.pauseResume = this.pauseResume.bind(this)
     this.pauseResume = this.pauseResume.bind(this)
-    this._calculateProgress = this._calculateProgress.bind(this)
+
+    // ___Why throttle at 500ms?
+    //    - We must throttle at >250ms for superfocus in Dashboard to work well (because animation takes 0.25s, and we want to wait for all animations to be over before refocusing).
+    //    [Practical Check]: if thottle is at 100ms, then if you are uploading a file, and click 'ADD MORE FILES', - focus won't activate in Firefox.
+    //    - We must throttle at around >500ms to avoid performance lags.
+    //    [Practical Check] Firefox, try to upload a big file for a prolonged period of time. Laptop will start to heat up.
+    this._calculateProgress = throttle(this._calculateProgress.bind(this), 500, { leading: true, trailing: true })
+
     this.updateOnlineStatus = this.updateOnlineStatus.bind(this)
     this.updateOnlineStatus = this.updateOnlineStatus.bind(this)
     this.resetProgress = this.resetProgress.bind(this)
     this.resetProgress = this.resetProgress.bind(this)
 
 
@@ -759,12 +766,6 @@ class Uppy {
       })
       })
     })
     })
 
 
-    // upload progress events can occur frequently, especially when you have a good
-    // connection to the remote server. Therefore, we are throtteling them to
-    // prevent accessive function calls.
-    // see also: https://github.com/tus/tus-js-client/commit/9940f27b2361fd7e10ba58b09b60d82422183bbb
-    // const _throttledCalculateProgress = throttle(this._calculateProgress, 100, { leading: true, trailing: true })
-
     this.on('upload-progress', this._calculateProgress)
     this.on('upload-progress', this._calculateProgress)
 
 
     this.on('upload-success', (file, uploadResp) => {
     this.on('upload-success', (file, uploadResp) => {

+ 6 - 0
packages/@uppy/core/src/index.test.js

@@ -998,6 +998,9 @@ describe('src/Core', () => {
         bytesUploaded: 17175,
         bytesUploaded: 17175,
         bytesTotal: 17175
         bytesTotal: 17175
       })
       })
+
+      core._calculateProgress.flush()
+
       expect(core.getFile(fileId).progress).toEqual({
       expect(core.getFile(fileId).progress).toEqual({
         percentage: 100,
         percentage: 100,
         bytesUploaded: 17175,
         bytesUploaded: 17175,
@@ -1100,6 +1103,8 @@ describe('src/Core', () => {
       })
       })
 
 
       core._calculateTotalProgress()
       core._calculateTotalProgress()
+      core._calculateProgress.flush()
+
       expect(core.getState().totalProgress).toEqual(66)
       expect(core.getState().totalProgress).toEqual(66)
     })
     })
 
 
@@ -1136,6 +1141,7 @@ describe('src/Core', () => {
       })
       })
 
 
       core._calculateTotalProgress()
       core._calculateTotalProgress()
+      core._calculateProgress.flush()
 
 
       expect(core.getState().totalProgress).toEqual(66)
       expect(core.getState().totalProgress).toEqual(66)
 
 

+ 8 - 6
packages/@uppy/core/types/index.d.ts

@@ -51,17 +51,19 @@ declare module Uppy {
     pluralize?: (n: number) => number;
     pluralize?: (n: number) => number;
   }
   }
 
 
+  interface Restrictions {
+    maxFileSize: number | null;
+    maxNumberOfFiles: number | null;
+    minNumberOfFiles: number | null;
+    allowedFileTypes: string[] | null;
+  }
+
   interface UppyOptions {
   interface UppyOptions {
     id: string;
     id: string;
     autoProceed: boolean;
     autoProceed: boolean;
     allowMultipleUploads: boolean;
     allowMultipleUploads: boolean;
     debug: boolean;
     debug: boolean;
-    restrictions: {
-      maxFileSize: number | null;
-      maxNumberOfFiles: number | null;
-      minNumberOfFiles: number | null;
-      allowedFileTypes: string[] | null;
-    };
+    restrictions: Partial<Restrictions>;
     target: string | Plugin;
     target: string | Plugin;
     meta: any;
     meta: any;
     onBeforeFileAdded: (currentFile: UppyFile, files: {[key: string]: UppyFile}) => UppyFile | boolean | undefined;
     onBeforeFileAdded: (currentFile: UppyFile, files: {[key: string]: UppyFile}) => UppyFile | boolean | undefined;

+ 2 - 0
packages/@uppy/dashboard/package.json

@@ -29,6 +29,8 @@
     "@uppy/utils": "1.1.0",
     "@uppy/utils": "1.1.0",
     "classnames": "^2.2.6",
     "classnames": "^2.2.6",
     "cuid": "^2.1.1",
     "cuid": "^2.1.1",
+    "drag-drop": "2.13.3",
+    "lodash.debounce": "^4.0.8",
     "lodash.throttle": "^4.1.1",
     "lodash.throttle": "^4.1.1",
     "preact": "8.2.9",
     "preact": "8.2.9",
     "preact-css-transition-group": "^1.3.0",
     "preact-css-transition-group": "^1.3.0",

+ 3 - 1
packages/@uppy/dashboard/src/components/AddFiles.js

@@ -40,7 +40,7 @@ class AddFiles extends Component {
         target="_blank"
         target="_blank"
         class="uppy-Dashboard-poweredBy">
         class="uppy-Dashboard-poweredBy">
         {this.props.i18n('poweredBy') + ' '}
         {this.props.i18n('poweredBy') + ' '}
-        <svg aria-hidden="true" class="UppyIcon uppy-Dashboard-poweredByIcon" width="11" height="11" viewBox="0 0 11 11">
+        <svg aria-hidden="true" focusable="false" class="UppyIcon uppy-Dashboard-poweredByIcon" width="11" height="11" viewBox="0 0 11 11">
           <path d="M7.365 10.5l-.01-4.045h2.612L5.5.806l-4.467 5.65h2.604l.01 4.044h3.718z" fill-rule="evenodd" />
           <path d="M7.365 10.5l-.01-4.045h2.612L5.5.806l-4.467 5.65h2.604l.01 4.044h3.718z" fill-rule="evenodd" />
         </svg>
         </svg>
         <span class="uppy-Dashboard-poweredByUppy">Uppy</span>
         <span class="uppy-Dashboard-poweredByUppy">Uppy</span>
@@ -88,6 +88,7 @@ class AddFiles extends Component {
           class="uppy-DashboardTab-btn"
           class="uppy-DashboardTab-btn"
           role="tab"
           role="tab"
           tabindex={0}
           tabindex={0}
+          data-uppy-super-focusable
           onclick={this.triggerFileInputClick}>
           onclick={this.triggerFileInputClick}>
           {localIcon()}
           {localIcon()}
           <div class="uppy-DashboardTab-name">{this.props.i18n('myDevice')}</div>
           <div class="uppy-DashboardTab-name">{this.props.i18n('myDevice')}</div>
@@ -105,6 +106,7 @@ class AddFiles extends Component {
           tabindex={0}
           tabindex={0}
           aria-controls={`uppy-DashboardContent-panel--${acquirer.id}`}
           aria-controls={`uppy-DashboardContent-panel--${acquirer.id}`}
           aria-selected={this.props.activePickerPanel.id === acquirer.id}
           aria-selected={this.props.activePickerPanel.id === acquirer.id}
+          data-uppy-super-focusable
           onclick={() => this.props.showPanel(acquirer.id)}>
           onclick={() => this.props.showPanel(acquirer.id)}>
           {acquirer.icon()}
           {acquirer.icon()}
           <div class="uppy-DashboardTab-name">{acquirer.name}</div>
           <div class="uppy-DashboardTab-name">{acquirer.name}</div>

+ 2 - 10
packages/@uppy/dashboard/src/components/FileCard.js

@@ -15,13 +15,6 @@ class FileCard extends Component {
     this.handleCancel = this.handleCancel.bind(this)
     this.handleCancel = this.handleCancel.bind(this)
   }
   }
 
 
-  componentDidMount () {
-    setTimeout(() => {
-      if (!this.firstInput) return
-      this.firstInput.focus({ preventScroll: true })
-    }, 150)
-  }
-
   tempStoreMetaOrSubmit (ev) {
   tempStoreMetaOrSubmit (ev) {
     const file = this.props.files[this.props.fileCardFor]
     const file = this.props.files[this.props.fileCardFor]
 
 
@@ -50,9 +43,8 @@ class FileCard extends Component {
           onkeyup={this.tempStoreMetaOrSubmit}
           onkeyup={this.tempStoreMetaOrSubmit}
           onkeydown={this.tempStoreMetaOrSubmit}
           onkeydown={this.tempStoreMetaOrSubmit}
           onkeypress={this.tempStoreMetaOrSubmit}
           onkeypress={this.tempStoreMetaOrSubmit}
-          ref={(el) => {
-            if (i === 0) this.firstInput = el
-          }} /></fieldset>
+          data-uppy-super-focusable />
+      </fieldset>
     })
     })
   }
   }
 
 

+ 40 - 30
packages/@uppy/dashboard/src/components/FileItem.js

@@ -113,11 +113,11 @@ module.exports = function FileItem (props) {
     ? !isUploaded
     ? !isUploaded
     : !uploadInProgress && !isUploaded
     : !uploadInProgress && !isUploaded
 
 
-  return <li class={dashboardItemClass} id={`uppy_${file.id}`} title={file.meta.name}>
+  return <li class={dashboardItemClass} id={`uppy_${file.id}`}>
     <div class="uppy-DashboardItem-preview">
     <div class="uppy-DashboardItem-preview">
       <div class="uppy-DashboardItem-previewInnerWrap" style={{ backgroundColor: getFileTypeIcon(file.type).color }}>
       <div class="uppy-DashboardItem-previewInnerWrap" style={{ backgroundColor: getFileTypeIcon(file.type).color }}>
         {props.showLinkToFileUploadResult && file.uploadURL
         {props.showLinkToFileUploadResult && file.uploadURL
-          ? <a class="uppy-DashboardItem-previewLink" href={file.uploadURL} rel="noreferrer noopener" target="_blank" />
+          ? <a class="uppy-DashboardItem-previewLink" href={file.uploadURL} rel="noreferrer noopener" target="_blank" aria-label={file.meta.name} />
           : null
           : null
         }
         }
         <FilePreview file={file} />
         <FilePreview file={file} />
@@ -128,41 +128,51 @@ module.exports = function FileItem (props) {
           onPauseResumeCancelRetry={onPauseResumeCancelRetry}
           onPauseResumeCancelRetry={onPauseResumeCancelRetry}
           file={file}
           file={file}
           error={error}
           error={error}
+          isUploaded={isUploaded}
           {...props} />
           {...props} />
       </div>
       </div>
     </div>
     </div>
     <div class="uppy-DashboardItem-info">
     <div class="uppy-DashboardItem-info">
-      <div class="uppy-DashboardItem-name" title={fileName}>
-        {props.showLinkToFileUploadResult && file.uploadURL
-          ? <a href={file.uploadURL} rel="noreferrer noopener" target="_blank">
-            {file.extension ? truncatedFileName + '.' + file.extension : truncatedFileName}
-          </a>
-          : file.extension ? truncatedFileName + '.' + file.extension : truncatedFileName
-        }
+      <div class="uppy-DashboardItem-name">
+        {file.extension ? truncatedFileName + '.' + file.extension : truncatedFileName}
       </div>
       </div>
       <div class="uppy-DashboardItem-status">
       <div class="uppy-DashboardItem-status">
-        {file.data.size ? <div class="uppy-DashboardItem-statusSize">{prettyBytes(file.data.size)}</div> : null}
-        {(file.source && file.source !== props.id) && (
-          <div class="uppy-DashboardItem-sourceIcon">
-            {acquirers.map(acquirer => {
-              if (acquirer.id === file.source) {
-                return <span title={props.i18n('fileSource', { name: acquirer.name })}>
-                  {acquirer.icon()}
-                </span>
-              }
-            })}
-          </div>
-        )}
-        {(!uploadInProgressOrComplete && props.metaFields && props.metaFields.length)
-          ? <button class="uppy-u-reset uppy-DashboardItem-edit"
-            type="button"
-            aria-label={props.i18n('editFile')}
-            title={props.i18n('editFile')}
-            onclick={(e) => props.toggleFileCard(file.id)}>
-            {props.i18n('edit')}
-          </button>
-          : null
+        {
+          file.data.size && [
+            <div class="uppy-DashboardItem-statusSize">
+              {prettyBytes(file.data.size)}
+            </div>,
+            <span class="uppy-DashboardItem-statusbar-dot">·</span>
+          ]
         }
         }
+
+        {
+          (file.source && file.source !== props.id) && [
+            <div class="uppy-DashboardItem-sourceIcon">
+              {acquirers.map(acquirer => {
+                if (acquirer.id === file.source) {
+                  return <span title={props.i18n('fileSource', { name: acquirer.name })}>
+                    {acquirer.icon()}
+                  </span>
+                }
+              })}
+            </div>,
+            <span class="uppy-DashboardItem-statusbar-dot">·</span>
+          ]
+        }
+        {
+          (!uploadInProgressOrComplete && props.metaFields && props.metaFields.length) && [
+            <button class="uppy-u-reset uppy-DashboardItem-edit"
+              type="button"
+              aria-label={props.i18n('editFile') + ' ' + fileName}
+              title={props.i18n('editFile')}
+              onclick={(e) => props.toggleFileCard(file.id)}>
+              {props.i18n('edit')}
+            </button>,
+            <span class="uppy-DashboardItem-statusbar-dot">·</span>
+          ]
+        }
+
         {props.showLinkToFileUploadResult && file.uploadURL
         {props.showLinkToFileUploadResult && file.uploadURL
           ? <button class="uppy-u-reset uppy-DashboardItem-copyLink"
           ? <button class="uppy-u-reset uppy-DashboardItem-copyLink"
             type="button"
             type="button"

+ 4 - 1
packages/@uppy/dashboard/src/components/FileList.js

@@ -10,7 +10,10 @@ module.exports = (props) => {
   )
   )
 
 
   return (
   return (
-    <ul class={dashboardFilesClass}>
+    <ul
+      class={dashboardFilesClass}
+      // making <ul> not focusable for firefox
+      tabindex="-1">
       {Object.keys(props.files).map((fileID) => (
       {Object.keys(props.files).map((fileID) => (
         <FileItem
         <FileItem
           {...props}
           {...props}

+ 3 - 3
packages/@uppy/dashboard/src/components/PickerPanelContent.js

@@ -1,12 +1,12 @@
 const { h } = require('preact')
 const { h } = require('preact')
 const ignoreEvent = require('../utils/ignoreEvent.js')
 const ignoreEvent = require('../utils/ignoreEvent.js')
 
 
-function PanelContent (props) {
+function PickerPanelContent (props) {
   return (
   return (
     <div class="uppy-DashboardContent-panel"
     <div class="uppy-DashboardContent-panel"
       role="tabpanel"
       role="tabpanel"
       data-uppy-panelType="PickerPanel"
       data-uppy-panelType="PickerPanel"
-      id={props.activePickerPanel && `uppy-DashboardContent-panel--${props.activePickerPanel.id}`}
+      id={`uppy-DashboardContent-panel--${props.activePickerPanel.id}`}
       onDragOver={ignoreEvent}
       onDragOver={ignoreEvent}
       onDragLeave={ignoreEvent}
       onDragLeave={ignoreEvent}
       onDrop={ignoreEvent}
       onDrop={ignoreEvent}
@@ -26,4 +26,4 @@ function PanelContent (props) {
   )
   )
 }
 }
 
 
-module.exports = PanelContent
+module.exports = PickerPanelContent

+ 88 - 69
packages/@uppy/dashboard/src/index.js

@@ -7,26 +7,11 @@ const ThumbnailGenerator = require('@uppy/thumbnail-generator')
 const findAllDOMElements = require('@uppy/utils/lib/findAllDOMElements')
 const findAllDOMElements = require('@uppy/utils/lib/findAllDOMElements')
 const toArray = require('@uppy/utils/lib/toArray')
 const toArray = require('@uppy/utils/lib/toArray')
 const getDroppedFiles = require('@uppy/utils/lib/getDroppedFiles')
 const getDroppedFiles = require('@uppy/utils/lib/getDroppedFiles')
+const trapFocus = require('./utils/trapFocus')
 const cuid = require('cuid')
 const cuid = require('cuid')
 const ResizeObserver = require('resize-observer-polyfill').default || require('resize-observer-polyfill')
 const ResizeObserver = require('resize-observer-polyfill').default || require('resize-observer-polyfill')
 const { defaultPickerIcon } = require('./components/icons')
 const { defaultPickerIcon } = require('./components/icons')
-
-// 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 = [
-  'a[href]:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
-  'area[href]:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
-  'input:not([disabled]):not([inert]):not([aria-hidden])',
-  'select:not([disabled]):not([inert]):not([aria-hidden])',
-  'textarea:not([disabled]):not([inert]):not([aria-hidden])',
-  'button:not([disabled]):not([inert]):not([aria-hidden])',
-  'iframe:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
-  'object:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
-  'embed:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
-  '[contenteditable]:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
-  '[tabindex]:not([tabindex^="-"]):not([inert]):not([aria-hidden])'
-]
+const createSuperFocus = require('./utils/createSuperFocus')
 
 
 const TAB_KEY = 9
 const TAB_KEY = 9
 const ESC_KEY = 27
 const ESC_KEY = 27
@@ -158,13 +143,11 @@ module.exports = class Dashboard extends Plugin {
     this.removeTarget = this.removeTarget.bind(this)
     this.removeTarget = this.removeTarget.bind(this)
     this.hideAllPanels = this.hideAllPanels.bind(this)
     this.hideAllPanels = this.hideAllPanels.bind(this)
     this.showPanel = this.showPanel.bind(this)
     this.showPanel = this.showPanel.bind(this)
-    this.getFocusableNodes = this.getFocusableNodes.bind(this)
-    this.setFocusToFirstNode = this.setFocusToFirstNode.bind(this)
     this.handlePopState = this.handlePopState.bind(this)
     this.handlePopState = this.handlePopState.bind(this)
-    this.maintainFocus = this.maintainFocus.bind(this)
 
 
     this.initEvents = this.initEvents.bind(this)
     this.initEvents = this.initEvents.bind(this)
-    this.handleKeyDown = this.handleKeyDown.bind(this)
+    this.handleKeyDownInModal = this.handleKeyDownInModal.bind(this)
+    this.handleKeyDownInInline = this.handleKeyDownInInline.bind(this)
     this.handleFileAdded = this.handleFileAdded.bind(this)
     this.handleFileAdded = this.handleFileAdded.bind(this)
     this.handleComplete = this.handleComplete.bind(this)
     this.handleComplete = this.handleComplete.bind(this)
     this.handleClickOutside = this.handleClickOutside.bind(this)
     this.handleClickOutside = this.handleClickOutside.bind(this)
@@ -179,6 +162,11 @@ module.exports = class Dashboard extends Plugin {
     this.handleDragOver = this.handleDragOver.bind(this)
     this.handleDragOver = this.handleDragOver.bind(this)
     this.handleDragLeave = this.handleDragLeave.bind(this)
     this.handleDragLeave = this.handleDragLeave.bind(this)
     this.handleDrop = this.handleDrop.bind(this)
     this.handleDrop = this.handleDrop.bind(this)
+    this.superFocusOnEachUpdate = this.superFocusOnEachUpdate.bind(this)
+    this.recordIfFocusedOnUppyRecently = this.recordIfFocusedOnUppyRecently.bind(this)
+
+    this.superFocus = createSuperFocus()
+    this.ifFocusedOnUppyRecently = false
 
 
     // Timeouts
     // Timeouts
     this.makeDashboardInsidesVisibleAnywayTimeout = null
     this.makeDashboardInsidesVisibleAnywayTimeout = null
@@ -253,24 +241,6 @@ module.exports = class Dashboard extends Plugin {
     return this.closeModal()
     return this.closeModal()
   }
   }
 
 
-  getFocusableNodes () {
-    // if an overlay is open, we should trap focus inside the overlay
-    const activeOverlayType = this.getPluginState().activeOverlayType
-    if (activeOverlayType) {
-      const activeOverlay = this.el.querySelector(`[data-uppy-panelType="${activeOverlayType}"]`)
-      const nodes = activeOverlay.querySelectorAll(FOCUSABLE_ELEMENTS)
-      return Object.keys(nodes).map((key) => nodes[key])
-    }
-
-    const nodes = this.el.querySelectorAll(FOCUSABLE_ELEMENTS)
-    return Object.keys(nodes).map((key) => nodes[key])
-  }
-
-  setFocusToFirstNode () {
-    const focusableNodes = this.getFocusableNodes()
-    if (focusableNodes.length) focusableNodes[0].focus()
-  }
-
   updateBrowserHistory () {
   updateBrowserHistory () {
     // Ensure history state does not already contain our modal name to avoid double-pushing
     // Ensure history state does not already contain our modal name to avoid double-pushing
     if (!history.state || !history.state[this.modalName]) {
     if (!history.state || !history.state[this.modalName]) {
@@ -299,26 +269,6 @@ module.exports = class Dashboard extends Plugin {
     }
     }
   }
   }
 
 
-  setFocusToBrowse () {
-    const browseBtn = this.el.querySelector('.uppy-Dashboard-browse')
-    if (browseBtn) browseBtn.focus()
-  }
-
-  maintainFocus (event) {
-    var focusableNodes = this.getFocusableNodes()
-    var focusedItemIndex = focusableNodes.indexOf(document.activeElement)
-
-    if (event.shiftKey && focusedItemIndex === 0) {
-      focusableNodes[focusableNodes.length - 1].focus()
-      event.preventDefault()
-    }
-
-    if (!event.shiftKey && focusedItemIndex === focusableNodes.length - 1) {
-      focusableNodes[0].focus()
-      event.preventDefault()
-    }
-  }
-
   openModal () {
   openModal () {
     const { promise, resolve } = createPromise()
     const { promise, resolve } = createPromise()
     // save scroll position
     // save scroll position
@@ -351,10 +301,7 @@ module.exports = class Dashboard extends Plugin {
     }
     }
 
 
     // handle ESC and TAB keys in modal dialog
     // handle ESC and TAB keys in modal dialog
-    document.addEventListener('keydown', this.handleKeyDown)
-
-    // this.rerender(this.uppy.getState())
-    this.setFocusToBrowse()
+    document.addEventListener('keydown', this.handleKeyDownInModal)
 
 
     return promise
     return promise
   }
   }
@@ -385,6 +332,10 @@ module.exports = class Dashboard extends Plugin {
           isHidden: true,
           isHidden: true,
           isClosing: false
           isClosing: false
         })
         })
+
+        this.superFocus.cancel()
+        this.savedActiveElement.focus()
+
         this.el.removeEventListener('animationend', handler, false)
         this.el.removeEventListener('animationend', handler, false)
         resolve()
         resolve()
       }
       }
@@ -393,13 +344,15 @@ module.exports = class Dashboard extends Plugin {
       this.setPluginState({
       this.setPluginState({
         isHidden: true
         isHidden: true
       })
       })
+
+      this.superFocus.cancel()
+      this.savedActiveElement.focus()
+
       resolve()
       resolve()
     }
     }
 
 
     // handle ESC and TAB keys in modal dialog
     // handle ESC and TAB keys in modal dialog
-    document.removeEventListener('keydown', this.handleKeyDown)
-
-    this.savedActiveElement.focus()
+    document.removeEventListener('keydown', this.handleKeyDownInModal)
 
 
     if (manualClose) {
     if (manualClose) {
       if (this.opts.browserBackButtonClose) {
       if (this.opts.browserBackButtonClose) {
@@ -418,11 +371,11 @@ module.exports = class Dashboard extends Plugin {
     return !this.getPluginState().isHidden || false
     return !this.getPluginState().isHidden || false
   }
   }
 
 
-  handleKeyDown (event) {
+  handleKeyDownInModal (event) {
     // close modal on esc key press
     // close modal on esc key press
     if (event.keyCode === ESC_KEY) this.requestCloseModal(event)
     if (event.keyCode === ESC_KEY) this.requestCloseModal(event)
-    // maintainFocus on tab key press
-    if (event.keyCode === TAB_KEY) this.maintainFocus(event)
+    // trap focus on tab key press
+    if (event.keyCode === TAB_KEY) trapFocus.forModal(event, this.getPluginState().activeOverlayType, this.el)
   }
   }
 
 
   handleClickOutside () {
   handleClickOutside () {
@@ -580,6 +533,33 @@ module.exports = class Dashboard extends Plugin {
     this.uppy.on('plugin-remove', this.removeTarget)
     this.uppy.on('plugin-remove', this.removeTarget)
     this.uppy.on('file-added', this.handleFileAdded)
     this.uppy.on('file-added', this.handleFileAdded)
     this.uppy.on('complete', this.handleComplete)
     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)
+    }
+  }
+
+  handleKeyDownInInline (event) {
+    // Trap focus on tab key press.
+    if (event.keyCode === TAB_KEY) trapFocus.forInline(event, this.getPluginState().activeOverlayType, this.el)
+  }
+
+  // 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()
+    }
   }
   }
 
 
   // ___Why do we listen to the 'paste' event on a document instead of onPaste={props.handlePaste} prop, or this.el.addEventListener('paste')?
   // ___Why do we listen to the 'paste' event on a document instead of onPaste={props.handlePaste} prop, or this.el.addEventListener('paste')?
@@ -619,6 +599,13 @@ module.exports = class Dashboard extends Plugin {
     this.uppy.off('plugin-remove', this.removeTarget)
     this.uppy.off('plugin-remove', this.removeTarget)
     this.uppy.off('file-added', this.handleFileAdded)
     this.uppy.off('file-added', this.handleFileAdded)
     this.uppy.off('complete', this.handleComplete)
     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)
+    }
   }
   }
 
 
   toggleFileCard (fileId) {
   toggleFileCard (fileId) {
@@ -635,6 +622,38 @@ module.exports = class Dashboard extends Plugin {
     })
     })
   }
   }
 
 
+  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.querySelector('body') || document.activeElement === null
+    const isInformerHidden = this.uppy.getState().info.isHidden
+    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 () {
+    this.superFocusOnEachUpdate()
+  }
+
   render (state) {
   render (state) {
     const pluginState = this.getPluginState()
     const pluginState = this.getPluginState()
     const { files, capabilities, allowNewUpload } = state
     const { files, capabilities, allowNewUpload } = state

+ 30 - 15
packages/@uppy/dashboard/src/style.scss

@@ -192,9 +192,12 @@
   position: relative;
   position: relative;
   text-align: center;
   text-align: center;
   flex: 1;
   flex: 1;
-  margin: 7px;
-  border: 1px dashed $gray-250;
-  border-radius: 3px;
+
+  .uppy-size--md & {
+    margin: 7px;
+    border-radius: 3px;
+    border: 1px dashed $gray-250; // Show dashed border on large screens only
+  }
 
 
   .uppy-Dashboard-AddFilesPanel & {
   .uppy-Dashboard-AddFilesPanel & {
     border: none;
     border: none;
@@ -610,7 +613,7 @@
   max-width: 300px;
   max-width: 300px;
   text-align: center;
   text-align: center;
   font-size: 16px;
   font-size: 16px;
-  line-height: 1.45;
+  line-height: 1.35;
   font-weight: 400;
   font-weight: 400;
   color: $gray-700;
   color: $gray-700;
   margin: auto;
   margin: auto;
@@ -729,6 +732,9 @@ a.uppy-Dashboard-poweredBy {
   right: 0;
   right: 0;
   bottom: 0;
   bottom: 0;
   z-index: $zIndex-3;
   z-index: $zIndex-3;
+  &:focus{
+    box-shadow: inset 0px 0px 0px 4px rgb(59, 153, 252);
+  }
 }
 }
 
 
 .uppy-DashboardItem-sourceIcon {
 .uppy-DashboardItem-sourceIcon {
@@ -902,19 +908,28 @@ a.uppy-Dashboard-poweredBy {
   }
   }
 }
 }
 
 
-.uppy-DashboardItem-edit:not(:first-child),
-.uppy-DashboardItem-copyLink:not(:first-child),
-.uppy-DashboardItem-sourceIcon:not(:first-child) {
+span.uppy-DashboardItem-statusbar-dot{
+  display: inline-block;
+  color: $gray-600;
+  &:last-child{
+    display: none;
+  }
+}
+
+.uppy-DashboardItem-statusSize,
+.uppy-DashboardItem-edit,
+.uppy-DashboardItem-copyLink,
+.uppy-DashboardItem-sourceIcon {
   position: relative;
   position: relative;
-  margin-left: 14px;
-  // margin-right: 7px;
+  margin-left: 3px;
+  margin-right: 3px;
 
 
-  &:before {
-    content: '\00B7';
-    position: absolute;
-    top: 0;
-    left: -9px;
-    color: $gray-600;
+  padding-left: 3px;
+  padding-right: 3px;
+
+  &:first-child{
+    margin-left: 0;
+    padding-left: 0;
   }
   }
 }
 }
 
 

+ 43 - 0
packages/@uppy/dashboard/src/utils/createSuperFocus.js

@@ -0,0 +1,43 @@
+const debounce = require('lodash.debounce')
+const FOCUSABLE_ELEMENTS = require('@uppy/utils/lib/FOCUSABLE_ELEMENTS')
+const getActiveOverlayEl = require('./getActiveOverlayEl')
+
+/*
+  Focuses on some element in the currently topmost overlay.
+
+  1. If there are some [data-uppy-super-focusable] elements rendered already - focuses on the first superfocusable element, and leaves focus up to the control of a user (until currently focused element disappears from the screen [which can happen when overlay changes, or, e.g., when we click on a folder in googledrive]).
+  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 () {
+  let lastFocusWasOnSuperFocusableEl = false
+
+  const superFocus = (dashboardEl, activeOverlayType) => {
+    const overlayEl = getActiveOverlayEl(dashboardEl, activeOverlayType)
+
+    const isFocusInOverlay = overlayEl.contains(document.activeElement)
+    // If focus is already in the topmost overlay, AND on last update we focused on the superfocusable element - then leave focus up to the user.
+    // [Practical check] without this line, typing in the search input in googledrive overlay won't work.
+    if (isFocusInOverlay && lastFocusWasOnSuperFocusableEl) return
+
+    const superFocusableEl = overlayEl.querySelector(`[data-uppy-super-focusable]`)
+    // If we are already in the topmost overlay, AND there are no super focusable elements yet, - leave focus up to the user.
+    // [Practical check] without this line, if you are in an empty folder in google drive, and something's uploading in the bg, - focus will be jumping to Done all the time.
+    if (isFocusInOverlay && !superFocusableEl) return
+
+    if (superFocusableEl) {
+      superFocusableEl.focus({ preventScroll: true })
+      lastFocusWasOnSuperFocusableEl = true
+    } else {
+      const firstEl = overlayEl.querySelector(FOCUSABLE_ELEMENTS)
+      firstEl && firstEl.focus({ preventScroll: true })
+      lastFocusWasOnSuperFocusableEl = false
+    }
+  }
+
+  // ___Why do we need to debounce?
+  //    1. To deal with animations: overlay changes via animations, which results in the DOM updating AFTER plugin.update() already executed.
+  //    [Practical check] without debounce, if we open the Url overlay, and click 'Done', Dashboard won't get focused again.
+  //    [Practical check] if we delay 250ms instead of 260ms - IE11 won't get focused in same situation.
+  //    2. Performance: there can be many state update()s in a second, and this function is called every time.
+  return debounce(superFocus, 260)
+}

+ 10 - 0
packages/@uppy/dashboard/src/utils/createSuperFocus.test.js

@@ -0,0 +1,10 @@
+const createSuperFocus = require('./createSuperFocus')
+
+describe('createSuperFocus', () => {
+  // superFocus.cancel() is used in dashboard
+  it('should return a function that can be cancelled', () => {
+    const superFocus = createSuperFocus()
+    expect(typeof superFocus).toBe('function')
+    expect(typeof superFocus.cancel).toBe('function')
+  })
+})

+ 11 - 0
packages/@uppy/dashboard/src/utils/getActiveOverlayEl.js

@@ -0,0 +1,11 @@
+/**
+ * @returns {HTMLElement} - either dashboard element, or the overlay that's most on top
+ */
+module.exports = function getActiveOverlayEl (dashboardEl, activeOverlayType) {
+  if (activeOverlayType) {
+    const overlayEl = dashboardEl.querySelector(`[data-uppy-paneltype="${activeOverlayType}"]`)
+    // if an overlay is already mounted
+    if (overlayEl) return overlayEl
+  }
+  return dashboardEl
+}

+ 65 - 0
packages/@uppy/dashboard/src/utils/trapFocus.js

@@ -0,0 +1,65 @@
+const toArray = require('@uppy/utils/lib/toArray')
+const getActiveOverlayEl = require('./getActiveOverlayEl')
+const FOCUSABLE_ELEMENTS = require('@uppy/utils/lib//FOCUSABLE_ELEMENTS')
+
+function focusOnFirstNode (event, nodes) {
+  const node = nodes[0]
+  if (node) {
+    node.focus()
+    event.preventDefault()
+  }
+}
+
+function focusOnLastNode (event, nodes) {
+  const node = nodes[nodes.length - 1]
+  if (node) {
+    node.focus()
+    event.preventDefault()
+  }
+}
+
+// ___Why not just use (focusedItemIndex === -1)?
+//    Firefox thinks <ul> is focusable, but we don't have <ul>s in our FOCUSABLE_ELEMENTS. Which means that if we tab into the <ul>, code will think that we are not in the active overlay, and we should focusOnFirstNode() of the currently active overlay!
+//    [Practical check] if we use (focusedItemIndex === -1), instagram provider in firefox will never get focus on its pics in the <ul>.
+function isFocusInOverlay (activeOverlayEl) {
+  return activeOverlayEl.contains(document.activeElement)
+}
+
+function trapFocus (event, activeOverlayType, dashboardEl) {
+  const activeOverlayEl = getActiveOverlayEl(dashboardEl, activeOverlayType)
+  const focusableNodes = toArray(activeOverlayEl.querySelectorAll(FOCUSABLE_ELEMENTS))
+
+  const focusedItemIndex = focusableNodes.indexOf(document.activeElement)
+
+  // If we pressed tab, and focus is not yet within the current overlay - focus on the first element within the current overlay.
+  // This is a safety measure (for when user returns from another tab e.g.), most plugins will try to focus on some important element as it loads.
+  if (!isFocusInOverlay(activeOverlayEl)) {
+    focusOnFirstNode(event, focusableNodes)
+  // If we pressed shift + tab, and we're on the first element of a modal
+  } else if (event.shiftKey && focusedItemIndex === 0) {
+    focusOnLastNode(event, focusableNodes)
+  // If we pressed tab, and we're on the last element of the modal
+  } else if (!event.shiftKey && focusedItemIndex === focusableNodes.length - 1) {
+    focusOnFirstNode(event, focusableNodes)
+  }
+}
+
+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, 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
+    // ___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)
+    }
+  }
+}

+ 2 - 15
packages/@uppy/provider-views/src/AuthView.js

@@ -1,13 +1,6 @@
 const { h, Component } = require('preact')
 const { h, Component } = require('preact')
 
 
-class AuthBlock extends Component {
-  componentDidMount () {
-    setTimeout(() => {
-      if (!this.connectButton) return
-      this.connectButton.focus({ preventScroll: true })
-    }, 150)
-  }
-
+class AuthView extends Component {
   render () {
   render () {
     const pluginNameComponent = (
     const pluginNameComponent = (
       <span class="uppy-Provider-authTitleName">{this.props.pluginName}<br /></span>
       <span class="uppy-Provider-authTitleName">{this.props.pluginName}<br /></span>
@@ -21,7 +14,7 @@ class AuthBlock extends Component {
         type="button"
         type="button"
         class="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Provider-authBtn"
         class="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Provider-authBtn"
         onclick={this.props.handleAuth}
         onclick={this.props.handleAuth}
-        ref={(el) => { this.connectButton = el }}
+        data-uppy-super-focusable
       >
       >
         {this.props.i18nArray('authenticateWith', { pluginName: this.props.pluginName })}
         {this.props.i18nArray('authenticateWith', { pluginName: this.props.pluginName })}
       </button>
       </button>
@@ -29,10 +22,4 @@ class AuthBlock extends Component {
   }
   }
 }
 }
 
 
-class AuthView extends Component {
-  render () {
-    return <AuthBlock {...this.props} />
-  }
-}
-
 module.exports = AuthView
 module.exports = AuthView

+ 3 - 2
packages/@uppy/provider-views/src/Browser.js

@@ -1,7 +1,7 @@
 const classNames = require('classnames')
 const classNames = require('classnames')
 const Breadcrumbs = require('./Breadcrumbs')
 const Breadcrumbs = require('./Breadcrumbs')
 const Filter = require('./Filter')
 const Filter = require('./Filter')
-const Table = require('./ItemList')
+const ItemList = require('./ItemList')
 const FooterActions = require('./FooterActions')
 const FooterActions = require('./FooterActions')
 const { h } = require('preact')
 const { h } = require('preact')
 
 
@@ -33,7 +33,7 @@ const Browser = (props) => {
         </div>
         </div>
       </div>
       </div>
       { props.showFilter && <Filter {...props} /> }
       { props.showFilter && <Filter {...props} /> }
-      <Table
+      <ItemList
         columns={[{
         columns={[{
           name: 'Name',
           name: 'Name',
           key: 'title'
           key: 'title'
@@ -50,6 +50,7 @@ const Browser = (props) => {
         title={props.title}
         title={props.title}
         showTitles={props.showTitles}
         showTitles={props.showTitles}
         i18n={props.i18n}
         i18n={props.i18n}
+        viewType={props.viewType}
       />
       />
       {selected > 0 && <FooterActions selected={selected} {...props} />}
       {selected > 0 && <FooterActions selected={selected} {...props} />}
     </div>
     </div>

+ 7 - 9
packages/@uppy/provider-views/src/Filter.js

@@ -3,16 +3,14 @@ const { h, Component } = require('preact')
 module.exports = class Filter extends Component {
 module.exports = class Filter extends Component {
   constructor (props) {
   constructor (props) {
     super(props)
     super(props)
-    this.handleKeyPress = this.handleKeyPress.bind(this)
+    this.preventEnterPress = this.preventEnterPress.bind(this)
   }
   }
 
 
-  handleKeyPress (ev) {
+  preventEnterPress (ev) {
     if (ev.keyCode === 13) {
     if (ev.keyCode === 13) {
       ev.stopPropagation()
       ev.stopPropagation()
       ev.preventDefault()
       ev.preventDefault()
-      return
     }
     }
-    this.props.filterQuery(ev)
   }
   }
 
 
   render () {
   render () {
@@ -22,11 +20,11 @@ module.exports = class Filter extends Component {
         type="text"
         type="text"
         placeholder={this.props.i18n('filter')}
         placeholder={this.props.i18n('filter')}
         aria-label={this.props.i18n('filter')}
         aria-label={this.props.i18n('filter')}
-        onkeyup={this.handleKeyPress}
-        onkeydown={this.handleKeyPress}
-        onkeypress={this.handleKeyPress}
-        value={this.props.filterInput}
-        ref={(input) => { this.input = input }} />
+        onkeyup={this.preventEnterPress}
+        onkeydown={this.preventEnterPress}
+        onkeypress={this.preventEnterPress}
+        oninput={(e) => this.props.filterQuery(e)}
+        value={this.props.filterInput} />
       <svg aria-hidden="true" class="UppyIcon uppy-ProviderBrowser-searchIcon" width="12" height="12" viewBox="0 0 12 12">
       <svg aria-hidden="true" class="UppyIcon uppy-ProviderBrowser-searchIcon" width="12" height="12" viewBox="0 0 12 12">
         <path d="M8.638 7.99l3.172 3.172a.492.492 0 1 1-.697.697L7.91 8.656a4.977 4.977 0 0 1-2.983.983C2.206 9.639 0 7.481 0 4.819 0 2.158 2.206 0 4.927 0c2.721 0 4.927 2.158 4.927 4.82a4.74 4.74 0 0 1-1.216 3.17zm-3.71.685c2.176 0 3.94-1.726 3.94-3.856 0-2.129-1.764-3.855-3.94-3.855C2.75.964.984 2.69.984 4.819c0 2.13 1.765 3.856 3.942 3.856z" />
         <path d="M8.638 7.99l3.172 3.172a.492.492 0 1 1-.697.697L7.91 8.656a4.977 4.977 0 0 1-2.983.983C2.206 9.639 0 7.481 0 4.819 0 2.158 2.206 0 4.927 0c2.721 0 4.927 2.158 4.927 4.82a4.74 4.74 0 0 1-1.216 3.17zm-3.71.685c2.176 0 3.94-1.726 3.94-3.856 0-2.129-1.764-3.855-3.94-3.855C2.75.964.984 2.69.984 4.819c0 2.13 1.765 3.856 3.942 3.856z" />
       </svg>
       </svg>

+ 22 - 0
packages/@uppy/provider-views/src/Item/components/GridLi.js

@@ -0,0 +1,22 @@
+const { h } = require('preact')
+
+// it could be a <li><button class="fake-checkbox"/> <button/></li>
+module.exports = (props) => {
+  return <li class={props.className}>
+    <div aria-hidden class={`uppy-ProviderBrowserItem-fakeCheckbox ${props.isChecked ? 'uppy-ProviderBrowserItem-fakeCheckbox--is-checked' : ''}`} />
+    <button
+      type="button"
+      class="uppy-u-reset uppy-ProviderBrowserItem-inner"
+      onclick={props.toggleCheckbox}
+
+      role="option"
+      aria-label={`${props.isChecked ? 'Unselect' : 'Select'} ${props.title} file`}
+      aria-selected={props.isChecked}
+      aria-disabled={props.isDisabled}
+      data-uppy-super-focusable
+    >
+      {props.itemIconEl}
+      {props.showTitles && props.title}
+    </button>
+  </li>
+}

+ 4 - 54
packages/@uppy/provider-views/src/Item.js → packages/@uppy/provider-views/src/Item/components/ItemIcon.js

@@ -1,9 +1,9 @@
 const { h } = require('preact')
 const { h } = require('preact')
 
 
-function mapStringToIcon (string) {
-  if (string === null) return
+module.exports = (props) => {
+  if (props.itemIconString === null) return
 
 
-  switch (string) {
+  switch (props.itemIconString) {
     case 'file':
     case 'file':
       return <svg aria-hidden="true" class="UppyIcon" width={11} height={14.5} viewBox="0 0 44 58">
       return <svg aria-hidden="true" class="UppyIcon" width={11} height={14.5} viewBox="0 0 44 58">
         <path d="M27.437.517a1 1 0 0 0-.094.03H4.25C2.037.548.217 2.368.217 4.58v48.405c0 2.212 1.82 4.03 4.03 4.03H39.03c2.21 0 4.03-1.818 4.03-4.03V15.61a1 1 0 0 0-.03-.28 1 1 0 0 0 0-.093 1 1 0 0 0-.03-.032 1 1 0 0 0 0-.03 1 1 0 0 0-.032-.063 1 1 0 0 0-.03-.063 1 1 0 0 0-.032 0 1 1 0 0 0-.03-.063 1 1 0 0 0-.032-.03 1 1 0 0 0-.03-.063 1 1 0 0 0-.063-.062l-14.593-14a1 1 0 0 0-.062-.062A1 1 0 0 0 28 .708a1 1 0 0 0-.374-.157 1 1 0 0 0-.156 0 1 1 0 0 0-.03-.03l-.003-.003zM4.25 2.547h22.218v9.97c0 2.21 1.82 4.03 4.03 4.03h10.564v36.438a2.02 2.02 0 0 1-2.032 2.032H4.25c-1.13 0-2.032-.9-2.032-2.032V4.58c0-1.13.902-2.032 2.03-2.032zm24.218 1.345l10.375 9.937.75.718H30.5c-1.13 0-2.032-.9-2.032-2.03V3.89z" />
         <path d="M27.437.517a1 1 0 0 0-.094.03H4.25C2.037.548.217 2.368.217 4.58v48.405c0 2.212 1.82 4.03 4.03 4.03H39.03c2.21 0 4.03-1.818 4.03-4.03V15.61a1 1 0 0 0-.03-.28 1 1 0 0 0 0-.093 1 1 0 0 0-.03-.032 1 1 0 0 0 0-.03 1 1 0 0 0-.032-.063 1 1 0 0 0-.03-.063 1 1 0 0 0-.032 0 1 1 0 0 0-.03-.063 1 1 0 0 0-.032-.03 1 1 0 0 0-.03-.063 1 1 0 0 0-.063-.062l-14.593-14a1 1 0 0 0-.062-.062A1 1 0 0 0 28 .708a1 1 0 0 0-.374-.157 1 1 0 0 0-.156 0 1 1 0 0 0-.03-.03l-.003-.003zM4.25 2.547h22.218v9.97c0 2.21 1.82 4.03 4.03 4.03h10.564v36.438a2.02 2.02 0 0 1-2.032 2.032H4.25c-1.13 0-2.032-.9-2.032-2.032V4.58c0-1.13.902-2.032 2.03-2.032zm24.218 1.345l10.375 9.937.75.718H30.5c-1.13 0-2.032-.9-2.032-2.03V3.89z" />
@@ -17,56 +17,6 @@ function mapStringToIcon (string) {
         <path d="M36.537 28.156l-11-7a1.005 1.005 0 0 0-1.02-.033C24.2 21.3 24 21.635 24 22v14a1 1 0 0 0 1.537.844l11-7a1.002 1.002 0 0 0 0-1.688zM26 34.18V23.82L34.137 29 26 34.18z" /><path d="M57 6H1a1 1 0 0 0-1 1v44a1 1 0 0 0 1 1h56a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1zM10 28H2v-9h8v9zm-8 2h8v9H2v-9zm10 10V8h34v42H12V40zm44-12h-8v-9h8v9zm-8 2h8v9h-8v-9zm8-22v9h-8V8h8zM2 8h8v9H2V8zm0 42v-9h8v9H2zm54 0h-8v-9h8v9z" />
         <path d="M36.537 28.156l-11-7a1.005 1.005 0 0 0-1.02-.033C24.2 21.3 24 21.635 24 22v14a1 1 0 0 0 1.537.844l11-7a1.002 1.002 0 0 0 0-1.688zM26 34.18V23.82L34.137 29 26 34.18z" /><path d="M57 6H1a1 1 0 0 0-1 1v44a1 1 0 0 0 1 1h56a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1zM10 28H2v-9h8v9zm-8 2h8v9H2v-9zm10 10V8h34v42H12V40zm44-12h-8v-9h8v9zm-8 2h8v9h-8v-9zm8-22v9h-8V8h8zM2 8h8v9H2V8zm0 42v-9h8v9H2zm54 0h-8v-9h8v9z" />
       </svg>
       </svg>
     default:
     default:
-      return <img src={string} />
-  }
-}
-
-module.exports = (props) => {
-  const stop = (ev) => {
-    if (ev.keyCode === 13) {
-      ev.stopPropagation()
-      ev.preventDefault()
-    }
+      return <img src={props.itemIconString} />
   }
   }
-
-  const handleItemClick = (ev) => {
-    ev.preventDefault()
-    // when file is clicked, select it, but when folder is clicked, open it
-    if (props.type === 'folder') {
-      return props.handleFolderClick(ev)
-    }
-    props.handleClick(ev)
-  }
-
-  const itemIcon = props.getItemIcon()
-
-  return (
-    <li class={'uppy-ProviderBrowserItem' + (props.isChecked ? ' uppy-ProviderBrowserItem--selected' : '') + (itemIcon === 'video' ? ' uppy-ProviderBrowserItem--noPreview' : '')}>
-      <div class="uppy-ProviderBrowserItem-checkbox">
-        <input type="checkbox"
-          role="option"
-          tabindex={0}
-          aria-label={`Select ${props.title}`}
-          id={props.id}
-          checked={props.isChecked}
-          disabled={props.isDisabled}
-          onchange={props.handleClick}
-          onkeyup={stop}
-          onkeydown={stop}
-          onkeypress={stop} />
-        <label
-          for={props.id}
-          onclick={props.handleClick}
-        />
-      </div>
-      <button type="button"
-        class="uppy-u-reset uppy-ProviderBrowserItem-inner"
-        aria-label={`Select ${props.title}`}
-        tabindex={0}
-        onclick={handleItemClick}>
-        {mapStringToIcon(props.getItemIcon())}
-        {props.showTitles && props.title}
-      </button>
-    </li>
-  )
 }
 }

+ 48 - 0
packages/@uppy/provider-views/src/Item/components/ListLi.js

@@ -0,0 +1,48 @@
+const { h } = require('preact')
+
+// if folder:
+//   + checkbox (selects all files from folder)
+//   + folder name (opens folder)
+// if file:
+//   + checkbox (selects file)
+//   + file name (selects file)
+module.exports = (props) => {
+  return <li class={props.className}>
+    <button
+      type="button"
+      class={`uppy-u-reset uppy-ProviderBrowserItem-fakeCheckbox ${props.isChecked ? 'uppy-ProviderBrowserItem-fakeCheckbox--is-checked' : ''}`}
+      onClick={props.toggleCheckbox}
+
+      // for the <label/>
+      id={props.id}
+      role="option"
+      aria-label={
+        props.type === 'folder'
+          ? `${props.isChecked ? 'Unselect' : 'Select'} all files from ${props.title} folder`
+          : `${props.isChecked ? 'Unselect' : 'Select'} ${props.title} file`
+      }
+      aria-selected={props.isChecked}
+      aria-disabled={props.isDisabled}
+      data-uppy-super-focusable
+    />
+
+    {
+      props.type === 'file'
+        // label for a checkbox
+        ? <label for={props.id} className="uppy-u-reset uppy-ProviderBrowserItem-inner">
+          {props.itemIconEl}
+          {props.showTitles && props.title}
+        </label>
+        // button to open a folder
+        : <button
+          type="button"
+          class="uppy-u-reset uppy-ProviderBrowserItem-inner"
+          onclick={props.handleFolderClick}
+          aria-label={`Open ${props.title} folder`}
+        >
+          {props.itemIconEl}
+          {props.showTitles && props.title}
+        </button>
+    }
+  </li>
+}

+ 26 - 0
packages/@uppy/provider-views/src/Item/index.js

@@ -0,0 +1,26 @@
+const { h } = require('preact')
+const classNames = require('classnames')
+const ItemIcon = require('./components/ItemIcon')
+const GridLi = require('./components/GridLi')
+const ListLi = require('./components/ListLi')
+
+module.exports = (props) => {
+  const itemIconString = props.getItemIcon()
+
+  const className = classNames(
+    'uppy-ProviderBrowserItem',
+    { 'uppy-ProviderBrowserItem--selected': props.isChecked },
+    { 'uppy-ProviderBrowserItem--noPreview': itemIconString === 'video' }
+  )
+
+  const itemIconEl = <ItemIcon itemIconString={itemIconString} />
+
+  switch (props.viewType) {
+    case 'grid':
+      return <GridLi {...props} className={className} itemIconEl={itemIconEl} />
+    case 'list':
+      return <ListLi {...props} className={className} itemIconEl={itemIconEl} />
+    default:
+      throw new Error(`There is no such type ${props.viewType}`)
+  }
+}

+ 27 - 32
packages/@uppy/provider-views/src/ItemList.js

@@ -1,5 +1,17 @@
-const Row = require('./Item')
 const { h } = require('preact')
 const { h } = require('preact')
+const Item = require('./Item/index')
+
+const getSharedProps = (fileOrFolder, props) => ({
+  id: fileOrFolder.id,
+  title: fileOrFolder.name,
+  getItemIcon: () => fileOrFolder.icon,
+  isChecked: props.isChecked(fileOrFolder),
+
+  toggleCheckbox: (e) => props.toggleCheckbox(e, fileOrFolder),
+  columns: props.columns,
+  showTitles: props.showTitles,
+  viewType: props.viewType
+})
 
 
 module.exports = (props) => {
 module.exports = (props) => {
   if (!props.folders.length && !props.files.length) {
   if (!props.folders.length && !props.files.length) {
@@ -11,41 +23,24 @@ module.exports = (props) => {
       <ul class="uppy-ProviderBrowser-list"
       <ul class="uppy-ProviderBrowser-list"
         onscroll={props.handleScroll}
         onscroll={props.handleScroll}
         role="listbox"
         role="listbox"
-        aria-label={`List of files from ${props.title}`}>
-        {props.folders.map(folder => {
-          let isDisabled = false
-          let isChecked = props.isChecked(folder)
-          if (isChecked) {
-            isDisabled = isChecked.loading
-          }
-          return Row({
-            title: folder.name,
-            id: folder.id,
+        aria-label={`List of files from ${props.title}`}
+        // making <ul> not focusable for firefox
+        tabindex="-1">
+        {props.folders.map(folder =>
+          Item({
+            ...getSharedProps(folder, props),
             type: 'folder',
             type: 'folder',
-            // active: props.activeRow(folder),
-            getItemIcon: () => folder.icon,
-            isDisabled: isDisabled,
-            isChecked: isChecked,
-            handleFolderClick: () => props.handleFolderClick(folder),
-            handleClick: (e) => props.toggleCheckbox(e, folder),
-            columns: props.columns,
-            showTitles: props.showTitles
+            isDisabled: props.isChecked(folder) ? props.isChecked(folder).loading : false,
+            handleFolderClick: () => props.handleFolderClick(folder)
           })
           })
-        })}
-        {props.files.map(file => {
-          return Row({
-            title: file.name,
-            id: file.id,
+        )}
+        {props.files.map(file =>
+          Item({
+            ...getSharedProps(file, props),
             type: 'file',
             type: 'file',
-            // active: props.activeRow(file),
-            getItemIcon: () => file.icon,
-            isDisabled: false,
-            isChecked: props.isChecked(file),
-            handleClick: (e) => props.toggleCheckbox(e, file),
-            columns: props.columns,
-            showTitles: props.showTitles
+            isDisabled: false
           })
           })
-        })}
+        )}
       </ul>
       </ul>
     </div>
     </div>
   )
   )

+ 1 - 0
packages/@uppy/provider-views/src/index.js

@@ -380,6 +380,7 @@ module.exports = class ProviderView {
   toggleCheckbox (e, file) {
   toggleCheckbox (e, file) {
     e.stopPropagation()
     e.stopPropagation()
     e.preventDefault()
     e.preventDefault()
+    e.currentTarget.focus()
     let { folders, files } = this.plugin.getPluginState()
     let { folders, files } = this.plugin.getPluginState()
     let items = this.filterItems(folders.concat(files))
     let items = this.filterItems(folders.concat(files))
 
 

+ 4 - 235
packages/@uppy/provider-views/src/style.scss

@@ -1,6 +1,10 @@
 @import '@uppy/core/src/_utils.scss';
 @import '@uppy/core/src/_utils.scss';
 @import '@uppy/core/src/_variables.scss';
 @import '@uppy/core/src/_variables.scss';
 
 
+@import './style/uppy-ProviderBrowser-viewType--grid';
+@import './style/uppy-ProviderBrowser-viewType--list';
+@import './style/uppy-ProviderBrowserItem-fakeCheckbox';
+
 .uppy-DashboardContent-panelBody {
 .uppy-DashboardContent-panelBody {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -247,241 +251,6 @@
   font-size: 13px;
   font-size: 13px;
 }
 }
 
 
-// ***
-// View type: list
-// ***
-
-.uppy-ProviderBrowser-viewType--list {
-  background-color: $white;
-
-  .uppy-ProviderBrowserItem {
-    display: flex;
-    padding: 7px 15px;
-    margin: 0;
-  }
-
-  .uppy-ProviderBrowserItem-checkbox {
-    vertical-align: middle;
-  }
-
-    .uppy-ProviderBrowserItem-checkbox label:before {
-      border-color: $gray-300;
-    }
-
-    .uppy-ProviderBrowserItem-checkbox input:checked + label:before {
-      border-color: $blue;
-    }
-
-  .uppy-ProviderBrowserItem-inner {
-    text-overflow: ellipsis;
-    white-space: nowrap;
-    overflow: hidden;
-    text-align: left;
-    line-height: 1.4;
-  }
-
-  .uppy-ProviderBrowserItem-inner img,
-  .uppy-ProviderBrowserItem-inner svg {
-    vertical-align: middle;
-    margin-right: 8px;
-    max-width: 20px;
-    max-height: 20px;
-  }
-}
-
-// ***
-// View type: grid
-// ***
-
-.uppy-ProviderBrowser-viewType--grid {
-
-  .uppy-ProviderBrowser-list {
-    display: flex;
-    flex-direction: row;
-    flex-wrap: wrap;
-    justify-content: space-between;
-    align-items: flex-start;
-    padding: 6px;
-  }
-
-  .uppy-ProviderBrowser-list:after {
-    content: '';
-    flex: auto;
-  }
-
-  .uppy-ProviderBrowserItem {
-    display: inline-block;
-    width: 50%;
-    position: relative;
-    margin: 0;
-  }
-
-    .uppy-ProviderBrowserItem:before {
-      content: '';
-      padding-top: 100%;
-      display: block;
-    }
-
-    // .uppy-ProviderBrowserItem--selected {
-    //   border-color: $blue;
-    //   outline: none;
-    // }
-
-    // .uppy-ProviderBrowserItem--selected .uppy-ProviderBrowserItem-inner {
-    //   box-shadow: 0 0 0 3px rgba(darken($blue, 10%), 0.9);
-    // }
-
-  .uppy-ProviderBrowserItem-inner {
-    border-radius: 4px;
-    overflow: hidden;
-    position: absolute;
-    top: 7px;
-    left: 7px;
-    right: 7px;
-    bottom: 7px;
-    text-align: center;
-
-  }
-
-  .uppy-ProviderBrowserItem-inner:focus {
-    outline: none;
-    box-shadow: 0 0 0 3px rgba($blue, 0.9);
-  }
-
-  .uppy-ProviderBrowserItem img,
-  .uppy-ProviderBrowserItem svg {
-    width: 100%;
-    height: 100%;
-    object-fit: cover;
-    border-radius: 4px;
-  }
-
-  .uppy-ProviderBrowserItem--selected img,
-  .uppy-ProviderBrowserItem--selected svg {
-    opacity: 0.85;
-  }
-
-  .uppy-ProviderBrowserItem--noPreview .uppy-ProviderBrowserItem-inner {
-    background-color: rgba($gray-500, 0.3);
-  }
-
-  .uppy-ProviderBrowserItem--noPreview svg {
-    fill: rgba($black, 0.7);
-    width: 30%;
-    height: 30%;
-  }
-
-  .uppy-ProviderBrowserItem-checkbox {
-    position: absolute;
-    width: 26px;
-    height: 26px;
-    top: 16px;
-    right: 16px;
-    display: block;
-    margin-right: 0;
-    z-index: $zIndex-3;
-  }
-
-  .uppy-ProviderBrowserItem-checkbox label:before {
-    background-color: $blue;
-    border-radius: 50%;
-    width: 26px;
-    height: 26px;
-    top: 0;
-  }
-
-  .uppy-ProviderBrowserItem-checkbox label:after {
-    width: 12px;
-    height: 7px;
-    left: 7px;
-    top: 8px;
-  }
-
-  // Hide checkbox when unchecked in grid view
-  .uppy-ProviderBrowserItem-checkbox input + label {
-    opacity: 0;
-  }
-
-  // Unhide the checkbox on the checked state
-  .uppy-ProviderBrowserItem-checkbox input:checked + label {
-    opacity: 1;
-  }
-
-}
-
-.uppy-size--md .uppy-ProviderBrowser-viewType--grid .uppy-ProviderBrowserItem {
-  width: 33.3333%;
-}
-
-.uppy-size--lg .uppy-ProviderBrowser-viewType--grid .uppy-ProviderBrowserItem {
-  width: 25%;
-}
-
-.uppy-ProviderBrowserItem-checkbox input {
-  opacity: 0;
-}
-
-// https://medium.com/claritydesignsystem/pure-css-accessible-checkboxes-and-radios-buttons-54063e759bb3
-.uppy-ProviderBrowserItem-checkbox {
-  position: relative;
-  display: inline-block;
-  margin-right: 15px;
-}
-
-.uppy-ProviderBrowserItem-checkbox label {
-  display: block;
-}
-
-.uppy-ProviderBrowserItem-checkbox label::before,
-.uppy-ProviderBrowserItem-checkbox label::after {
-  position: absolute;
-  cursor: pointer;
-}
-
-// Outer circle
-.uppy-ProviderBrowserItem-checkbox label:before {
-  content: "";
-  display: inline-block;
-  height: 17px;
-  width: 17px;
-  top: 2px;
-  border: 1px solid $blue;
-  background-color: $white;
-  border-radius: 3px;
-}
-
-// Inner checkbox
-.uppy-ProviderBrowserItem-checkbox label:after {
-  content: '';
-  display: inline-block;
-  height: 5px;
-  width: 9px;
-  left: 4px;
-  top: 7px;
-  border-left: 2px solid $white;
-  border-bottom: 2px solid $white;
-  transform: rotate(-45deg);
-}
-
-// Hide the checkmark by default
-.uppy-ProviderBrowserItem-checkbox input + label::after {
-  content: none;
-}
-
-// Unhide the checkmark on the checked state
-.uppy-ProviderBrowserItem-checkbox input:checked + label::after {
-  content: '';
-}
-
-.uppy-ProviderBrowserItem-checkbox input:checked + label::before {
-  background-color: $blue;
-}
-
-// Adding focus styles on the outer-box of the fake checkbox*/
-.uppy-ProviderBrowserItem-checkbox input:focus + label::before {
-  outline: rgb(59, 153, 252) auto 5px;
-}
-
 .uppy-ProviderBrowser-footer {
 .uppy-ProviderBrowser-footer {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;

+ 92 - 0
packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--grid.scss

@@ -0,0 +1,92 @@
+// ***
+// View type: grid
+// ***
+.uppy-ProviderBrowser-viewType--grid {
+  ul.uppy-ProviderBrowser-list {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    justify-content: space-between;
+    align-items: flex-start;
+    padding: 6px;
+    &::after {
+      content: '';
+      flex: auto;
+    }
+  }
+
+  li.uppy-ProviderBrowserItem {
+    width: 50%;
+    position: relative;
+    margin: 0;
+    .uppy-size--md & {
+      width: 33.3333%;
+    }
+    .uppy-size--lg & {
+      width: 25%;
+    }
+    &::before {
+      content: '';
+      padding-top: 100%;
+      display: block;
+    }
+  }
+  li.uppy-ProviderBrowserItem--selected {
+    img, svg{
+      opacity: 0.85;
+    }
+  }
+  li.uppy-ProviderBrowserItem--noPreview {
+    .uppy-ProviderBrowserItem-inner {
+      background-color: rgba($gray-500, 0.3);
+    }
+    svg {
+      fill: rgba($black, 0.7);
+      width: 30%;
+      height: 30%;
+    }
+  }
+
+  // button with a large picture
+  button.uppy-ProviderBrowserItem-inner {
+    border-radius: 4px;
+    overflow: hidden;
+    position: absolute;
+    top: 7px;
+    left: 7px;
+    right: 7px;
+    bottom: 7px;
+    text-align: center;
+    &:focus {
+      outline: none;
+      box-shadow: 0 0 0 3px rgba($blue, 0.9);
+    }
+    img, svg {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      border-radius: 4px;
+    }
+  }
+
+  // Checkbox
+  .uppy-ProviderBrowserItem-fakeCheckbox {
+    position: absolute;
+    top: 16px; right: 16px;
+    width: 26px; height: 26px;
+    background-color: $blue;
+    border-radius: 50%;
+    z-index: $zIndex-3;
+    opacity: 0;
+
+    // Checkmark icon
+    &:after {
+      width: 12px; height: 7px;
+      left: 7px; top: 8px;
+    }
+  }
+  // Checked: show the checkmark
+  .uppy-ProviderBrowserItem-fakeCheckbox--is-checked {
+    opacity: 1;
+  }
+}

+ 66 - 0
packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--list.scss

@@ -0,0 +1,66 @@
+// ***
+// View type: list
+// ***
+
+.uppy-ProviderBrowser-viewType--list {
+  background-color: $white;
+
+  li.uppy-ProviderBrowserItem {
+    display: flex;
+    align-items: center;
+    padding: 7px 15px;
+    margin: 0;
+  }
+
+  // Checkbox
+  .uppy-ProviderBrowserItem-fakeCheckbox {
+    margin-right: 15px;
+    height: 17px; width: 17px;
+    border-radius: 3px;
+    background-color: $white;
+    border: 1px solid $gray-300;
+
+    // Focus: show blue outline
+    &:focus {
+      border: 1px solid $blue;
+      box-shadow: 0 0 0 3px rgba($blue, 0.25);
+      outline: none;
+    }
+
+    // Checkmark icon
+    &::after {
+      opacity: 0;
+      height: 5px; width: 9px;
+      left: 3px; top: 4px;
+    }
+  }
+  // Checked: color the background, show the checkmark
+  .uppy-ProviderBrowserItem-fakeCheckbox--is-checked {
+    background-color: $blue;
+    border-color: $blue;
+    &::after {
+      opacity: 1;
+    }
+  }
+
+  // Either a <label/> for a file,
+  // or a <button/> we can click on for a folder
+  .uppy-ProviderBrowserItem-inner {
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    overflow: hidden;
+    display: flex;
+    align-items: center;
+    // For better outline
+    padding: 2px;
+    &:focus {
+      outline: none;
+      text-decoration: underline;
+    }
+    img, svg {
+      margin-right: 8px;
+      max-width: 20px;
+      max-height: 20px;
+    }
+  }
+}

+ 14 - 0
packages/@uppy/provider-views/src/style/uppy-ProviderBrowserItem-fakeCheckbox.scss

@@ -0,0 +1,14 @@
+.uppy-ProviderBrowserItem-fakeCheckbox {
+  position: relative;
+  cursor: pointer;
+
+  // Checkmark icon
+  &::after {
+    content: '';
+    position: absolute;
+    cursor: pointer;
+    border-left: 2px solid $white;
+    border-bottom: 2px solid $white;
+    transform: rotate(-45deg);
+  }
+}

+ 8 - 4
packages/@uppy/status-bar/src/StatusBar.js

@@ -149,7 +149,8 @@ const UploadBtn = (props) => {
   return <button type="button"
   return <button type="button"
     class={uploadBtnClassNames}
     class={uploadBtnClassNames}
     aria-label={props.i18n('uploadXFiles', { smart_count: props.newFiles })}
     aria-label={props.i18n('uploadXFiles', { smart_count: props.newFiles })}
-    onclick={props.startUpload}>
+    onclick={props.startUpload}
+    data-uppy-super-focusable>
     {props.newFiles && props.isUploadStarted
     {props.newFiles && props.isUploadStarted
       ? props.i18n('uploadXNewFiles', { smart_count: props.newFiles })
       ? props.i18n('uploadXNewFiles', { smart_count: props.newFiles })
       : props.i18n('uploadXFiles', { smart_count: props.newFiles })
       : props.i18n('uploadXFiles', { smart_count: props.newFiles })
@@ -160,7 +161,8 @@ const UploadBtn = (props) => {
 const RetryBtn = (props) => {
 const RetryBtn = (props) => {
   return (
   return (
     <button type="button"
     <button type="button"
-      class="uppy-u-reset uppy-c-btn uppy-StatusBar-actionBtn uppy-StatusBar-actionBtn--retry" aria-label={props.i18n('retryUpload')} onclick={props.retryAll}>
+      class="uppy-u-reset uppy-c-btn uppy-StatusBar-actionBtn uppy-StatusBar-actionBtn--retry" aria-label={props.i18n('retryUpload')} onclick={props.retryAll}
+      data-uppy-super-focusable>
       <svg aria-hidden="true" class="UppyIcon" width="8" height="10" viewBox="0 0 8 10">
       <svg aria-hidden="true" class="UppyIcon" width="8" height="10" viewBox="0 0 8 10">
         <path d="M4 2.408a2.75 2.75 0 1 0 2.75 2.75.626.626 0 0 1 1.25.018v.023a4 4 0 1 1-4-4.041V.25a.25.25 0 0 1 .389-.208l2.299 1.533a.25.25 0 0 1 0 .416l-2.3 1.533A.25.25 0 0 1 4 3.316v-.908z" />
         <path d="M4 2.408a2.75 2.75 0 1 0 2.75 2.75.626.626 0 0 1 1.25.018v.023a4 4 0 1 1-4-4.041V.25a.25.25 0 0 1 .389-.208l2.299 1.533a.25.25 0 0 1 0 .416l-2.3 1.533A.25.25 0 0 1 4 3.316v-.908z" />
       </svg>
       </svg>
@@ -175,7 +177,8 @@ const CancelBtn = (props) => {
     class="uppy-u-reset uppy-StatusBar-actionCircleBtn"
     class="uppy-u-reset uppy-StatusBar-actionCircleBtn"
     title={props.i18n('cancel')}
     title={props.i18n('cancel')}
     aria-label={props.i18n('cancel')}
     aria-label={props.i18n('cancel')}
-    onclick={props.cancelAll}>
+    onclick={props.cancelAll}
+    data-uppy-super-focusable>
     <svg aria-hidden="true" class="UppyIcon" width="16" height="16" viewBox="0 0 16 16">
     <svg aria-hidden="true" class="UppyIcon" width="16" height="16" viewBox="0 0 16 16">
       <g fill="none" fill-rule="evenodd">
       <g fill="none" fill-rule="evenodd">
         <circle fill="#888" cx="8" cy="8" r="8" />
         <circle fill="#888" cx="8" cy="8" r="8" />
@@ -194,7 +197,8 @@ const PauseResumeButton = (props) => {
     aria-label={title}
     aria-label={title}
     class="uppy-u-reset uppy-StatusBar-actionCircleBtn"
     class="uppy-u-reset uppy-StatusBar-actionCircleBtn"
     type="button"
     type="button"
-    onclick={() => togglePauseResume(props)}>
+    onclick={() => togglePauseResume(props)}
+    data-uppy-super-focusable>
     {isAllPaused
     {isAllPaused
       ? <svg aria-hidden="true" class="UppyIcon" width="16" height="16" viewBox="0 0 16 16">
       ? <svg aria-hidden="true" class="UppyIcon" width="16" height="16" viewBox="0 0 16 16">
         <g fill="none" fill-rule="evenodd">
         <g fill="none" fill-rule="evenodd">

+ 2 - 8
packages/@uppy/url/src/UrlUI.js

@@ -9,13 +9,6 @@ class UrlUI extends Component {
 
 
   componentDidMount () {
   componentDidMount () {
     this.input.value = ''
     this.input.value = ''
-    // My guess about why browser scrolls to top on focus:
-    // Component is mounted right away, but the tab panel might be animating
-    // still, so input element is positioned outside viewport. This fixes it.
-    setTimeout(() => {
-      if (!this.input) return
-      this.input.focus({ preventScroll: true })
-    }, 150)
   }
   }
 
 
   handleKeyPress (ev) {
   handleKeyPress (ev) {
@@ -36,7 +29,8 @@ class UrlUI extends Component {
         aria-label={this.props.i18n('enterUrlToImport')}
         aria-label={this.props.i18n('enterUrlToImport')}
         placeholder={this.props.i18n('enterUrlToImport')}
         placeholder={this.props.i18n('enterUrlToImport')}
         onkeyup={this.handleKeyPress}
         onkeyup={this.handleKeyPress}
-        ref={(input) => { this.input = input }} />
+        ref={(input) => { this.input = input }}
+        data-uppy-super-focusable />
       <button
       <button
         class="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Url-importButton"
         class="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Url-importButton"
         type="button"
         type="button"

+ 7 - 4
packages/@uppy/url/src/utils/forEachDroppedOrPastedUrl.js

@@ -49,10 +49,13 @@ const toArray = require('@uppy/utils/lib/toArray')
       Take 'text/uri-list' items. Safari has an additional item of .kind === 'file', and you may worry about the item being duplicated (first by DashboardPlugin, and then by UrlPlugin, now), but don't. Directory handling code won't pay attention to this particular item of kind 'file'.
       Take 'text/uri-list' items. Safari has an additional item of .kind === 'file', and you may worry about the item being duplicated (first by DashboardPlugin, and then by UrlPlugin, now), but don't. Directory handling code won't pay attention to this particular item of kind 'file'.
 */
 */
 
 
-// Finds all links dropped/pasted from one browser window to another.
-// @param {object} dataTransfer - DataTransfer instance, e.g. e.clipboardData, or e.dataTransfer
-// @param {string} isDropOrPaste - either 'drop' or 'paste'
-// @param {function} callback - (urlString) => {}
+/**
+ * Finds all links dropped/pasted from one browser window to another.
+ *
+ * @param {Object} dataTransfer - DataTransfer instance, e.g. e.clipboardData, or e.dataTransfer
+ * @param {string} isDropOrPaste - either 'drop' or 'paste'
+ * @param {Function} callback - (urlString) => {}
+ */
 module.exports = function forEachDroppedOrPastedUrl (dataTransfer, isDropOrPaste, callback) {
 module.exports = function forEachDroppedOrPastedUrl (dataTransfer, isDropOrPaste, callback) {
   const items = toArray(dataTransfer.items)
   const items = toArray(dataTransfer.items)
 
 

+ 13 - 0
packages/@uppy/utils/src/FOCUSABLE_ELEMENTS.js

@@ -0,0 +1,13 @@
+module.exports = [
+  'a[href]:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
+  'area[href]:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
+  'input:not([disabled]):not([inert]):not([aria-hidden])',
+  'select:not([disabled]):not([inert]):not([aria-hidden])',
+  'textarea:not([disabled]):not([inert]):not([aria-hidden])',
+  'button:not([disabled]):not([inert]):not([aria-hidden])',
+  'iframe:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
+  'object:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
+  'embed:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
+  '[contenteditable]:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
+  '[tabindex]:not([tabindex^="-"]):not([inert]):not([aria-hidden])'
+]

+ 7 - 5
packages/@uppy/utils/src/getDroppedFiles/index.js

@@ -1,11 +1,13 @@
 const webkitGetAsEntryApi = require('./utils/webkitGetAsEntryApi')
 const webkitGetAsEntryApi = require('./utils/webkitGetAsEntryApi')
 const fallbackApi = require('./utils/fallbackApi')
 const fallbackApi = require('./utils/fallbackApi')
 
 
-// Returns a promise that resolves to the array of dropped files (if a folder is dropped, and browser supports folder parsing - promise resolves to the flat array of all files in all directories).
-// Each file has .relativePath prop appended to it (e.g. "/docs/Prague/ticket_from_prague_to_ufa.pdf") if browser supports it. Otherwise it's undefined.
-//
-// @param {DataTransfer} dataTransfer
-// @returns {Promise} - Array<File>
+/**
+ * Returns a promise that resolves to the array of dropped files (if a folder is dropped, and browser supports folder parsing - promise resolves to the flat array of all files in all directories).
+ * Each file has .relativePath prop appended to it (e.g. "/docs/Prague/ticket_from_prague_to_ufa.pdf") if browser supports it. Otherwise it's undefined.
+ *
+ * @param {DataTransfer} dataTransfer
+ * @returns {Promise} - Array<File>
+ */
 module.exports = function getDroppedFiles (dataTransfer) {
 module.exports = function getDroppedFiles (dataTransfer) {
   // Get all files from all subdirs. Works (at least) in Chrome, Mozilla, and Safari
   // Get all files from all subdirs. Works (at least) in Chrome, Mozilla, and Safari
   if (dataTransfer.items && dataTransfer.items[0] && 'webkitGetAsEntry' in dataTransfer.items[0]) {
   if (dataTransfer.items && dataTransfer.items[0] && 'webkitGetAsEntry' in dataTransfer.items[0]) {

+ 22 - 11
packages/@uppy/utils/src/getDroppedFiles/utils/webkitGetAsEntryApi.js

@@ -5,7 +5,7 @@ const toArray = require('../../toArray')
  *
  *
  * @param {FileEntry} fileEntry
  * @param {FileEntry} fileEntry
  *
  *
- * @return {string|null} - if file is not in a folder - return null (this is to be consistent with .relativePath-s of files selected from My Device). If file is in a folder - return its fullPath, e.g. '/simpsons/hi.jpeg'.
+ * @returns {string|null} - if file is not in a folder - return null (this is to be consistent with .relativePath-s of files selected from My Device). If file is in a folder - return its fullPath, e.g. '/simpsons/hi.jpeg'.
  */
  */
 function getRelativePath (fileEntry) {
 function getRelativePath (fileEntry) {
   // fileEntry.fullPath - "/simpsons/hi.jpeg" or undefined (for browsers that don't support it)
   // fileEntry.fullPath - "/simpsons/hi.jpeg" or undefined (for browsers that don't support it)
@@ -17,8 +17,13 @@ function getRelativePath (fileEntry) {
   }
   }
 }
 }
 
 
-// Recursive function, calls the original callback() when the directory is entirely parsed.
-// @param {function} callback - called with ([ all files and directories in that directoryReader ])
+/**
+ * Recursive function, calls the original callback() when the directory is entirely parsed.
+ *
+ * @param {FileSystemDirectoryReader} directoryReader
+ * @param {Array} oldEntries
+ * @param {Function} callback - called with ([ all files and directories in that directoryReader ])
+ */
 function readEntries (directoryReader, oldEntries, callback) {
 function readEntries (directoryReader, oldEntries, callback) {
   directoryReader.readEntries(
   directoryReader.readEntries(
     (entries) => {
     (entries) => {
@@ -39,9 +44,11 @@ function readEntries (directoryReader, oldEntries, callback) {
   )
   )
 }
 }
 
 
-// @param {function} resolve - function that will be called when :files array is appended with a file
-// @param {Array<File>} files - array of files to enhance
-// @param {FileSystemFileEntry} fileEntry
+/**
+ * @param {Function} resolve - function that will be called when :files array is appended with a file
+ * @param {Array<File>} files - array of files to enhance
+ * @param {FileSystemFileEntry} fileEntry
+ */
 function addEntryToFiles (resolve, files, fileEntry) {
 function addEntryToFiles (resolve, files, fileEntry) {
   // Creates a new File object which can be used to read the file.
   // Creates a new File object which can be used to read the file.
   fileEntry.file(
   fileEntry.file(
@@ -56,9 +63,11 @@ function addEntryToFiles (resolve, files, fileEntry) {
   )
   )
 }
 }
 
 
-// @param {function} resolve - function that will be called when :directoryEntry is done being recursively parsed
-// @param {Array<File>} files - array of files to enhance
-// @param {FileSystemDirectoryEntry} directoryEntry
+/**
+ * @param {Function} resolve - function that will be called when :directoryEntry is done being recursively parsed
+ * @param {Array<File>} files - array of files to enhance
+ * @param {FileSystemDirectoryEntry} directoryEntry
+ */
 function recursivelyAddFilesFromDirectory (resolve, files, directoryEntry) {
 function recursivelyAddFilesFromDirectory (resolve, files, directoryEntry) {
   const directoryReader = directoryEntry.createReader()
   const directoryReader = directoryEntry.createReader()
   readEntries(directoryReader, [], (entries) => {
   readEntries(directoryReader, [], (entries) => {
@@ -73,8 +82,10 @@ function recursivelyAddFilesFromDirectory (resolve, files, directoryEntry) {
   })
   })
 }
 }
 
 
-// @param {Array<File>} files - array of files to enhance
-// @param {(FileSystemFileEntry|FileSystemDirectoryEntry)} entry
+/**
+ * @param {Array<File>} files - array of files to enhance
+ * @param {(FileSystemFileEntry|FileSystemDirectoryEntry)} entry
+ */
 function createPromiseToAddFileOrParseDirectory (files, entry) {
 function createPromiseToAddFileOrParseDirectory (files, entry) {
   return new Promise((resolve) => {
   return new Promise((resolve) => {
     if (entry.isFile) {
     if (entry.isFile) {

+ 1 - 2
packages/@uppy/webcam/src/CameraScreen.js

@@ -9,7 +9,6 @@ function isModeAvailable (modes, mode) {
 class CameraScreen extends Component {
 class CameraScreen extends Component {
   componentDidMount () {
   componentDidMount () {
     this.props.onFocus()
     this.props.onFocus()
-    this.btnContainer.firstChild.focus()
   }
   }
 
 
   componentWillUnmount () {
   componentWillUnmount () {
@@ -29,7 +28,7 @@ class CameraScreen extends Component {
         <div class="uppy-Webcam-videoContainer">
         <div class="uppy-Webcam-videoContainer">
           <video class={`uppy-Webcam-video  ${this.props.mirror ? 'uppy-Webcam-video--mirrored' : ''}`} autoplay muted playsinline srcObject={this.props.src || ''} />
           <video class={`uppy-Webcam-video  ${this.props.mirror ? 'uppy-Webcam-video--mirrored' : ''}`} autoplay muted playsinline srcObject={this.props.src || ''} />
         </div>
         </div>
-        <div class="uppy-Webcam-buttonContainer" ref={(el) => { this.btnContainer = el }}>
+        <div class="uppy-Webcam-buttonContainer">
           {shouldShowSnapshotButton ? SnapshotButton(this.props) : null}
           {shouldShowSnapshotButton ? SnapshotButton(this.props) : null}
           {' '}
           {' '}
           {shouldShowRecordButton ? RecordButton(this.props) : null}
           {shouldShowRecordButton ? RecordButton(this.props) : null}

+ 4 - 2
packages/@uppy/webcam/src/RecordButton.js

@@ -8,7 +8,8 @@ module.exports = function RecordButton ({ recording, onStartRecording, onStopRec
         type="button"
         type="button"
         title={i18n('stopRecording')}
         title={i18n('stopRecording')}
         aria-label={i18n('stopRecording')}
         aria-label={i18n('stopRecording')}
-        onclick={onStopRecording}>
+        onclick={onStopRecording}
+        data-uppy-super-focusable>
         <svg aria-hidden="true" class="UppyIcon" width="100" height="100" viewBox="0 0 100 100">
         <svg aria-hidden="true" class="UppyIcon" width="100" height="100" viewBox="0 0 100 100">
           <rect x="15" y="15" width="70" height="70" />
           <rect x="15" y="15" width="70" height="70" />
         </svg>
         </svg>
@@ -21,7 +22,8 @@ module.exports = function RecordButton ({ recording, onStartRecording, onStopRec
       type="button"
       type="button"
       title={i18n('startRecording')}
       title={i18n('startRecording')}
       aria-label={i18n('startRecording')}
       aria-label={i18n('startRecording')}
-      onclick={onStartRecording}>
+      onclick={onStartRecording}
+      data-uppy-super-focusable>
       <svg aria-hidden="true" class="UppyIcon" width="100" height="100" viewBox="0 0 100 100">
       <svg aria-hidden="true" class="UppyIcon" width="100" height="100" viewBox="0 0 100 100">
         <circle cx="50" cy="50" r="40" />
         <circle cx="50" cy="50" r="40" />
       </svg>
       </svg>

+ 2 - 1
packages/@uppy/webcam/src/SnapshotButton.js

@@ -7,7 +7,8 @@ module.exports = ({ onSnapshot, i18n }) => {
       type="button"
       type="button"
       title={i18n('takePicture')}
       title={i18n('takePicture')}
       aria-label={i18n('takePicture')}
       aria-label={i18n('takePicture')}
-      onclick={onSnapshot}>
+      onclick={onSnapshot}
+      data-uppy-super-focusable>
       {CameraIcon()}
       {CameraIcon()}
     </button>
     </button>
   )
   )

+ 8 - 18
website/src/_template/contributing.md

@@ -124,28 +124,18 @@ Even though bundled in this repo, the website is regarded as a separate project.
 
 
 ### Local previews
 ### Local previews
 
 
-It is recommended to exclude `./website/public/` from your editor if you want efficient searches.
-
-To install the required node modules, type:
-
-```bash
-npm install && cd website && npm install && cd ..
-```
-
-For local previews on http://localhost:4000, type:
-
-```bash
-npm run web:start # that gets you just the website. if you need companion, etc. you can use `npm start` instead
-```
-
-This will watch the website, as well as Uppy, as the examples, and rebuild everything and refresh your browser as files change.
+1. `npm install`
+2. `npm run bootstrap`
+3. `cd website && npm install && cd ..`
+4. `npm start`
+5. Go to http://localhost:4000. Your changes in `/website` and `/packages/@uppy` will be watched, your browser will refresh as files change.
 
 
 Then, to work on, for instance, the XHRUpload example, you would edit the following files:
 Then, to work on, for instance, the XHRUpload example, you would edit the following files:
 
 
 ```bash
 ```bash
-${EDITOR} src/core/Core.js \
-  src/plugins/XHRUpload.js \
-  src/plugins/Plugin.js \
+${EDITOR} packages/@uppy/core/src/index.js \
+  packages/@uppy/core/src/Plugin.js \
+  packages/@uppy/xhr-upload/src/index.js \
   website/src/examples/xhrupload/app.es6
   website/src/examples/xhrupload/app.es6
 ```
 ```
 
 

+ 6 - 2
website/src/docs/uppy.md

@@ -120,6 +120,8 @@ A function run before a file is added to Uppy. It gets passed `(currentFile, fil
 
 
 Use this function to run any number of custom checks on the selected file, or manipulate it, for instance, by optimizing a file name.
 Use this function to run any number of custom checks on the selected file, or manipulate it, for instance, by optimizing a file name.
 
 
+> ⚠️ Note that this method is intended for quick synchronous checks/modifications only. If you need to do an async API call, or heavy work on a file (like compression or encryption), you should utilize a [custom plugin](/docs/writing-plugins/#Example-of-a-custom-plugin) instead.
+
 Return true/nothing or a modified file object to proceed with adding the file:
 Return true/nothing or a modified file object to proceed with adding the file:
 
 
 ```js
 ```js
@@ -155,7 +157,7 @@ onBeforeFileAdded: (currentFile, files) => {
 }
 }
 ```
 ```
 
 
-**Note:** it is up to you to show a notification to the user about a file not passing validation. We recommend showing a message using [uppy.info()](#uppy-info) and logging to console for debugging purposes.
+**Note:** it is up to you to show a notification to the user about a file not passing validation. We recommend showing a message using [uppy.info()](#uppy-info) and logging to console for debugging purposes via [uppy.log()](#uppy-log).
 
 
 
 
 <a id="onBeforeUpload"></a>
 <a id="onBeforeUpload"></a>
@@ -165,6 +167,8 @@ A function run before an upload begins. Gets passed `files` object with all the
 
 
 Use this to check if all files or their total number match your requirements, or manipulate all the files at once before upload.
 Use this to check if all files or their total number match your requirements, or manipulate all the files at once before upload.
 
 
+> ⚠️ Note that this method is intended for quick synchronous checks/modifications only. If you need to do an async API call, or heavy work on a file (like compression or encryption), you should utilize a [custom plugin](/docs/writing-plugins/#Example-of-a-custom-plugin) instead.
+
 Return true or modified `files` object to proceed:
 Return true or modified `files` object to proceed:
 
 
 ```js
 ```js
@@ -191,7 +195,7 @@ onBeforeUpload: (files) => {
 }
 }
 ```
 ```
 
 
-**Note:** it is up to you to show a notification to the user about a file not passing validation. We recommend showing a message using [uppy.info()](#uppy-info) and logging to console for debugging purposes.
+**Note:** it is up to you to show a notification to the user about a file not passing validation. We recommend showing a message using [uppy.info()](#uppy-info) and logging to console for debugging purposes via [uppy.log()](#uppy-log).
 
 
 ### `locale: {}`
 ### `locale: {}`
 
 

+ 66 - 1
website/src/docs/writing-plugins.md

@@ -10,8 +10,11 @@ There are already a few useful Uppy plugins out there, but there might come a ti
 
 
  - Render some custom UI element, e.g., [StatusBar](/docs/statusbar) or [Dashboard](/docs/dashboard).
  - Render some custom UI element, e.g., [StatusBar](/docs/statusbar) or [Dashboard](/docs/dashboard).
  - Do the actual uploading, e.g., [XHRUpload](/docs/xhrupload) or [Tus](/docs/tus).
  - Do the actual uploading, e.g., [XHRUpload](/docs/xhrupload) or [Tus](/docs/tus).
+ - Do work before the upload, like compressing an image or calling external API.
  - Interact with a third-party service to process uploads correctly, e.g., [Transloadit](/docs/transloadit) or [AwsS3](/docs/aws-s3).
  - Interact with a third-party service to process uploads correctly, e.g., [Transloadit](/docs/transloadit) or [AwsS3](/docs/aws-s3).
 
 
+See a [full example of a plugin](#Example-of-a-custom-plugin) below.
+
 ## Creating A Plugin
 ## Creating A Plugin
 
 
 Plugins are classes that extend from Uppy's `Plugin` class. Each plugin has an `id` and a `type`. `id`s are used to uniquely identify plugins. A `type` can be anything—some plugins use `type`s to determine whether to do something to some other plugin. For example, when targeting plugins at the built-in `Dashboard` plugin, the Dashboard uses the `type` to figure out where to mount different UI elements. `'acquirer'`-type plugins are mounted into the tab bar, while `'progressindicator'`-type plugins are mounted into the progress bar area.
 Plugins are classes that extend from Uppy's `Plugin` class. Each plugin has an `id` and a `type`. `id`s are used to uniquely identify plugins. A `type` can be anything—some plugins use `type`s to determine whether to do something to some other plugin. For example, when targeting plugins at the built-in `Dashboard` plugin, the Dashboard uses the `type` to figure out where to mount different UI elements. `'acquirer'`-type plugins are mounted into the tab bar, while `'progressindicator'`-type plugins are mounted into the progress bar area.
@@ -219,4 +222,66 @@ this.i18nArray = this.translator.translateArray.bind(this.translator)
 // ^-- Only if you're using i18nArray, which allows you to pass in JSX Components as well.
 // ^-- Only if you're using i18nArray, which allows you to pass in JSX Components as well.
 ```
 ```
 
 
-[core.setfilestate]: /docs/uppy#uppy-setFileState-fileID-state
+## Example of a custom plugin
+
+Below is a full example of a simple plugin that compresses images before uploading them. You can replace `compress` method with any other work you need to do. This works especially well for async stuff, like calling an external API.
+
+```js
+const Compressor = require('compressorjs')
+
+class UppyCompressor extends Plugin {
+  constructor (uppy, options) {
+    super(uppy, options)
+    this.id = options.id || 'Compressor'
+    this.type = 'modifier'
+
+    this.prepareUpload = this.prepareUpload.bind(this)
+    this.compress = this.compress.bind(this)
+  }
+
+  compress (blob) {
+    this.uppy.log(`[Compressor] Image size before compression: ${blob.size}`)
+    return new Promise((resolve, reject) => {
+      new Compressor(blob, Object.assign(
+        {},
+        this.opts,
+        {
+          success: (result) => {
+            this.uppy.log(`[Compressor] Image size after compression: ${result.size}`)
+            return resolve(result)
+          },
+          error: (err) => {
+            return reject(err)
+          }
+        }
+      ))
+    })
+  }
+
+  prepareUpload (fileIDs) {
+    const promises = fileIDs.map((fileID) => {
+      const file = this.uppy.getFile(fileID)
+      if (file.type.split('/')[0] !== 'image') {
+        return
+      }
+      return this.compress(file.data).then((compressedBlob) => {
+        const compressedFile = Object.assign({}, file, { data: compressedBlob })
+        this.uppy.setFileState(fileID, compressedFile)
+      })
+    })
+    return Promise.all(promises)
+  }
+
+  install () {
+    this.uppy.addPreProcessor(this.prepareUpload)
+  }
+
+  uninstall () {
+    this.uppy.removePreProcessor(this.prepareUpload)
+  }
+}
+
+module.exports = UppyCompressor
+```
+
+[core.setfilestate]: /docs/uppy#uppy-setFileState-fileID-state