UNPKG

@octopusdeploy/design-system-components

Version:
277 lines (276 loc) • 14.5 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.FileUpload = FileUpload; const jsx_runtime_1 = require("react/jsx-runtime"); const css_1 = require("@emotion/css"); const design_system_icons_1 = require("@octopusdeploy/design-system-icons"); const design_system_tokens_1 = require("@octopusdeploy/design-system-tokens"); const react_1 = require("react"); const react_dropzone_1 = require("react-dropzone"); const utils_1 = require("../../../utils"); const Button_1 = require("../../Button"); const FormDescription_1 = require("../Primitives/FormDescription"); const InputLabel_1 = __importDefault(require("../Primitives/InputLabel")); const InputValidationMessage_1 = require("../Primitives/InputValidationMessage"); const ONE_TB_BYTES = 1000 * 1000 * 1000 * 1000; const MAX_SUPPORTED_FILE_SIZE = ONE_TB_BYTES; function FileUpload({ label, description, popover, hasOptionalMarker, hasRequiredMarker, hasDefaultMarker, validationMessages, validationState = "idle", validatingMessage = "Validation in progress", onChange, onError, filesLimit = 1, maxFileSizeBytes = MAX_SUPPORTED_FILE_SIZE, acceptedFileTypes, }) { const baseId = (0, react_1.useId)(); const inputId = `fileUpload-${baseId}`; const validationId = `${inputId}-validation`; if (maxFileSizeBytes <= 0) { maxFileSizeBytes = MAX_SUPPORTED_FILE_SIZE; } const acceptedFileTypesSet = (0, react_1.useMemo)(() => new Set(acceptedFileTypes?.map((ext) => ext.toLowerCase().trim())), [acceptedFileTypes]); const [files, setFiles] = (0, react_1.useState)([]); const [internalError, setInternalError] = (0, react_1.useState)(); const setError = (error) => { setInternalError(error); onError?.(error); }; const maxFileSizeText = formatBytes(maxFileSizeBytes); const handleChange = (files) => { setError(undefined); setFiles(files); onChange(files); }; const handleDrop = (acceptedFiles, rejectedFiles) => { if (filesLimit > 1 && files.length + acceptedFiles.length > filesLimit) { setError(`Maximum number of files exceeded. You can upload up to ${filesLimit} files.`); return; } if (acceptedFiles.length > 0) { handleChange(filesLimit <= 1 ? acceptedFiles : [...files, ...acceptedFiles]); } if (rejectedFiles.length === 0) { setError(undefined); } else { if (rejectedFiles.length > 0 && rejectedFiles[0].errors.length > 0) { const rejectedFile = rejectedFiles[0]; const error = rejectedFiles[0].errors[0]; let message = ""; switch (error.code) { case react_dropzone_1.ErrorCode.FileTooLarge: message = `File ${rejectedFile.file.name} is too big. Size limit is ` + maxFileSizeText + "."; break; case react_dropzone_1.ErrorCode.FileInvalidType: message = `File type for ${rejectedFile.file.name} is not supported.`; break; case react_dropzone_1.ErrorCode.TooManyFiles: message = "Maximum number of files exceeded. "; message += filesLimit > 1 ? `You can upload up to ${filesLimit} files.` : "You can upload a single file."; break; default: message = `File ${rejectedFile.file.name} was rejected. `; } setError(message); } } }; const validateFileType = (file) => { if (acceptedFileTypesSet.size === 0) { return true; } const filename = file.name.toLowerCase(); const lastDotIndex = filename.lastIndexOf("."); if (lastDotIndex === -1) { return false; } const extension = filename.slice(lastDotIndex); return acceptedFileTypesSet.has(extension); }; const handleRemoveFile = (removedFile) => { handleChange(files.filter((file) => file !== removedFile)); }; const { getRootProps, getInputProps, isDragActive, isDragReject } = (0, react_dropzone_1.useDropzone)({ onDrop: handleDrop, maxSize: maxFileSizeBytes, maxFiles: filesLimit, multiple: filesLimit > 1, noKeyboard: false, noClick: false, validator: (file) => { if (!validateFileType(file)) { return { code: react_dropzone_1.ErrorCode.FileInvalidType, message: "File type not accepted", }; } return null; }, }); const acceptString = acceptedFileTypes?.join(","); const invalidCount = (0, react_1.useMemo)(() => { if (!validationMessages) return 0; let count = 0; for (const file of files) { const message = validationMessages.get(file); if (message) { count++; } } return count; }, [files, validationMessages]); const globalMessage = computeGlobalMessage({ validationState, internalError, validatingMessage, invalidCount, totalFiles: files.length, }); return ((0, jsx_runtime_1.jsxs)("div", { className: fileUploadStyles.container, children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)(InputLabel_1.default, { label: label, htmlFor: inputId, hasRequiredMarker: hasRequiredMarker, hasOptionalMarker: hasOptionalMarker, hasDefaultMarker: hasDefaultMarker, popover: popover }), description && (0, jsx_runtime_1.jsx)(FormDescription_1.FormDescription, { id: `${inputId}-description`, description: description })] }), (0, jsx_runtime_1.jsxs)("div", { ...getRootProps({ className: (0, css_1.cx)(fileUploadStyles.uploadArea, { [fileUploadStyles.dragActive]: isDragActive && !isDragReject, [fileUploadStyles.dragReject]: isDragActive && isDragReject, }), }), children: [(0, jsx_runtime_1.jsx)("div", { className: fileUploadStyles.uploadIconContainer, children: (0, jsx_runtime_1.jsx)(design_system_icons_1.CloudArrowUpIcon, { size: 24, color: design_system_tokens_1.themeTokens.color.icon.primary }) }), (0, jsx_runtime_1.jsxs)("p", { className: fileUploadStyles.uploadText, children: [(0, jsx_runtime_1.jsx)("span", { className: fileUploadStyles.boldUploadText, children: "Browse" }), " or drag and drop a file"] }), (0, jsx_runtime_1.jsx)("input", { ...getInputProps({ id: inputId, accept: acceptString, "aria-describedby": [description ? `${inputId}-description` : null, globalMessage ? validationId : null].filter(Boolean).join(" ") || undefined, "aria-required": hasRequiredMarker, }) })] }), globalMessage && (0, jsx_runtime_1.jsx)(InputValidationMessage_1.InputValidationMessage, { message: globalMessage.message, displayState: globalMessage.displayState, id: validationId }), files.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: fileUploadStyles.fileList, "aria-label": "File list", children: files.map((file, index) => { const fileError = validationMessages?.get(file); return ((0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)(File, { file: file, onRemove: () => handleRemoveFile(file), validationMessage: fileError, validationId: `${inputId}-file-${index}-validation` }) }, index)); }) }))] })); } function computeGlobalMessage({ validationState, internalError, validatingMessage, invalidCount, totalFiles }) { if (internalError) { return { message: internalError, displayState: "error" }; } if (validationState === "validating") { return { message: validatingMessage, displayState: "progress" }; } if (validationState === "error" && invalidCount > 0 && totalFiles > 1) { return { message: `${invalidCount} of ${totalFiles} files failed validation.`, displayState: "error", }; } return undefined; } function formatBytes(bytes) { if (bytes === 0) { return "0 B"; } const baseBytes = 1024; const units = ["B", "KB", "MB", "GB", "TB"]; const targetUnitIndex = Math.floor(Math.log(bytes) / Math.log(baseBytes)); const unitIndex = Math.min(targetUnitIndex, units.length - 1); const divisor = Math.pow(baseBytes, unitIndex); const valueInUnit = bytes / divisor; const roundedValue = parseFloat(valueInUnit.toFixed(2)); return `${roundedValue} ${units[unitIndex]}`; } function File({ file, onRemove, validationMessage, validationId }) { const hasError = !!validationMessage; const filenameId = `${validationId}-filename`; return ((0, jsx_runtime_1.jsxs)("div", { className: fileStyles.wrapper, role: "group", "aria-labelledby": filenameId, "aria-describedby": hasError ? validationId : undefined, "aria-invalid": hasError || undefined, children: [(0, jsx_runtime_1.jsxs)("div", { className: (0, css_1.cx)(fileStyles.container, { [fileStyles.containerError]: hasError }), children: [(0, jsx_runtime_1.jsx)("p", { id: filenameId, className: fileStyles.filename, title: file.name, children: file.name }), (0, jsx_runtime_1.jsx)(Button_1.Button, { icon: (0, jsx_runtime_1.jsx)(design_system_icons_1.TrashIcon, { size: 20 }), size: "small", importance: "neutral", onClick: onRemove, accessibleName: `Remove ${file.name}` })] }), hasError && ((0, jsx_runtime_1.jsx)("div", { className: fileStyles.errorMessage, children: (0, jsx_runtime_1.jsx)(InputValidationMessage_1.InputValidationMessage, { message: validationMessage, displayState: "error", id: validationId }) }))] })); } const uploadIconBackgroundCssVar = "--upload-icon-container-background"; const fileUploadStyles = { container: (0, css_1.css)({ display: "flex", flexDirection: "column", gap: design_system_tokens_1.space[8], width: "100%", maxWidth: "800px", }), uploadArea: (0, css_1.css)({ position: "relative", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", boxSizing: "border-box", padding: `${design_system_tokens_1.space[48]} ${design_system_tokens_1.space[24]}`, gap: design_system_tokens_1.space[16], height: 180, borderRadius: design_system_tokens_1.borderRadius.small, background: design_system_tokens_1.themeTokens.color.background.secondary.default, border: `${design_system_tokens_1.borderWidth[1]} dashed ${design_system_tokens_1.themeTokens.color.border.tertiary}`, cursor: "pointer", transition: "borderColor 120ms linear", "&:hover": { borderColor: design_system_tokens_1.themeTokens.color.border.bold, [uploadIconBackgroundCssVar]: design_system_tokens_1.themeTokens.color.background.tertiary, }, "&:focus-visible": { borderStyle: "solid", outline: `${design_system_tokens_1.borderWidth[2]} solid ${design_system_tokens_1.themeTokens.color.border.selected}`, outlineOffset: "0", borderColor: design_system_tokens_1.themeTokens.color.border.selected, }, }), dragActive: (0, css_1.css)({ borderStyle: "solid", outline: `${design_system_tokens_1.borderWidth[2]} solid ${design_system_tokens_1.themeTokens.color.border.selected}`, outlineOffset: "0", borderColor: design_system_tokens_1.themeTokens.color.border.selected, }), dragReject: (0, css_1.css)({ borderStyle: "solid", outline: `${design_system_tokens_1.borderWidth[2]} solid ${design_system_tokens_1.themeTokens.color.border.danger}`, outlineOffset: "0", borderColor: design_system_tokens_1.themeTokens.color.border.danger, }), uploadIconContainer: (0, css_1.css)({ borderRadius: design_system_tokens_1.borderRadius.circle, background: `var(${uploadIconBackgroundCssVar}, ${design_system_tokens_1.themeTokens.color.background.bold})`, padding: design_system_tokens_1.space[12], transition: "background 120ms linear", }), uploadText: (0, css_1.css)({ ...utils_1.resetStyles.paragraph, font: design_system_tokens_1.text.body.regular.medium, color: design_system_tokens_1.themeTokens.color.text.primary, textAlign: "center", }), boldUploadText: (0, css_1.css)({ font: design_system_tokens_1.text.body.bold.medium, color: design_system_tokens_1.themeTokens.color.text.link.default, }), fileList: (0, css_1.css)({ ...utils_1.resetStyles.list, display: "flex", flexDirection: "column", paddingTop: design_system_tokens_1.space[4], gap: design_system_tokens_1.space[6], }), }; const fileStyles = { wrapper: (0, css_1.css)({ display: "flex", flexDirection: "column", gap: design_system_tokens_1.space[8], }), container: (0, css_1.css)({ boxSizing: "border-box", display: "flex", alignItems: "center", gap: design_system_tokens_1.space[4], padding: `${design_system_tokens_1.space[4]} ${design_system_tokens_1.space[4]} ${design_system_tokens_1.space[4]} ${design_system_tokens_1.space[12]}`, borderRadius: design_system_tokens_1.borderRadius.small, border: `${design_system_tokens_1.borderWidth[1]} solid ${design_system_tokens_1.themeTokens.color.border.primary}`, background: design_system_tokens_1.themeTokens.color.background.secondary.default, font: design_system_tokens_1.text.body.regular.medium, }), containerError: (0, css_1.css)({ borderColor: design_system_tokens_1.themeTokens.color.border.danger, }), filename: (0, css_1.css)({ ...utils_1.resetStyles.paragraph, flex: 1, minWidth: 0, textOverflow: "ellipsis", whiteSpace: "nowrap", overflow: "clip", }), errorMessage: (0, css_1.css)({ paddingBottom: design_system_tokens_1.space[6], }), };