diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 746d4afdb6e1..761b5a3ee06e 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -3314,9 +3314,7 @@ Map { "render": [Function], }, "FileUploader" => Object { - "contextType": Object { - "$$typeof": Symbol(react.context), - }, + "$$typeof": Symbol(react.forward_ref), "propTypes": Object { "accept": Object { "args": Array [ @@ -3396,6 +3394,7 @@ Map { "type": "oneOf", }, }, + "render": [Function], }, "FileUploaderButton" => Object { "propTypes": Object { diff --git a/packages/react/src/components/FileUploader/FileUploader.tsx b/packages/react/src/components/FileUploader/FileUploader.tsx index 145eeee321b8..4fbacbabff77 100644 --- a/packages/react/src/components/FileUploader/FileUploader.tsx +++ b/packages/react/src/components/FileUploader/FileUploader.tsx @@ -7,14 +7,15 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useState, ForwardedRef, ReactElement } from 'react'; import Filename from './Filename'; import FileUploaderButton from './FileUploaderButton'; import { ButtonKinds } from '../../prop-types/types'; import { keys, matches } from '../../internal/keyboard'; -import { PrefixContext } from '../../internal/usePrefix'; +import { usePrefix } from '../../internal/usePrefix'; import { ReactAttr } from '../../types/common'; import { Text } from '../Text'; +import { useId } from '../../internal/useId'; export interface FileUploaderProps extends ReactAttr { /** @@ -106,174 +107,78 @@ export interface FileUploaderProps extends ReactAttr { size?: 'sm' | 'small' | 'md' | 'field' | 'lg'; } -export default class FileUploader extends React.Component< - FileUploaderProps, - { filenames: string[] } -> { - static propTypes = { - /** - * Specify the types of files that this input should be able to receive - */ - accept: PropTypes.arrayOf(PropTypes.string), - - /** - * Specify the type of the `` - */ - buttonKind: PropTypes.oneOf(ButtonKinds), - - /** - * Provide the label text to be read by screen readers when interacting with - * the `` - */ - buttonLabel: PropTypes.string, - - /** - * Provide a custom className to be applied to the container node - */ - className: PropTypes.string, - - /** - * Specify whether file input is disabled - */ - disabled: PropTypes.bool, - - /** - * Specify the status of the File Upload - */ - filenameStatus: PropTypes.oneOf(['edit', 'complete', 'uploading']) - .isRequired, - - /** - * Provide a description for the complete/close icon that can be read by screen readers - */ - iconDescription: PropTypes.string, - - /** - * Specify the description text of this `` - */ - labelDescription: PropTypes.string, - - /** - * Specify the title text of this `` - */ - labelTitle: PropTypes.string, - - /** - * Specify if the component should accept multiple files to upload - */ - multiple: PropTypes.bool, - - /** - * Provide a name for the underlying `` node - */ - name: PropTypes.string, - - /** - * Provide an optional `onChange` hook that is called each time the input is - * changed - */ - onChange: PropTypes.func, - - /** - * Provide an optional `onClick` hook that is called each time the - * FileUploader is clicked - */ - onClick: PropTypes.func, - - /** - * Provide an optional `onDelete` hook that is called when an uploaded item - * is removed - */ - onDelete: PropTypes.func, - - /** - * Specify the size of the FileUploaderButton, from a list of available - * sizes. - */ - size: PropTypes.oneOf(['sm', 'md', 'lg']), - }; - - static contextType = PrefixContext; - - state = { - filenames: [] as string[], - }; - - nodes: HTMLElement[] = []; - - uploaderButton = React.createRef(); - - static getDerivedStateFromProps({ filenameStatus }, state) { - const { prevFilenameStatus } = state; - return prevFilenameStatus === filenameStatus - ? null - : { - filenameStatus, - prevFilenameStatus: filenameStatus, - }; - } - - handleChange = (evt) => { - evt.stopPropagation(); - const filenames = Array.prototype.map.call( - evt.target.files, - (file) => file.name - ) as string[]; - this.setState({ - filenames: this.props.multiple - ? [...new Set([...this.state.filenames, ...filenames])] - : filenames, - }); - if (this.props.onChange) { - this.props.onChange(evt); - } - }; - - handleClick = (evt, { index, filenameStatus }) => { - if (filenameStatus === 'edit') { - evt.stopPropagation(); - const filteredArray = this.state.filenames.filter( - (filename) => filename !== this.nodes[index].innerText.trim() - ); - this.setState({ filenames: filteredArray }); - if (this.props.onDelete) { - this.props.onDelete(evt); - this.uploaderButton.current?.focus?.(); - } - this.props.onClick?.(evt); - } - }; - - clearFiles = () => { - // A clearFiles function that resets filenames and can be referenced using a ref by the parent. - this.setState({ filenames: [] }); - }; - - render() { - const { +const FileUploader = React.forwardRef( + ( + { + accept, + buttonKind, + buttonLabel, + className, + disabled, + filenameStatus, iconDescription, - buttonLabel = '', - buttonKind = 'primary', - disabled = false, - filenameStatus = 'uploading', labelDescription, labelTitle, - className, - multiple = false, - accept = [], + multiple, name, - size = 'md', - onDelete, // eslint-disable-line + onChange, + onClick, + onDelete, + size, ...other - } = this.props; + }: FileUploaderProps, + ref: ForwardedRef + ) => { + const fileUploaderInstanceId = useId('file-uploader'); + const [state, updateState] = useState({ + fileNames: [] as (string | undefined)[], + }); + const nodes: HTMLElement[] = []; + const prefix = usePrefix(); - const prefix = this.context; + const handleChange = (evt) => { + evt.stopPropagation(); + const filenames = Array.prototype.map.call( + evt.target.files, + (file) => file.name + ) as string[]; + updateState((prevState) => ({ + fileNames: multiple + ? [...new Set([...prevState.fileNames, ...filenames])] + : filenames, + })); + if (onChange) { + onChange(evt); + } + }; + + const handleClick = (evt, { index, filenameStatus }) => { + if (filenameStatus === 'edit') { + evt.stopPropagation(); + const filteredArray = state.fileNames.filter( + (filename) => filename !== nodes[index]?.innerText?.trim() + ); + + updateState({ fileNames: filteredArray }); + + if (onDelete) { + onDelete(evt); + uploaderButton.current?.focus?.(); + } + onClick?.(evt); + } + }; + const clearFiles = () => { + // A clearFiles function that resets filenames and can be referenced using a ref by the parent. + updateState({ fileNames: [] }); + }; + + const uploaderButton = React.createRef(); const classes = classNames({ [`${prefix}--form-item`]: true, [className as string]: className, }); - const getHelperLabelClasses = (baseClass) => classNames(baseClass, { [`${prefix}--label-description--disabled`]: disabled, @@ -296,30 +201,30 @@ export default class FileUploader extends React.Component< + id={fileUploaderInstanceId}> {labelDescription}
- {this.state.filenames.length === 0 + {state.fileNames.length === 0 ? null - : this.state.filenames.map((name, index) => ( + : state.fileNames.map((name, index) => ( (this.nodes[index] = node as HTMLSpanElement)} // eslint-disable-line + ref={(node) => (nodes[index] = node as HTMLSpanElement)} // eslint-disable-line {...other}> {name} @@ -336,11 +241,11 @@ export default class FileUploader extends React.Component< keys.Space, ]) ) { - this.handleClick(evt, { index, filenameStatus }); + handleClick(evt, { index, filenameStatus }); } }} onClick={(evt) => - this.handleClick(evt, { index, filenameStatus }) + handleClick(evt, { index, filenameStatus }) } /> @@ -350,4 +255,93 @@ export default class FileUploader extends React.Component<
); } -} +) as { + (props: FileUploaderProps): ReactElement; + propTypes?: any; + contextTypes?: any; + defaultProps?: any; +}; + +FileUploader.propTypes = { + /** + * Specify the types of files that this input should be able to receive + */ + accept: PropTypes.arrayOf(PropTypes.string), + + /** + * Specify the type of the `` + */ + buttonKind: PropTypes.oneOf(ButtonKinds), + + /** + * Provide the label text to be read by screen readers when interacting with + * the `` + */ + buttonLabel: PropTypes.string, + + /** + * Provide a custom className to be applied to the container node + */ + className: PropTypes.string, + + /** + * Specify whether file input is disabled + */ + disabled: PropTypes.bool, + + /** + * Specify the status of the File Upload + */ + filenameStatus: PropTypes.oneOf(['edit', 'complete', 'uploading']).isRequired, + + /** + * Provide a description for the complete/close icon that can be read by screen readers + */ + iconDescription: PropTypes.string, + + /** + * Specify the description text of this `` + */ + labelDescription: PropTypes.string, + + /** + * Specify the title text of this `` + */ + labelTitle: PropTypes.string, + + /** + * Specify if the component should accept multiple files to upload + */ + multiple: PropTypes.bool, + + /** + * Provide a name for the underlying `` node + */ + name: PropTypes.string, + + /** + * Provide an optional `onChange` hook that is called each time the input is + * changed + */ + onChange: PropTypes.func, + + /** + * Provide an optional `onClick` hook that is called each time the + * FileUploader is clicked + */ + onClick: PropTypes.func, + + /** + * Provide an optional `onDelete` hook that is called when an uploaded item + * is removed + */ + onDelete: PropTypes.func, + + /** + * Specify the size of the FileUploaderButton, from a list of available + * sizes. + */ + size: PropTypes.oneOf(['sm', 'md', 'lg']), +}; + +export default FileUploader; diff --git a/packages/react/src/components/FileUploader/__tests__/FileUploader-test.js b/packages/react/src/components/FileUploader/__tests__/FileUploader-test.js index 72cf8c1ba114..a3164aed7274 100644 --- a/packages/react/src/components/FileUploader/__tests__/FileUploader-test.js +++ b/packages/react/src/components/FileUploader/__tests__/FileUploader-test.js @@ -10,6 +10,7 @@ import { act, render } from '@testing-library/react'; import React from 'react'; import FileUploader from '../'; import { uploadFiles } from '../test-helpers'; +import { Simulate } from 'react-dom/test-utils'; const iconDescription = 'test description'; const requiredProps = { @@ -48,22 +49,38 @@ describe('FileUploader', () => { it('should clear all uploaded files when `clearFiles` is called on a ref', () => { const ref = React.createRef(); - const { container } = render(); + const onClick = jest.fn(); + let requiredProps1 = { + ...requiredProps, + filenameStatus: 'edit', + }; + const fileUpload = render( + + ); // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const input = container.querySelector('input'); + const input = fileUpload.container.querySelector('input'); const filename = 'test.png'; act(() => { uploadFiles(input, [new File(['test'], filename, { type: 'image/png' })]); }); + expect(getByText(fileUpload.container, filename)).toBeInstanceOf( + HTMLElement + ); - // eslint-disable-next-line testing-library/prefer-screen-queries - expect(getByText(container, filename)).toBeInstanceOf(HTMLElement); + const onDelete = jest.fn(); + const description = 'test-description'; + // eslint-disable-next-line testing-library/render-result-naming-convention + + let removeFile = getByLabel( + fileUpload.container, + 'test description - test.png' + ); act(() => { - ref.current.clearFiles(); + Simulate.click(removeFile); }); - // eslint-disable-next-line testing-library/prefer-screen-queries - expect(getByText(container, filename)).not.toBeInstanceOf(HTMLElement); + + expect(onClick).toHaveBeenCalledTimes(1); }); it('should synchronize the filename status state when its prop changes', () => {