UNPKG

@octopusdeploy/design-system-components

Version:
289 lines (288 loc) • 15.3 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; exports.isValidFile = isValidFile; exports.getValidFile = getValidFile; exports.getValidFiles = getValidFiles; 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, value, validationMessage, validationState, onChange, 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 [internalError, setInternalError] = (0, react_1.useState)(); const maxFileSizeText = formatBytes(maxFileSizeBytes); const handleChange = (selectedFiles, error) => { setInternalError(error); onChange(selectedFiles); }; const handleDrop = (acceptedFiles, rejectedFiles) => { const acceptedSelections = acceptedFiles.map((file) => ({ file })); const rejectedSelections = []; let rejectionError = undefined; for (const rejection of rejectedFiles) { const message = describeRejection(rejection, maxFileSizeText); const code = rejection.errors[0]?.code; const isPerFileRejection = code === react_dropzone_1.ErrorCode.FileTooLarge || code === react_dropzone_1.ErrorCode.FileInvalidType; if (isPerFileRejection) { rejectedSelections.push({ file: rejection.file, validationMessage: message }); } else { // Any other rejection isn't a per-file concern, so surface it globally. rejectionError ??= message; } } const newSelections = [...acceptedSelections, ...rejectedSelections]; // Single-file mode replaces the selection; multi-file appends up to the limit and drops the overflow. if (filesLimit <= 1) { if (newSelections.length > 0) { handleChange(newSelections, rejectionError); } else { setInternalError(rejectionError); } return; } const kept = newSelections.slice(0, Math.max(0, filesLimit - value.length)); const limitError = kept.length < newSelections.length ? `You can upload up to ${filesLimit} files.` : undefined; if (kept.length > 0) { handleChange([...value, ...kept], rejectionError ?? limitError); } else { setInternalError(rejectionError ?? limitError); } }; 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(value.filter((selected) => selected.file !== removedFile), undefined); }; const { getRootProps, getInputProps, isDragActive, isDragReject } = (0, react_dropzone_1.useDropzone)({ onDrop: handleDrop, maxSize: maxFileSizeBytes, 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 globalMessage = computeGlobalMessage({ validationState, internalError, validationMessage, }); 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 }), value.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: fileUploadStyles.fileList, "aria-label": "File list", children: value.map((selected, index) => ((0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)(File, { file: selected.file, onRemove: () => handleRemoveFile(selected.file), validationMessage: selected.validationMessage, validationId: `${inputId}-file-${index}-validation` }) }, index))) }))] })); } function computeGlobalMessage({ validationState, internalError, validationMessage }) { if (internalError) { return { message: internalError, displayState: "error" }; } if (validationState === "validating") { return validationMessage ? { message: validationMessage, displayState: "progress" } : undefined; } if (validationMessage) { return { message: validationMessage, displayState: "error" }; } return undefined; } function describeRejection(rejectedFile, maxFileSizeText) { const error = rejectedFile.errors[0]; switch (error?.code) { case react_dropzone_1.ErrorCode.FileTooLarge: return `File ${rejectedFile.file.name} is too big. Size limit is ${maxFileSizeText}.`; case react_dropzone_1.ErrorCode.FileInvalidType: return `File type for ${rejectedFile.file.name} is not supported.`; case react_dropzone_1.ErrorCode.TooManyFiles: // Multi-file mode accepts up to the limit in handleDrop, so this only fires in single-file mode. return "You can upload a single file."; default: return `File ${rejectedFile.file.name} was rejected.`; } } 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 }) }))] })); } /** * Whether a selected file is valid: it has no error message. * Use this to filter `value` down to the files safe to act on (read, upload, submit). */ function isValidFile(selectedFile) { return selectedFile.validationMessage === undefined; } /** * The underlying `File` from a single-file selection, or `undefined` when there is no selection * or it failed validation. */ function getValidFile(selectedFile) { return selectedFile && isValidFile(selectedFile) ? selectedFile.file : undefined; } /** * The underlying `File`s from a multi-file selection, excluding any that failed validation. */ function getValidFiles(selectedFiles) { return selectedFiles.filter(isValidFile).map((selectedFile) => selectedFile.file); } 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], }), };