UNPKG

@carbon/react

Version:

React components for the Carbon Design System

279 lines (277 loc) 9.32 kB
/** * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ import { usePrefix } from "../../internal/usePrefix.js"; import { Text } from "../Text/Text.js"; import { Enter, Space } from "../../internal/keyboard/keys.js"; import { matches } from "../../internal/keyboard/match.js"; import { useId } from "../../internal/useId.js"; import { useFeatureFlag } from "../FeatureFlags/index.js"; import { ButtonKinds } from "../Button/Button.js"; import Filename from "./Filename.js"; import FileUploaderButton from "./FileUploaderButton.js"; import classNames from "classnames"; import React, { forwardRef, useCallback, useImperativeHandle, useState } from "react"; import PropTypes from "prop-types"; import { jsx, jsxs } from "react/jsx-runtime"; //#region src/components/FileUploader/FileUploader.tsx /** * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ const FileUploader = forwardRef(({ accept, buttonKind, buttonLabel, className, disabled, filenameStatus, iconDescription, labelDescription, labelTitle, maxFileSize, multiple, name, onAddFiles, onChange, onClick, onDelete, size, ...other }, ref) => { const fileUploaderInstanceId = useId("file-uploader"); const prefix = usePrefix(); const enhancedFileUploaderEnabled = useFeatureFlag("enable-enhanced-file-uploader"); const [fileItems, setFileItems] = useState([]); const [legacyFileNames, setLegacyFileNames] = useState([]); const uploaderButton = React.createRef(); const nodes = []; const createFileItem = useCallback((file) => ({ name: file.name, uuid: `${fileUploaderInstanceId}-${Date.now()}-${Array.from(crypto.getRandomValues(new Uint8Array(8))).map((b) => b.toString(36)).join("")}`, file }), [fileUploaderInstanceId]); /** * Validates files based on file size restrictions. * Marks invalid files with `invalidFileType: true` but includes them in the result. * * Note: The `accept` prop is passed to the native HTML input element (`FileUploaderButton`), * which provides UI-level filtering in the file picker dialog, but there is no JavaScript validation * for file types - users can bypass this by changing the file type filter in the dialog. * https://github.com/carbon-design-system/carbon/issues/21166 */ const validateFiles = useCallback((files) => { return files.map((file) => { if (maxFileSize && file.size > maxFileSize) file.invalidFileType = true; return file; }); }, [maxFileSize]); const handleChange = useCallback((evt) => { evt.stopPropagation(); const incomingFiles = Array.from(evt.target.files); const validatedFiles = validateFiles(multiple ? incomingFiles : [incomingFiles[0]]); if (onAddFiles) onAddFiles(evt, { addedFiles: validatedFiles }); const validFiles = validatedFiles.filter((file) => !file.invalidFileType); if (validFiles.length === 0) return; if (enhancedFileUploaderEnabled) { const newFileItems = validFiles.map(createFileItem); let updatedFileItems; if (multiple) { const existingNames = new Set(fileItems.map((item) => item.name)); const uniqueNewItems = newFileItems.filter((item) => !existingNames.has(item.name)); updatedFileItems = [...fileItems, ...uniqueNewItems]; } else updatedFileItems = newFileItems; setFileItems(updatedFileItems); if (onChange) { const allFiles = updatedFileItems.map((item) => item.file); onChange({ ...evt, target: { ...evt.target, files: Object.assign(allFiles, { item: (index) => allFiles[index] || null }), addedFiles: newFileItems, currentFiles: updatedFileItems, action: "add" } }); } } else { const filenames = validFiles.map((file) => file.name); setLegacyFileNames(multiple ? [...new Set([...legacyFileNames, ...filenames])] : filenames); if (onChange) onChange(evt); } }, [ enhancedFileUploaderEnabled, fileItems, legacyFileNames, multiple, onAddFiles, onChange, createFileItem, validateFiles ]); const handleClick = useCallback((evt, { index, filenameStatus }) => { if (filenameStatus === "edit") { evt.stopPropagation(); if (enhancedFileUploaderEnabled) { const deletedItem = fileItems[index]; if (!deletedItem) return; const remainingItems = fileItems.filter((_, i) => i !== index); setFileItems(remainingItems); const remainingFiles = remainingItems.map((item) => item.file); const enhancedEvent = { ...evt, target: { ...evt.target, files: Object.assign(remainingFiles, { item: (index) => remainingFiles[index] || null }), deletedFile: deletedItem, deletedFileName: deletedItem.name, remainingFiles: remainingItems, currentFiles: remainingItems, action: "remove" } }; if (onDelete) onDelete(enhancedEvent); if (onChange) onChange(enhancedEvent); } else { const deletedFileName = legacyFileNames[index]; setLegacyFileNames(legacyFileNames.filter((filename) => filename !== deletedFileName)); if (onDelete) onDelete(evt); } if (onClick) onClick(evt); uploaderButton.current?.focus?.(); } }, [ enhancedFileUploaderEnabled, fileItems, legacyFileNames, onDelete, onChange, onClick, uploaderButton ]); useImperativeHandle(ref, () => ({ clearFiles() { if (enhancedFileUploaderEnabled) { const previousItems = [...fileItems]; setFileItems([]); if (onChange && previousItems.length > 0) onChange({ target: { files: Object.assign([], { item: () => null }), clearedFiles: previousItems, currentFiles: [], action: "clear" }, preventDefault: () => {}, stopPropagation: () => {} }); } else setLegacyFileNames([]); }, ...enhancedFileUploaderEnabled && { getCurrentFiles() { return [...fileItems]; } } }), [ enhancedFileUploaderEnabled, fileItems, onChange ]); const classes = classNames({ [`${prefix}--form-item`]: true, [className]: className }); const getHelperLabelClasses = (baseClass) => classNames(baseClass, { [`${prefix}--label-description--disabled`]: disabled }); const selectedFileClasses = classNames(`${prefix}--file__selected-file`, { [`${prefix}--file__selected-file--md`]: size === "field" || size === "md", [`${prefix}--file__selected-file--sm`]: size === "small" || size === "sm" }); const displayFiles = enhancedFileUploaderEnabled ? fileItems.map((item, index) => ({ name: item.name, key: item.uuid, index })) : legacyFileNames.map((name, index) => ({ name, key: index, index })); return /* @__PURE__ */ jsxs("div", { className: classes, ...other, children: [ !labelTitle ? null : /* @__PURE__ */ jsx(Text, { as: "h3", className: getHelperLabelClasses(`${prefix}--file--label`), children: labelTitle }), /* @__PURE__ */ jsx(Text, { as: "p", className: getHelperLabelClasses(`${prefix}--label-description`), id: fileUploaderInstanceId, children: labelDescription }), /* @__PURE__ */ jsx(FileUploaderButton, { innerRef: uploaderButton, disabled, labelText: buttonLabel, multiple, buttonKind, onChange: handleChange, disableLabelChanges: true, accept, name, size, "aria-describedby": fileUploaderInstanceId }), /* @__PURE__ */ jsx("div", { className: `${prefix}--file-container`, children: displayFiles.length === 0 ? null : displayFiles.map((file) => /* @__PURE__ */ jsxs("span", { className: selectedFileClasses, ref: (node) => { nodes[file.index] = node; }, ...other, children: [/* @__PURE__ */ jsx(Text, { as: "p", className: `${prefix}--file-filename`, id: enhancedFileUploaderEnabled ? `${fileUploaderInstanceId}-file-${fileItems[file.index]?.uuid || file.index}` : `${fileUploaderInstanceId}-file-${file.index}`, children: file.name }), /* @__PURE__ */ jsx("span", { className: `${prefix}--file__state-container`, children: /* @__PURE__ */ jsx(Filename, { name: file.name, iconDescription, status: filenameStatus, onKeyDown: (evt) => { if (matches(evt, [Enter, Space])) handleClick(evt, { index: file.index, filenameStatus }); }, onClick: (evt) => handleClick(evt, { index: file.index, filenameStatus }) }) })] }, file.key)) }) ] }); }); FileUploader.displayName = "FileUploader"; FileUploader.propTypes = { accept: PropTypes.arrayOf(PropTypes.string), buttonKind: PropTypes.oneOf(ButtonKinds), buttonLabel: PropTypes.string, className: PropTypes.string, disabled: PropTypes.bool, filenameStatus: PropTypes.oneOf([ "edit", "complete", "uploading" ]).isRequired, iconDescription: PropTypes.string, labelDescription: PropTypes.string, labelTitle: PropTypes.string, maxFileSize: PropTypes.number, multiple: PropTypes.bool, name: PropTypes.string, onAddFiles: PropTypes.func, onChange: PropTypes.func, onClick: PropTypes.func, onDelete: PropTypes.func, size: PropTypes.oneOf([ "sm", "small", "md", "field", "lg" ]) }; //#endregion export { FileUploader as default };