@octopusdeploy/design-system-components
Version:
The design systems component library.
277 lines (276 loc) • 14.5 kB
JavaScript
;
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],
}),
};