Ver código fonte

@uppy/provider-views: migrate to TS (#4919)

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>
Merlijn Vos 1 ano atrás
pai
commit
a4bbd82977
34 arquivos alterados com 1186 adições e 565 exclusões
  1. 3 0
      package.json
  2. 1 0
      packages/@uppy/provider-views/.npmignore
  3. 0 38
      packages/@uppy/provider-views/src/Breadcrumbs.jsx
  4. 54 0
      packages/@uppy/provider-views/src/Breadcrumbs.tsx
  5. 72 20
      packages/@uppy/provider-views/src/Browser.tsx
  6. 3 3
      packages/@uppy/provider-views/src/CloseWrapper.ts
  7. 0 16
      packages/@uppy/provider-views/src/FooterActions.jsx
  8. 35 0
      packages/@uppy/provider-views/src/FooterActions.tsx
  9. 22 2
      packages/@uppy/provider-views/src/Item/components/GridLi.tsx
  10. 41 9
      packages/@uppy/provider-views/src/Item/components/ItemIcon.tsx
  11. 0 79
      packages/@uppy/provider-views/src/Item/components/ListLi.jsx
  12. 104 0
      packages/@uppy/provider-views/src/Item/components/ListLi.tsx
  13. 0 54
      packages/@uppy/provider-views/src/Item/index.jsx
  14. 87 0
      packages/@uppy/provider-views/src/Item/index.tsx
  15. 8 1
      packages/@uppy/provider-views/src/Loader.tsx
  16. 55 24
      packages/@uppy/provider-views/src/ProviderView/AuthView.tsx
  17. 0 22
      packages/@uppy/provider-views/src/ProviderView/Header.jsx
  18. 37 0
      packages/@uppy/provider-views/src/ProviderView/Header.tsx
  19. 232 96
      packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx
  20. 0 10
      packages/@uppy/provider-views/src/ProviderView/User.jsx
  21. 29 0
      packages/@uppy/provider-views/src/ProviderView/User.tsx
  22. 0 1
      packages/@uppy/provider-views/src/ProviderView/index.js
  23. 1 0
      packages/@uppy/provider-views/src/ProviderView/index.ts
  24. 0 101
      packages/@uppy/provider-views/src/SearchFilterInput.jsx
  25. 127 0
      packages/@uppy/provider-views/src/SearchFilterInput.tsx
  26. 81 53
      packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.tsx
  27. 0 1
      packages/@uppy/provider-views/src/SearchProviderView/index.js
  28. 1 0
      packages/@uppy/provider-views/src/SearchProviderView/index.ts
  29. 141 32
      packages/@uppy/provider-views/src/View.ts
  30. 0 2
      packages/@uppy/provider-views/src/index.js
  31. 5 0
      packages/@uppy/provider-views/src/index.ts
  32. 25 0
      packages/@uppy/provider-views/tsconfig.build.json
  33. 21 0
      packages/@uppy/provider-views/tsconfig.json
  34. 1 1
      tsconfig.shared.json

+ 3 - 0
package.json

@@ -165,6 +165,9 @@
     "watch:js": "npm-run-all --parallel watch:js:bundle watch:js:lib",
     "watch": "npm-run-all --parallel watch:css watch:js"
   },
+  "alias": {
+    "preact/jsx-dev-runtime": "preact/jsx-runtime"
+  },
   "resolutions": {
     "@types/eslint@^7.2.13": "^8.2.0",
     "@types/react": "^17",

+ 1 - 0
packages/@uppy/provider-views/.npmignore

@@ -0,0 +1 @@
+tsconfig.*

+ 0 - 38
packages/@uppy/provider-views/src/Breadcrumbs.jsx

@@ -1,38 +0,0 @@
-import { h, Fragment } from 'preact'
-
-const Breadcrumb = (props) => {
-  const { getFolder, title, isLast } = props
-
-  return (
-    <Fragment>
-      <button
-        type="button"
-        className="uppy-u-reset uppy-c-btn"
-        onClick={getFolder}
-      >
-        {title}
-      </button>
-      {!isLast ? ' / ' : ''}
-    </Fragment>
-  )
-}
-
-export default (props) => {
-  const { getFolder, title, breadcrumbsIcon, breadcrumbs } = props
-
-  return (
-    <div className="uppy-Provider-breadcrumbs">
-      <div className="uppy-Provider-breadcrumbsIcon">{breadcrumbsIcon}</div>
-      {
-        breadcrumbs.map((directory, i) => (
-          <Breadcrumb
-            key={directory.id}
-            getFolder={() => getFolder(directory.requestPath)}
-            title={i === 0 ? title : directory.name}
-            isLast={i + 1 === breadcrumbs.length}
-          />
-        ))
-      }
-    </div>
-  )
-}

+ 54 - 0
packages/@uppy/provider-views/src/Breadcrumbs.tsx

@@ -0,0 +1,54 @@
+import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy'
+import { h, Fragment } from 'preact'
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
+import type ProviderView from './ProviderView'
+
+type BreadcrumbProps = {
+  getFolder: () => void
+  title: string
+  isLast: boolean
+}
+
+const Breadcrumb = (props: BreadcrumbProps) => {
+  const { getFolder, title, isLast } = props
+
+  return (
+    <Fragment>
+      <button
+        type="button"
+        className="uppy-u-reset uppy-c-btn"
+        onClick={getFolder}
+      >
+        {title}
+      </button>
+      {!isLast ? ' / ' : ''}
+    </Fragment>
+  )
+}
+
+type BreadcrumbsProps<M extends Meta, B extends Body> = {
+  getFolder: ProviderView<M, B>['getFolder']
+  title: string
+  breadcrumbsIcon: JSX.Element
+  breadcrumbs: UnknownProviderPluginState['breadcrumbs']
+}
+
+export default function Breadcrumbs<M extends Meta, B extends Body>(
+  props: BreadcrumbsProps<M, B>,
+): JSX.Element {
+  const { getFolder, title, breadcrumbsIcon, breadcrumbs } = props
+
+  return (
+    <div className="uppy-Provider-breadcrumbs">
+      <div className="uppy-Provider-breadcrumbsIcon">{breadcrumbsIcon}</div>
+      {breadcrumbs.map((directory, i) => (
+        <Breadcrumb
+          key={directory.id}
+          getFolder={() => getFolder(directory.requestPath, directory.name)}
+          title={i === 0 ? title : directory.name}
+          isLast={i + 1 === breadcrumbs.length}
+        />
+      ))}
+    </div>
+  )
+}

+ 72 - 20
packages/@uppy/provider-views/src/Browser.jsx → packages/@uppy/provider-views/src/Browser.tsx

@@ -1,16 +1,39 @@
+/* eslint-disable react/require-default-props */
 import { h } from 'preact'
 
 import classNames from 'classnames'
 import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal'
 import { useMemo } from 'preact/hooks'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore untyped
 import VirtualList from '@uppy/utils/lib/VirtualList'
-import SearchFilterInput from './SearchFilterInput.jsx'
-import FooterActions from './FooterActions.jsx'
-import Item from './Item/index.jsx'
+import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
+import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
+import type { I18n } from '@uppy/utils/lib/Translator'
+import type Uppy from '@uppy/core'
+import SearchFilterInput from './SearchFilterInput.tsx'
+import FooterActions from './FooterActions.tsx'
+import Item from './Item/index.tsx'
 
 const VIRTUAL_SHARED_DIR = 'shared-with-me'
 
-function ListItem (props) {
+type ListItemProps<M extends Meta, B extends Body> = {
+  currentSelection: any[]
+  uppyFiles: UppyFile<M, B>[]
+  viewType: string
+  isChecked: (file: any) => boolean
+  toggleCheckbox: (event: Event, file: CompanionFile) => void
+  recordShiftKeyPress: (event: KeyboardEvent | MouseEvent) => void
+  showTitles: boolean
+  i18n: I18n
+  validateRestrictions: Uppy<M, B>['validateRestrictions']
+  getNextFolder?: (folder: any) => void
+  f: CompanionFile
+}
+
+function ListItem<M extends Meta, B extends Body>(
+  props: ListItemProps<M, B>,
+): JSX.Element {
   const {
     currentSelection,
     uppyFiles,
@@ -22,13 +45,11 @@ function ListItem (props) {
     i18n,
     validateRestrictions,
     getNextFolder,
-    columns,
     f,
   } = props
 
   if (f.isFolder) {
-    return Item({
-      columns,
+    return Item<M, B>({
       showTitles,
       viewType,
       i18n,
@@ -36,12 +57,14 @@ function ListItem (props) {
       title: f.name,
       getItemIcon: () => f.icon,
       isChecked: isChecked(f),
-      toggleCheckbox: (event) => toggleCheckbox(event, f),
+      toggleCheckbox: (event: Event) => toggleCheckbox(event, f),
       recordShiftKeyPress,
       type: 'folder',
-      isDisabled: isChecked(f)?.loading,
+      // TODO: when was this supposed to be true?
+      isDisabled: false,
       isCheckboxDisabled: f.id === VIRTUAL_SHARED_DIR,
-      handleFolderClick: () => getNextFolder(f),
+      // getNextFolder always exists when f.isFolder is true
+      handleFolderClick: () => getNextFolder!(f),
     })
   }
   const restrictionError = validateRestrictions(remoteFileObjToLocal(f), [
@@ -49,25 +72,57 @@ function ListItem (props) {
     ...currentSelection,
   ])
 
-  return Item({
+  return Item<M, B>({
     id: f.id,
     title: f.name,
     author: f.author,
     getItemIcon: () => f.icon,
     isChecked: isChecked(f),
-    toggleCheckbox: (event) => toggleCheckbox(event, f),
+    toggleCheckbox: (event: Event) => toggleCheckbox(event, f),
+    isCheckboxDisabled: false,
     recordShiftKeyPress,
-    columns,
     showTitles,
     viewType,
     i18n,
     type: 'file',
-    isDisabled: restrictionError && !isChecked(f),
+    isDisabled: Boolean(restrictionError) && !isChecked(f),
     restrictionError,
   })
 }
 
-function Browser (props) {
+type BrowserProps<M extends Meta, B extends Body> = {
+  currentSelection: any[]
+  folders: CompanionFile[]
+  files: CompanionFile[]
+  uppyFiles: UppyFile<M, B>[]
+  viewType: string
+  headerComponent?: JSX.Element
+  showBreadcrumbs: boolean
+  isChecked: (file: any) => boolean
+  toggleCheckbox: (event: Event, file: CompanionFile) => void
+  recordShiftKeyPress: (event: KeyboardEvent | MouseEvent) => void
+  handleScroll: (event: Event) => Promise<void>
+  showTitles: boolean
+  i18n: I18n
+  validateRestrictions: Uppy<M, B>['validateRestrictions']
+  isLoading: boolean | string
+  showSearchFilter: boolean
+  search: (query: string) => void
+  searchTerm?: string | null
+  clearSearch: () => void
+  searchOnInput: boolean
+  searchInputLabel: string
+  clearSearchLabel: string
+  getNextFolder?: (folder: any) => void
+  cancel: () => void
+  done: () => void
+  noResultsLabel: string
+  loadAllFiles?: boolean
+}
+
+function Browser<M extends Meta, B extends Body>(
+  props: BrowserProps<M, B>,
+): JSX.Element {
   const {
     currentSelection,
     folders,
@@ -94,7 +149,6 @@ function Browser (props) {
     getNextFolder,
     cancel,
     done,
-    columns,
     noResultsLabel,
     loadAllFiles,
   } = props
@@ -156,7 +210,7 @@ function Browser (props) {
               <ul className="uppy-ProviderBrowser-list">
                 <VirtualList
                   data={rows}
-                  renderRow={(f) => (
+                  renderRow={(f: CompanionFile) => (
                     <ListItem
                       currentSelection={currentSelection}
                       uppyFiles={uppyFiles}
@@ -168,7 +222,6 @@ function Browser (props) {
                       i18n={i18n}
                       validateRestrictions={validateRestrictions}
                       getNextFolder={getNextFolder}
-                      columns={columns}
                       f={f}
                     />
                   )}
@@ -186,7 +239,7 @@ function Browser (props) {
               onScroll={handleScroll}
               role="listbox"
               // making <ul> not focusable for firefox
-              tabIndex="-1"
+              tabIndex={-1}
             >
               {rows.map((f) => (
                 <ListItem
@@ -200,7 +253,6 @@ function Browser (props) {
                   i18n={i18n}
                   validateRestrictions={validateRestrictions}
                   getNextFolder={getNextFolder}
-                  columns={columns}
                   f={f}
                 />
               ))}

+ 3 - 3
packages/@uppy/provider-views/src/CloseWrapper.js → packages/@uppy/provider-views/src/CloseWrapper.ts

@@ -1,12 +1,12 @@
 import { Component, toChildArray } from 'preact'
 
-export default class CloseWrapper extends Component {
-  componentWillUnmount () {
+export default class CloseWrapper extends Component<{ onUnmount: () => void }> {
+  componentWillUnmount(): void {
     const { onUnmount } = this.props
     onUnmount()
   }
 
-  render () {
+  render(): ReturnType<typeof toChildArray>[0] {
     const { children } = this.props
     return toChildArray(children)[0]
   }

+ 0 - 16
packages/@uppy/provider-views/src/FooterActions.jsx

@@ -1,16 +0,0 @@
-import { h } from 'preact'
-
-export default ({ cancel, done, i18n, selected }) => {
-  return (
-    <div className="uppy-ProviderBrowser-footer">
-      <button className="uppy-u-reset uppy-c-btn uppy-c-btn-primary" onClick={done} type="button">
-        {i18n('selectX', {
-          smart_count: selected,
-        })}
-      </button>
-      <button className="uppy-u-reset uppy-c-btn uppy-c-btn-link" onClick={cancel} type="button">
-        {i18n('cancel')}
-      </button>
-    </div>
-  )
-}

+ 35 - 0
packages/@uppy/provider-views/src/FooterActions.tsx

@@ -0,0 +1,35 @@
+import { h } from 'preact'
+import type { I18n } from '@uppy/utils/lib/Translator'
+
+export default function FooterActions({
+  cancel,
+  done,
+  i18n,
+  selected,
+}: {
+  cancel: () => void
+  done: () => void
+  i18n: I18n
+  selected: number
+}): JSX.Element {
+  return (
+    <div className="uppy-ProviderBrowser-footer">
+      <button
+        className="uppy-u-reset uppy-c-btn uppy-c-btn-primary"
+        onClick={done}
+        type="button"
+      >
+        {i18n('selectX', {
+          smart_count: selected,
+        })}
+      </button>
+      <button
+        className="uppy-u-reset uppy-c-btn uppy-c-btn-link"
+        onClick={cancel}
+        type="button"
+      >
+        {i18n('cancel')}
+      </button>
+    </div>
+  )
+}

+ 22 - 2
packages/@uppy/provider-views/src/Item/components/GridLi.jsx → packages/@uppy/provider-views/src/Item/components/GridLi.tsx

@@ -1,7 +1,26 @@
+/* eslint-disable react/require-default-props */
 import { h } from 'preact'
 import classNames from 'classnames'
+import type { RestrictionError } from '@uppy/core/lib/Restricter'
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
 
-function GridListItem (props) {
+type GridListItemProps<M extends Meta, B extends Body> = {
+  className: string
+  isDisabled: boolean
+  restrictionError?: RestrictionError<M, B> | null
+  isChecked: boolean
+  title?: string
+  itemIconEl: any
+  showTitles?: boolean
+  toggleCheckbox: (event: Event) => void
+  recordShiftKeyPress: (event: KeyboardEvent) => void
+  id: string
+  children?: JSX.Element
+}
+
+function GridListItem<M extends Meta, B extends Body>(
+  props: GridListItemProps<M, B>,
+): h.JSX.Element {
   const {
     className,
     isDisabled,
@@ -26,13 +45,14 @@ function GridListItem (props) {
   return (
     <li
       className={className}
-      title={isDisabled ? restrictionError?.message : null}
+      title={isDisabled ? restrictionError?.message : undefined}
     >
       <input
         type="checkbox"
         className={checkBoxClassName}
         onChange={toggleCheckbox}
         onKeyDown={recordShiftKeyPress}
+        // @ts-expect-error this is fine onMouseDown too
         onMouseDown={recordShiftKeyPress}
         name="listitem"
         id={id}

+ 41 - 9
packages/@uppy/provider-views/src/Item/components/ItemIcon.jsx → packages/@uppy/provider-views/src/Item/components/ItemIcon.tsx

@@ -1,33 +1,55 @@
+/* eslint-disable react/require-default-props */
 import { h } from 'preact'
 
-function FileIcon () {
+function FileIcon() {
   return (
-    <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width={11} height={14.5} viewBox="0 0 44 58">
+    <svg
+      aria-hidden="true"
+      focusable="false"
+      className="uppy-c-icon"
+      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" />
     </svg>
   )
 }
 
-function FolderIcon () {
+function FolderIcon() {
   return (
-    <svg aria-hidden="true" focusable="false" className="uppy-c-icon" style={{ minWidth: 16, marginRight: 3 }} viewBox="0 0 276.157 276.157">
+    <svg
+      aria-hidden="true"
+      focusable="false"
+      className="uppy-c-icon"
+      style={{ minWidth: 16, marginRight: 3 }}
+      viewBox="0 0 276.157 276.157"
+    >
       <path d="M273.08 101.378c-3.3-4.65-8.86-7.32-15.254-7.32h-24.34V67.59c0-10.2-8.3-18.5-18.5-18.5h-85.322c-3.63 0-9.295-2.875-11.436-5.805l-6.386-8.735c-4.982-6.814-15.104-11.954-23.546-11.954H58.73c-9.292 0-18.638 6.608-21.737 15.372l-2.033 5.752c-.958 2.71-4.72 5.37-7.596 5.37H18.5C8.3 49.09 0 57.39 0 67.59v167.07c0 .886.16 1.73.443 2.52.152 3.306 1.18 6.424 3.053 9.064 3.3 4.652 8.86 7.32 15.255 7.32h188.487c11.395 0 23.27-8.425 27.035-19.18l40.677-116.188c2.11-6.035 1.43-12.164-1.87-16.816zM18.5 64.088h8.864c9.295 0 18.64-6.607 21.738-15.37l2.032-5.75c.96-2.712 4.722-5.373 7.597-5.373h29.565c3.63 0 9.295 2.876 11.437 5.806l6.386 8.735c4.982 6.815 15.104 11.954 23.546 11.954h85.322c1.898 0 3.5 1.602 3.5 3.5v26.47H69.34c-11.395 0-23.27 8.423-27.035 19.178L15 191.23V67.59c0-1.898 1.603-3.5 3.5-3.5zm242.29 49.15l-40.676 116.188c-1.674 4.78-7.812 9.135-12.877 9.135H18.75c-1.447 0-2.576-.372-3.02-.997-.442-.625-.422-1.814.057-3.18l40.677-116.19c1.674-4.78 7.812-9.134 12.877-9.134h188.487c1.448 0 2.577.372 3.02.997.443.625.423 1.814-.056 3.18z" />
     </svg>
   )
 }
 
-function VideoIcon () {
+function VideoIcon() {
   return (
-    <svg aria-hidden="true" focusable="false" style={{ width: 16, marginRight: 4 }} viewBox="0 0 58 58">
+    <svg
+      aria-hidden="true"
+      focusable="false"
+      style={{ width: 16, marginRight: 4 }}
+      viewBox="0 0 58 58"
+    >
       <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>
   )
 }
 
-export default (props) => {
+export default function ItemIcon(props: {
+  itemIconString: string
+  alt?: string
+}): h.JSX.Element | null {
   const { itemIconString } = props
-  if (itemIconString === null) return undefined
+  if (itemIconString === null) return null
 
   switch (itemIconString) {
     case 'file':
@@ -38,7 +60,17 @@ export default (props) => {
       return <VideoIcon />
     default: {
       const { alt } = props
-      return <img src={itemIconString} alt={alt} referrerPolicy="no-referrer" loading="lazy" width={16} height={16} />
+      return (
+        <img
+          src={itemIconString}
+          alt={alt}
+          // @ts-expect-error TS does not understand but attribute exists here.
+          referrerPolicy="no-referrer"
+          loading="lazy"
+          width={16}
+          height={16}
+        />
+      )
     }
   }
 }

+ 0 - 79
packages/@uppy/provider-views/src/Item/components/ListLi.jsx

@@ -1,79 +0,0 @@
-import { h } from 'preact'
-
-// if folder:
-//   + checkbox (selects all files from folder)
-//   + folder name (opens folder)
-// if file:
-//   + checkbox (selects file)
-//   + file name (selects file)
-
-function ListItem (props) {
-  const {
-    className,
-    isDisabled,
-    restrictionError,
-    isCheckboxDisabled,
-    isChecked,
-    toggleCheckbox,
-    recordShiftKeyPress,
-    type,
-    id,
-    itemIconEl,
-    title,
-    handleFolderClick,
-    showTitles,
-    i18n,
-  } = props
-
-  return (
-    <li
-      className={className}
-      title={isDisabled ? restrictionError?.message : null}
-    >
-      {!isCheckboxDisabled ? (
-        <input
-          type="checkbox"
-          className={`uppy-u-reset uppy-ProviderBrowserItem-checkbox ${isChecked ? 'uppy-ProviderBrowserItem-checkbox--is-checked' : ''}`}
-          onChange={toggleCheckbox}
-          onKeyDown={recordShiftKeyPress}
-          onMouseDown={recordShiftKeyPress}
-          // for the <label/>
-          name="listitem"
-          id={id}
-          checked={isChecked}
-          aria-label={type === 'file' ? null : i18n('allFilesFromFolderNamed', { name: title })}
-          disabled={isDisabled}
-          data-uppy-super-focusable
-        />
-      ) : null}
-
-      {type === 'file' ? (
-        // label for a checkbox
-        <label
-          htmlFor={id}
-          className="uppy-u-reset uppy-ProviderBrowserItem-inner"
-        >
-          <div className="uppy-ProviderBrowserItem-iconWrap">
-            {itemIconEl}
-          </div>
-          {showTitles && title}
-        </label>
-      ) : (
-        // button to open a folder
-        <button
-          type="button"
-          className="uppy-u-reset uppy-c-btn uppy-ProviderBrowserItem-inner"
-          onClick={handleFolderClick}
-          aria-label={i18n('openFolderNamed', { name: title })}
-        >
-          <div className="uppy-ProviderBrowserItem-iconWrap">
-            {itemIconEl}
-          </div>
-          {showTitles && <span>{title}</span>}
-        </button>
-      )}
-    </li>
-  )
-}
-
-export default ListItem

+ 104 - 0
packages/@uppy/provider-views/src/Item/components/ListLi.tsx

@@ -0,0 +1,104 @@
+/* eslint-disable react/require-default-props */
+import type { RestrictionError } from '@uppy/core/lib/Restricter'
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
+import { h } from 'preact'
+
+// if folder:
+//   + checkbox (selects all files from folder)
+//   + folder name (opens folder)
+// if file:
+//   + checkbox (selects file)
+//   + file name (selects file)
+
+type ListItemProps<M extends Meta, B extends Body> = {
+  className: string
+  isDisabled: boolean
+  restrictionError?: RestrictionError<M, B> | null
+  isCheckboxDisabled: boolean
+  isChecked: boolean
+  toggleCheckbox: (event: Event) => void
+  recordShiftKeyPress: (event: KeyboardEvent | MouseEvent) => void
+  type: string
+  id: string
+  itemIconEl: any
+  title: string
+  handleFolderClick?: () => void
+  showTitles: boolean
+  i18n: any
+}
+
+export default function ListItem<M extends Meta, B extends Body>(
+  props: ListItemProps<M, B>,
+): h.JSX.Element {
+  const {
+    className,
+    isDisabled,
+    restrictionError,
+    isCheckboxDisabled,
+    isChecked,
+    toggleCheckbox,
+    recordShiftKeyPress,
+    type,
+    id,
+    itemIconEl,
+    title,
+    handleFolderClick,
+    showTitles,
+    i18n,
+  } = props
+
+  return (
+    <li
+      className={className}
+      title={isDisabled ? restrictionError?.message : undefined}
+    >
+      {!isCheckboxDisabled ?
+        <input
+          type="checkbox"
+          className={`uppy-u-reset uppy-ProviderBrowserItem-checkbox ${isChecked ? 'uppy-ProviderBrowserItem-checkbox--is-checked' : ''}`}
+          onChange={toggleCheckbox}
+          onKeyDown={recordShiftKeyPress}
+          onMouseDown={recordShiftKeyPress}
+          // for the <label/>
+          name="listitem"
+          id={id}
+          checked={isChecked}
+          aria-label={
+            type === 'file' ? null : (
+              i18n('allFilesFromFolderNamed', { name: title })
+            )
+          }
+          disabled={isDisabled}
+          data-uppy-super-focusable
+        />
+      : null}
+
+      {
+        type === 'file' ?
+          // label for a checkbox
+          <label
+            htmlFor={id}
+            className="uppy-u-reset uppy-ProviderBrowserItem-inner"
+          >
+            <div className="uppy-ProviderBrowserItem-iconWrap">
+              {itemIconEl}
+            </div>
+            {showTitles && title}
+          </label>
+          // button to open a folder
+        : <button
+            type="button"
+            className="uppy-u-reset uppy-c-btn uppy-ProviderBrowserItem-inner"
+            onClick={handleFolderClick}
+            aria-label={i18n('openFolderNamed', { name: title })}
+          >
+            <div className="uppy-ProviderBrowserItem-iconWrap">
+              {itemIconEl}
+            </div>
+            {showTitles && <span>{title}</span>}
+          </button>
+
+      }
+    </li>
+  )
+}

+ 0 - 54
packages/@uppy/provider-views/src/Item/index.jsx

@@ -1,54 +0,0 @@
-import { h } from 'preact'
-
-import classNames from 'classnames'
-import ItemIcon from './components/ItemIcon.jsx'
-import GridListItem from './components/GridLi.jsx'
-import ListItem from './components/ListLi.jsx'
-
-export default (props) => {
-  const { author, getItemIcon, isChecked, isDisabled, viewType } = props
-  const itemIconString = getItemIcon()
-
-  const className = classNames(
-    'uppy-ProviderBrowserItem',
-    { 'uppy-ProviderBrowserItem--selected': isChecked },
-    { 'uppy-ProviderBrowserItem--disabled': isDisabled },
-    { 'uppy-ProviderBrowserItem--noPreview': itemIconString === 'video' },
-  )
-
-  const itemIconEl = <ItemIcon itemIconString={itemIconString} />
-
-  switch (viewType) {
-    case 'grid':
-      return (
-        <GridListItem
-          // eslint-disable-next-line react/jsx-props-no-spreading
-          {...props}
-          className={className}
-          itemIconEl={itemIconEl}
-        />
-      )
-    case 'list':
-      return (
-        // eslint-disable-next-line react/jsx-props-no-spreading
-        <ListItem {...props} className={className} itemIconEl={itemIconEl} />
-      )
-    case 'unsplash':
-      return (
-        // eslint-disable-next-line react/jsx-props-no-spreading
-        <GridListItem {...props} className={className} itemIconEl={itemIconEl}>
-          <a
-            href={`${author.url}?utm_source=Companion&utm_medium=referral`}
-            target="_blank"
-            rel="noopener noreferrer"
-            className="uppy-ProviderBrowserItem-author"
-            tabIndex="-1"
-          >
-            {author.name}
-          </a>
-        </GridListItem>
-      )
-    default:
-      throw new Error(`There is no such type ${viewType}`)
-  }
-}

+ 87 - 0
packages/@uppy/provider-views/src/Item/index.tsx

@@ -0,0 +1,87 @@
+/* eslint-disable react/require-default-props */
+import { h } from 'preact'
+
+import classNames from 'classnames'
+import type { I18n } from '@uppy/utils/lib/Translator'
+import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
+import type { RestrictionError } from '@uppy/core/lib/Restricter.ts'
+import type { Meta, Body } from '@uppy/utils/lib/UppyFile'
+import ItemIcon from './components/ItemIcon.tsx'
+import GridListItem from './components/GridLi.tsx'
+import ListItem from './components/ListLi.tsx'
+
+type ItemProps<M extends Meta, B extends Body> = {
+  showTitles: boolean
+  i18n: I18n
+  id: string
+  title: string
+  toggleCheckbox: (event: Event) => void
+  recordShiftKeyPress: (event: KeyboardEvent | MouseEvent) => void
+  handleFolderClick?: () => void
+  restrictionError?: RestrictionError<M, B> | null
+  isCheckboxDisabled: boolean
+  type: 'folder' | 'file'
+  author?: CompanionFile['author']
+  getItemIcon: () => string
+  isChecked: boolean
+  isDisabled: boolean
+  viewType: string
+}
+
+export default function Item<M extends Meta, B extends Body>(
+  props: ItemProps<M, B>,
+): h.JSX.Element {
+  const { author, getItemIcon, isChecked, isDisabled, viewType } = props
+  const itemIconString = getItemIcon()
+
+  const className = classNames(
+    'uppy-ProviderBrowserItem',
+    { 'uppy-ProviderBrowserItem--selected': isChecked },
+    { 'uppy-ProviderBrowserItem--disabled': isDisabled },
+    { 'uppy-ProviderBrowserItem--noPreview': itemIconString === 'video' },
+  )
+
+  const itemIconEl = <ItemIcon itemIconString={itemIconString} />
+
+  switch (viewType) {
+    case 'grid':
+      return (
+        <GridListItem<M, B>
+          // eslint-disable-next-line react/jsx-props-no-spreading
+          {...props}
+          className={className}
+          itemIconEl={itemIconEl}
+        />
+      )
+    case 'list':
+      return (
+        <ListItem<M, B>
+          // eslint-disable-next-line react/jsx-props-no-spreading
+          {...props}
+          className={className}
+          itemIconEl={itemIconEl}
+        />
+      )
+    case 'unsplash':
+      return (
+        <GridListItem<M, B>
+          // eslint-disable-next-line react/jsx-props-no-spreading
+          {...props}
+          className={className}
+          itemIconEl={itemIconEl}
+        >
+          <a
+            href={`${author!.url}?utm_source=Companion&utm_medium=referral`}
+            target="_blank"
+            rel="noopener noreferrer"
+            className="uppy-ProviderBrowserItem-author"
+            tabIndex={-1}
+          >
+            {author!.name}
+          </a>
+        </GridListItem>
+      )
+    default:
+      throw new Error(`There is no such type ${viewType}`)
+  }
+}

+ 8 - 1
packages/@uppy/provider-views/src/Loader.jsx → packages/@uppy/provider-views/src/Loader.tsx

@@ -1,6 +1,13 @@
 import { h } from 'preact'
+import type { I18n } from '@uppy/utils/lib/Translator'
 
-export default ({ i18n, loading }) => {
+export default function Loader({
+  i18n,
+  loading,
+}: {
+  i18n: I18n
+  loading: string | boolean
+}): JSX.Element {
   return (
     <div className="uppy-Provider-loading">
       <span>{i18n('loading')}</span>

+ 55 - 24
packages/@uppy/provider-views/src/ProviderView/AuthView.jsx → packages/@uppy/provider-views/src/ProviderView/AuthView.tsx

@@ -1,7 +1,21 @@
+/* eslint-disable react/require-default-props */
 import { h } from 'preact'
 import { useCallback } from 'preact/hooks'
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
+import type Translator from '@uppy/utils/lib/Translator'
+import type { ProviderViewOptions } from './ProviderView'
+import type ProviderViews from './ProviderView'
 
-function GoogleIcon () {
+type AuthViewProps<M extends Meta, B extends Body> = {
+  loading: boolean | string
+  pluginName: string
+  pluginIcon: () => JSX.Element
+  i18n: Translator['translateArray']
+  handleAuth: ProviderViews<M, B>['handleAuth']
+  renderForm?: ProviderViewOptions<M, B>['renderAuthForm']
+}
+
+function GoogleIcon() {
   return (
     <svg
       width="26"
@@ -37,19 +51,30 @@ function GoogleIcon () {
   )
 }
 
-const DefaultForm = ({ pluginName, i18n, onAuth }) => {
+function DefaultForm<M extends Meta, B extends Body>({
+  pluginName,
+  i18n,
+  onAuth,
+}: {
+  pluginName: string
+  i18n: Translator['translateArray']
+  onAuth: AuthViewProps<M, B>['handleAuth']
+}) {
   // In order to comply with Google's brand we need to create a different button
   // for the Google Drive plugin
   const isGoogleDrive = pluginName === 'Google Drive'
 
-  const onSubmit = useCallback((e) => {
-    e.preventDefault()
-    onAuth()
-  }, [onAuth])
+  const onSubmit = useCallback(
+    (e: Event) => {
+      e.preventDefault()
+      onAuth()
+    },
+    [onAuth],
+  )
 
   return (
     <form onSubmit={onSubmit}>
-      {isGoogleDrive ? (
+      {isGoogleDrive ?
         <button
           type="submit"
           className="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Provider-authBtn uppy-Provider-btn-google"
@@ -58,38 +83,46 @@ const DefaultForm = ({ pluginName, i18n, onAuth }) => {
           <GoogleIcon />
           {i18n('signInWithGoogle')}
         </button>
-      ) : (
-        <button
+      : <button
           type="submit"
           className="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Provider-authBtn"
           data-uppy-super-focusable
         >
           {i18n('authenticateWith', { pluginName })}
         </button>
-      )}
+      }
     </form>
   )
 }
 
-const defaultRenderForm = ({ pluginName, i18n, onAuth }) => (
-  <DefaultForm pluginName={pluginName} i18n={i18n} onAuth={onAuth} />
-)
+const defaultRenderForm = ({
+  pluginName,
+  i18n,
+  onAuth,
+}: {
+  pluginName: string
+  i18n: Translator['translateArray']
+  onAuth: AuthViewProps<Meta, Body>['handleAuth']
+}) => <DefaultForm pluginName={pluginName} i18n={i18n} onAuth={onAuth} />
 
-function AuthView (props) {
-  const { loading, pluginName, pluginIcon, i18n, handleAuth, renderForm = defaultRenderForm } = props
+export default function AuthView<M extends Meta, B extends Body>(
+  props: AuthViewProps<M, B>,
+): JSX.Element {
+  const {
+    loading,
+    pluginName,
+    pluginIcon,
+    i18n,
+    handleAuth,
+    renderForm = defaultRenderForm,
+  } = props
 
-  const pluginNameComponent = (
-    <span className="uppy-Provider-authTitleName">
-      {pluginName}
-      <br />
-    </span>
-  )
   return (
     <div className="uppy-Provider-auth">
       <div className="uppy-Provider-authIcon">{pluginIcon()}</div>
       <div className="uppy-Provider-authTitle">
         {i18n('authenticateWithTitle', {
-          pluginName: pluginNameComponent,
+          pluginName,
         })}
       </div>
 
@@ -99,5 +132,3 @@ function AuthView (props) {
     </div>
   )
 }
-
-export default AuthView

+ 0 - 22
packages/@uppy/provider-views/src/ProviderView/Header.jsx

@@ -1,22 +0,0 @@
-import User from './User.jsx'
-import Breadcrumbs from '../Breadcrumbs.jsx'
-
-export default (props) => {
-  const components = []
-  if (props.showBreadcrumbs) {
-    components.push(Breadcrumbs({
-      getFolder: props.getFolder,
-      breadcrumbs: props.breadcrumbs,
-      breadcrumbsIcon: props.pluginIcon && props.pluginIcon(),
-      title: props.title,
-    }))
-  }
-
-  components.push(User({
-    logout: props.logout,
-    username: props.username,
-    i18n: props.i18n,
-  }))
-
-  return components
-}

+ 37 - 0
packages/@uppy/provider-views/src/ProviderView/Header.tsx

@@ -0,0 +1,37 @@
+/* eslint-disable react/destructuring-assignment */
+import { h, Fragment } from 'preact'
+import type { I18n } from '@uppy/utils/lib/Translator'
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
+import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.ts'
+import User from './User.tsx'
+import Breadcrumbs from '../Breadcrumbs.tsx'
+import type ProviderView from './ProviderView.tsx'
+
+type HeaderProps<M extends Meta, B extends Body> = {
+  showBreadcrumbs: boolean
+  getFolder: ProviderView<M, B>['getFolder']
+  breadcrumbs: UnknownProviderPluginState['breadcrumbs']
+  pluginIcon: () => JSX.Element
+  title: string
+  logout: () => void
+  username: string | undefined
+  i18n: I18n
+}
+
+export default function Header<M extends Meta, B extends Body>(
+  props: HeaderProps<M, B>,
+): JSX.Element {
+  return (
+    <Fragment>
+      {props.showBreadcrumbs && (
+        <Breadcrumbs
+          getFolder={props.getFolder}
+          breadcrumbs={props.breadcrumbs}
+          breadcrumbsIcon={props.pluginIcon && props.pluginIcon()}
+          title={props.title}
+        />
+      )}
+      <User logout={props.logout} username={props.username} i18n={props.i18n} />
+    </Fragment>
+  )
+}

+ 232 - 96
packages/@uppy/provider-views/src/ProviderView/ProviderView.jsx → packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx

@@ -3,54 +3,98 @@ import PQueue from 'p-queue'
 
 import { getSafeFileId } from '@uppy/utils/lib/generateFileID'
 
-import AuthView from './AuthView.jsx'
-import Header from './Header.jsx'
-import Browser from '../Browser.jsx'
-import CloseWrapper from '../CloseWrapper.js'
-import View from '../View.js'
-
+import type {
+  UnknownProviderPlugin,
+  UnknownProviderPluginState,
+  Uppy,
+} from '@uppy/core/lib/Uppy.ts'
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
+import type { CompanionFile } from '@uppy/utils/lib/CompanionFile.ts'
+import type Translator from '@uppy/utils/lib/Translator'
+import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts'
+import AuthView from './AuthView.tsx'
+import Header from './Header.tsx'
+import Browser from '../Browser.tsx'
+import CloseWrapper from '../CloseWrapper.ts'
+import View, { type ViewOptions } from '../View.ts'
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore We don't want TS to generate types for the package.json
 import packageJson from '../../package.json'
 
-function formatBreadcrumbs (breadcrumbs) {
-  return breadcrumbs.slice(1).map((directory) => directory.name).join('/')
+function formatBreadcrumbs(
+  breadcrumbs: UnknownProviderPluginState['breadcrumbs'],
+): string {
+  return breadcrumbs
+    .slice(1)
+    .map((directory) => directory.name)
+    .join('/')
 }
 
-function prependPath (path, component) {
+function prependPath(path: string | undefined, component: string): string {
   if (!path) return component
   return `${path}/${component}`
 }
 
-export function defaultPickerIcon () {
+export function defaultPickerIcon(): JSX.Element {
   return (
-    <svg aria-hidden="true" focusable="false" width="30" height="30" viewBox="0 0 30 30">
+    <svg
+      aria-hidden="true"
+      focusable="false"
+      width="30"
+      height="30"
+      viewBox="0 0 30 30"
+    >
       <path d="M15 30c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15C6.716 0 0 6.716 0 15c0 8.284 6.716 15 15 15zm4.258-12.676v6.846h-8.426v-6.846H5.204l9.82-12.364 9.82 12.364H19.26z" />
     </svg>
   )
 }
 
+type PluginType = 'Provider'
+
+const defaultOptions = {
+  viewType: 'list',
+  showTitles: true,
+  showFilter: true,
+  showBreadcrumbs: true,
+  loadAllFiles: false,
+}
+
+export interface ProviderViewOptions<M extends Meta, B extends Body>
+  extends ViewOptions<M, B, PluginType> {
+  renderAuthForm?: (args: {
+    pluginName: string
+    i18n: Translator['translateArray']
+    loading: boolean | string
+    onAuth: (authFormData: unknown) => Promise<void>
+  }) => JSX.Element
+}
+
+type Opts<M extends Meta, B extends Body> = DefinePluginOpts<
+  ProviderViewOptions<M, B>,
+  keyof typeof defaultOptions
+>
+
 /**
  * Class to easily generate generic views for Provider plugins
  */
-export default class ProviderView extends View {
+export default class ProviderView<M extends Meta, B extends Body> extends View<
+  M,
+  B,
+  PluginType,
+  Opts<M, B>
+> {
   static VERSION = packageJson.version
 
-  /**
-   * @param {object} plugin instance of the plugin
-   * @param {object} opts
-   */
-  constructor (plugin, opts) {
-    super(plugin, opts)
-    // set default options
-    const defaultOptions = {
-      viewType: 'list',
-      showTitles: true,
-      showFilter: true,
-      showBreadcrumbs: true,
-      loadAllFiles: false,
-    }
+  username: string | undefined
+
+  nextPagePath: string
 
-    // merge default options with the ones set by user
-    this.opts = { ...defaultOptions, ...opts }
+  constructor(
+    plugin: UnknownProviderPlugin<M, B>,
+    opts: ProviderViewOptions<M, B>,
+  ) {
+    super(plugin, { ...defaultOptions, ...opts })
 
     // Logic
     this.filterQuery = this.filterQuery.bind(this)
@@ -80,13 +124,13 @@ export default class ProviderView extends View {
   }
 
   // eslint-disable-next-line class-methods-use-this
-  tearDown () {
+  tearDown(): void {
     // Nothing.
   }
 
-  #abortController
+  #abortController: AbortController | undefined
 
-  async #withAbort (op) {
+  async #withAbort(op: (signal: AbortSignal) => Promise<void>) {
     // prevent multiple requests in parallel from causing race conditions
     this.#abortController?.abort()
     const abortController = new AbortController()
@@ -96,19 +140,37 @@ export default class ProviderView extends View {
       this.clearSelection()
     }
     try {
+      // @ts-expect-error this should be typed in @uppy/dashboard.
+      // Even then I don't think we can make this work without adding dashboard
+      // as a dependency to provider-views.
       this.plugin.uppy.on('dashboard:close-panel', cancelRequest)
       this.plugin.uppy.on('cancel-all', cancelRequest)
 
       await op(abortController.signal)
     } finally {
+      // @ts-expect-error this should be typed in @uppy/dashboard.
+      // Even then I don't think we can make this work without adding dashboard
+      // as a dependency to provider-views.
       this.plugin.uppy.off('dashboard:close-panel', cancelRequest)
       this.plugin.uppy.off('cancel-all', cancelRequest)
       this.#abortController = undefined
     }
   }
 
-  async #list ({ requestPath, absDirPath, signal }) {
-    const { username, nextPagePath, items } = await this.provider.list(requestPath, { signal })
+  async #list({
+    requestPath,
+    absDirPath,
+    signal,
+  }: {
+    requestPath: string
+    absDirPath: string
+    signal: AbortSignal
+  }) {
+    const { username, nextPagePath, items } = await this.provider.list<{
+      username: string
+      nextPagePath: string
+      items: CompanionFile[]
+    }>(requestPath, { signal })
     this.username = username || this.username
 
     return {
@@ -120,15 +182,25 @@ export default class ProviderView extends View {
     }
   }
 
-  async #listFilesAndFolders ({ breadcrumbs, signal }) {
+  async #listFilesAndFolders({
+    breadcrumbs,
+    signal,
+  }: {
+    breadcrumbs: UnknownProviderPluginState['breadcrumbs']
+    signal: AbortSignal
+  }) {
     const absDirPath = formatBreadcrumbs(breadcrumbs)
 
-    const { items, nextPagePath } = await this.#list({ requestPath: this.nextPagePath, absDirPath, signal })
+    const { items, nextPagePath } = await this.#list({
+      requestPath: this.nextPagePath,
+      absDirPath,
+      signal,
+    })
 
     this.nextPagePath = nextPagePath
 
-    const files = []
-    const folders = []
+    const files: CompanionFile[] = []
+    const folders: CompanionFile[] = []
 
     items.forEach((item) => {
       if (item.isFolder) {
@@ -145,12 +217,8 @@ export default class ProviderView extends View {
    * Select a folder based on its id: fetches the folder and then updates state with its contents
    * TODO rename to something better like selectFolder or navigateToFolder (breaking change?)
    *
-   * @param  {string} requestPath
-   * the path we need to use when sending list request to companion (for some providers it's different from ID)
-   * @param  {string} name used in the UI and to build the absDirPath
-   * @returns {Promise}   Folders/files in folder
    */
-  async getFolder (requestPath, name) {
+  async getFolder(requestPath: string, name: string): Promise<void> {
     this.setLoading(true)
     try {
       await this.#withAbort(async (signal) => {
@@ -158,7 +226,9 @@ export default class ProviderView extends View {
 
         let { breadcrumbs } = this.plugin.getPluginState()
 
-        const index = breadcrumbs.findIndex((dir) => requestPath === dir.requestPath)
+        const index = breadcrumbs.findIndex(
+          (dir) => requestPath === dir.requestPath,
+        )
 
         if (index !== -1) {
           // means we navigated back to a known directory (already in the stack), so cut the stack off there
@@ -169,28 +239,41 @@ export default class ProviderView extends View {
         }
 
         this.nextPagePath = requestPath
-        let files = []
-        let folders = []
+        let files: CompanionFile[] = []
+        let folders: CompanionFile[] = []
         do {
-          const { files: newFiles, folders: newFolders } = await this.#listFilesAndFolders({
-            breadcrumbs, signal,
-          })
+          const { files: newFiles, folders: newFolders } =
+            await this.#listFilesAndFolders({
+              breadcrumbs,
+              signal,
+            })
 
           files = files.concat(newFiles)
           folders = folders.concat(newFolders)
 
-          this.setLoading(this.plugin.uppy.i18n('loadedXFiles', { numFiles: files.length + folders.length }))
-        } while (
-          this.opts.loadAllFiles && this.nextPagePath
-        )
-
-        this.plugin.setPluginState({ folders, files, breadcrumbs, filterInput: '' })
+          this.setLoading(
+            this.plugin.uppy.i18n('loadedXFiles', {
+              numFiles: files.length + folders.length,
+            }),
+          )
+        } while (this.opts.loadAllFiles && this.nextPagePath)
+
+        this.plugin.setPluginState({
+          folders,
+          files,
+          breadcrumbs,
+          filterInput: '',
+        })
       })
     } catch (err) {
       // This is the first call that happens when the provider view loads, after auth, so it's probably nice to show any
       // error occurring here to the user.
       if (err?.name === 'UserFacingApiError') {
-        this.plugin.uppy.info({ message: this.plugin.uppy.i18n(err.message) }, 'warning', 5000)
+        this.plugin.uppy.info(
+          { message: this.plugin.uppy.i18n(err.message) },
+          'warning',
+          5000,
+        )
         return
       }
 
@@ -202,10 +285,8 @@ export default class ProviderView extends View {
 
   /**
    * Fetches new folder
-   *
-   * @param  {object} folder
    */
-  getNextFolder (folder) {
+  getNextFolder(folder: CompanionFile): void {
     this.getFolder(folder.requestPath, folder.name)
     this.lastCheckbox = undefined
   }
@@ -213,10 +294,17 @@ export default class ProviderView extends View {
   /**
    * Removes session token on client side.
    */
-  async logout () {
+  async logout(): Promise<void> {
     try {
       await this.#withAbort(async (signal) => {
-        const res = await this.provider.logout({ signal })
+        const res = await this.provider.logout<{
+          ok: boolean
+          revoked: boolean
+          manual_revoke_url: string
+        }>({
+          signal,
+        })
+        // res.ok is from the JSON body, not to be confused with Response.ok
         if (res.ok) {
           if (!res.revoked) {
             const message = this.plugin.uppy.i18n('companionUnauthorizeHint', {
@@ -241,15 +329,15 @@ export default class ProviderView extends View {
     }
   }
 
-  filterQuery (input) {
+  filterQuery(input: string): void {
     this.plugin.setPluginState({ filterInput: input })
   }
 
-  clearFilter () {
+  clearFilter(): void {
     this.plugin.setPluginState({ filterInput: '' })
   }
 
-  async handleAuth (authFormData) {
+  async handleAuth(authFormData?: unknown): Promise<void> {
     try {
       await this.#withAbort(async (signal) => {
         this.setLoading(true)
@@ -259,7 +347,11 @@ export default class ProviderView extends View {
       })
     } catch (err) {
       if (err.name === 'UserFacingApiError') {
-        this.plugin.uppy.info({ message: this.plugin.uppy.i18n(err.message) }, 'warning', 5000)
+        this.plugin.uppy.info(
+          { message: this.plugin.uppy.i18n(err.message) },
+          'warning',
+          5000,
+        )
         return
       }
 
@@ -269,7 +361,7 @@ export default class ProviderView extends View {
     }
   }
 
-  async handleScroll (event) {
+  async handleScroll(event: Event): Promise<void> {
     if (this.shouldHandleScroll(event) && this.nextPagePath) {
       this.isHandlingScroll = true
 
@@ -277,14 +369,19 @@ export default class ProviderView extends View {
         await this.#withAbort(async (signal) => {
           const { files, folders, breadcrumbs } = this.plugin.getPluginState()
 
-          const { files: newFiles, folders: newFolders } = await this.#listFilesAndFolders({
-            breadcrumbs, signal,
-          })
+          const { files: newFiles, folders: newFolders } =
+            await this.#listFilesAndFolders({
+              breadcrumbs,
+              signal,
+            })
 
           const combinedFiles = files.concat(newFiles)
           const combinedFolders = folders.concat(newFolders)
 
-          this.plugin.setPluginState({ folders: combinedFolders, files: combinedFiles })
+          this.plugin.setPluginState({
+            folders: combinedFolders,
+            files: combinedFiles,
+          })
         })
       } catch (error) {
         this.handleError(error)
@@ -294,7 +391,21 @@ export default class ProviderView extends View {
     }
   }
 
-  async #recursivelyListAllFiles ({ requestPath, absDirPath, relDirPath, queue, onFiles, signal }) {
+  async #recursivelyListAllFiles({
+    requestPath,
+    absDirPath,
+    relDirPath,
+    queue,
+    onFiles,
+    signal,
+  }: {
+    requestPath: string
+    absDirPath: string
+    relDirPath: string
+    queue: PQueue
+    onFiles: (files: CompanionFile[]) => void
+    signal: AbortSignal
+  }) {
     let curPath = requestPath
 
     while (curPath) {
@@ -307,37 +418,41 @@ export default class ProviderView extends View {
       onFiles(files)
 
       // recursively queue call to self for each folder
-      const promises = folders.map(async (folder) => queue.add(async () => (
-        this.#recursivelyListAllFiles({
-          requestPath: folder.requestPath,
-          absDirPath: prependPath(absDirPath, folder.name),
-          relDirPath: prependPath(relDirPath, folder.name),
-          queue,
-          onFiles,
-          signal,
-        })
-      )))
+      const promises = folders.map(async (folder) =>
+        queue.add(async () =>
+          this.#recursivelyListAllFiles({
+            requestPath: folder.requestPath,
+            absDirPath: prependPath(absDirPath, folder.name),
+            relDirPath: prependPath(relDirPath, folder.name),
+            queue,
+            onFiles,
+            signal,
+          }),
+        ),
+      )
       await Promise.all(promises) // in case we get an error
     }
   }
 
-  async donePicking () {
+  async donePicking(): Promise<void> {
     this.setLoading(true)
     try {
       await this.#withAbort(async (signal) => {
         const { currentSelection } = this.plugin.getPluginState()
 
-        const messages = []
-        const newFiles = []
+        const messages: string[] = []
+        const newFiles: CompanionFile[] = []
 
         for (const selectedItem of currentSelection) {
           const { requestPath } = selectedItem
 
-          const withRelDirPath = (newItem) => ({
+          const withRelDirPath = (newItem: CompanionFile) => ({
             ...newItem,
             // calculate the file's path relative to the user's selected item's path
             // see https://github.com/transloadit/uppy/pull/4537#issuecomment-1614236655
-            relDirPath: newItem.absDirPath.replace(selectedItem.absDirPath, '').replace(/^\//, ''),
+            relDirPath: (newItem.absDirPath as string)
+              .replace(selectedItem.absDirPath as string, '')
+              .replace(/^\//, ''),
           })
 
           if (selectedItem.isFolder) {
@@ -346,9 +461,10 @@ export default class ProviderView extends View {
 
             const queue = new PQueue({ concurrency: 6 })
 
-            const onFiles = (files) => {
+            const onFiles = (files: CompanionFile[]) => {
               for (const newFile of files) {
                 const tagFile = this.getTagFile(newFile)
+
                 const id = getSafeFileId(tagFile)
                 // If the same folder is added again, we don't want to send
                 // X amount of duplicate file notifications, we want to say
@@ -357,7 +473,11 @@ export default class ProviderView extends View {
                 if (!this.plugin.uppy.checkIfFileAlreadyExists(id)) {
                   newFiles.push(withRelDirPath(newFile))
                   numNewFiles++
-                  this.setLoading(this.plugin.uppy.i18n('addedNumFiles', { numFiles: numNewFiles }))
+                  this.setLoading(
+                    this.plugin.uppy.i18n('addedNumFiles', {
+                      numFiles: numNewFiles,
+                    }),
+                  )
                 }
                 isEmpty = false
               }
@@ -365,7 +485,10 @@ export default class ProviderView extends View {
 
             await this.#recursivelyListAllFiles({
               requestPath,
-              absDirPath: prependPath(selectedItem.absDirPath, selectedItem.name),
+              absDirPath: prependPath(
+                selectedItem.absDirPath,
+                selectedItem.name,
+              ),
               relDirPath: selectedItem.name,
               queue,
               onFiles,
@@ -385,7 +508,8 @@ export default class ProviderView extends View {
               // (only later after addFiles has been called) so we should probably rewrite this.
               // Example: If all files fail to add due to restriction error, it will still say "Added 100 files from folder"
               message = this.plugin.uppy.i18n('folderAdded', {
-                smart_count: numNewFiles, folder: selectedItem.name,
+                smart_count: numNewFiles,
+                folder: selectedItem.name,
               })
             }
 
@@ -401,10 +525,15 @@ export default class ProviderView extends View {
         // finished all async operations before we add any file
         // see https://github.com/transloadit/uppy/pull/4384
         this.plugin.uppy.log('Adding files from a remote provider')
-        this.plugin.uppy.addFiles(newFiles.map((file) => this.getTagFile(file, this.requestClientId)))
+        this.plugin.uppy.addFiles(
+          // @ts-expect-error `addFiles` expects `body` to be `File` or `Blob`,
+          // but as the todo comment in `View.ts` indicates, we strangly pass `CompanionFile` as `body`.
+          // For now it's better to ignore than to have a potential breaking change.
+          newFiles.map((file) => this.getTagFile(file, this.requestClientId)),
+        )
 
         this.plugin.setPluginState({ filterInput: '' })
-        messages.forEach(message => this.plugin.uppy.info(message))
+        messages.forEach((message) => this.plugin.uppy.info(message))
 
         this.clearSelection()
       })
@@ -415,7 +544,10 @@ export default class ProviderView extends View {
     }
   }
 
-  render (state, viewOptions = {}) {
+  render(
+    state: unknown,
+    viewOptions: Omit<ViewOptions<M, B, PluginType>, 'provider'> = {},
+  ): JSX.Element {
     const { authenticated, didFirstRender } = this.plugin.getPluginState()
     const { i18n } = this.plugin.uppy
 
@@ -424,7 +556,8 @@ export default class ProviderView extends View {
     }
 
     const targetViewOptions = { ...this.opts, ...viewOptions }
-    const { files, folders, filterInput, loading, currentSelection } = this.plugin.getPluginState()
+    const { files, folders, filterInput, loading, currentSelection } =
+      this.plugin.getPluginState()
     const { isChecked, toggleCheckbox, recordShiftKeyPress, filterItems } = this
     const hasInput = filterInput !== ''
     const pluginIcon = this.plugin.icon || defaultPickerIcon
@@ -465,7 +598,8 @@ export default class ProviderView extends View {
       handleScroll: this.handleScroll,
       done: this.donePicking,
       cancel: this.cancelPicking,
-      headerComponent: Header(headerProps),
+      // eslint-disable-next-line react/jsx-props-no-spreading
+      headerComponent: <Header<M, B> {...headerProps} />,
       title: this.plugin.title,
       viewType: targetViewOptions.viewType,
       showTitles: targetViewOptions.showTitles,
@@ -473,7 +607,9 @@ export default class ProviderView extends View {
       pluginIcon,
       i18n: this.plugin.uppy.i18n,
       uppyFiles: this.plugin.uppy.getFiles(),
-      validateRestrictions: (...args) => this.plugin.uppy.validateRestrictions(...args),
+      validateRestrictions: (
+        ...args: Parameters<Uppy<M, B>['validateRestrictions']>
+      ) => this.plugin.uppy.validateRestrictions(...args),
       isLoading: loading,
     }
 
@@ -495,7 +631,7 @@ export default class ProviderView extends View {
     return (
       <CloseWrapper onUnmount={this.clearSelection}>
         {/* eslint-disable-next-line react/jsx-props-no-spreading */}
-        <Browser {...browserProps} />
+        <Browser<M, B> {...browserProps} />
       </CloseWrapper>
     )
   }

+ 0 - 10
packages/@uppy/provider-views/src/ProviderView/User.jsx

@@ -1,10 +0,0 @@
-import { h } from 'preact'
-
-export default ({ i18n, logout, username }) => {
-  return ([
-    <span className="uppy-ProviderBrowser-user" key="username">{username}</span>,
-    <button type="button" onClick={logout} className="uppy-u-reset uppy-c-btn uppy-ProviderBrowser-userLogout" key="logout">
-      {i18n('logOut')}
-    </button>,
-  ])
-}

+ 29 - 0
packages/@uppy/provider-views/src/ProviderView/User.tsx

@@ -0,0 +1,29 @@
+import { h, Fragment } from 'preact'
+
+type UserProps = {
+  i18n: (phrase: string) => string
+  logout: () => void
+  username: string | undefined
+}
+
+export default function User({
+  i18n,
+  logout,
+  username,
+}: UserProps): JSX.Element {
+  return (
+    <Fragment>
+      <span className="uppy-ProviderBrowser-user" key="username">
+        {username}
+      </span>
+      <button
+        type="button"
+        onClick={logout}
+        className="uppy-u-reset uppy-c-btn uppy-ProviderBrowser-userLogout"
+        key="logout"
+      >
+        {i18n('logOut')}
+      </button>
+    </Fragment>
+  )
+}

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

@@ -1 +0,0 @@
-export { default, defaultPickerIcon } from './ProviderView.jsx'

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

@@ -0,0 +1 @@
+export { default, defaultPickerIcon } from './ProviderView.tsx'

+ 0 - 101
packages/@uppy/provider-views/src/SearchFilterInput.jsx

@@ -1,101 +0,0 @@
-import { h, Fragment } from 'preact'
-import { useEffect, useState, useCallback } from 'preact/hooks'
-import { nanoid } from 'nanoid/non-secure'
-// import debounce from 'lodash.debounce'
-
-export default function SearchFilterInput (props) {
-  const {
-    search,
-    searchOnInput,
-    searchTerm,
-    showButton,
-    inputLabel,
-    clearSearchLabel,
-    buttonLabel,
-    clearSearch,
-    inputClassName,
-    buttonCSSClassName,
-  } = props
-  const [searchText, setSearchText] = useState(searchTerm ?? '')
-  // const debouncedSearch = debounce((q) => search(q), 1000)
-
-  const validateAndSearch = useCallback((ev) => {
-    ev.preventDefault()
-    search(searchText)
-  }, [search, searchText])
-
-  const handleInput = useCallback((ev) => {
-    const inputValue = ev.target.value
-    setSearchText(inputValue)
-    if (searchOnInput) search(inputValue)
-  }, [setSearchText, searchOnInput, search])
-
-  const handleReset = () => {
-    setSearchText('')
-    if (clearSearch) clearSearch()
-  }
-
-  const [form] = useState(() => {
-    const formEl = document.createElement('form')
-    formEl.setAttribute('tabindex', '-1')
-    formEl.id = nanoid()
-    return formEl
-  })
-
-  useEffect(() => {
-    document.body.appendChild(form)
-    form.addEventListener('submit', validateAndSearch)
-    return () => {
-      form.removeEventListener('submit', validateAndSearch)
-      document.body.removeChild(form)
-    }
-  }, [form, validateAndSearch])
-
-  return (
-    <Fragment>
-      <input
-        className={`uppy-u-reset ${inputClassName}`}
-        type="search"
-        aria-label={inputLabel}
-        placeholder={inputLabel}
-        value={searchText}
-        onInput={handleInput}
-        form={form.id}
-        data-uppy-super-focusable
-      />
-      {
-        !showButton && (
-          <svg aria-hidden="true" focusable="false" class="uppy-c-icon uppy-ProviderBrowser-searchFilterIcon" 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>
-        )
-      }
-      {
-        !showButton && searchText && (
-          <button
-            className="uppy-u-reset uppy-ProviderBrowser-searchFilterReset"
-            type="button"
-            aria-label={clearSearchLabel}
-            title={clearSearchLabel}
-            onClick={handleReset}
-          >
-            <svg aria-hidden="true" focusable="false" className="uppy-c-icon" viewBox="0 0 19 19">
-              <path d="M17.318 17.232L9.94 9.854 9.586 9.5l-.354.354-7.378 7.378h.707l-.62-.62v.706L9.318 9.94l.354-.354-.354-.354L1.94 1.854v.707l.62-.62h-.706l7.378 7.378.354.354.354-.354 7.378-7.378h-.707l.622.62v-.706L9.854 9.232l-.354.354.354.354 7.378 7.378.708-.707-7.38-7.378v.708l7.38-7.38.353-.353-.353-.353-.622-.622-.353-.353-.354.352-7.378 7.38h.708L2.56 1.23 2.208.88l-.353.353-.622.62-.353.355.352.353 7.38 7.38v-.708l-7.38 7.38-.353.353.352.353.622.622.353.353.354-.353 7.38-7.38h-.708l7.38 7.38z" />
-            </svg>
-          </button>
-        )
-      }
-      {
-        showButton && (
-          <button
-            className={`uppy-u-reset uppy-c-btn uppy-c-btn-primary ${buttonCSSClassName}`}
-            type="submit"
-            form={form.id}
-          >
-            {buttonLabel}
-          </button>
-        )
-      }
-    </Fragment>
-  )
-}

+ 127 - 0
packages/@uppy/provider-views/src/SearchFilterInput.tsx

@@ -0,0 +1,127 @@
+/* eslint-disable react/require-default-props */
+import { h, Fragment } from 'preact'
+import { useEffect, useState, useCallback } from 'preact/hooks'
+import { nanoid } from 'nanoid/non-secure'
+
+type Props = {
+  search: (query: string) => void
+  searchOnInput?: boolean
+  searchTerm?: string | null
+  showButton?: boolean
+  inputLabel: string
+  clearSearchLabel?: string
+  buttonLabel?: string
+  // eslint-disable-next-line react/require-default-props
+  clearSearch?: () => void
+  inputClassName: string
+  buttonCSSClassName?: string
+}
+
+export default function SearchFilterInput(props: Props): JSX.Element {
+  const {
+    search,
+    searchOnInput,
+    searchTerm,
+    showButton,
+    inputLabel,
+    clearSearchLabel,
+    buttonLabel,
+    clearSearch,
+    inputClassName,
+    buttonCSSClassName,
+  } = props
+  const [searchText, setSearchText] = useState(searchTerm ?? '')
+  // const debouncedSearch = debounce((q) => search(q), 1000)
+
+  const validateAndSearch = useCallback(
+    (ev: Event) => {
+      ev.preventDefault()
+      search(searchText)
+    },
+    [search, searchText],
+  )
+
+  const handleInput = useCallback(
+    (ev: Event) => {
+      const inputValue = (ev.target as HTMLInputElement).value
+      setSearchText(inputValue)
+      if (searchOnInput) search(inputValue)
+    },
+    [setSearchText, searchOnInput, search],
+  )
+
+  const handleReset = () => {
+    setSearchText('')
+    if (clearSearch) clearSearch()
+  }
+
+  const [form] = useState(() => {
+    const formEl = document.createElement('form')
+    formEl.setAttribute('tabindex', '-1')
+    formEl.id = nanoid()
+    return formEl
+  })
+
+  useEffect(() => {
+    document.body.appendChild(form)
+    form.addEventListener('submit', validateAndSearch)
+    return () => {
+      form.removeEventListener('submit', validateAndSearch)
+      document.body.removeChild(form)
+    }
+  }, [form, validateAndSearch])
+
+  return (
+    <Fragment>
+      <input
+        className={`uppy-u-reset ${inputClassName}`}
+        type="search"
+        aria-label={inputLabel}
+        placeholder={inputLabel}
+        value={searchText}
+        onInput={handleInput}
+        form={form.id}
+        data-uppy-super-focusable
+      />
+      {!showButton && (
+        <svg
+          aria-hidden="true"
+          focusable="false"
+          className="uppy-c-icon uppy-ProviderBrowser-searchFilterIcon"
+          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>
+      )}
+      {!showButton && searchText && (
+        <button
+          className="uppy-u-reset uppy-ProviderBrowser-searchFilterReset"
+          type="button"
+          aria-label={clearSearchLabel}
+          title={clearSearchLabel}
+          onClick={handleReset}
+        >
+          <svg
+            aria-hidden="true"
+            focusable="false"
+            className="uppy-c-icon"
+            viewBox="0 0 19 19"
+          >
+            <path d="M17.318 17.232L9.94 9.854 9.586 9.5l-.354.354-7.378 7.378h.707l-.62-.62v.706L9.318 9.94l.354-.354-.354-.354L1.94 1.854v.707l.62-.62h-.706l7.378 7.378.354.354.354-.354 7.378-7.378h-.707l.622.62v-.706L9.854 9.232l-.354.354.354.354 7.378 7.378.708-.707-7.38-7.378v.708l7.38-7.38.353-.353-.353-.353-.622-.622-.353-.353-.354.352-7.378 7.38h.708L2.56 1.23 2.208.88l-.353.353-.622.62-.353.355.352.353 7.38 7.38v-.708l-7.38 7.38-.353.353.352.353.622.622.353.353.354-.353 7.38-7.38h-.708l7.38 7.38z" />
+          </svg>
+        </button>
+      )}
+      {showButton && (
+        <button
+          className={`uppy-u-reset uppy-c-btn uppy-c-btn-primary ${buttonCSSClassName}`}
+          type="submit"
+          form={form.id}
+        >
+          {buttonLabel}
+        </button>
+      )}
+    </Fragment>
+  )
+}

+ 81 - 53
packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.jsx → packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.tsx

@@ -1,75 +1,95 @@
 import { h } from 'preact'
 
-import SearchFilterInput from '../SearchFilterInput.jsx'
-import Browser from '../Browser.jsx'
-import CloseWrapper from '../CloseWrapper.js'
-import View from '../View.js'
-
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
+import type { UnknownSearchProviderPlugin } from '@uppy/core/lib/Uppy.ts'
+import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts'
+import type Uppy from '@uppy/core'
+import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
+import SearchFilterInput from '../SearchFilterInput.tsx'
+import Browser from '../Browser.tsx'
+import CloseWrapper from '../CloseWrapper.ts'
+import View, { type ViewOptions } from '../View.ts'
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore We don't want TS to generate types for the package.json
 import packageJson from '../../package.json'
 
+const defaultState = {
+  isInputMode: true,
+  files: [],
+  folders: [],
+  breadcrumbs: [],
+  filterInput: '',
+  currentSelection: [],
+  searchTerm: null,
+}
+
+type PluginType = 'SearchProvider'
+
+const defaultOptions = {
+  viewType: 'grid',
+  showTitles: true,
+  showFilter: true,
+  showBreadcrumbs: true,
+}
+
+type Opts<
+  M extends Meta,
+  B extends Body,
+  T extends PluginType,
+> = DefinePluginOpts<ViewOptions<M, B, T>, keyof typeof defaultOptions>
+
+type Res = {
+  items: CompanionFile[]
+  nextPageQuery: string | null
+  searchedFor: string
+}
+
 /**
  * SearchProviderView, used for Unsplash and future image search providers.
  * Extends generic View, shared with regular providers like Google Drive and Instagram.
  */
-export default class SearchProviderView extends View {
+export default class SearchProviderView<
+  M extends Meta,
+  B extends Body,
+> extends View<M, B, PluginType, Opts<M, B, PluginType>> {
   static VERSION = packageJson.version
 
-  /**
-   * @param {object} plugin instance of the plugin
-   * @param {object} opts
-   */
-  constructor (plugin, opts) {
-    super(plugin, opts)
-
-    // set default options
-    const defaultOptions = {
-      viewType: 'grid',
-      showTitles: false,
-      showFilter: false,
-      showBreadcrumbs: false,
-    }
+  nextPageQuery: string | null = null
 
-    // merge default options with the ones set by user
-    this.opts = { ...defaultOptions, ...opts }
+  constructor(
+    plugin: UnknownSearchProviderPlugin<M, B>,
+    opts: ViewOptions<M, B, PluginType>,
+  ) {
+    super(plugin, { ...defaultOptions, ...opts })
 
-    // Logic
     this.search = this.search.bind(this)
     this.clearSearch = this.clearSearch.bind(this)
     this.resetPluginState = this.resetPluginState.bind(this)
     this.handleScroll = this.handleScroll.bind(this)
     this.donePicking = this.donePicking.bind(this)
 
-    // Visual
     this.render = this.render.bind(this)
 
-    this.defaultState = {
-      isInputMode: true,
-      files: [],
-      folders: [],
-      breadcrumbs: [],
-      filterInput: '',
-      currentSelection: [],
-      searchTerm: null,
-    }
-
-    // Set default state for the plugin
-    this.plugin.setPluginState(this.defaultState)
+    this.plugin.setPluginState(defaultState)
 
     this.registerRequestClient()
   }
 
   // eslint-disable-next-line class-methods-use-this
-  tearDown () {
+  tearDown(): void {
     // Nothing.
   }
 
-  resetPluginState () {
-    this.plugin.setPluginState(this.defaultState)
+  resetPluginState(): void {
+    this.plugin.setPluginState(defaultState)
   }
 
-  #updateFilesAndInputMode (res, files) {
+  #updateFilesAndInputMode(res: Res, files: CompanionFile[]): void {
     this.nextPageQuery = res.nextPageQuery
-    res.items.forEach((item) => { files.push(item) })
+    res.items.forEach((item) => {
+      files.push(item)
+    })
     this.plugin.setPluginState({
       currentSelection: [],
       isInputMode: false,
@@ -78,7 +98,7 @@ export default class SearchProviderView extends View {
     })
   }
 
-  async search (query) {
+  async search(query: string): Promise<void> {
     const { searchTerm } = this.plugin.getPluginState()
     if (query && query === searchTerm) {
       // no need to search again as this is the same as the previous search
@@ -87,7 +107,7 @@ export default class SearchProviderView extends View {
 
     this.setLoading(true)
     try {
-      const res = await this.provider.search(query)
+      const res = await this.provider.search<Res>(query)
       this.#updateFilesAndInputMode(res, [])
     } catch (err) {
       this.handleError(err)
@@ -96,7 +116,7 @@ export default class SearchProviderView extends View {
     }
   }
 
-  clearSearch () {
+  clearSearch(): void {
     this.plugin.setPluginState({
       currentSelection: [],
       files: [],
@@ -104,7 +124,7 @@ export default class SearchProviderView extends View {
     })
   }
 
-  async handleScroll (event) {
+  async handleScroll(event: Event): Promise<void> {
     const query = this.nextPageQuery || null
 
     if (this.shouldHandleScroll(event) && query) {
@@ -112,7 +132,7 @@ export default class SearchProviderView extends View {
 
       try {
         const { files, searchTerm } = this.plugin.getPluginState()
-        const response = await this.provider.search(searchTerm, query)
+        const response = await this.provider.search<Res>(searchTerm!, query)
 
         this.#updateFilesAndInputMode(response, files)
       } catch (error) {
@@ -123,15 +143,21 @@ export default class SearchProviderView extends View {
     }
   }
 
-  donePicking () {
+  donePicking(): void {
     const { currentSelection } = this.plugin.getPluginState()
     this.plugin.uppy.log('Adding remote search provider files')
-    this.plugin.uppy.addFiles(currentSelection.map((file) => this.getTagFile(file)))
+    this.plugin.uppy.addFiles(
+      currentSelection.map((file) => this.getTagFile(file)),
+    )
     this.resetPluginState()
   }
 
-  render (state, viewOptions = {}) {
-    const { didFirstRender, isInputMode, searchTerm } = this.plugin.getPluginState()
+  render(
+    state: unknown,
+    viewOptions: Omit<ViewOptions<M, B, PluginType>, 'provider'> = {},
+  ): JSX.Element {
+    const { didFirstRender, isInputMode, searchTerm } =
+      this.plugin.getPluginState()
     const { i18n } = this.plugin.uppy
 
     if (!didFirstRender) {
@@ -139,7 +165,8 @@ export default class SearchProviderView extends View {
     }
 
     const targetViewOptions = { ...this.opts, ...viewOptions }
-    const { files, folders, filterInput, loading, currentSelection } = this.plugin.getPluginState()
+    const { files, folders, filterInput, loading, currentSelection } =
+      this.plugin.getPluginState()
     const { isChecked, toggleCheckbox, filterItems, recordShiftKeyPress } = this
     const hasInput = filterInput !== ''
 
@@ -173,7 +200,9 @@ export default class SearchProviderView extends View {
       pluginIcon: this.plugin.icon,
       i18n,
       uppyFiles: this.plugin.uppy.getFiles(),
-      validateRestrictions: (...args) => this.plugin.uppy.validateRestrictions(...args),
+      validateRestrictions: (
+        ...args: Parameters<Uppy<M, B>['validateRestrictions']>
+      ) => this.plugin.uppy.validateRestrictions(...args),
     }
 
     if (isInputMode) {
@@ -182,7 +211,6 @@ export default class SearchProviderView extends View {
           <div className="uppy-SearchProvider">
             <SearchFilterInput
               search={this.search}
-              clearSelection={this.clearSelection}
               inputLabel={i18n('enterTextToSearch')}
               buttonLabel={i18n('searchImages')}
               inputClassName="uppy-c-textInput uppy-SearchProvider-input"

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

@@ -1 +0,0 @@
-export { default } from './SearchProviderView.jsx'

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

@@ -0,0 +1 @@
+export { default } from './SearchProviderView.tsx'

+ 141 - 32
packages/@uppy/provider-views/src/View.js → packages/@uppy/provider-views/src/View.ts

@@ -1,11 +1,91 @@
+import type {
+  UnknownProviderPlugin,
+  UnknownSearchProviderPlugin,
+} from '@uppy/core/lib/Uppy'
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
+import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
 import getFileType from '@uppy/utils/lib/getFileType'
 import isPreviewSupported from '@uppy/utils/lib/isPreviewSupported'
 import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal'
 
-export default class View {
-  constructor (plugin, opts) {
+type TagFile<M extends Meta> = {
+  id: string
+  source: string
+  data: Blob
+  name: string
+  type: string
+  isRemote: boolean
+  preview?: string
+  meta: {
+    authorName?: string
+    authorUrl?: string
+    relativePath?: string | null
+    absolutePath?: string
+  } & M
+  remote: {
+    companionUrl: string
+    url: string
+    body: {
+      fileId: string
+    }
+    providerName: string
+    provider: string
+    requestClientId: string
+  }
+}
+
+type PluginType = 'Provider' | 'SearchProvider'
+
+// Conditional type for selecting the plugin
+type SelectedPlugin<M extends Meta, B extends Body, T extends PluginType> =
+  T extends 'Provider' ? UnknownProviderPlugin<M, B>
+  : T extends 'SearchProvider' ? UnknownSearchProviderPlugin<M, B>
+  : never
+
+// Conditional type for selecting the provider from the selected plugin
+type SelectedProvider<
+  M extends Meta,
+  B extends Body,
+  T extends PluginType,
+> = SelectedPlugin<M, B, T>['provider']
+
+export interface ViewOptions<
+  M extends Meta,
+  B extends Body,
+  T extends PluginType,
+> {
+  provider: SelectedProvider<M, B, T>
+  viewType?: string
+  showTitles?: boolean
+  showFilter?: boolean
+  showBreadcrumbs?: boolean
+  loadAllFiles?: boolean
+}
+
+export default class View<
+  M extends Meta,
+  B extends Body,
+  T extends PluginType,
+  O extends ViewOptions<M, B, T>,
+> {
+  plugin: SelectedPlugin<M, B, T>
+
+  provider: SelectedProvider<M, B, T>
+
+  isHandlingScroll: boolean
+
+  requestClientId: string
+
+  isShiftKeyPressed: boolean
+
+  lastCheckbox: CompanionFile | undefined
+
+  protected opts: O
+
+  constructor(plugin: SelectedPlugin<M, B, T>, opts: O) {
     this.plugin = plugin
     this.provider = opts.provider
+    this.opts = opts
 
     this.isHandlingScroll = false
 
@@ -15,40 +95,45 @@ export default class View {
     this.cancelPicking = this.cancelPicking.bind(this)
   }
 
-  preFirstRender () {
+  preFirstRender(): void {
     this.plugin.setPluginState({ didFirstRender: true })
     this.plugin.onFirstRender()
   }
 
-  // eslint-disable-next-line class-methods-use-this
-  shouldHandleScroll (event) {
-    const { scrollHeight, scrollTop, offsetHeight } = event.target
+  shouldHandleScroll(event: Event): boolean {
+    const { scrollHeight, scrollTop, offsetHeight } =
+      event.target as HTMLElement
     const scrollPosition = scrollHeight - (scrollTop + offsetHeight)
 
     return scrollPosition < 50 && !this.isHandlingScroll
   }
 
-  clearSelection () {
+  clearSelection(): void {
     this.plugin.setPluginState({ currentSelection: [], filterInput: '' })
   }
 
-  cancelPicking () {
+  cancelPicking(): void {
     this.clearSelection()
 
     const dashboard = this.plugin.uppy.getPlugin('Dashboard')
 
     if (dashboard) {
+      // @ts-expect-error impossible to type this correctly without adding dashboard
+      // as a dependency to this package.
       dashboard.hideAllPanels()
     }
   }
 
-  handleError (error) {
+  handleError(error: Error): void {
     const { uppy } = this.plugin
     const message = uppy.i18n('companionError')
 
     uppy.log(error.toString())
 
-    if (error.isAuthError || error.cause?.name === 'AbortError') {
+    if (
+      (error as any).isAuthError ||
+      (error.cause as Error)?.name === 'AbortError'
+    ) {
       // authError just means we're not authenticated, don't show to user
       // AbortError means the user has clicked "cancel" on an operation
       return
@@ -57,26 +142,29 @@ export default class View {
     uppy.info({ message, details: error.toString() }, 'error', 5000)
   }
 
-  registerRequestClient() {
-    this.requestClientId = this.provider.provider;
+  registerRequestClient(): void {
+    this.requestClientId = this.provider.provider
     this.plugin.uppy.registerRequestClient(this.requestClientId, this.provider)
   }
 
-  // todo document what is a "tagFile" or get rid of this concept
-  getTagFile (file) {
-    const tagFile = {
+  // TODO: document what is a "tagFile" or get rid of this concept
+  getTagFile(file: CompanionFile): TagFile<M> {
+    const tagFile: TagFile<M> = {
       id: file.id,
       source: this.plugin.id,
-      data: file,
       name: file.name || file.id,
       type: file.mimeType,
       isRemote: true,
+      // @ts-expect-error meta is filled conditionally below
+      data: file,
+      // @ts-expect-error meta is filled conditionally below
       meta: {},
       body: {
         fileId: file.id,
       },
       remote: {
         companionUrl: this.plugin.opts.companionUrl,
+        // @ts-expect-error untyped for now
         url: `${this.provider.fileUrl(file.requestPath)}`,
         body: {
           fileId: file.id,
@@ -95,29 +183,39 @@ export default class View {
     }
 
     if (file.author) {
-      if (file.author.name != null) tagFile.meta.authorName = String(file.author.name)
+      if (file.author.name != null)
+        tagFile.meta.authorName = String(file.author.name)
       if (file.author.url) tagFile.meta.authorUrl = file.author.url
     }
 
     // add relativePath similar to non-remote files: https://github.com/transloadit/uppy/pull/4486#issuecomment-1579203717
-    if (file.relDirPath != null) tagFile.meta.relativePath = file.relDirPath ? `${file.relDirPath}/${tagFile.name}` : null
+    if (file.relDirPath != null)
+      tagFile.meta.relativePath =
+        file.relDirPath ? `${file.relDirPath}/${tagFile.name}` : null
     // and absolutePath (with leading slash) https://github.com/transloadit/uppy/pull/4537#issuecomment-1614236655
-    if (file.absDirPath != null) tagFile.meta.absolutePath = file.absDirPath ? `/${file.absDirPath}/${tagFile.name}` : `/${tagFile.name}`
+    if (file.absDirPath != null)
+      tagFile.meta.absolutePath =
+        file.absDirPath ?
+          `/${file.absDirPath}/${tagFile.name}`
+        : `/${tagFile.name}`
 
     return tagFile
   }
 
-  filterItems = (items) => {
+  filterItems = (items: CompanionFile[]): CompanionFile[] => {
     const state = this.plugin.getPluginState()
     if (!state.filterInput || state.filterInput === '') {
       return items
     }
     return items.filter((folder) => {
-      return folder.name.toLowerCase().indexOf(state.filterInput.toLowerCase()) !== -1
+      return (
+        folder.name.toLowerCase().indexOf(state.filterInput.toLowerCase()) !==
+        -1
+      )
     })
   }
 
-  recordShiftKeyPress = (e) => {
+  recordShiftKeyPress = (e: KeyboardEvent | MouseEvent): void => {
     this.isShiftKeyPressed = e.shiftKey
   }
 
@@ -128,10 +226,10 @@ export default class View {
    * toggle multiple checkboxes at once, which is done by getting all files
    * in between last checked file and current one.
    */
-  toggleCheckbox = (e, file) => {
+  toggleCheckbox = (e: Event, file: CompanionFile): void => {
     e.stopPropagation()
     e.preventDefault()
-    e.currentTarget.focus()
+    ;(e.currentTarget as HTMLInputElement).focus()
     const { folders, files } = this.plugin.getPluginState()
     const items = this.filterItems(folders.concat(files))
     // Shift-clicking selects a single consecutive list of items
@@ -140,10 +238,11 @@ export default class View {
       const { currentSelection } = this.plugin.getPluginState()
       const prevIndex = items.indexOf(this.lastCheckbox)
       const currentIndex = items.indexOf(file)
-      const newSelection = (prevIndex < currentIndex)
-        ? items.slice(prevIndex, currentIndex + 1)
+      const newSelection =
+        prevIndex < currentIndex ?
+          items.slice(prevIndex, currentIndex + 1)
         : items.slice(currentIndex, prevIndex + 1)
-      const reducedNewSelection = []
+      const reducedNewSelection: CompanionFile[] = []
 
       // Check restrictions on each file in currentSelection,
       // reduce it to only contain files that pass restrictions
@@ -157,10 +256,18 @@ export default class View {
         if (!restrictionError) {
           reducedNewSelection.push(item)
         } else {
-          uppy.info({ message: restrictionError.message }, 'error', uppy.opts.infoTimeout)
+          uppy.info(
+            { message: restrictionError.message },
+            'error',
+            uppy.opts.infoTimeout,
+          )
         }
       }
-      this.plugin.setPluginState({ currentSelection: [...new Set([...currentSelection, ...reducedNewSelection])] })
+      this.plugin.setPluginState({
+        currentSelection: [
+          ...new Set([...currentSelection, ...reducedNewSelection]),
+        ],
+      })
       return
     }
 
@@ -168,7 +275,9 @@ export default class View {
     const { currentSelection } = this.plugin.getPluginState()
     if (this.isChecked(file)) {
       this.plugin.setPluginState({
-        currentSelection: currentSelection.filter((item) => item.id !== file.id),
+        currentSelection: currentSelection.filter(
+          (item) => item.id !== file.id,
+        ),
       })
     } else {
       this.plugin.setPluginState({
@@ -177,14 +286,14 @@ export default class View {
     }
   }
 
-  isChecked = (file) => {
+  isChecked = (file: CompanionFile): boolean => {
     const { currentSelection } = this.plugin.getPluginState()
     // comparing id instead of the file object, because the reference to the object
     // changes when we switch folders, and the file list is updated
     return currentSelection.some((item) => item.id === file.id)
   }
 
-  setLoading (loading) {
+  setLoading(loading: boolean | string): void {
     this.plugin.setPluginState({ loading })
   }
 }

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

@@ -1,2 +0,0 @@
-export { default as ProviderViews, defaultPickerIcon } from './ProviderView/index.js'
-export { default as SearchProviderViews } from './SearchProviderView/index.js'

+ 5 - 0
packages/@uppy/provider-views/src/index.ts

@@ -0,0 +1,5 @@
+export {
+  default as ProviderViews,
+  defaultPickerIcon,
+} from './ProviderView/index.ts'
+export { default as SearchProviderViews } from './SearchProviderView/index.ts'

+ 25 - 0
packages/@uppy/provider-views/tsconfig.build.json

@@ -0,0 +1,25 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "noImplicitAny": false,
+    "outDir": "./lib",
+    "paths": {
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"]
+    },
+    "resolveJsonModule": false,
+    "rootDir": "./src",
+    "skipLibCheck": true
+  },
+  "include": ["./src/**/*.*"],
+  "exclude": ["./src/**/*.test.ts"],
+  "references": [
+    {
+      "path": "../utils/tsconfig.build.json"
+    },
+    {
+      "path": "../core/tsconfig.build.json"
+    }
+  ]
+}

+ 21 - 0
packages/@uppy/provider-views/tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "emitDeclarationOnly": false,
+    "noEmit": true,
+    "paths": {
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"],
+    },
+  },
+  "include": ["./package.json", "./src/**/*.*"],
+  "references": [
+    {
+      "path": "../utils/tsconfig.build.json",
+    },
+    {
+      "path": "../core/tsconfig.build.json",
+    },
+  ],
+}

+ 1 - 1
tsconfig.shared.json

@@ -12,7 +12,7 @@
     "declaration": true,
     "emitDeclarationOnly": true,
     "declarationMap": true,
-    "jsx": "preserve",
+    "jsx": "react-jsx",
     "jsxImportSource": "preact",
     "noImplicitAny": true,
     "noImplicitThis": true,