Repository: ant-design/ant-design-mobile (ts)

Issue:
ImageUploader增加手动上传功能
### Version of antd-mobile

5.7.2

### What is this feature about?

ImageUploader组件缺少一个主动调用上传功能的需求，例如通过ref调用方法打开相册

Pull request description:
feat: support for incoming ref to image-uploader
resolve #6289 ，从 #6311 移动。

Patch:
diff --git a/src/components/image-uploader/demos/demo2.tsx b/src/components/image-uploader/demos/demo2.tsx
index 5a2509e61..41f6c3d3b 100644
--- a/src/components/image-uploader/demos/demo2.tsx
+++ b/src/components/image-uploader/demos/demo2.tsx
@@ -1,8 +1,7 @@
-import React, { useState } from 'react'
-import type { FC } from 'react'
+import React, { FC, useRef, useState } from 'react'
 import { DemoBlock } from 'demos'
-import { ImageUploadItem } from 'antd-mobile/es/components/image-uploader'
-import { ImageUploader } from 'antd-mobile'
+import type { ImageUploadItem, ImageUploaderRef } from 'antd-mobile'
+import { ImageUploader, Button, Space } from 'antd-mobile'
 import { PictureOutline } from 'antd-mobile-icons'
 
 import { demoSrc, mockUpload } from './utils'
@@ -75,6 +74,35 @@ const CustomUploadButton: FC = () => {
   )
 }
 
+// 手动调起相册
+const ManualOpenPhoto: FC = () => {
+  const input = useRef<ImageUploaderRef>(null)
+  const [fileList, setFileList] = useState<ImageUploadItem[]>([
+    {
+      url: demoSrc,
+    },
+  ])
+
+  const onOpen = () => {
+    const nativeInput = input.current?.nativeElement
+    if (nativeInput) {
+      nativeInput.click()
+    }
+  }
+
+  return (
+    <Space direction='vertical'>
+      <ImageUploader
+        ref={input}
+        value={fileList}
+        onChange={setFileList}
+        upload={mockUpload}
+      />
+      <Button onClick={onOpen}>手动调起相册</Button>
+    </Space>
+  )
+}
+
 export default () => {
   return (
     <>
@@ -89,6 +117,10 @@ export default () => {
       <DemoBlock title='自定义上传按钮'>
         <CustomUploadButton />
       </DemoBlock>
+
+      <DemoBlock title='手动调起相册'>
+        <ManualOpenPhoto />
+      </DemoBlock>
     </>
   )
 }
diff --git a/src/components/image-uploader/image-uploader.tsx b/src/components/image-uploader/image-uploader.tsx
index 38aef6eef..699218b14 100644
--- a/src/components/image-uploader/image-uploader.tsx
+++ b/src/components/image-uploader/image-uploader.tsx
@@ -1,6 +1,5 @@
-import React, { useRef, useState } from 'react'
+import React, { forwardRef, useRef, useState, useImperativeHandle } from 'react'
 import type {
-  FC,
   ReactNode,
   InputHTMLAttributes,
   CSSProperties,
@@ -70,6 +69,10 @@ export type ImageUploaderProps = {
   ) => ReactNode
 } & NativeProps<'--cell-size' | '--gap' | '--gap-vertical' | '--gap-horizontal'>
 
+export interface ImageUploaderRef {
+  nativeElement: HTMLInputElement | null
+}
+
 const classPrefix = `adm-image-uploader`
 
 const defaultProps = {
@@ -86,258 +89,273 @@ const defaultProps = {
   imageFit: 'cover',
 }
 
-export const ImageUploader: FC<ImageUploaderProps> = p => {
-  const { locale } = useConfig()
-  const props = mergeProps(defaultProps, p)
-  const { columns } = props
-  const [value, setValue] = usePropsValue(props)
+export const ImageUploader = forwardRef<ImageUploaderRef, ImageUploaderProps>(
+  (p, ref) => {
+    const { locale } = useConfig()
+    const props = mergeProps(defaultProps, p)
+    const { columns } = props
+    const [value, setValue] = usePropsValue(props)
 
-  const [tasks, setTasks] = useState<Task[]>([])
+    const [tasks, setTasks] = useState<Task[]>([])
 
-  const containerRef = useRef<HTMLDivElement>(null)
+    const containerRef = useRef<HTMLDivElement>(null)
 
-  const containerSize = useSize(containerRef)
-  const gapMeasureRef = useRef<HTMLDivElement>(null)
-  const [cellSize, setCellSize] = useState<number>(80)
+    const containerSize = useSize(containerRef)
+    const gapMeasureRef = useRef<HTMLDivElement>(null)
+    const [cellSize, setCellSize] = useState<number>(80)
 
-  useIsomorphicLayoutEffect(() => {
-    const gapMeasure = gapMeasureRef.current
-    if (columns && containerSize && gapMeasure) {
-      const width = containerSize.width
-      const gap = measureCSSLength(
-        window.getComputedStyle(gapMeasure).getPropertyValue('height')
-      )
-      setCellSize((width - gap * (columns - 1)) / columns)
-    }
-  }, [containerSize?.width])
+    const inputRef = useRef<HTMLInputElement>(null)
 
-  const style: CSSProperties & {
-    '--cell-size': string
-  } = {
-    '--cell-size': cellSize + 'px',
-  }
+    useIsomorphicLayoutEffect(() => {
+      const gapMeasure = gapMeasureRef.current
+      if (columns && containerSize && gapMeasure) {
+        const width = containerSize.width
+        const gap = measureCSSLength(
+          window.getComputedStyle(gapMeasure).getPropertyValue('height')
+        )
+        setCellSize((width - gap * (columns - 1)) / columns)
+      }
+    }, [containerSize?.width])
 
-  useIsomorphicLayoutEffect(() => {
-    setTasks(prev =>
-      prev.filter(task => {
-        if (task.url === undefined) return true
-        return !value.some(fileItem => fileItem.url === task.url)
-      })
-    )
-  }, [value])
+    const style: CSSProperties & {
+      '--cell-size': string
+    } = {
+      '--cell-size': cellSize + 'px',
+    }
 
-  useIsomorphicLayoutEffect(() => {
-    props.onUploadQueueChange?.(
-      tasks.map(item => ({ id: item.id, status: item.status }))
-    )
-  }, [tasks])
+    useIsomorphicLayoutEffect(() => {
+      setTasks(prev =>
+        prev.filter(task => {
+          if (task.url === undefined) return true
+          return !value.some(fileItem => fileItem.url === task.url)
+        })
+      )
+    }, [value])
 
-  const idCountRef = useRef(0)
+    useIsomorphicLayoutEffect(() => {
+      props.onUploadQueueChange?.(
+        tasks.map(item => ({ id: item.id, status: item.status }))
+      )
+    }, [tasks])
 
-  const { maxCount, onPreview, renderItem } = props
+    const idCountRef = useRef(0)
 
-  async function processFile(file: File, fileList: File[]) {
-    const { beforeUpload } = props
+    const { maxCount, onPreview, renderItem } = props
 
-    let transformedFile: File | null | undefined = file
+    async function processFile(file: File, fileList: File[]) {
+      const { beforeUpload } = props
 
-    transformedFile = await beforeUpload?.(file, fileList)
+      let transformedFile: File | null | undefined = file
 
-    return transformedFile
-  }
+      transformedFile = await beforeUpload?.(file, fileList)
 
-  function getFinalTasks(tasks: Task[]) {
-    return props.showFailed
-      ? tasks
-      : tasks.filter(task => task.status !== 'fail')
-  }
+      return transformedFile
+    }
 
-  async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
-    e.persist()
-    const { files: rawFiles } = e.target
-    if (!rawFiles) return
-    let files = [].slice.call(rawFiles) as File[]
-    e.target.value = '' // HACK: fix the same file doesn't trigger onChange
+    function getFinalTasks(tasks: Task[]) {
+      return props.showFailed
+        ? tasks
+        : tasks.filter(task => task.status !== 'fail')
+    }
 
-    if (props.beforeUpload) {
-      const postFiles = files.map(file => processFile(file, files))
+    async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
+      e.persist()
+      const { files: rawFiles } = e.target
+      if (!rawFiles) return
+      let files = [].slice.call(rawFiles) as File[]
+      e.target.value = '' // HACK: fix the same file doesn't trigger onChange
 
-      await Promise.all(postFiles).then(filesList => {
-        files = filesList.filter(Boolean) as File[]
-      })
-    }
+      if (props.beforeUpload) {
+        const postFiles = files.map(file => processFile(file, files))
 
-    if (files.length === 0) {
-      return
-    }
+        await Promise.all(postFiles).then(filesList => {
+          files = filesList.filter(Boolean) as File[]
+        })
+      }
 
-    if (maxCount > 0) {
-      const exceed = value.length + files.length - maxCount
-      if (exceed > 0) {
-        files = files.slice(0, files.length - exceed)
-        props.onCountExceed?.(exceed)
+      if (files.length === 0) {
+        return
       }
-    }
 
-    const newTasks = files.map(
-      file =>
-        ({
-          id: idCountRef.current++,
-          status: 'pending',
-          file,
-        } as Task)
-    )
+      if (maxCount > 0) {
+        const exceed = value.length + files.length - maxCount
+        if (exceed > 0) {
+          files = files.slice(0, files.length - exceed)
+          props.onCountExceed?.(exceed)
+        }
+      }
 
-    setTasks(prev => [...getFinalTasks(prev), ...newTasks])
-    const newVal: ImageUploadItem[] = []
-    await Promise.all(
-      newTasks.map(async (currentTask, index) => {
-        try {
-          const result = await props.upload(currentTask.file)
-          newVal[index] = result
-          setTasks(prev => {
-            return prev.map(task => {
-              if (task.id === currentTask.id) {
-                return {
-                  ...task,
-                  status: 'success',
-                  url: result.url,
+      const newTasks = files.map(
+        file =>
+          ({
+            id: idCountRef.current++,
+            status: 'pending',
+            file,
+          } as Task)
+      )
+
+      setTasks(prev => [...getFinalTasks(prev), ...newTasks])
+      const newVal: ImageUploadItem[] = []
+      await Promise.all(
+        newTasks.map(async (currentTask, index) => {
+          try {
+            const result = await props.upload(currentTask.file)
+            newVal[index] = result
+            setTasks(prev => {
+              return prev.map(task => {
+                if (task.id === currentTask.id) {
+                  return {
+                    ...task,
+                    status: 'success',
+                    url: result.url,
+                  }
                 }
-              }
-              return task
+                return task
+              })
             })
-          })
-        } catch (e) {
-          setTasks(prev => {
-            return prev.map(task => {
-              if (task.id === currentTask.id) {
-                return {
-                  ...task,
-                  status: 'fail',
+          } catch (e) {
+            setTasks(prev => {
+              return prev.map(task => {
+                if (task.id === currentTask.id) {
+                  return {
+                    ...task,
+                    status: 'fail',
+                  }
                 }
-              }
-              return task
+                return task
+              })
             })
-          })
-          throw e
-        }
-      })
-    ).catch(error => console.error(error))
-    setValue(prev => prev.concat(newVal))
-  }
+            throw e
+          }
+        })
+      ).catch(error => console.error(error))
+      setValue(prev => prev.concat(newVal))
+    }
 
-  const imageViewerHandlerRef = useRef<ImageViewerShowHandler | null>(null)
+    const imageViewerHandlerRef = useRef<ImageViewerShowHandler | null>(null)
 
-  function previewImage(index: number) {
-    imageViewerHandlerRef.current = ImageViewer.Multi.show({
-      images: value.map(fileItem => fileItem.url),
-      defaultIndex: index,
-      onClose: () => {
-        imageViewerHandlerRef.current = null
-      },
-    })
-  }
+    function previewImage(index: number) {
+      imageViewerHandlerRef.current = ImageViewer.Multi.show({
+        images: value.map(fileItem => fileItem.url),
+        defaultIndex: index,
+        onClose: () => {
+          imageViewerHandlerRef.current = null
+        },
+      })
+    }
 
-  useUnmount(() => {
-    imageViewerHandlerRef.current?.close()
-  })
-
-  const finalTasks = getFinalTasks(tasks)
-
-  const showUpload =
-    props.showUpload &&
-    (maxCount === 0 || value.length + finalTasks.length < maxCount)
-
-  const renderImages = () => {
-    return value.map((fileItem, index) => {
-      const originNode = (
-        <PreviewItem
-          key={fileItem.key ?? index}
-          url={fileItem.thumbnailUrl ?? fileItem.url}
-          deletable={props.deletable}
-          deleteIcon={props.deleteIcon}
-          imageFit={props.imageFit}
-          onClick={() => {
-            if (props.preview) {
-              previewImage(index)
-            }
-            onPreview && onPreview(index, fileItem)
-          }}
-          onDelete={async () => {
-            const canDelete = await props.onDelete?.(fileItem)
-            if (canDelete === false) return
-            setValue(value.filter((x, i) => i !== index))
-          }}
-        />
-      )
-      return renderItem ? renderItem(originNode, fileItem, value) : originNode
+    useUnmount(() => {
+      imageViewerHandlerRef.current?.close()
     })
-  }
 
-  const contentNode = (
-    <>
-      {renderImages()}
-      {tasks.map(task => {
-        if (!props.showFailed && task.status === 'fail') {
-          return null
-        }
-        return (
+    const finalTasks = getFinalTasks(tasks)
+
+    const showUpload =
+      props.showUpload &&
+      (maxCount === 0 || value.length + finalTasks.length < maxCount)
+
+    const renderImages = () => {
+      return value.map((fileItem, index) => {
+        const originNode = (
           <PreviewItem
-            key={task.id}
-            file={task.file}
-            deletable={task.status !== 'pending'}
+            key={fileItem.key ?? index}
+            url={fileItem.thumbnailUrl ?? fileItem.url}
+            deletable={props.deletable}
             deleteIcon={props.deleteIcon}
-            status={task.status}
             imageFit={props.imageFit}
-            onDelete={() => {
-              setTasks(tasks.filter(x => x.id !== task.id))
+            onClick={() => {
+              if (props.preview) {
+                previewImage(index)
+              }
+              onPreview && onPreview(index, fileItem)
+            }}
+            onDelete={async () => {
+              const canDelete = await props.onDelete?.(fileItem)
+              if (canDelete === false) return
+              setValue(value.filter((x, i) => i !== index))
             }}
           />
         )
-      })}
-      <div
-        className={`${classPrefix}-upload-button-wrap`}
-        style={showUpload ? undefined : { display: 'none' }}
-      >
-        {props.children || (
-          <span
-            className={`${classPrefix}-cell ${classPrefix}-upload-button`}
-            role='button'
-            aria-label={locale.ImageUploader.upload}
-          >
-            <span className={`${classPrefix}-upload-button-icon`}>
-              <AddOutline />
+        return renderItem ? renderItem(originNode, fileItem, value) : originNode
+      })
+    }
+
+    const contentNode = (
+      <>
+        {renderImages()}
+        {tasks.map(task => {
+          if (!props.showFailed && task.status === 'fail') {
+            return null
+          }
+          return (
+            <PreviewItem
+              key={task.id}
+              file={task.file}
+              deletable={task.status !== 'pending'}
+              deleteIcon={props.deleteIcon}
+              status={task.status}
+              imageFit={props.imageFit}
+              onDelete={() => {
+                setTasks(tasks.filter(x => x.id !== task.id))
+              }}
+            />
+          )
+        })}
+        <div
+          className={`${classPrefix}-upload-button-wrap`}
+          style={showUpload ? undefined : { display: 'none' }}
+        >
+          {props.children || (
+            <span
+              className={`${classPrefix}-cell ${classPrefix}-upload-button`}
+              role='button'
+              aria-label={locale.ImageUploader.upload}
+            >
+              <span className={`${classPrefix}-upload-button-icon`}>
+                <AddOutline />
+              </span>
             </span>
-          </span>
-        )}
-        {!props.disableUpload && (
-          <input
-            capture={props.capture}
-            accept={props.accept}
-            multiple={props.multiple}
-            type='file'
-            className={`${classPrefix}-input`}
-            onChange={onChange}
-            aria-hidden
-          />
+          )}
+          {!props.disableUpload && (
+            <input
+              ref={inputRef}
+              capture={props.capture}
+              accept={props.accept}
+              multiple={props.multiple}
+              type='file'
+              className={`${classPrefix}-input`}
+              onChange={onChange}
+              aria-hidden
+            />
+          )}
+        </div>
+      </>
+    )
+
+    useImperativeHandle(ref, () => ({
+      get nativeElement() {
+        return inputRef.current
+      },
+    }))
+
+    return withNativeProps(
+      props,
+      <div className={classPrefix} ref={containerRef}>
+        {columns ? (
+          <Grid
+            className={`${classPrefix}-grid`}
+            columns={columns}
+            style={style}
+          >
+            <div className={`${classPrefix}-gap-measure`} ref={gapMeasureRef} />
+            {contentNode.props.children}
+          </Grid>
+        ) : (
+          <Space className={`${classPrefix}-space`} wrap block>
+            {contentNode.props.children}
+          </Space>
         )}
       </div>
-    </>
-  )
-
-  return withNativeProps(
-    props,
-    <div className={classPrefix} ref={containerRef}>
-      {columns ? (
-        <Grid className={`${classPrefix}-grid`} columns={columns} style={style}>
-          <div className={`${classPrefix}-gap-measure`} ref={gapMeasureRef} />
-          {contentNode.props.children}
-        </Grid>
-      ) : (
-        <Space className={`${classPrefix}-space`} wrap block>
-          {contentNode.props.children}
-        </Space>
-      )}
-    </div>
-  )
-}
+    )
+  }
+)
diff --git a/src/components/image-uploader/index.en.md b/src/components/image-uploader/index.en.md
index 5ef8779a7..98e5cde11 100644
--- a/src/components/image-uploader/index.en.md
+++ b/src/components/image-uploader/index.en.md
@@ -36,6 +36,12 @@
 | onUploadQueueChange | Triggered when the image upload queue changes | `(tasks: UploadTask[]) => void` |
 | renderItem | Custom item of uploadList | `(originNode: React.ReactElement, file: ImageUploadItem, fileList: ImageUploadItem[] ) => React.ReactNode` | - |
 
+### Ref
+
+| Name          | Description          | Type                       | Version |
+| ------------- | -------------------- | -------------------------- | ------- |
+| nativeElement | native input element | `HTMLInputElement \| null` | 5.33.0  |
+
 ### ImageUploadItem
 
 | Name | Description | Type | Default |
diff --git a/src/components/image-uploader/index.ts b/src/components/image-uploader/index.ts
index 6f7eb9059..b3380e3f1 100644
--- a/src/components/image-uploader/index.ts
+++ b/src/components/image-uploader/index.ts
@@ -3,6 +3,7 @@ import './image-uploader.less'
 
 export type {
   ImageUploadItem,
+  ImageUploaderRef,
   ImageUploaderProps,
   UploadTask,
 } from './image-uploader'
diff --git a/src/components/image-uploader/index.zh.md b/src/components/image-uploader/index.zh.md
index d9678c508..5e15cb87d 100644
--- a/src/components/image-uploader/index.zh.md
+++ b/src/components/image-uploader/index.zh.md
@@ -36,6 +36,12 @@
 | onUploadQueueChange | 图片上传队列变化时触发 | `(tasks: UploadTask[]) => void` | - |
 | renderItem | 自定义上传列表项 | `(originNode: React.ReactElement, file: ImageUploadItem, fileList: ImageUploadItem[] ) => React.ReactNode` | - |
 
+### Ref
+
+| 属性          | 说明           | 类型                       | 版本   |
+| ------------- | -------------- | -------------------------- | ------ |
+| nativeElement | 原生输入框节点 | `HTMLInputElement \| null` | 5.33.0 |
+
 ### ImageUploadItem
 
 | 属性 | 说明 | 类型 | 默认值 |
diff --git a/src/index.ts b/src/index.ts
index d1f21bb94..5c10894cf 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -100,6 +100,7 @@ export { default as ImageUploader } from './components/image-uploader'
 export type {
   ImageUploaderProps,
   ImageUploadItem,
+  ImageUploaderRef,
 } from './components/image-uploader'
 export { default as ImageViewer } from './components/image-viewer'
 export type {


Test patch:
diff --git a/src/components/image-uploader/tests/image-uploader.test.tsx b/src/components/image-uploader/tests/image-uploader.test.tsx
index 227a97e9b..0d9c23322 100644
--- a/src/components/image-uploader/tests/image-uploader.test.tsx
+++ b/src/components/image-uploader/tests/image-uploader.test.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react'
+import React, { createRef, forwardRef, useState } from 'react'
 import {
   render,
   testA11y,
@@ -11,7 +11,7 @@ import {
   act,
   waitForElementToBeRemoved,
 } from 'testing'
-import ImageUploader, { ImageUploadItem } from '..'
+import ImageUploader, { ImageUploadItem, ImageUploaderRef } from '..'
 import Dialog from '../../dialog'
 
 const classPrefix = `adm-image-uploader`
@@ -64,7 +64,7 @@ describe('ImageUploader', () => {
     jest.useRealTimers()
   })
 
-  const App = (props: any) => {
+  const App = forwardRef<ImageUploaderRef, any>((props, ref) => {
     const [fileList, setFileList] = useState<ImageUploadItem[]>([
       {
         url: demoSrc,
@@ -73,13 +73,14 @@ describe('ImageUploader', () => {
 
     return (
       <ImageUploader
+        ref={ref}
         value={fileList}
         onChange={setFileList}
         upload={mockUpload}
         {...props}
       />
     )
-  }
+  })
 
   test('a11y', async () => {
     jest.useRealTimers()
@@ -380,4 +381,12 @@ describe('ImageUploader', () => {
     expect(fn).toBeCalledWith([{ id: 0, status: 'success' }])
     expect(fn.mock.lastCall[0]).toMatchObject([])
   })
+
+  test('task get nativeElement', () => {
+    const ref = createRef<ImageUploaderRef>()
+
+    render(<App ref={ref} />)
+    expect(ref.current).toBeDefined()
+    expect(ref.current?.nativeElement).toBeDefined()
+  })
 })