@octopusdeploy/design-system-components
Version:
The design systems component library.
289 lines (288 loc) • 15.3 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;
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],
}),
};