Forráskód Böngészése

UI: Use form attribite with a form in doc root to prevent outer form submit (#4283)

* Use form attribite with a form in doc root to prevent outer form submit

* Fix Unsplash a11y bug — don't focus on hidden links

* Combine search and filter into one SearchFilterInput component for Unsplash

* Refactor Browser and ProviderView

* Refactor FileCard to hooks

* Finish SearchFilterInput, add reset labels and no results copy, better styles

* don't use debounce for now

* combine useEffects, named export, extract RenderMetaFields component

* inputCSSClassName --> inputClassName

* Remove typo

* 🤦

* Patch Preact to work with Jest

* tabs vs spaces

---------

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>
Artur Paikin 2 éve
szülő
commit
f9e9702166

+ 49 - 1
.yarn/patches/preact-npm-10.10.0-dd04de05e8.patch

@@ -1,5 +1,53 @@
+diff --git a/debug/package.json b/debug/package.json
+index 054944f5478a0a5cf7b6b8791950c595f956157b..06a4fe2719605eb42c5ee795101c21cfd10b59ce 100644
+--- a/debug/package.json
++++ b/debug/package.json
+@@ -9,6 +9,7 @@
+ 	"umd:main": "dist/debug.umd.js",
+ 	"source": "src/index.js",
+ 	"license": "MIT",
++	"type": "module",
+ 	"mangle": {
+ 		"regex": "^(?!_renderer)^_"
+ 	},
+diff --git a/devtools/package.json b/devtools/package.json
+index 09b04a77690bdfba01083939ff9eaf987dd50bcb..92c159fbb3cf312c6674202085fb237d6fb921ad 100644
+--- a/devtools/package.json
++++ b/devtools/package.json
+@@ -10,6 +10,7 @@
+ 	"source": "src/index.js",
+ 	"license": "MIT",
+ 	"types": "src/index.d.ts",
++	"type": "module",
+ 	"peerDependencies": {
+ 		"preact": "^10.0.0"
+ 	},
+diff --git a/hooks/package.json b/hooks/package.json
+index 74807025bf3de273ebada2cd355428a2c972503d..98501726ffbfe55ffa09928e56a9dcafb9a348ff 100644
+--- a/hooks/package.json
++++ b/hooks/package.json
+@@ -10,6 +10,7 @@
+ 	"source": "src/index.js",
+ 	"license": "MIT",
+ 	"types": "src/index.d.ts",
++	"type": "module",
+ 	"scripts": {
+ 		"build": "microbundle build --raw",
+ 		"dev": "microbundle watch --raw --format cjs",
+diff --git a/jsx-runtime/package.json b/jsx-runtime/package.json
+index 7a4027831223f16519a74e3028c34f2f8f5f011a..6b58d17dbacce81894467ef43c0a8e2435e388c4 100644
+--- a/jsx-runtime/package.json
++++ b/jsx-runtime/package.json
+@@ -10,6 +10,7 @@
+ 	"source": "src/index.js",
+ 	"types": "src/index.d.ts",
+ 	"license": "MIT",
++	"type": "module",
+ 	"peerDependencies": {
+ 		"preact": "^10.0.0"
+ 	},
 diff --git a/package.json b/package.json
-index 60279c24a08b808ffbf7dc64a038272bddb6785d..71cb8aa038daeeb7edf43564ed78a219003a0c99 100644
+index 60279c24a08b808ffbf7dc64a038272bddb6785d..088f35fb2c92f2e9b7248557857af2839988d1aa 100644
 --- a/package.json
 +++ b/package.json
 @@ -9,6 +9,7 @@

+ 2 - 0
packages/@uppy/core/src/locale.js

@@ -30,6 +30,7 @@ export default {
     connectedToInternet: 'Connected to the Internet',
     // Strings for remote providers
     noFilesFound: 'You have no files or folders here',
+    noSearchResults: 'Unfortunately, there are no results for this search',
     selectX: {
       0: 'Select %{smart_count}',
       1: 'Select %{smart_count}',
@@ -48,6 +49,7 @@ export default {
     searchImages: 'Search for images',
     enterTextToSearch: 'Enter text to search for images',
     search: 'Search',
+    resetSearch: 'Reset search',
     emptyFolderAdded: 'No files were added from empty folder',
     folderAlreadyAdded: 'The folder "%{folder}" was already added',
     folderAdded: {

+ 46 - 0
packages/@uppy/dashboard/src/components/FileCard/RenderMetaFields.jsx

@@ -0,0 +1,46 @@
+import { h } from 'preact'
+
+export default function RenderMetaFields (props)  {
+  const {
+    computedMetaFields,
+    requiredMetaFields,
+    updateMeta,
+    form,
+    formState,
+  } = props
+
+  const fieldCSSClasses = {
+    text: 'uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input',
+  }
+
+  return computedMetaFields.map((field) => {
+    const id = `uppy-Dashboard-FileCard-input-${field.id}`
+    const required = requiredMetaFields.includes(field.id)
+    return (
+      <fieldset key={field.id} className="uppy-Dashboard-FileCard-fieldset">
+        <label className="uppy-Dashboard-FileCard-label" htmlFor={id}>{field.name}</label>
+        {field.render !== undefined
+          ? field.render({
+            value: formState[field.id],
+            onChange: (newVal) => updateMeta(newVal, field.id),
+            fieldCSSClasses,
+            required,
+            form: form.id,
+          }, h)
+          : (
+            <input
+              className={fieldCSSClasses.text}
+              id={id}
+              form={form.id}
+              type={field.type || 'text'}
+              required={required}
+              value={formState[field.id]}
+              placeholder={field.placeholder}
+              onInput={ev => updateMeta(ev.target.value, field.id)}
+              data-uppy-super-focusable
+            />
+          )}
+      </fieldset>
+    )
+  })
+}

+ 127 - 176
packages/@uppy/dashboard/src/components/FileCard/index.jsx

@@ -1,202 +1,153 @@
-import {  h, Component  } from 'preact'
+import { h } from 'preact'
+import { useEffect, useState, useCallback } from 'preact/hooks'
 import classNames from 'classnames'
-import {  nanoid  } from 'nanoid/non-secure'
+import { nanoid } from 'nanoid/non-secure'
 import getFileTypeIcon from '../../utils/getFileTypeIcon.jsx'
 import ignoreEvent from '../../utils/ignoreEvent.js'
 import FilePreview from '../FilePreview.jsx'
-
-class FileCard extends Component {
-  form = document.createElement('form')
-
-  constructor (props) {
-    super(props)
-
-    const file = this.props.files[this.props.fileCardFor]
-    const metaFields = this.getMetaFields() || []
-
-    const storedMetaData = {}
-    metaFields.forEach((field) => {
-      storedMetaData[field.id] = file.meta[field.id] || ''
-    })
-
-    this.state = {
-      formState: storedMetaData,
-    }
-
-    this.form.id = nanoid()
+import RenderMetaFields from './RenderMetaFields.jsx'
+
+export default function FileCard (props) {
+  const {
+    uppy,
+    files,
+    fileCardFor,
+    toggleFileCard,
+    saveFileCard,
+    metaFields,
+    requiredMetaFields,
+    openFileEditor,
+    i18n,
+    i18nArray,
+    className,
+    canEditFile,
+  } = props
+
+  const getMetaFields = () => {
+    return typeof metaFields === 'function'
+      ? metaFields(files[fileCardFor])
+      : metaFields
   }
 
-  // TODO(aduh95): move this to `UNSAFE_componentWillMount` when updating to Preact X+.
-  componentWillMount () { // eslint-disable-line react/no-deprecated
-    this.form.addEventListener('submit', this.handleSave)
-    document.body.appendChild(this.form)
-  }
-
-  componentWillUnmount () {
-    this.form.removeEventListener('submit', this.handleSave)
-    document.body.removeChild(this.form)
-  }
+  const file = files[fileCardFor]
+  const computedMetaFields = getMetaFields() ?? []
+  const showEditButton = canEditFile(file)
 
-  getMetaFields () {
-    return typeof this.props.metaFields === 'function'
-      ? this.props.metaFields(this.props.files[this.props.fileCardFor])
-      : this.props.metaFields
-  }
+  const storedMetaData = {}
+  computedMetaFields.forEach((field) => {
+    storedMetaData[field.id] = file.meta[field.id] ?? ''
+  })
 
-  updateMeta = (newVal, name) => {
-    this.setState(({ formState }) => ({
-      formState: {
-        ...formState,
-        [name]: newVal,
-      },
-    }))
-  }
+  const [formState, setFormState] = useState(storedMetaData)
 
-  handleSave = (e) => {
-    e.preventDefault()
-    const fileID = this.props.fileCardFor
-    this.props.saveFileCard(this.state.formState, fileID)
-  }
+  const handleSave = useCallback((ev) => {
+    ev.preventDefault()
+    saveFileCard(formState, fileCardFor)
+  }, [saveFileCard, formState, fileCardFor])
 
-  handleCancel = () => {
-    const file = this.props.files[this.props.fileCardFor]
-    this.props.uppy.emit('file-editor:cancel', file)
-    this.props.toggleFileCard(false)
+  const updateMeta = (newVal, name) => {
+    setFormState({ [name]: newVal })
   }
 
-  saveOnEnter = (ev) => {
-    if (ev.keyCode === 13) {
-      ev.stopPropagation()
-      ev.preventDefault()
-      const file = this.props.files[this.props.fileCardFor]
-      this.props.saveFileCard(this.state.formState, file.id)
-    }
+  const handleCancel = () => {
+    uppy.emit('file-editor:cancel', file)
+    toggleFileCard(false)
   }
 
-  renderMetaFields = () => {
-    const metaFields = this.getMetaFields() || []
-    const fieldCSSClasses = {
-      text: 'uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input',
+  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', handleSave)
+    return () => {
+      form.removeEventListener('submit', handleSave)
+      document.body.removeChild(form)
     }
+  }, [form, handleSave])
+
+  return (
+    <div
+      className={classNames('uppy-Dashboard-FileCard', className)}
+      data-uppy-panelType="FileCard"
+      onDragOver={ignoreEvent}
+      onDragLeave={ignoreEvent}
+      onDrop={ignoreEvent}
+      onPaste={ignoreEvent}
+    >
+      <div className="uppy-DashboardContent-bar">
+        <div className="uppy-DashboardContent-title" role="heading" aria-level="1">
+          {i18nArray('editing', {
+            file: <span className="uppy-DashboardContent-titleFile">{file.meta ? file.meta.name : file.name}</span>,
+          })}
+        </div>
+        <button
+          className="uppy-DashboardContent-back"
+          type="button"
+          form={form.id}
+          title={i18n('finishEditingFile')}
+          onClick={handleCancel}
+        >
+          {i18n('cancel')}
+        </button>
+      </div>
 
-    return metaFields.map((field) => {
-      const id = `uppy-Dashboard-FileCard-input-${field.id}`
-      const required = this.props.requiredMetaFields.includes(field.id)
-      return (
-        <fieldset key={field.id} className="uppy-Dashboard-FileCard-fieldset">
-          <label className="uppy-Dashboard-FileCard-label" htmlFor={id}>{field.name}</label>
-          {field.render !== undefined
-            ? field.render({
-              value: this.state.formState[field.id],
-              onChange: (newVal) => this.updateMeta(newVal, field.id),
-              fieldCSSClasses,
-              required,
-              form: this.form.id,
-            }, h)
-            : (
-              <input
-                className={fieldCSSClasses.text}
-                id={id}
-                form={this.form.id}
-                type={field.type || 'text'}
-                required={required}
-                value={this.state.formState[field.id]}
-                placeholder={field.placeholder}
-                // If `form` attribute is not supported, we need to capture pressing Enter to avoid bubbling in case Uppy is
-                // embedded inside a <form>.
-                onKeyUp={'form' in HTMLInputElement.prototype ? undefined : this.saveOnEnter}
-                onKeyDown={'form' in HTMLInputElement.prototype ? undefined : this.saveOnEnter}
-                onKeyPress={'form' in HTMLInputElement.prototype ? undefined : this.saveOnEnter}
-                onInput={ev => this.updateMeta(ev.target.value, field.id)}
-                data-uppy-super-focusable
-              />
+      <div className="uppy-Dashboard-FileCard-inner">
+        <div className="uppy-Dashboard-FileCard-preview" style={{ backgroundColor: getFileTypeIcon(file.type).color }}>
+          <FilePreview file={file} />
+          {showEditButton
+            && (
+            <button
+              type="button"
+              className="uppy-u-reset uppy-c-btn uppy-Dashboard-FileCard-edit"
+              onClick={(event) => {
+                // When opening the image editor we want to save any meta fields changes.
+                // Otherwise it's confusing for the user to click save in the editor,
+                // but the changes here are discarded. This bypasses validation,
+                // but we are okay with that.
+                handleSave(event)
+                openFileEditor(file)
+              }}
+            >
+              {i18n('editFile')}
+            </button>
             )}
-        </fieldset>
-      )
-    })
-  }
+        </div>
 
-  render () {
-    const file = this.props.files[this.props.fileCardFor]
-    const showEditButton = this.props.canEditFile(file)
+        <div className="uppy-Dashboard-FileCard-info">
+          <RenderMetaFields
+            computedMetaFields={computedMetaFields}
+            requiredMetaFields={requiredMetaFields}
+            updateMeta={updateMeta}
+            form={form}
+            formState={formState}
+          />
+        </div>
 
-    return (
-      <div
-        className={classNames('uppy-Dashboard-FileCard', this.props.className)}
-        data-uppy-panelType="FileCard"
-        onDragOver={ignoreEvent}
-        onDragLeave={ignoreEvent}
-        onDrop={ignoreEvent}
-        onPaste={ignoreEvent}
-      >
-        <div className="uppy-DashboardContent-bar">
-          <div className="uppy-DashboardContent-title" role="heading" aria-level="1">
-            {this.props.i18nArray('editing', {
-              file: <span className="uppy-DashboardContent-titleFile">{file.meta ? file.meta.name : file.name}</span>,
-            })}
-          </div>
+        <div className="uppy-Dashboard-FileCard-actions">
+          <button
+            className="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Dashboard-FileCard-actionsBtn"
+            // If `form` attribute is supported, we want a submit button to trigger the form validation.
+            // Otherwise, fallback to a classic button with a onClick event handler.
+            type="submit"
+            form={form.id}
+          >
+            {i18n('saveChanges')}
+          </button>
           <button
-            className="uppy-DashboardContent-back"
+            className="uppy-u-reset uppy-c-btn uppy-c-btn-link uppy-Dashboard-FileCard-actionsBtn"
             type="button"
-            form={this.form.id}
-            title={this.props.i18n('finishEditingFile')}
-            onClick={this.handleCancel}
+            onClick={handleCancel}
+            form={form.id}
           >
-            {this.props.i18n('cancel')}
+            {i18n('cancel')}
           </button>
         </div>
-
-        <div className="uppy-Dashboard-FileCard-inner">
-          <div className="uppy-Dashboard-FileCard-preview" style={{ backgroundColor: getFileTypeIcon(file.type).color }}>
-            <FilePreview file={file} />
-            {showEditButton
-              && (
-              <button
-                type="button"
-                className="uppy-u-reset uppy-c-btn uppy-Dashboard-FileCard-edit"
-                onClick={(event) => {
-                  // When opening the image editor we want to save any meta fields changes.
-                  // Otherwise it's confusing for the user to click save in the editor,
-                  // but the changes here are discarded. This bypasses validation,
-                  // but we are okay with that.
-                  this.handleSave(event)
-                  this.props.openFileEditor(file)
-                }}
-                form={this.form.id}
-              >
-                {this.props.i18n('editFile')}
-              </button>
-              )}
-          </div>
-
-          <div className="uppy-Dashboard-FileCard-info">
-            {this.renderMetaFields()}
-          </div>
-
-          <div className="uppy-Dashboard-FileCard-actions">
-            <button
-              className="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Dashboard-FileCard-actionsBtn"
-              // If `form` attribute is supported, we want a submit button to trigger the form validation.
-              // Otherwise, fallback to a classic button with a onClick event handler.
-              type={'form' in HTMLButtonElement.prototype ? 'submit' : 'button'}
-              onClick={'form' in HTMLButtonElement.prototype ? undefined : this.handleSave}
-              form={this.form.id}
-            >
-              {this.props.i18n('saveChanges')}
-            </button>
-            <button
-              className="uppy-u-reset uppy-c-btn uppy-c-btn-link uppy-Dashboard-FileCard-actionsBtn"
-              type="button"
-              onClick={this.handleCancel}
-              form={this.form.id}
-            >
-              {this.props.i18n('cancel')}
-            </button>
-          </div>
-        </div>
       </div>
-    )
-  }
+    </div>
+  )
 }
-
-export default FileCard

+ 1 - 0
packages/@uppy/provider-views/package.json

@@ -22,6 +22,7 @@
   "dependencies": {
     "@uppy/utils": "workspace:^",
     "classnames": "^2.2.6",
+    "nanoid": "^4.0.0",
     "preact": "^10.5.13"
   },
   "peerDependencies": {

+ 42 - 22
packages/@uppy/provider-views/src/Browser.jsx

@@ -1,10 +1,8 @@
 import { h } from 'preact'
 
 import classNames from 'classnames'
-
 import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal'
-
-import Filter from './Filter.jsx'
+import SearchFilterInput from './SearchFilterInput.jsx'
 import FooterActions from './FooterActions.jsx'
 import Item from './Item/index.jsx'
 
@@ -26,13 +24,19 @@ function Browser (props) {
     showTitles,
     i18n,
     validateRestrictions,
-    showFilter,
-    filterQuery,
-    filterInput,
+    isLoading,
+    showSearchFilter,
+    search,
+    searchTerm,
+    clearSearch,
+    searchOnInput,
+    searchInputLabel,
+    clearSearchLabel,
     getNextFolder,
     cancel,
     done,
     columns,
+    noResultsLabel,
   } = props
 
   const selected = currentSelection.length
@@ -44,30 +48,46 @@ function Browser (props) {
         `uppy-ProviderBrowser-viewType--${viewType}`,
       )}
     >
-      <div className="uppy-ProviderBrowser-header">
-        <div
-          className={classNames(
-            'uppy-ProviderBrowser-headerBar',
-            !showBreadcrumbs && 'uppy-ProviderBrowser-headerBar--simple',
-          )}
-        >
-          {headerComponent}
+      {headerComponent && (
+        <div className="uppy-ProviderBrowser-header">
+          <div
+            className={classNames(
+              'uppy-ProviderBrowser-headerBar',
+              !showBreadcrumbs && 'uppy-ProviderBrowser-headerBar--simple',
+            )}
+          >
+            {headerComponent}
+          </div>
         </div>
-      </div>
+      )}
 
-      {showFilter && (
-        <Filter
-          i18n={i18n}
-          filterQuery={filterQuery}
-          filterInput={filterInput}
-        />
+      {showSearchFilter && (
+        <div class="uppy-ProviderBrowser-searchFilter">
+          <SearchFilterInput
+            search={search}
+            searchTerm={searchTerm}
+            clearSearch={clearSearch}
+            inputLabel={searchInputLabel}
+            clearSearchLabel={clearSearchLabel}
+            inputClassName="uppy-ProviderBrowser-searchFilterInput"
+            searchOnInput={searchOnInput}
+          />
+        </div>
       )}
 
       {(() => {
+        if (isLoading) {
+          return (
+            <div className="uppy-Provider-loading">
+              <span>{i18n('loading')}</span>
+            </div>
+          )
+        }
+
         if (!folders.length && !files.length) {
           return (
             <div className="uppy-Provider-empty">
-              {i18n('noFilesFound')}
+              {noResultsLabel}
             </div>
           )
         }

+ 0 - 51
packages/@uppy/provider-views/src/Filter.jsx

@@ -1,51 +0,0 @@
-import { h, Component } from 'preact'
-
-export default class Filter extends Component {
-  constructor (props) {
-    super(props)
-    this.preventEnterPress = this.preventEnterPress.bind(this)
-  }
-
-  // eslint-disable-next-line class-methods-use-this
-  preventEnterPress (ev) {
-    if (ev.keyCode === 13) {
-      ev.stopPropagation()
-      ev.preventDefault()
-    }
-  }
-
-  render () {
-    const { i18n, filterInput, filterQuery } = this.props
-    return (
-      <div className="uppy-ProviderBrowser-filter">
-        <input
-          className="uppy-u-reset uppy-ProviderBrowser-filterInput"
-          type="text"
-          placeholder={i18n('filter')}
-          aria-label={i18n('filter')}
-          onKeyUp={this.preventEnterPress}
-          onKeyDown={this.preventEnterPress}
-          onKeyPress={this.preventEnterPress}
-          onInput={(e) => filterQuery(e)}
-          value={filterInput}
-        />
-        <svg aria-hidden="true" focusable="false" className="uppy-c-icon uppy-ProviderBrowser-filterIcon" 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>
-        {filterInput && (
-          <button
-            className="uppy-u-reset uppy-ProviderBrowser-filterClose"
-            type="button"
-            aria-label={i18n('resetFilter')}
-            title={i18n('resetFilter')}
-            onClick={filterQuery}
-          >
-            <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>
-        )}
-      </div>
-    )
-  }
-}

+ 12 - 10
packages/@uppy/provider-views/src/Item/components/GridLi.jsx

@@ -1,4 +1,5 @@
 import { h } from 'preact'
+import classNames from 'classnames'
 
 function GridListItem (props) {
   const {
@@ -15,6 +16,13 @@ function GridListItem (props) {
     children,
   } = props
 
+  const checkBoxClassName = classNames(
+    'uppy-u-reset',
+    'uppy-ProviderBrowserItem-checkbox',
+    'uppy-ProviderBrowserItem-checkbox--grid',
+    { 'uppy-ProviderBrowserItem-checkbox--is-checked': isChecked },
+  )
+
   return (
     <li
       className={className}
@@ -22,9 +30,7 @@ function GridListItem (props) {
     >
       <input
         type="checkbox"
-        className={`uppy-u-reset uppy-ProviderBrowserItem-checkbox ${
-          isChecked ? 'uppy-ProviderBrowserItem-checkbox--is-checked' : ''
-        } uppy-ProviderBrowserItem-checkbox--grid`}
+        className={checkBoxClassName}
         onChange={toggleCheckbox}
         onKeyDown={recordShiftKeyPress}
         name="listitem"
@@ -38,13 +44,9 @@ function GridListItem (props) {
         aria-label={title}
         className="uppy-u-reset uppy-ProviderBrowserItem-inner"
       >
-        <span className="uppy-ProviderBrowserItem-inner-relative">
-          {itemIconEl}
-
-          {showTitles && title}
-
-          {children}
-        </span>
+        {itemIconEl}
+        {showTitles && title}
+        {children}
       </label>
     </li>
   )

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

@@ -42,6 +42,7 @@ export default (props) => {
             target="_blank"
             rel="noopener noreferrer"
             className="uppy-ProviderBrowserItem-author"
+            tabIndex="-1"
           >
             {author.name}
           </a>

+ 20 - 6
packages/@uppy/provider-views/src/ProviderView/ProviderView.jsx

@@ -53,6 +53,7 @@ export default class ProviderView extends View {
 
     // Logic
     this.filterQuery = this.filterQuery.bind(this)
+    this.clearFilter = this.clearFilter.bind(this)
     this.getFolder = this.getFolder.bind(this)
     this.getNextFolder = this.getNextFolder.bind(this)
     this.logout = this.logout.bind(this)
@@ -162,9 +163,12 @@ export default class ProviderView extends View {
       }).catch(this.handleError)
   }
 
-  filterQuery (e) {
-    const state = this.plugin.getPluginState()
-    this.plugin.setPluginState({ ...state, filterInput: e ? e.target.value : '' })
+  filterQuery (input) {
+    this.plugin.setPluginState({ filterInput: input })
+  }
+
+  clearFilter () {
+    this.plugin.setPluginState({ filterInput: '' })
   }
 
   /**
@@ -329,6 +333,7 @@ export default class ProviderView extends View {
 
   render (state, viewOptions = {}) {
     const { authenticated, didFirstRender } = this.plugin.getPluginState()
+    const { i18n } = this.plugin.uppy
 
     if (!didFirstRender) {
       this.preFirstRender()
@@ -346,7 +351,7 @@ export default class ProviderView extends View {
       title: this.plugin.title,
       logout: this.logout,
       username: this.username,
-      i18n: this.plugin.uppy.i18n,
+      i18n,
     }
 
     const browserProps = {
@@ -360,7 +365,17 @@ export default class ProviderView extends View {
       getNextFolder: this.getNextFolder,
       getFolder: this.getFolder,
       filterItems: this.sharedHandler.filterItems,
-      filterQuery: this.filterQuery,
+
+      // For SearchFilterInput component
+      showSearchFilter: targetViewOptions.showFilter,
+      search: this.filterQuery,
+      clearSearch: this.clearFilter,
+      searchTerm: filterInput,
+      searchOnInput: true,
+      searchInputLabel: i18n('filter'),
+      clearSearchLabel: i18n('resetFilter'),
+
+      noResultsLabel: i18n('noFilesFound'),
       logout: this.logout,
       handleScroll: this.handleScroll,
       listAllFiles: this.listAllFiles,
@@ -370,7 +385,6 @@ export default class ProviderView extends View {
       title: this.plugin.title,
       viewType: targetViewOptions.viewType,
       showTitles: targetViewOptions.showTitles,
-      showFilter: targetViewOptions.showFilter,
       showBreadcrumbs: targetViewOptions.showBreadcrumbs,
       pluginIcon: this.plugin.icon,
       i18n: this.plugin.uppy.i18n,

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

@@ -0,0 +1,101 @@
+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>
+  )
+}

+ 0 - 32
packages/@uppy/provider-views/src/SearchProviderView/Header.jsx

@@ -1,32 +0,0 @@
-import { h } from 'preact'
-
-const SUBMIT_KEY = 13
-
-export default (props) => {
-  const { searchTerm, i18n, search } = props
-
-  const handleKeyPress = (ev) => {
-    if (ev.keyCode === SUBMIT_KEY) {
-      ev.stopPropagation()
-      ev.preventDefault()
-      search(ev.target.value)
-    }
-  }
-
-  return (
-    <div class="uppy-ProviderBrowser-search">
-      <input
-        class="uppy-u-reset uppy-ProviderBrowser-searchInput"
-        type="text"
-        placeholder={i18n('search')}
-        aria-label={i18n('search')}
-        value={searchTerm}
-        onKeyUp={handleKeyPress}
-        data-uppy-super-focusable
-      />
-      <svg aria-hidden="true" focusable="false" class="uppy-c-icon uppy-ProviderBrowser-searchIcon" width="12" height="12" viewBox="0 0 12 12">
-        <path d="M8.638 7.99l3.172 3.172a.492.492 0 1 1-.697.697L7.91 8.656a4.977 4.977 0 0 1-2.983.983C2.206 9.639 0 7.481 0 4.819 0 2.158 2.206 0 4.927 0c2.721 0 4.927 2.158 4.927 4.82a4.74 4.74 0 0 1-1.216 3.17zm-3.71.685c2.176 0 3.94-1.726 3.94-3.856 0-2.129-1.764-3.855-3.94-3.855C2.75.964.984 2.69.984 4.819c0 2.13 1.765 3.856 3.942 3.856z" />
-      </svg>
-    </div>
-  )
-}

+ 0 - 36
packages/@uppy/provider-views/src/SearchProviderView/InputView.jsx

@@ -1,36 +0,0 @@
-import { h } from 'preact'
-
-export default ({ i18n, search }) => {
-  let input
-  const validateAndSearch = () => {
-    if (input.value) {
-      search(input.value)
-    }
-  }
-  const handleKeyPress = (ev) => {
-    if (ev.keyCode === 13) {
-      validateAndSearch()
-    }
-  }
-
-  return (
-    <div className="uppy-SearchProvider">
-      <input
-        className="uppy-u-reset uppy-c-textInput uppy-SearchProvider-input"
-        type="search"
-        aria-label={i18n('enterTextToSearch')}
-        placeholder={i18n('enterTextToSearch')}
-        onKeyUp={handleKeyPress}
-        ref={(input_) => { input = input_ }}
-        data-uppy-super-focusable
-      />
-      <button
-        className="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-SearchProvider-searchButton"
-        type="button"
-        onClick={validateAndSearch}
-      >
-        {i18n('searchImages')}
-      </button>
-    </div>
-  )
-}

+ 47 - 38
packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.jsx

@@ -1,16 +1,15 @@
 import { h } from 'preact'
 
-import SearchInput from './InputView.jsx'
+import SearchFilterInput from '../SearchFilterInput.jsx'
 import Browser from '../Browser.jsx'
-import LoaderView from '../Loader.jsx'
-import Header from './Header.jsx'
 import CloseWrapper from '../CloseWrapper.js'
 import View from '../View.js'
 
 import packageJson from '../../package.json'
 
 /**
- * Class to easily generate generic views for Provider plugins
+ * 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 {
   static VERSION = packageJson.version
@@ -35,7 +34,8 @@ export default class SearchProviderView extends View {
 
     // Logic
     this.search = this.search.bind(this)
-    this.triggerSearchInput = this.triggerSearchInput.bind(this)
+    this.clearSearch = this.clearSearch.bind(this)
+    this.resetPluginState = this.resetPluginState.bind(this)
     this.addFile = this.addFile.bind(this)
     this.handleScroll = this.handleScroll.bind(this)
     this.donePicking = this.donePicking.bind(this)
@@ -43,8 +43,7 @@ export default class SearchProviderView extends View {
     // Visual
     this.render = this.render.bind(this)
 
-    // Set default state for the plugin
-    this.plugin.setPluginState({
+    this.defaultState = {
       isInputMode: true,
       files: [],
       folders: [],
@@ -52,7 +51,10 @@ export default class SearchProviderView extends View {
       filterInput: '',
       currentSelection: [],
       searchTerm: null,
-    })
+    }
+
+    // Set default state for the plugin
+    this.plugin.setPluginState(this.defaultState)
   }
 
   // eslint-disable-next-line class-methods-use-this
@@ -60,19 +62,15 @@ export default class SearchProviderView extends View {
     // Nothing.
   }
 
-  clearSelection () {
-    this.plugin.setPluginState({
-      currentSelection: [],
-      isInputMode: true,
-      files: [],
-      searchTerm: null,
-    })
+  resetPluginState () {
+    this.plugin.setPluginState(this.defaultState)
   }
 
   #updateFilesAndInputMode (res, files) {
     this.nextPageQuery = res.nextPageQuery
     res.items.forEach((item) => { files.push(item) })
     this.plugin.setPluginState({
+      currentSelection: [],
       isInputMode: false,
       files,
       searchTerm: res.searchedFor,
@@ -95,8 +93,12 @@ export default class SearchProviderView extends View {
     )
   }
 
-  triggerSearchInput () {
-    this.plugin.setPluginState({ isInputMode: true })
+  clearSearch () {
+    this.plugin.setPluginState({
+      currentSelection: [],
+      files: [],
+      searchTerm: null,
+    })
   }
 
   async handleScroll (event) {
@@ -123,12 +125,13 @@ export default class SearchProviderView extends View {
     const promises = currentSelection.map((file) => this.addFile(file))
 
     this.sharedHandler.loaderWrapper(Promise.all(promises), () => {
-      this.clearSelection()
+      this.resetPluginState()
     }, () => {})
   }
 
   render (state, viewOptions = {}) {
     const { didFirstRender, isInputMode, searchTerm } = this.plugin.getPluginState()
+    const { i18n } = this.plugin.uppy
 
     if (!didFirstRender) {
       this.preFirstRender()
@@ -148,43 +151,49 @@ export default class SearchProviderView extends View {
       handleScroll: this.handleScroll,
       done: this.donePicking,
       cancel: this.cancelPicking,
-      headerComponent: Header({
-        search: this.search,
-        i18n: this.plugin.uppy.i18n,
-        searchTerm,
-      }),
+
+      // For SearchFilterInput component
+      showSearchFilter: targetViewOptions.showFilter,
+      search: this.search,
+      clearSearch: this.clearSearch,
+      searchTerm,
+      searchOnInput: false,
+      searchInputLabel: i18n('search'),
+      clearSearchLabel: i18n('resetSearch'),
+
+      noResultsLabel: i18n('noSearchResults'),
       title: this.plugin.title,
       viewType: targetViewOptions.viewType,
       showTitles: targetViewOptions.showTitles,
       showFilter: targetViewOptions.showFilter,
+      isLoading: loading,
       showBreadcrumbs: targetViewOptions.showBreadcrumbs,
       pluginIcon: this.plugin.icon,
-      i18n: this.plugin.uppy.i18n,
+      i18n,
       uppyFiles: this.plugin.uppy.getFiles(),
       validateRestrictions: (...args) => this.plugin.uppy.validateRestrictions(...args),
     }
 
-    if (loading) {
-      return (
-        <CloseWrapper onUnmount={this.clearSelection}>
-          <LoaderView i18n={this.plugin.uppy.i18n} />
-        </CloseWrapper>
-      )
-    }
-
     if (isInputMode) {
       return (
-        <CloseWrapper onUnmount={this.clearSelection}>
-          <SearchInput
-            search={this.search}
-            i18n={this.plugin.uppy.i18n}
-          />
+        <CloseWrapper onUnmount={this.resetPluginState}>
+          <div className="uppy-SearchProvider">
+            <SearchFilterInput
+              search={this.search}
+              clearSelection={this.clearSelection}
+              inputLabel={i18n('enterTextToSearch')}
+              buttonLabel={i18n('searchImages')}
+              inputClassName="uppy-c-textInput uppy-SearchProvider-input"
+              buttonCSSClassName="uppy-SearchProvider-searchButton"
+              showButton
+            />
+          </div>
         </CloseWrapper>
       )
     }
 
     return (
-      <CloseWrapper onUnmount={this.clearSelection}>
+      <CloseWrapper onUnmount={this.resetPluginState}>
         {/* eslint-disable-next-line react/jsx-props-no-spreading */}
         <Browser {...browserProps} />
       </CloseWrapper>

+ 37 - 69
packages/@uppy/provider-views/src/style.scss

@@ -203,79 +203,21 @@
   vertical-align: middle;
 }
 
-// Filter
-
-.uppy-ProviderBrowser-filter {
-  position: relative;
-  display: flex;
-  align-items: center;
-  width: 100%;
-  height: 30px;
-  margin-top: 10px;
-  margin-bottom: 5px;
-  background-color: $white;
-
-  [data-uppy-theme="dark"] & {
-    background-color: $gray-900;
-  }
-}
-
-.uppy-ProviderBrowser-filterIcon {
-  position: absolute;
-  z-index: $zIndex-3;
-  width: 12px;
-  height: 12px;
-  color: $gray-400;
-  inset-inline-start: 16px;
-}
-
-.uppy-ProviderBrowser-filterInput {
-  z-index: $zIndex-2;
-  width: 100%;
-  height: 30px;
-  margin: 0 8px;
-  font-size: 12px;
-  font-family: $font-family-base;
-  line-height: 1.4;
-  background-color: transparent;
-  border: 0;
-  border-radius: 4px;
-  outline: 0;
-  padding-inline-start: 27px;
-
-  [data-uppy-theme="dark"] & {
-    color: $gray-200;
-    background-color: $gray-900;
-  }
-}
-
-.uppy-ProviderBrowser-filterInput:focus {
-  background-color: $gray-100;
-  outline: 0;
-
-  [data-uppy-theme="dark"] & {
-    background-color: $gray-800;
-  }
-}
-
-.uppy-ProviderBrowser-filterInput::placeholder {
-  color: $gray-500;
-  opacity: 1;
-}
-
 // Search
 
-.uppy-ProviderBrowser-search {
+.uppy-ProviderBrowser-searchFilter {
   position: relative;
   display: flex;
   align-items: center;
   width: 100%;
   height: 30px;
-  margin-top: 2px;
-  margin-bottom: 2px;
+  padding-left: 8px;
+  padding-right: 8px;
+  margin-top: 15px;
+  margin-bottom: 15px;
 }
 
-.uppy-ProviderBrowser-searchInput {
+.uppy-ProviderBrowser-searchFilterInput {
   z-index: $zIndex-2;
   width: 100%;
   height: 30px;
@@ -287,37 +229,63 @@
   border-radius: 4px;
   outline: 0;
   padding-inline-start: 30px;
+  padding-inline-end: 30px;
   color: $gray-800;
 
+  &::-webkit-search-cancel-button {
+    display: none;
+  }
+
   [data-uppy-theme="dark"] & {
     color: $gray-200;
     background-color: $gray-900;
   }
 }
 
-.uppy-ProviderBrowser-searchInput:focus {
+.uppy-ProviderBrowser-searchFilterInput:focus {
   background-color: $gray-300;
-  outline: 0;
+  border: 0;
 
   [data-uppy-theme="dark"] & {
     background-color: $gray-800;
   }
 }
 
-.uppy-ProviderBrowser-searchIcon {
+.uppy-ProviderBrowser-searchFilterIcon {
   position: absolute;
   z-index: $zIndex-3;
   width: 12px;
   height: 12px;
   color: $gray-600;
-  inset-inline-start: 10px;
+  inset-inline-start: 16px;
 }
 
-.uppy-ProviderBrowser-searchInput::placeholder {
+.uppy-ProviderBrowser-searchFilterInput::placeholder {
   color: $gray-500;
   opacity: 1;
 }
 
+.uppy-ProviderBrowser-searchFilterReset {
+  @include blue-border-focus;
+  border-radius: 3px;
+  position: absolute;
+  z-index: $zIndex-3;
+  width: 22px;
+  height: 22px;
+  padding: 6px;
+  color: $gray-500;
+  cursor: pointer;
+  inset-inline-end: 16px;
+
+  &:hover {
+    color: $gray-600;
+  }
+
+  svg {
+    vertical-align: text-top;
+  }
+}
+
 .uppy-ProviderBrowser-userLogout {
   @include highlight-focus;
   // for focus

+ 20 - 24
packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--grid.scss

@@ -81,30 +81,6 @@
     text-align: center;
     border-radius: 4px;
 
-    .uppy.uppy-ProviderBrowserItem-inner-relative {
-      position: relative;
-    }
-
-    .uppy-ProviderBrowserItem-author {
-      position: absolute;
-      display: none;
-      bottom: 0;
-      left: 0;
-      width: 100%;
-      background: rgba(black, 0.3);
-      color: white;
-      font-weight: 500;
-      font-size: 12px;
-      margin: 0;
-      padding: 5px;
-      text-decoration: none;
-
-      &:hover {
-        background: rgba(black, 0.4);
-        text-decoration: underline;
-      }
-    }
-
     // Always show the author on touch devices
     // https://www.w3.org/TR/mediaqueries-4/#hover
     @media (hover: none) {
@@ -125,6 +101,26 @@
     }
   }
 
+  .uppy-ProviderBrowserItem-author {
+    position: absolute;
+    display: none;
+    bottom: 0;
+    left: 0;
+    width: 100%;
+    background: rgba(black, 0.3);
+    color: white;
+    font-weight: 500;
+    font-size: 12px;
+    margin: 0;
+    padding: 5px;
+    text-decoration: none;
+
+    &:hover {
+      background: rgba(black, 0.4);
+      text-decoration: underline;
+    }
+  }
+
   // Checkbox
   .uppy-ProviderBrowserItem-checkbox {
     position: absolute;

+ 4 - 0
packages/@uppy/provider-views/src/style/uppy-SearchProvider-input.scss

@@ -21,6 +21,10 @@
   .uppy-size--md & {
     margin-bottom: 20px;
   }
+
+  &::-webkit-search-cancel-button {
+    display: none;   
+  }
 }
 
 .uppy-SearchProvider-searchButton {

+ 1 - 0
packages/@uppy/unsplash/src/Unsplash.jsx

@@ -47,6 +47,7 @@ export default class Unsplash extends UIPlugin {
     this.view = new SearchProviderViews(this, {
       provider: this.provider,
       viewType: 'unsplash',
+      showFilter: true,
     })
 
     const { target } = this.opts

+ 1 - 0
packages/@uppy/url/package.json

@@ -25,6 +25,7 @@
   "dependencies": {
     "@uppy/companion-client": "workspace:^",
     "@uppy/utils": "workspace:^",
+    "nanoid": "^4.0.0",
     "preact": "^10.5.13"
   },
   "peerDependencies": {

+ 18 - 8
packages/@uppy/url/src/UrlUI.jsx

@@ -1,17 +1,27 @@
 import { h, Component } from 'preact'
+import { nanoid } from 'nanoid/non-secure'
 
 class UrlUI extends Component {
+  form = document.createElement('form')
+
+  constructor (props) {
+    super(props)
+    this.form.id = nanoid()
+  }
+
   componentDidMount () {
     this.input.value = ''
+    this.form.addEventListener('submit', this.#handleSubmit)
+    document.body.appendChild(this.form)
   }
 
-  #handleKeyPress = (ev) => {
-    if (ev.keyCode === 13) {
-      this.#handleSubmit()
-    }
+  componentWillUnmount () {
+    this.form.removeEventListener('submit', this.#handleSubmit)
+    document.body.removeChild(this.form)
   }
 
-  #handleSubmit = () => {
+  #handleSubmit = (ev) => {
+    ev.preventDefault()
     const { addFile } = this.props
     const preparedValue = this.input.value.trim()
     addFile(preparedValue)
@@ -26,14 +36,14 @@ class UrlUI extends Component {
           type="text"
           aria-label={i18n('enterUrlToImport')}
           placeholder={i18n('enterUrlToImport')}
-          onKeyUp={this.#handleKeyPress}
           ref={(input) => { this.input = input }}
           data-uppy-super-focusable
+          form={this.form.id}
         />
         <button
           className="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Url-importButton"
-          type="button"
-          onClick={this.#handleSubmit}
+          type="submit"
+          form={this.form.id}
         >
           {i18n('import')}
         </button>

+ 4 - 2
yarn.lock

@@ -8954,6 +8954,7 @@ __metadata:
   dependencies:
     "@uppy/utils": "workspace:^"
     classnames: ^2.2.6
+    nanoid: ^4.0.0
     preact: ^10.5.13
   peerDependencies:
     "@uppy/core": "workspace:^"
@@ -9162,6 +9163,7 @@ __metadata:
   dependencies:
     "@uppy/companion-client": "workspace:^"
     "@uppy/utils": "workspace:^"
+    nanoid: ^4.0.0
     preact: ^10.5.13
   peerDependencies:
     "@uppy/core": "workspace:^"
@@ -27599,8 +27601,8 @@ __metadata:
 
 "preact@patch:preact@npm:10.10.0#.yarn/patches/preact-npm-10.10.0-dd04de05e8.patch::locator=%40uppy-dev%2Fbuild%40workspace%3A.":
   version: 10.10.0
-  resolution: "preact@patch:preact@npm%3A10.10.0#.yarn/patches/preact-npm-10.10.0-dd04de05e8.patch::version=10.10.0&hash=a66388&locator=%40uppy-dev%2Fbuild%40workspace%3A."
-  checksum: f610d7f206e8cd71739023c3dbeae13fcaf9f9e6488295e2ae28f71c615d216c9b1d8b2fa2d616a629276948ee58a8d16fe77b8b7bc2e8d4aee1101e3336fe2b
+  resolution: "preact@patch:preact@npm%3A10.10.0#.yarn/patches/preact-npm-10.10.0-dd04de05e8.patch::version=10.10.0&hash=e9860f&locator=%40uppy-dev%2Fbuild%40workspace%3A."
+  checksum: 8fefec89ac68b5bd8fc0c4f1672beb7585c878663f9e929de85ff0f5c6d12dc49d9910867d7c9ef2bc449ec09512241574e08cb0546bdb3a34600891db5e0a96
   languageName: node
   linkType: hard