Przeglądaj źródła

Dashboard - convert some files to typescript (#5367)

Co-authored-by: Murderlon <merlijn@soverin.net>
Evgenia Karunus 6 miesięcy temu
rodzic
commit
922e7fbf02

+ 1 - 1
packages/@uppy/core/src/UIPlugin.ts

@@ -183,7 +183,7 @@ class UIPlugin<
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
     state: Record<string, unknown>,
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    container: HTMLElement,
+    container?: HTMLElement,
   ): any {
     throw new Error(
       'Extend the render method to add your plugin to a DOM element',

+ 33 - 17
packages/@uppy/dashboard/src/Dashboard.tsx

@@ -97,8 +97,8 @@ interface Target {
   type: string
 }
 
-interface TargetWithRender extends Target {
-  icon: ComponentChild
+export interface TargetWithRender extends Target {
+  icon: () => ComponentChild
   render: () => ComponentChild
 }
 
@@ -110,6 +110,12 @@ export interface DashboardState<M extends Meta, B extends Body> {
   fileCardFor: string | null
   showFileEditor: boolean
   metaFields?: MetaField[] | ((file: UppyFile<M, B>) => MetaField[])
+  isHidden: boolean
+  isClosing: boolean
+  containerWidth: number
+  containerHeight: number
+  areInsidesReadyToBeVisible: boolean
+  isDraggingOver: boolean
   [key: string]: unknown
 }
 
@@ -146,7 +152,7 @@ interface DashboardMiscOptions<M extends Meta, B extends Body>
   hideRetryButton?: boolean
   hideUploadButton?: boolean
   metaFields?: MetaField[] | ((file: UppyFile<M, B>) => MetaField[])
-  nativeCameraFacingMode?: ConstrainDOMString
+  nativeCameraFacingMode?: 'user' | 'environment' | ''
   note?: string | null
   onDragLeave?: (event: DragEvent) => void
   onDragOver?: (event: DragEvent) => void
@@ -165,7 +171,7 @@ interface DashboardMiscOptions<M extends Meta, B extends Body>
   thumbnailHeight?: number
   thumbnailType?: string
   thumbnailWidth?: number
-  trigger?: string | Element
+  trigger?: string | Element | null
   waitForThumbnailsBeforeUpload?: boolean
 }
 
@@ -178,9 +184,6 @@ export type DashboardOptions<
 const defaultOptions = {
   target: 'body',
   metaFields: [],
-  inline: false as boolean,
-  width: 750,
-  height: 550,
   thumbnailWidth: 280,
   thumbnailType: 'image/jpeg',
   waitForThumbnailsBeforeUpload: false,
@@ -193,31 +196,44 @@ const defaultOptions = {
   hidePauseResumeButton: false,
   hideProgressAfterFinish: false,
   note: null,
-  closeModalOnClickOutside: false,
-  closeAfterFinish: false,
   singleFileFullScreen: true,
   disableStatusBar: false,
   disableInformer: false,
   disableThumbnailGenerator: false,
-  disablePageScrollWhenModalOpen: true,
-  animateOpenClose: true,
   fileManagerSelectionType: 'files',
   proudlyDisplayPoweredByUppy: true,
   showSelectedFiles: true,
   showRemoveButtonAfterComplete: false,
-  browserBackButtonClose: false,
   showNativePhotoCameraButton: false,
   showNativeVideoCameraButton: false,
   theme: 'light',
   autoOpen: null,
   disabled: false,
   disableLocalFiles: false,
+  nativeCameraFacingMode: '',
+  onDragLeave: () => {},
+  onDragOver: () => {},
+  onDrop: () => {},
+  plugins: [],
 
   // Dynamic default options, they have to be defined in the constructor (because
   // they require access to the `this` keyword), but we still want them to
   // appear in the default options so TS knows they'll be defined.
   doneButtonHandler: undefined as any,
   onRequestCloseModal: null as any,
+
+  // defaultModalOptions
+  inline: false as boolean,
+  animateOpenClose: true,
+  browserBackButtonClose: false,
+  closeAfterFinish: false,
+  closeModalOnClickOutside: false,
+  disablePageScrollWhenModalOpen: true,
+  trigger: null,
+
+  // defaultInlineOptions
+  width: 750,
+  height: 550,
 } satisfies Partial<DashboardOptions<any, any>>
 
 /**
@@ -824,7 +840,7 @@ export default class Dashboard<M extends Meta, B extends Body> extends UIPlugin<
 
     this.setPluginState({ isDraggingOver: true })
 
-    this.opts.onDragOver?.(event)
+    this.opts.onDragOver(event)
   }
 
   private handleDragLeave = (event: DragEvent) => {
@@ -833,7 +849,7 @@ export default class Dashboard<M extends Meta, B extends Body> extends UIPlugin<
 
     this.setPluginState({ isDraggingOver: false })
 
-    this.opts.onDragLeave?.(event)
+    this.opts.onDragLeave(event)
   }
 
   private handleDrop = async (event: DragEvent) => {
@@ -872,7 +888,7 @@ export default class Dashboard<M extends Meta, B extends Body> extends UIPlugin<
       this.addFiles(files)
     }
 
-    this.opts.onDrop?.(event)
+    this.opts.onDrop(event)
   }
 
   private handleRequestThumbnail = (file: UppyFile<M, B>) => {
@@ -1260,7 +1276,7 @@ export default class Dashboard<M extends Meta, B extends Body> extends UIPlugin<
   }
 
   #addSpecifiedPluginsFromOptions = () => {
-    const plugins = this.opts.plugins || []
+    const { plugins } = this.opts
 
     plugins.forEach((pluginID) => {
       const plugin = this.uppy.getPlugin(pluginID)
@@ -1466,7 +1482,7 @@ export default class Dashboard<M extends Meta, B extends Body> extends UIPlugin<
       if (thumbnail) this.uppy.removePlugin(thumbnail)
     }
 
-    const plugins = this.opts.plugins || []
+    const { plugins } = this.opts
     plugins.forEach((pluginID) => {
       const plugin = this.uppy.getPlugin(pluginID)
       if (plugin) (plugin as any).unmount()

+ 67 - 41
packages/@uppy/dashboard/src/components/AddFiles.tsx

@@ -1,34 +1,51 @@
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-nocheck Typing this file requires more work, skipping it to unblock the rest of the transition.
-
 /* eslint-disable react/destructuring-assignment */
 import { h, Component, Fragment, type ComponentChild } from 'preact'
+import type { I18n } from '@uppy/utils/lib/Translator'
+import type Translator from '@uppy/utils/lib/Translator'
+import type { TargetedEvent } from 'preact/compat'
+import type { DashboardState, TargetWithRender } from '../Dashboard'
+
+interface AddFilesProps {
+  i18n: I18n
+  i18nArray: Translator['translateArray']
+  acquirers: TargetWithRender[]
+  handleInputChange: (event: TargetedEvent<HTMLInputElement, Event>) => void
+  maxNumberOfFiles: number | null
+  allowedFileTypes: string[] | null
+  showNativePhotoCameraButton: boolean
+  showNativeVideoCameraButton: boolean
+  nativeCameraFacingMode: 'user' | 'environment' | ''
+  showPanel: (id: string) => void
+  activePickerPanel: DashboardState<any, any>['activePickerPanel']
+  disableLocalFiles: boolean
+  fileManagerSelectionType: string
+  note: string | null
+  proudlyDisplayPoweredByUppy: boolean
+}
 
-type $TSFixMe = any
-
-class AddFiles extends Component {
-  fileInput: $TSFixMe
+class AddFiles extends Component<AddFilesProps> {
+  fileInput: HTMLInputElement | null = null
 
-  folderInput: $TSFixMe
+  folderInput: HTMLInputElement | null = null
 
-  mobilePhotoFileInput: $TSFixMe
+  mobilePhotoFileInput: HTMLInputElement | null = null
 
-  mobileVideoFileInput: $TSFixMe
+  mobileVideoFileInput: HTMLInputElement | null = null
 
   private triggerFileInputClick = () => {
-    this.fileInput.click()
+    this.fileInput?.click()
   }
 
   private triggerFolderInputClick = () => {
-    this.folderInput.click()
+    this.folderInput?.click()
   }
 
   private triggerVideoCameraInputClick = () => {
-    this.mobileVideoFileInput.click()
+    this.mobileVideoFileInput?.click()
   }
 
   private triggerPhotoCameraInputClick = () => {
-    this.mobilePhotoFileInput.click()
+    this.mobilePhotoFileInput?.click()
   }
 
   private onFileInputChange = (
@@ -42,13 +59,17 @@ class AddFiles extends Component {
     event.currentTarget.value = ''
   }
 
-  private renderHiddenInput = (isFolder: $TSFixMe, refCallback: $TSFixMe) => {
+  private renderHiddenInput = (
+    isFolder: boolean,
+    refCallback: (ref: HTMLInputElement | null) => void,
+  ) => {
     return (
       <input
         className="uppy-Dashboard-input"
         hidden
         aria-hidden="true"
         tabIndex={-1}
+        // @ts-expect-error default types don't yet know about the `webkitdirectory` property
         webkitdirectory={isFolder}
         type="file"
         name="files[]"
@@ -61,9 +82,9 @@ class AddFiles extends Component {
   }
 
   private renderHiddenCameraInput = (
-    type: $TSFixMe,
-    nativeCameraFacingMode: $TSFixMe,
-    refCallback: $TSFixMe,
+    type: 'photo' | 'video',
+    nativeCameraFacingMode: 'user' | 'environment' | '',
+    refCallback: (ref: HTMLInputElement | null) => void,
   ) => {
     const typeToAccept = { photo: 'image/*', video: 'video/*' }
     const accept = typeToAccept[type]
@@ -193,7 +214,10 @@ class AddFiles extends Component {
     )
   }
 
-  private renderBrowseButton = (text: $TSFixMe, onClickFn: $TSFixMe) => {
+  private renderBrowseButton = (
+    text: string,
+    onClickFn: (event: Event) => void,
+  ) => {
     const numberOfAcquirers = this.props.acquirers.length
     return (
       <button
@@ -207,7 +231,7 @@ class AddFiles extends Component {
     )
   }
 
-  private renderDropPasteBrowseTagline = (numberOfAcquirers: $TSFixMe) => {
+  private renderDropPasteBrowseTagline = (numberOfAcquirers: number) => {
     const browseFiles = this.renderBrowseButton(
       this.props.i18n('browseFiles'),
       this.triggerFileInputClick,
@@ -257,7 +281,7 @@ class AddFiles extends Component {
     this.props.i18nArray('dropPasteImportFolders')
   }
 
-  private renderAcquirer = (acquirer: $TSFixMe) => {
+  private renderAcquirer = (acquirer: TargetWithRender) => {
     return (
       <div
         className="uppy-DashboardTab"
@@ -282,7 +306,7 @@ class AddFiles extends Component {
     )
   }
 
-  private renderAcquirers = (acquirers: $TSFixMe) => {
+  private renderAcquirers = (acquirers: TargetWithRender[]) => {
     // Group last two buttons, so we don’t end up with
     // just one button on a new line
     const acquirersWithoutLastTwo = [...acquirers]
@@ -304,18 +328,22 @@ class AddFiles extends Component {
   }
 
   private renderSourcesList = (
-    acquirers: $TSFixMe,
-    disableLocalFiles: $TSFixMe,
+    acquirers: TargetWithRender[],
+    disableLocalFiles: boolean,
   ) => {
     const { showNativePhotoCameraButton, showNativeVideoCameraButton } =
       this.props
 
-    let list = []
+    type RenderListItem = { key: string; elements: ComponentChild }
+    let list: RenderListItem[] = []
 
     const myDeviceKey = 'myDevice'
 
     if (!disableLocalFiles)
-      list.push({ key: myDeviceKey, elements: this.renderMyDeviceAcquirer() })
+      list.push({
+        key: myDeviceKey,
+        elements: this.renderMyDeviceAcquirer(),
+      })
     if (showNativePhotoCameraButton)
       list.push({
         key: 'nativePhotoCameraButton',
@@ -327,7 +355,7 @@ class AddFiles extends Component {
         elements: this.renderVideoCamera(),
       })
     list.push(
-      ...acquirers.map((acquirer: $TSFixMe) => ({
+      ...acquirers.map((acquirer: TargetWithRender) => ({
         key: acquirer.id,
         elements: this.renderAcquirer(acquirer),
       })),
@@ -342,20 +370,19 @@ class AddFiles extends Component {
     const listWithoutLastTwo = [...list]
     const lastTwo = listWithoutLastTwo.splice(list.length - 2, list.length)
 
-    const renderList = (l: $TSFixMe) =>
-      l.map(({ key, elements }: $TSFixMe) => (
-        <Fragment key={key}>{elements}</Fragment>
-      ))
-
     return (
       <>
         {this.renderDropPasteBrowseTagline(list.length)}
 
         <div className="uppy-Dashboard-AddFiles-list" role="tablist">
-          {renderList(listWithoutLastTwo)}
+          {listWithoutLastTwo.map(({ key, elements }) => (
+            <Fragment key={key}>{elements}</Fragment>
+          ))}
 
           <span role="presentation" style={{ 'white-space': 'nowrap' }}>
-            {renderList(lastTwo)}
+            {lastTwo.map(({ key, elements }) => (
+              <Fragment key={key}>{elements}</Fragment>
+            ))}
           </span>
         </div>
       </>
@@ -363,7 +390,7 @@ class AddFiles extends Component {
   }
 
   private renderPoweredByUppy() {
-    const { i18nArray } = this.props as $TSFixMe
+    const { i18nArray } = this.props
 
     const uppyBranding = (
       <span>
@@ -408,17 +435,17 @@ class AddFiles extends Component {
 
     return (
       <div className="uppy-Dashboard-AddFiles">
-        {this.renderHiddenInput(false, (ref: $TSFixMe) => {
+        {this.renderHiddenInput(false, (ref) => {
           this.fileInput = ref
         })}
-        {this.renderHiddenInput(true, (ref: $TSFixMe) => {
+        {this.renderHiddenInput(true, (ref) => {
           this.folderInput = ref
         })}
         {showNativePhotoCameraButton &&
           this.renderHiddenCameraInput(
             'photo',
             nativeCameraFacingMode,
-            (ref: $TSFixMe) => {
+            (ref) => {
               this.mobilePhotoFileInput = ref
             },
           )}
@@ -426,7 +453,7 @@ class AddFiles extends Component {
           this.renderHiddenCameraInput(
             'video',
             nativeCameraFacingMode,
-            (ref: $TSFixMe) => {
+            (ref) => {
               this.mobileVideoFileInput = ref
             },
           )}
@@ -438,8 +465,7 @@ class AddFiles extends Component {
           {this.props.note && (
             <div className="uppy-Dashboard-note">{this.props.note}</div>
           )}
-          {this.props.proudlyDisplayPoweredByUppy &&
-            this.renderPoweredByUppy(this.props)}
+          {this.props.proudlyDisplayPoweredByUppy && this.renderPoweredByUppy()}
         </div>
       </div>
     )

+ 148 - 37
packages/@uppy/dashboard/src/components/Dashboard.tsx

@@ -2,6 +2,11 @@
 import { h } from 'preact'
 import classNames from 'classnames'
 import isDragDropSupported from '@uppy/utils/lib/isDragDropSupported'
+import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
+import type { State, UIPlugin, UIPluginOptions, Uppy } from '@uppy/core'
+import type { I18n } from '@uppy/utils/lib/Translator'
+import type Translator from '@uppy/utils/lib/Translator'
+import type { TargetedEvent } from 'preact/compat'
 import FileList from './FileList.tsx'
 import AddFiles from './AddFiles.tsx'
 import AddFilesPanel from './AddFilesPanel.tsx'
@@ -10,6 +15,7 @@ import EditorPanel from './EditorPanel.tsx'
 import PanelTopBar from './PickerPanelTopBar.tsx'
 import FileCard from './FileCard/index.tsx'
 import Slide from './Slide.tsx'
+import type { DashboardState, TargetWithRender } from '../Dashboard'
 
 // http://dev.edenspiekermann.com/2016/02/11/introducing-accessible-modal-dialog
 // https://github.com/ghosh/micromodal
@@ -23,9 +29,95 @@ const HEIGHT_MD = 330
 // const HEIGHT_LG = 400
 // const HEIGHT_XL = 460
 
-type $TSFixMe = any
+type DashboardUIProps<M extends Meta, B extends Body> = {
+  state: State<M, B>
+  isHidden: boolean
+  files: State<M, B>['files']
+  newFiles: UppyFile<M, B>[]
+  uploadStartedFiles: UppyFile<M, B>[]
+  completeFiles: UppyFile<M, B>[]
+  erroredFiles: UppyFile<M, B>[]
+  inProgressFiles: UppyFile<M, B>[]
+  inProgressNotPausedFiles: UppyFile<M, B>[]
+  processingFiles: UppyFile<M, B>[]
+  isUploadStarted: boolean
+  isAllComplete: boolean
+  isAllPaused: boolean
+  totalFileCount: number
+  totalProgress: number
+  allowNewUpload: boolean
+  acquirers: TargetWithRender[]
+  theme: string
+  disabled: boolean
+  disableLocalFiles: boolean
+  direction: UIPluginOptions['direction']
+  activePickerPanel: DashboardState<M, B>['activePickerPanel']
+  showFileEditor: boolean
+  saveFileEditor: () => void
+  closeFileEditor: () => void
+  disableInteractiveElements: (disable: boolean) => void
+  animateOpenClose: boolean
+  isClosing: boolean
+  progressindicators: TargetWithRender[]
+  editors: TargetWithRender[]
+  autoProceed: boolean
+  id: string
+  closeModal: () => void
+  handleClickOutside: () => void
+  handleInputChange: (event: TargetedEvent<HTMLInputElement, Event>) => void
+  handlePaste: (event: ClipboardEvent) => void
+  inline: boolean
+  showPanel: (id: string) => void
+  hideAllPanels: () => void
+  i18n: I18n
+  i18nArray: Translator['translateArray']
+  uppy: Uppy<M, B>
+  note: string | null
+  recoveredState: State<M, B>['recoveredState']
+  metaFields: DashboardState<M, B>['metaFields']
+  resumableUploads: boolean
+  individualCancellation: boolean
+  isMobileDevice?: boolean
+  fileCardFor: string | null
+  toggleFileCard: (show: boolean, fileID: string) => void
+  toggleAddFilesPanel: (show: boolean) => void
+  showAddFilesPanel: boolean
+  saveFileCard: (meta: M, fileID: string) => void
+  openFileEditor: (file: UppyFile<M, B>) => void
+  canEditFile: (file: UppyFile<M, B>) => boolean
+  width: string | number
+  height: string | number
+  showLinkToFileUploadResult: boolean
+  fileManagerSelectionType: string
+  proudlyDisplayPoweredByUppy: boolean
+  hideCancelButton: boolean
+  hideRetryButton: boolean
+  hidePauseResumeButton: boolean
+  showRemoveButtonAfterComplete: boolean
+  containerWidth: number
+  containerHeight: number
+  areInsidesReadyToBeVisible: boolean
+  parentElement: HTMLElement | null
+  allowedFileTypes: string[] | null
+  maxNumberOfFiles: number | null
+  requiredMetaFields: any
+  showSelectedFiles: boolean
+  showNativePhotoCameraButton: boolean
+  showNativeVideoCameraButton: boolean
+  nativeCameraFacingMode: 'user' | 'environment' | ''
+  singleFileFullScreen: boolean
+  handleCancelRestore: () => void
+  handleRequestThumbnail: (file: UppyFile<M, B>) => void
+  handleCancelThumbnail: (file: UppyFile<M, B>) => void
+  isDraggingOver: boolean
+  handleDragOver: (event: DragEvent) => void
+  handleDragLeave: (event: DragEvent) => void
+  handleDrop: (event: DragEvent) => void
+}
 
-export default function Dashboard(props: $TSFixMe) {
+export default function Dashboard<M extends Meta, B extends Body>(
+  props: DashboardUIProps<M, B>,
+) {
   const isNoFiles = props.totalFileCount === 0
   const isSingleFile = props.totalFileCount === 1
   const isSizeMD = props.containerWidth > WIDTH_MD
@@ -70,10 +162,10 @@ export default function Dashboard(props: $TSFixMe) {
     props.files ?
       Object.keys(props.files).filter((fileID) => props.files[fileID].isGhost)
         .length
-    : null
+    : 0
 
   const renderRestoredText = () => {
-    if (numberOfGhosts! > 0) {
+    if (numberOfGhosts > 0) {
       return props.i18n('recoveredXFiles', {
         smart_count: numberOfGhosts,
       })
@@ -166,37 +258,51 @@ export default function Dashboard(props: $TSFixMe) {
             </div>
           )}
 
-          {
-            showFileList ?
-              <FileList
-                id={props.id}
-                i18n={props.i18n}
-                uppy={props.uppy}
-                files={props.files}
-                resumableUploads={props.resumableUploads}
-                hideRetryButton={props.hideRetryButton}
-                hidePauseResumeButton={props.hidePauseResumeButton}
-                hideCancelButton={props.hideCancelButton}
-                showLinkToFileUploadResult={props.showLinkToFileUploadResult}
-                showRemoveButtonAfterComplete={
-                  props.showRemoveButtonAfterComplete
-                }
-                metaFields={props.metaFields}
-                toggleFileCard={props.toggleFileCard}
-                handleRequestThumbnail={props.handleRequestThumbnail}
-                handleCancelThumbnail={props.handleCancelThumbnail}
-                recoveredState={props.recoveredState}
-                individualCancellation={props.individualCancellation}
-                openFileEditor={props.openFileEditor}
-                canEditFile={props.canEditFile}
-                toggleAddFilesPanel={props.toggleAddFilesPanel}
-                isSingleFile={isSingleFile}
-                itemsPerRow={itemsPerRow}
-                containerWidth={props.containerWidth}
-                containerHeight={props.containerHeight}
-              />
-              // eslint-disable-next-line react/jsx-props-no-spreading
-            : <AddFiles {...props} isSizeMD={isSizeMD} />
+          {showFileList ?
+            <FileList
+              id={props.id}
+              i18n={props.i18n}
+              uppy={props.uppy}
+              files={props.files}
+              resumableUploads={props.resumableUploads}
+              hideRetryButton={props.hideRetryButton}
+              hidePauseResumeButton={props.hidePauseResumeButton}
+              hideCancelButton={props.hideCancelButton}
+              showLinkToFileUploadResult={props.showLinkToFileUploadResult}
+              showRemoveButtonAfterComplete={
+                props.showRemoveButtonAfterComplete
+              }
+              metaFields={props.metaFields}
+              toggleFileCard={props.toggleFileCard}
+              handleRequestThumbnail={props.handleRequestThumbnail}
+              handleCancelThumbnail={props.handleCancelThumbnail}
+              recoveredState={props.recoveredState}
+              individualCancellation={props.individualCancellation}
+              openFileEditor={props.openFileEditor}
+              canEditFile={props.canEditFile}
+              toggleAddFilesPanel={props.toggleAddFilesPanel}
+              isSingleFile={isSingleFile}
+              itemsPerRow={itemsPerRow}
+              containerWidth={props.containerWidth}
+              containerHeight={props.containerHeight}
+            />
+          : <AddFiles
+              i18n={props.i18n}
+              i18nArray={props.i18nArray}
+              acquirers={props.acquirers}
+              handleInputChange={props.handleInputChange}
+              maxNumberOfFiles={props.maxNumberOfFiles}
+              allowedFileTypes={props.allowedFileTypes}
+              showNativePhotoCameraButton={props.showNativePhotoCameraButton}
+              showNativeVideoCameraButton={props.showNativeVideoCameraButton}
+              nativeCameraFacingMode={props.nativeCameraFacingMode}
+              showPanel={props.showPanel}
+              activePickerPanel={props.activePickerPanel}
+              disableLocalFiles={props.disableLocalFiles}
+              fileManagerSelectionType={props.fileManagerSelectionType}
+              note={props.note}
+              proudlyDisplayPoweredByUppy={props.proudlyDisplayPoweredByUppy}
+            />
           }
 
           <Slide>
@@ -228,8 +334,13 @@ export default function Dashboard(props: $TSFixMe) {
           </Slide>
 
           <div className="uppy-Dashboard-progressindicators">
-            {props.progressindicators.map((target: $TSFixMe) => {
-              return props.uppy.getPlugin(target.id).render(props.state)
+            {props.progressindicators.map((target: TargetWithRender) => {
+              // TODO
+              // Here we're telling typescript all `this.type = 'progressindicator'` plugins inherit from `UIPlugin`
+              // This is factually true in Uppy right now, but maybe it doesn't have to be
+              return (
+                props.uppy.getPlugin(target.id) as UIPlugin<any, any, any>
+              ).render(props.state)
             })}
           </div>
         </div>

+ 3 - 1
packages/@uppy/utils/src/Translator.ts

@@ -1,3 +1,5 @@
+import type { h } from 'preact'
+
 // We're using a generic because languages have different plural rules.
 export interface Locale<T extends number = number> {
   strings: Record<string, string | Record<T, string>>
@@ -14,7 +16,7 @@ export type I18n = Translator['translate']
 type Options = {
   smart_count?: number
 } & {
-  [key: string]: string | number
+  [key: string]: string | number | h.JSX.Element
 }
 
 function insertReplacement(