Explorar o código

Feature/accessibility (#1507)

* @uppy/provider-views - added accessibility

* @uppy/provider-views - simplified checkbox css

* @uppy/dashboard - on tab set focus to the current verlay

* @uppy/dashboard - factored out code related to focus into a separate file

* @uppy/dashboard, and connected plugins - made focus travel well, and be managed in a single place in Dashboard

* @uppy/dashboard - made modal opener button focus keep focus on modal close

* @uppy/dashboard - focus management for uploaded-files

* @uppy/dashboard - made informer read messages without interruption

* @uppy/provider-views - removed the bug of focus jumping on safari

* @uppy/dashboard - made focus travel well in instagram in firefox

* @uppy - moved lodash.debounce dependency from / to @uppy/dashboard

* everywhere - made <ul>s not focusable for firefox

* @uppy/provider-views - unnested scss, copypasted alex's :focus changes

* Added JSDoc eslint package and rules, and fixed some JSDoc comments

* .eslintrc - made jsdoc errors warnings instead

* package-lock.json for new package.json

* eslint fix

* @uppy/dashboard - avoid forcing focus when we are inline

* @uppy/dashboard - in firefox, made sure superfocus is not switching back to uppy

* @uppy/dashboard - no focus jump for .info() inline,  fix for firefox

Made focus not interrupt .info() when we are in the inline mode;
Made firefox start superfocusing when we either tab into, or click on some element in Uppy

* @uppy/dashboard - only listen to click/focus on uppy

* @uppy/dashboard - started trapping focus in inline mode too

* @uppy/dashboard - restricted trapFocus() event listener to this.el

* @uppy/core - started throttling progress emits.

1. To fix heat up in firefox
2. To fix focus in firefox

* @uppy/core - fixed tests by adding .flush()-s for progress emits

* @uppy/dashboard - stopped trapping focus in inline mode, made background inert

* @uppy/dashboard - added MutationObserver polyfill

* tests - made locale-packs.js pass

* REVERTED last 3 commits that introduced inert

Inert requires too many polyfills, atm tests fail in IE 10.0, wicg-inert library reportedly has performance issues. We decided to at least wait until _some_ browser introduce inert html property natively.

* @uppy/dashboard - IE11 fix for focusing

* @uppy/dashboard - fix for a bug in Safari for long uploads with checkbox clicking

* RERUN TESTS - tiny commit to rerun tests

* @uppy/dashboard - removed useless touchstart event listener

* Cancel superFocus in recordIfFocusedOnUppyRecently, when focus moves away from Uppy

* @uppy/provider-views - made Filter not swallow letters

* @uppy/dashboard - stopped superfocus from refocusing if we're already in the overlay
Evgenia Karunus %!s(int64=5) %!d(string=hai) anos
pai
achega
6bcf2040e9
Modificáronse 40 ficheiros con 788 adicións e 572 borrados
  1. 12 2
      .eslintrc.json
  2. 69 47
      package-lock.json
  3. 1 0
      package.json
  4. 8 2
      packages/@uppy/core/src/Plugin.js
  5. 9 8
      packages/@uppy/core/src/index.js
  6. 6 0
      packages/@uppy/core/src/index.test.js
  7. 2 0
      packages/@uppy/dashboard/package.json
  8. 3 1
      packages/@uppy/dashboard/src/components/AddFiles.js
  9. 2 10
      packages/@uppy/dashboard/src/components/FileCard.js
  10. 40 30
      packages/@uppy/dashboard/src/components/FileItem.js
  11. 4 1
      packages/@uppy/dashboard/src/components/FileList.js
  12. 3 3
      packages/@uppy/dashboard/src/components/PickerPanelContent.js
  13. 88 69
      packages/@uppy/dashboard/src/index.js
  14. 30 15
      packages/@uppy/dashboard/src/style.scss
  15. 43 0
      packages/@uppy/dashboard/src/utils/createSuperFocus.js
  16. 10 0
      packages/@uppy/dashboard/src/utils/createSuperFocus.test.js
  17. 11 0
      packages/@uppy/dashboard/src/utils/getActiveOverlayEl.js
  18. 65 0
      packages/@uppy/dashboard/src/utils/trapFocus.js
  19. 2 15
      packages/@uppy/provider-views/src/AuthView.js
  20. 3 2
      packages/@uppy/provider-views/src/Browser.js
  21. 7 9
      packages/@uppy/provider-views/src/Filter.js
  22. 22 0
      packages/@uppy/provider-views/src/Item/components/GridLi.js
  23. 4 54
      packages/@uppy/provider-views/src/Item/components/ItemIcon.js
  24. 48 0
      packages/@uppy/provider-views/src/Item/components/ListLi.js
  25. 26 0
      packages/@uppy/provider-views/src/Item/index.js
  26. 27 32
      packages/@uppy/provider-views/src/ItemList.js
  27. 1 0
      packages/@uppy/provider-views/src/index.js
  28. 4 235
      packages/@uppy/provider-views/src/style.scss
  29. 92 0
      packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--grid.scss
  30. 66 0
      packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--list.scss
  31. 14 0
      packages/@uppy/provider-views/src/style/uppy-ProviderBrowserItem-fakeCheckbox.scss
  32. 8 4
      packages/@uppy/status-bar/src/StatusBar.js
  33. 2 8
      packages/@uppy/url/src/UrlUI.js
  34. 7 4
      packages/@uppy/url/src/utils/forEachDroppedOrPastedUrl.js
  35. 13 0
      packages/@uppy/utils/src/FOCUSABLE_ELEMENTS.js
  36. 7 5
      packages/@uppy/utils/src/getDroppedFiles/index.js
  37. 22 11
      packages/@uppy/utils/src/getDroppedFiles/utils/webkitGetAsEntryApi.js
  38. 1 2
      packages/@uppy/webcam/src/CameraScreen.js
  39. 4 2
      packages/@uppy/webcam/src/RecordButton.js
  40. 2 1
      packages/@uppy/webcam/src/SnapshotButton.js

+ 12 - 2
.eslintrc.json

@@ -9,10 +9,20 @@
     "window": true,
     "hexo": true
   },
-  "plugins": ["jest", "compat"],
+  "plugins": ["jest", "compat", "jsdoc"],
   "rules": {
     "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": {
     "polyfills": [

+ 69 - 47
package-lock.json

@@ -8188,6 +8188,12 @@
         "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": {
       "version": "0.5.4",
       "resolved": "https://registry.npmjs.org/common-shakeify/-/common-shakeify-0.5.4.tgz",
@@ -11500,6 +11506,17 @@
       "integrity": "sha512-c3WjZR/HBoi4GedJRwo2OGHa8Pzo1EbSVwQ2HFzJ+4t2OoYM7Alx646EH/aaxZ+9eGcPiq0FT0UGkRuFFx2FHg==",
       "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": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-8.0.1.tgz",
@@ -12759,20 +12776,20 @@
       "dependencies": {
         "abbrev": {
           "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==",
           "dev": true,
           "optional": true
         },
         "ansi-regex": {
           "version": "2.1.1",
-          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+          "resolved": false,
           "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
           "dev": true
         },
         "aproba": {
           "version": "1.2.0",
-          "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+          "resolved": false,
           "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
           "dev": true,
           "optional": true
@@ -12790,13 +12807,13 @@
         },
         "balanced-match": {
           "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+          "resolved": false,
           "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
           "dev": true
         },
         "brace-expansion": {
           "version": "1.1.11",
-          "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+          "resolved": false,
           "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
           "dev": true,
           "requires": {
@@ -12806,25 +12823,25 @@
         },
         "code-point-at": {
           "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=",
           "dev": true
         },
         "concat-map": {
           "version": "0.0.1",
-          "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+          "resolved": false,
           "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
           "dev": true
         },
         "console-control-strings": {
           "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=",
           "dev": true
         },
         "core-util-is": {
           "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+          "resolved": false,
           "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
           "dev": true,
           "optional": true
@@ -12848,28 +12865,28 @@
         },
         "delegates": {
           "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+          "resolved": false,
           "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
           "dev": true,
           "optional": true
         },
         "detect-libc": {
           "version": "1.0.3",
-          "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+          "resolved": false,
           "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
           "dev": true,
           "optional": true
         },
         "fs.realpath": {
           "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+          "resolved": false,
           "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
           "dev": true,
           "optional": true
         },
         "gauge": {
           "version": "2.7.4",
-          "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+          "resolved": false,
           "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
           "dev": true,
           "optional": true,
@@ -12901,7 +12918,7 @@
         },
         "has-unicode": {
           "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+          "resolved": false,
           "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
           "dev": true,
           "optional": true
@@ -12918,7 +12935,7 @@
         },
         "ignore-walk": {
           "version": "3.0.1",
-          "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz",
+          "resolved": false,
           "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==",
           "dev": true,
           "optional": true,
@@ -12928,7 +12945,7 @@
         },
         "inflight": {
           "version": "1.0.6",
-          "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+          "resolved": false,
           "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
           "dev": true,
           "optional": true,
@@ -12939,20 +12956,20 @@
         },
         "inherits": {
           "version": "2.0.3",
-          "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+          "resolved": false,
           "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
           "dev": true
         },
         "ini": {
           "version": "1.3.5",
-          "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
+          "resolved": false,
           "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
           "dev": true,
           "optional": true
         },
         "is-fullwidth-code-point": {
           "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=",
           "dev": true,
           "requires": {
@@ -12961,14 +12978,14 @@
         },
         "isarray": {
           "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+          "resolved": false,
           "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
           "dev": true,
           "optional": true
         },
         "minimatch": {
           "version": "3.0.4",
-          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+          "resolved": false,
           "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
           "dev": true,
           "requires": {
@@ -12977,13 +12994,13 @@
         },
         "minimist": {
           "version": "0.0.8",
-          "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
+          "resolved": false,
           "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
           "dev": true
         },
         "mkdirp": {
           "version": "0.5.1",
-          "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+          "resolved": false,
           "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
           "dev": true,
           "requires": {
@@ -13030,7 +13047,7 @@
         },
         "nopt": {
           "version": "4.0.1",
-          "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz",
+          "resolved": false,
           "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
           "dev": true,
           "optional": true,
@@ -13059,7 +13076,7 @@
         },
         "npmlog": {
           "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==",
           "dev": true,
           "optional": true,
@@ -13072,20 +13089,20 @@
         },
         "number-is-nan": {
           "version": "1.0.1",
-          "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+          "resolved": false,
           "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
           "dev": true
         },
         "object-assign": {
           "version": "4.1.1",
-          "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+          "resolved": false,
           "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
           "dev": true,
           "optional": true
         },
         "once": {
           "version": "1.4.0",
-          "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+          "resolved": false,
           "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
           "dev": true,
           "requires": {
@@ -13094,21 +13111,21 @@
         },
         "os-homedir": {
           "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+          "resolved": false,
           "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
           "dev": true,
           "optional": true
         },
         "os-tmpdir": {
           "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=",
           "dev": true,
           "optional": true
         },
         "osenv": {
           "version": "0.1.5",
-          "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz",
+          "resolved": false,
           "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
           "dev": true,
           "optional": true,
@@ -13119,14 +13136,14 @@
         },
         "path-is-absolute": {
           "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=",
           "dev": true,
           "optional": true
         },
         "process-nextick-args": {
           "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==",
           "dev": true,
           "optional": true
@@ -13146,7 +13163,7 @@
           "dependencies": {
             "minimist": {
               "version": "1.2.0",
-              "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+              "resolved": false,
               "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
               "dev": true,
               "optional": true
@@ -13155,7 +13172,7 @@
         },
         "readable-stream": {
           "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==",
           "dev": true,
           "optional": true,
@@ -13187,14 +13204,14 @@
         },
         "safer-buffer": {
           "version": "2.1.2",
-          "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+          "resolved": false,
           "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
           "dev": true,
           "optional": true
         },
         "sax": {
           "version": "1.2.4",
-          "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
+          "resolved": false,
           "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
           "dev": true,
           "optional": true
@@ -13208,21 +13225,21 @@
         },
         "set-blocking": {
           "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+          "resolved": false,
           "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
           "dev": true,
           "optional": true
         },
         "signal-exit": {
           "version": "3.0.2",
-          "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
+          "resolved": false,
           "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
           "dev": true,
           "optional": true
         },
         "string-width": {
           "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+          "resolved": false,
           "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
           "dev": true,
           "requires": {
@@ -13233,7 +13250,7 @@
         },
         "string_decoder": {
           "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==",
           "dev": true,
           "optional": true,
@@ -13243,7 +13260,7 @@
         },
         "strip-ansi": {
           "version": "3.0.1",
-          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "resolved": false,
           "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
           "dev": true,
           "requires": {
@@ -13252,7 +13269,7 @@
         },
         "strip-json-comments": {
           "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+          "resolved": false,
           "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
           "dev": true,
           "optional": true
@@ -13284,7 +13301,7 @@
         },
         "util-deprecate": {
           "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+          "resolved": false,
           "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
           "dev": true,
           "optional": true
@@ -13301,7 +13318,7 @@
         },
         "wrappy": {
           "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+          "resolved": false,
           "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
           "dev": true
         },
@@ -17441,6 +17458,12 @@
       "integrity": "sha512-xYuhvQ7I9PDJIGBWev9xm0+SMSed3ZDBAmvVjbFR1ZRLAF+vlXcQu6cRI9uAlj81rzikElRVteehwV7DuX2ZmQ==",
       "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": {
       "version": "11.12.0",
       "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-11.12.0.tgz",
@@ -18632,8 +18655,7 @@
     "lodash.debounce": {
       "version": "4.0.8",
       "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": {
       "version": "4.0.1",

+ 1 - 0
package.json

@@ -40,6 +40,7 @@
     "eslint-plugin-compat": "^3.1.1",
     "eslint-plugin-import": "^2.17.2",
     "eslint-plugin-jest": "^22.5.1",
+    "eslint-plugin-jsdoc": "^5.0.2",
     "eslint-plugin-node": "^8.0.1",
     "eslint-plugin-promise": "^4.1.1",
     "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.
   * Needed because sometimes plugins are mounted separately/after `install`,
@@ -105,6 +110,7 @@ module.exports = class Plugin {
         // hence the check
         if (!this.uppy.getPlugin(this.id)) return
         this.el = preact.render(this.render(state), targetElement, this.el)
+        this.afterUpdate()
       }
       this._updateUI = debounce(this.rerender)
 
@@ -147,8 +153,8 @@ module.exports = class Plugin {
     }
 
     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).`)
   }
 

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

@@ -1,7 +1,7 @@
 const Translator = require('@uppy/utils/lib/Translator')
 const ee = require('namespace-emitter')
 const cuid = require('cuid')
-// const throttle = require('lodash.throttle')
+const throttle = require('lodash.throttle')
 const prettyBytes = require('prettier-bytes')
 const match = require('mime-match')
 const DefaultStore = require('@uppy/store-default')
@@ -102,7 +102,14 @@ class Uppy {
     this.addFile = this.addFile.bind(this)
     this.removeFile = this.removeFile.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.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-success', (file, uploadResp) => {

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

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

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

@@ -29,6 +29,8 @@
     "@uppy/utils": "1.1.0",
     "classnames": "^2.2.6",
     "cuid": "^2.1.1",
+    "drag-drop": "2.13.3",
+    "lodash.debounce": "^4.0.8",
     "lodash.throttle": "^4.1.1",
     "preact": "8.2.9",
     "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"
         class="uppy-Dashboard-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" />
         </svg>
         <span class="uppy-Dashboard-poweredByUppy">Uppy</span>
@@ -88,6 +88,7 @@ class AddFiles extends Component {
           class="uppy-DashboardTab-btn"
           role="tab"
           tabindex={0}
+          data-uppy-super-focusable
           onclick={this.triggerFileInputClick}>
           {localIcon()}
           <div class="uppy-DashboardTab-name">{this.props.i18n('myDevice')}</div>
@@ -105,6 +106,7 @@ class AddFiles extends Component {
           tabindex={0}
           aria-controls={`uppy-DashboardContent-panel--${acquirer.id}`}
           aria-selected={this.props.activePickerPanel.id === acquirer.id}
+          data-uppy-super-focusable
           onclick={() => this.props.showPanel(acquirer.id)}>
           {acquirer.icon()}
           <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)
   }
 
-  componentDidMount () {
-    setTimeout(() => {
-      if (!this.firstInput) return
-      this.firstInput.focus({ preventScroll: true })
-    }, 150)
-  }
-
   tempStoreMetaOrSubmit (ev) {
     const file = this.props.files[this.props.fileCardFor]
 
@@ -50,9 +43,8 @@ class FileCard extends Component {
           onkeyup={this.tempStoreMetaOrSubmit}
           onkeydown={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
     : !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-previewInnerWrap" style={{ backgroundColor: getFileTypeIcon(file.type).color }}>
         {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
         }
         <FilePreview file={file} />
@@ -128,41 +128,51 @@ module.exports = function FileItem (props) {
           onPauseResumeCancelRetry={onPauseResumeCancelRetry}
           file={file}
           error={error}
+          isUploaded={isUploaded}
           {...props} />
       </div>
     </div>
     <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 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
           ? <button class="uppy-u-reset uppy-DashboardItem-copyLink"
             type="button"

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

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

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

@@ -1,12 +1,12 @@
 const { h } = require('preact')
 const ignoreEvent = require('../utils/ignoreEvent.js')
 
-function PanelContent (props) {
+function PickerPanelContent (props) {
   return (
     <div class="uppy-DashboardContent-panel"
       role="tabpanel"
       data-uppy-panelType="PickerPanel"
-      id={props.activePickerPanel && `uppy-DashboardContent-panel--${props.activePickerPanel.id}`}
+      id={`uppy-DashboardContent-panel--${props.activePickerPanel.id}`}
       onDragOver={ignoreEvent}
       onDragLeave={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 toArray = require('@uppy/utils/lib/toArray')
 const getDroppedFiles = require('@uppy/utils/lib/getDroppedFiles')
+const trapFocus = require('./utils/trapFocus')
 const cuid = require('cuid')
 const ResizeObserver = require('resize-observer-polyfill').default || require('resize-observer-polyfill')
 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 ESC_KEY = 27
@@ -158,13 +143,11 @@ module.exports = class Dashboard extends Plugin {
     this.removeTarget = this.removeTarget.bind(this)
     this.hideAllPanels = this.hideAllPanels.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.maintainFocus = this.maintainFocus.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.handleComplete = this.handleComplete.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.handleDragLeave = this.handleDragLeave.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
     this.makeDashboardInsidesVisibleAnywayTimeout = null
@@ -253,24 +241,6 @@ module.exports = class Dashboard extends Plugin {
     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 () {
     // Ensure history state does not already contain our modal name to avoid double-pushing
     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 () {
     const { promise, resolve } = createPromise()
     // save scroll position
@@ -351,10 +301,7 @@ module.exports = class Dashboard extends Plugin {
     }
 
     // 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
   }
@@ -385,6 +332,10 @@ module.exports = class Dashboard extends Plugin {
           isHidden: true,
           isClosing: false
         })
+
+        this.superFocus.cancel()
+        this.savedActiveElement.focus()
+
         this.el.removeEventListener('animationend', handler, false)
         resolve()
       }
@@ -393,13 +344,15 @@ module.exports = class Dashboard extends Plugin {
       this.setPluginState({
         isHidden: true
       })
+
+      this.superFocus.cancel()
+      this.savedActiveElement.focus()
+
       resolve()
     }
 
     // handle ESC and TAB keys in modal dialog
-    document.removeEventListener('keydown', this.handleKeyDown)
-
-    this.savedActiveElement.focus()
+    document.removeEventListener('keydown', this.handleKeyDownInModal)
 
     if (manualClose) {
       if (this.opts.browserBackButtonClose) {
@@ -418,11 +371,11 @@ module.exports = class Dashboard extends Plugin {
     return !this.getPluginState().isHidden || false
   }
 
-  handleKeyDown (event) {
+  handleKeyDownInModal (event) {
     // close modal on esc key press
     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 () {
@@ -580,6 +533,33 @@ module.exports = class Dashboard extends Plugin {
     this.uppy.on('plugin-remove', this.removeTarget)
     this.uppy.on('file-added', this.handleFileAdded)
     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')?
@@ -619,6 +599,13 @@ module.exports = class Dashboard extends Plugin {
     this.uppy.off('plugin-remove', this.removeTarget)
     this.uppy.off('file-added', this.handleFileAdded)
     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) {
@@ -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) {
     const pluginState = this.getPluginState()
     const { files, capabilities, allowNewUpload } = state

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

@@ -192,9 +192,12 @@
   position: relative;
   text-align: center;
   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 & {
     border: none;
@@ -610,7 +613,7 @@
   max-width: 300px;
   text-align: center;
   font-size: 16px;
-  line-height: 1.45;
+  line-height: 1.35;
   font-weight: 400;
   color: $gray-700;
   margin: auto;
@@ -729,6 +732,9 @@ a.uppy-Dashboard-poweredBy {
   right: 0;
   bottom: 0;
   z-index: $zIndex-3;
+  &:focus{
+    box-shadow: inset 0px 0px 0px 4px rgb(59, 153, 252);
+  }
 }
 
 .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;
-  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')
 
-class AuthBlock extends Component {
-  componentDidMount () {
-    setTimeout(() => {
-      if (!this.connectButton) return
-      this.connectButton.focus({ preventScroll: true })
-    }, 150)
-  }
-
+class AuthView extends Component {
   render () {
     const pluginNameComponent = (
       <span class="uppy-Provider-authTitleName">{this.props.pluginName}<br /></span>
@@ -21,7 +14,7 @@ class AuthBlock extends Component {
         type="button"
         class="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Provider-authBtn"
         onclick={this.props.handleAuth}
-        ref={(el) => { this.connectButton = el }}
+        data-uppy-super-focusable
       >
         {this.props.i18nArray('authenticateWith', { pluginName: this.props.pluginName })}
       </button>
@@ -29,10 +22,4 @@ class AuthBlock extends Component {
   }
 }
 
-class AuthView extends Component {
-  render () {
-    return <AuthBlock {...this.props} />
-  }
-}
-
 module.exports = AuthView

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

@@ -1,7 +1,7 @@
 const classNames = require('classnames')
 const Breadcrumbs = require('./Breadcrumbs')
 const Filter = require('./Filter')
-const Table = require('./ItemList')
+const ItemList = require('./ItemList')
 const FooterActions = require('./FooterActions')
 const { h } = require('preact')
 
@@ -33,7 +33,7 @@ const Browser = (props) => {
         </div>
       </div>
       { props.showFilter && <Filter {...props} /> }
-      <Table
+      <ItemList
         columns={[{
           name: 'Name',
           key: 'title'
@@ -50,6 +50,7 @@ const Browser = (props) => {
         title={props.title}
         showTitles={props.showTitles}
         i18n={props.i18n}
+        viewType={props.viewType}
       />
       {selected > 0 && <FooterActions selected={selected} {...props} />}
     </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 {
   constructor (props) {
     super(props)
-    this.handleKeyPress = this.handleKeyPress.bind(this)
+    this.preventEnterPress = this.preventEnterPress.bind(this)
   }
 
-  handleKeyPress (ev) {
+  preventEnterPress (ev) {
     if (ev.keyCode === 13) {
       ev.stopPropagation()
       ev.preventDefault()
-      return
     }
-    this.props.filterQuery(ev)
   }
 
   render () {
@@ -22,11 +20,11 @@ module.exports = class Filter extends Component {
         type="text"
         placeholder={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">
         <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>

+ 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')
 
-function mapStringToIcon (string) {
-  if (string === null) return
+module.exports = (props) => {
+  if (props.itemIconString === null) return
 
-  switch (string) {
+  switch (props.itemIconString) {
     case 'file':
       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" />
@@ -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" />
       </svg>
     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 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) => {
   if (!props.folders.length && !props.files.length) {
@@ -11,41 +23,24 @@ module.exports = (props) => {
       <ul class="uppy-ProviderBrowser-list"
         onscroll={props.handleScroll}
         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',
-            // 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',
-            // 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>
     </div>
   )

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

@@ -380,6 +380,7 @@ module.exports = class ProviderView {
   toggleCheckbox (e, file) {
     e.stopPropagation()
     e.preventDefault()
+    e.currentTarget.focus()
     let { folders, files } = this.plugin.getPluginState()
     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/_variables.scss';
 
+@import './style/uppy-ProviderBrowser-viewType--grid';
+@import './style/uppy-ProviderBrowser-viewType--list';
+@import './style/uppy-ProviderBrowserItem-fakeCheckbox';
+
 .uppy-DashboardContent-panelBody {
   display: flex;
   align-items: center;
@@ -247,241 +251,6 @@
   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 {
   display: flex;
   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"
     class={uploadBtnClassNames}
     aria-label={props.i18n('uploadXFiles', { smart_count: props.newFiles })}
-    onclick={props.startUpload}>
+    onclick={props.startUpload}
+    data-uppy-super-focusable>
     {props.newFiles && props.isUploadStarted
       ? props.i18n('uploadXNewFiles', { smart_count: props.newFiles })
       : props.i18n('uploadXFiles', { smart_count: props.newFiles })
@@ -160,7 +161,8 @@ const UploadBtn = (props) => {
 const RetryBtn = (props) => {
   return (
     <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">
         <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>
@@ -175,7 +177,8 @@ const CancelBtn = (props) => {
     class="uppy-u-reset uppy-StatusBar-actionCircleBtn"
     title={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">
       <g fill="none" fill-rule="evenodd">
         <circle fill="#888" cx="8" cy="8" r="8" />
@@ -194,7 +197,8 @@ const PauseResumeButton = (props) => {
     aria-label={title}
     class="uppy-u-reset uppy-StatusBar-actionCircleBtn"
     type="button"
-    onclick={() => togglePauseResume(props)}>
+    onclick={() => togglePauseResume(props)}
+    data-uppy-super-focusable>
     {isAllPaused
       ? <svg aria-hidden="true" class="UppyIcon" width="16" height="16" viewBox="0 0 16 16">
         <g fill="none" fill-rule="evenodd">

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

@@ -9,13 +9,6 @@ class UrlUI extends Component {
 
   componentDidMount () {
     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) {
@@ -36,7 +29,8 @@ class UrlUI extends Component {
         aria-label={this.props.i18n('enterUrlToImport')}
         placeholder={this.props.i18n('enterUrlToImport')}
         onkeyup={this.handleKeyPress}
-        ref={(input) => { this.input = input }} />
+        ref={(input) => { this.input = input }}
+        data-uppy-super-focusable />
       <button
         class="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Url-importButton"
         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'.
 */
 
-// 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) {
   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 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) {
   // 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]) {

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

@@ -5,7 +5,7 @@ const toArray = require('../../toArray')
  *
  * @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) {
   // 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) {
   directoryReader.readEntries(
     (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) {
   // Creates a new File object which can be used to read the 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) {
   const directoryReader = directoryEntry.createReader()
   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) {
   return new Promise((resolve) => {
     if (entry.isFile) {

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

@@ -9,7 +9,6 @@ function isModeAvailable (modes, mode) {
 class CameraScreen extends Component {
   componentDidMount () {
     this.props.onFocus()
-    this.btnContainer.firstChild.focus()
   }
 
   componentWillUnmount () {
@@ -29,7 +28,7 @@ class CameraScreen extends Component {
         <div class="uppy-Webcam-videoContainer">
           <video class={`uppy-Webcam-video  ${this.props.mirror ? 'uppy-Webcam-video--mirrored' : ''}`} autoplay muted playsinline srcObject={this.props.src || ''} />
         </div>
-        <div class="uppy-Webcam-buttonContainer" ref={(el) => { this.btnContainer = el }}>
+        <div class="uppy-Webcam-buttonContainer">
           {shouldShowSnapshotButton ? SnapshotButton(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"
         title={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">
           <rect x="15" y="15" width="70" height="70" />
         </svg>
@@ -21,7 +22,8 @@ module.exports = function RecordButton ({ recording, onStartRecording, onStopRec
       type="button"
       title={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">
         <circle cx="50" cy="50" r="40" />
       </svg>

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

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