diff --git a/packages/configs/cspell-config/index.json b/packages/configs/cspell-config/index.json
index 1c1284930..73ceb50f5 100644
--- a/packages/configs/cspell-config/index.json
+++ b/packages/configs/cspell-config/index.json
@@ -15,6 +15,7 @@
"depcruise",
"describedby",
"docgen",
+ "dropzone",
"elonkit",
"elonsoft",
"esfront",
diff --git a/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.api.mdx b/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.api.mdx
new file mode 100644
index 000000000..db37b5542
--- /dev/null
+++ b/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.api.mdx
@@ -0,0 +1,37 @@
+import { Meta } from '@storybook/blocks';
+import LinkTo from '@storybook/addon-links/react';
+import { TableInterface } from '~storybook/components/TableInterface';
+
+
+
+# DropzoneOverlay API
+
+```js
+import { DropzoneOverlay } from '@esfront/react';
+```
+
+## Component name
+
+The name `ESDropzoneOverlay` can be used when providing default props or style overrides in the theme.
+
+## Props
+
+
+
+
+
+## CSS
+
+
+
+
+
+## Demos
+
+
+ -
+
+
DropzoneOverlay
+
+
+
diff --git a/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.classes.ts b/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.classes.ts
new file mode 100644
index 000000000..cea559688
--- /dev/null
+++ b/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.classes.ts
@@ -0,0 +1,43 @@
+import { generateUtilityClass, generateUtilityClasses } from '@mui/material';
+
+export type DropzoneOverlayClasses = {
+ /** Styles applied to the root element. */
+ root: string;
+ /** Styles applied to the content wrapper element. */
+ contentWrapper: string;
+ /** Styles applied to the content element. */
+ content: string;
+ /** Styles applied to the dropzone overlay element if file is dragged over the document. */
+ dropzoneOverlayDragOverDocument: string;
+ /** Styles applied to the dropzone overlay element if file is dragged over. */
+ dropzoneOverlayDragOver: string;
+ /** Styles applied to the input element. */
+ input: string;
+ /** Styles applied to the icon wrapper element. */
+ icon: string;
+ /** Styles applied to the heading element. */
+ heading: string;
+ /** Styles applied to the headingText element. */
+ headingText: string;
+ /** Styles applied to the subheading element. */
+ subheading: string;
+};
+
+export type DropzoneOverlayClassKey = keyof DropzoneOverlayClasses;
+
+export function getDropzoneOverlayUtilityClass(slot: string): string {
+ return generateUtilityClass('ESDropzoneOverlay', slot);
+}
+
+export const dropzoneOverlayClasses: DropzoneOverlayClasses = generateUtilityClasses('ESDropzoneOverlay', [
+ 'root',
+ 'contentWrapper',
+ 'content',
+ 'dropzoneOverlayDragOverDocument',
+ 'dropzoneOverlayDragOver',
+ 'input',
+ 'icon',
+ 'heading',
+ 'headingText',
+ 'subheading',
+]);
diff --git a/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.stories.tsx b/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.stories.tsx
new file mode 100644
index 000000000..ce50cff49
--- /dev/null
+++ b/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.stories.tsx
@@ -0,0 +1,89 @@
+import { Args, Meta, StoryContext, StoryObj } from '@storybook/react';
+
+import { DropzoneOverlay } from '.';
+
+import { FileIcon } from '../FileIcon';
+
+const getHeading = (args: Args, context: StoryContext) => {
+ return args.heading || (context.globals.locale === 'en' ? 'Upload file' : 'Загрузить файл');
+};
+
+const getSubheading = (args: Args, context: StoryContext) => {
+ return (
+ args.subheading ||
+ (context.globals.locale === 'en'
+ ? 'File max size 50 MB. Supported extensions are JPG, JPEG, PNG, HEIC, HEIF or WEBP'
+ : 'Файл в формате JPG, JPEG, PNG, HEIC, HEIF или WEBP до 50 Мб')
+ );
+};
+
+const getText = (context: StoryContext) => {
+ return context.globals.locale === 'en' ? 'Drag file over the browser window' : 'Перетащите файл в окно браузера';
+};
+
+const meta: Meta = {
+ tags: ['autodocs'],
+ component: DropzoneOverlay,
+ parameters: {
+ references: ['DropzoneOverlay'],
+ },
+ argTypes: {
+ heading: {
+ table: {
+ category: 'General',
+ },
+ },
+ subheading: {
+ table: {
+ category: 'General',
+ },
+ },
+ accept: {
+ table: {
+ category: 'General',
+ },
+ },
+ maxSize: {
+ table: {
+ category: 'General',
+ },
+ },
+ multiple: {
+ table: {
+ category: 'General',
+ },
+ },
+ icon: {
+ table: {
+ disable: true,
+ },
+ },
+ TransitionProps: {
+ table: {
+ disable: true,
+ },
+ },
+ },
+ args: {
+ accept: '*',
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Demo: Story = {
+ render: (args, context) => {
+ return (
+ <>
+ {getText(context)}
+ }
+ subheading={getSubheading(args, context)}
+ />
+ >
+ );
+ },
+};
diff --git a/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.tsx b/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.tsx
new file mode 100644
index 000000000..445980a4a
--- /dev/null
+++ b/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.tsx
@@ -0,0 +1,290 @@
+import { ChangeEvent, DragEvent, useRef } from 'react';
+
+import { DropzoneOverlayFileError, DropzoneOverlayFileRejection, DropzoneOverlayProps } from './DropzoneOverlay.types';
+
+import clsx from 'clsx';
+import { getDropzoneOverlayUtilityClass } from './DropzoneOverlay.classes';
+
+import { unstable_composeClasses as composeClasses } from '@mui/base';
+
+import { styled, useThemeProps } from '@mui/material/styles';
+import { Fade, Modal, Typography } from '@mui/material';
+
+import { useDocumentEventListener, useDragOver } from '../../hooks';
+import { validateFileType } from '../Dropzone/validateFileType';
+
+type DropzoneOverlayOwnerState = {
+ classes?: DropzoneOverlayProps['classes'];
+ isDragOver: boolean;
+ isDragOverDocument: boolean;
+};
+
+const useUtilityClasses = (ownerState: DropzoneOverlayOwnerState) => {
+ const { classes, isDragOver, isDragOverDocument } = ownerState;
+
+ const slots = {
+ root: ['root'],
+ contentWrapper: ['contentWrapper'],
+ content: [
+ 'content',
+ isDragOver && 'dropzoneOverlayDragOver',
+ isDragOverDocument && 'dropzoneOverlayDragOverDocument',
+ ],
+ input: ['input'],
+ icon: ['icon'],
+ heading: ['heading'],
+ headingText: ['headingText'],
+ subheading: ['subheading'],
+ };
+
+ return composeClasses(slots, getDropzoneOverlayUtilityClass, classes);
+};
+
+const DropzoneOverlayRoot = styled(Modal, {
+ name: 'ESDropzoneOverlay',
+ slot: 'Root',
+ overridesResolver: (_props, styles) => styles.root,
+})(() => ({
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+}));
+
+const DropzoneOverlayContentWrapper = styled('div', {
+ name: 'ESDropzoneOverlay',
+ slot: 'Content',
+ overridesResolver: (_props, styles) => styles.contentWrapper,
+})(({ theme }) => ({
+ width: 288,
+ padding: 16,
+ borderRadius: 8,
+ boxShadow: theme.vars.palette.shadow.down[900],
+ backgroundColor: theme.vars.palette.surface[600],
+}));
+
+const DropzoneOverlayContent = styled('div', {
+ name: 'ESDropzoneOverlay',
+ slot: 'Content',
+ overridesResolver: (props, styles) => {
+ const {
+ ownerState: { isDragOver, isDragOverDocument },
+ } = props;
+ return [
+ styles.content,
+ isDragOver && styles.dropzoneOverlayDragOver,
+ isDragOverDocument && styles.dropzoneOverlayDragOverDocument,
+ ];
+ },
+})(({ theme }) => ({
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ padding: 24,
+ outline: `1px dashed ${theme.vars.palette.monoA.A300}`,
+ outlineOffset: -1,
+ borderRadius: 6,
+}));
+
+const DropzoneOverlayHeading = styled('div', {
+ name: 'ESDropzoneOverlay',
+ slot: 'Heading',
+ overridesResolver: (_props, styles) => styles.heading,
+})(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ color: theme.vars.palette.primary[300],
+}));
+
+const DropzoneOverlayHeadingText = styled(Typography, {
+ name: 'ESDropzoneOverlay',
+ slot: 'HeadingText',
+ overridesResolver: (_props, styles) => styles.headingText,
+})(() => ({
+ paddingBottom: 4,
+ paddingTop: 4,
+}));
+
+const DropzoneOverlaySubheading = styled(Typography, {
+ name: 'ESDropzoneOverlay',
+ slot: 'Subheading',
+ overridesResolver: (_props, styles) => styles.subheading,
+})(() => ({
+ display: 'flex',
+ justifyContent: 'center',
+ textAlign: 'center',
+ marginTop: 4,
+}));
+
+const DropzoneOverlayIcon = styled('div', {
+ name: 'ESDropzoneOverlay',
+ slot: 'Icon',
+ overridesResolver: (_props, styles) => styles.icon,
+})(() => ({
+ alignItems: 'center',
+ display: 'flex',
+ justifyContent: 'center',
+}));
+
+const DropzoneOverlayInput = styled('input', {
+ name: 'ESDropzoneOverlay',
+ slot: 'Input',
+ overridesResolver: (_props, styles) => styles.input,
+})(() => ({
+ display: 'none',
+}));
+
+/**
+ * This component allows to select files by drag and drop on browser window.
+ */
+export const DropzoneOverlay = (inProps: DropzoneOverlayProps) => {
+ const {
+ className,
+ sx,
+ heading,
+ subheading,
+ icon,
+ multiple,
+ accept = '*',
+ maxSize,
+ ref,
+ onChange,
+ onReject,
+ TransitionProps,
+ ...props
+ } = useThemeProps({
+ props: inProps,
+ name: 'ESDropzoneOverlay',
+ });
+
+ const { isDragOver, onDragEnter, onDragLeave, onDrop } = useDragOver();
+ const { isDragOver: isDragOverDocument, ...callbacks } = useDragOver();
+
+ const inputRef = useRef(null);
+
+ /**
+ * @param file File to validate.
+ * @returns True if file size is less than maxSize, false otherwise.
+ */
+ const validateFileSize = (file: File): boolean => (maxSize ? maxSize >= file.size : true);
+
+ const onFileList = (event: ChangeEvent, files: FileList) => {
+ const acceptedFiles: File[] = [];
+ const rejectedFiles: DropzoneOverlayFileRejection[] = [];
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ const typeMatch = validateFileType(file, accept);
+ const sizeMatch = validateFileSize(file);
+
+ if (typeMatch && sizeMatch) {
+ acceptedFiles.push(file);
+ } else {
+ const errors: DropzoneOverlayFileError[] = [];
+
+ if (!typeMatch) {
+ errors.push('file-invalid-type');
+ }
+
+ if (!sizeMatch) {
+ errors.push('file-too-large');
+ }
+
+ rejectedFiles.push({
+ file,
+ errors,
+ });
+ }
+ }
+
+ if (rejectedFiles.length) {
+ onReject?.(event, rejectedFiles);
+ } else if (!multiple && acceptedFiles.length > 1) {
+ acceptedFiles.forEach((file) => {
+ rejectedFiles.push({
+ file,
+ errors: ['too-many-files'],
+ });
+ });
+
+ onReject?.(event, rejectedFiles);
+ } else {
+ onChange?.(event, acceptedFiles);
+ }
+ };
+
+ const onDragOver = (event: DragEvent) => {
+ event.preventDefault();
+ };
+
+ const onDropzoneOverlayDrop = (event: DragEvent) => {
+ event.preventDefault();
+ onDrop();
+
+ if (event.dataTransfer) {
+ onFileList(event, event.dataTransfer.files);
+ }
+ };
+
+ const onInputChange = (event: ChangeEvent) => {
+ if (event.target.files) {
+ onFileList(event, event.target.files);
+ }
+ };
+
+ useDocumentEventListener('dragenter', callbacks.onDragEnter);
+ useDocumentEventListener('dragleave', callbacks.onDragLeave);
+ useDocumentEventListener('drop', callbacks.onDrop);
+
+ const ownerState = { ...props, isDragOver, isDragOverDocument };
+ const classes = useUtilityClasses(ownerState);
+
+ return (
+ ({ backgroundColor: theme.vars.palette.overlay[700] }) } }}
+ sx={sx}
+ onDragEnter={onDragEnter}
+ onDragLeave={onDragLeave}
+ onDragOver={onDragOver}
+ onDrop={onDropzoneOverlayDrop}
+ >
+
+
+
+
+ {!!icon && {icon}}
+
+ {!!heading && (
+
+ {heading}
+
+ )}
+
+
+ {!!subheading && (
+
+ {subheading}
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.types.ts b/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.types.ts
new file mode 100644
index 000000000..87ef43bb9
--- /dev/null
+++ b/packages/react/src/components/DropzoneOverlay/DropzoneOverlay.types.ts
@@ -0,0 +1,43 @@
+import { ChangeEvent, ReactNode, Ref } from 'react';
+
+import { DropzoneOverlayClasses } from './DropzoneOverlay.classes';
+
+import { SxProps, Theme } from '@mui/material';
+import { TransitionProps } from '@mui/material/transitions';
+
+export type DropzoneOverlayFileError = 'file-too-large' | 'file-invalid-type' | 'too-many-files';
+
+export type DropzoneOverlayFileRejection = {
+ file: File;
+ errors: DropzoneOverlayFileError[];
+};
+
+export interface DropzoneOverlayProps {
+ ref?: Ref;
+ /** Class applied to the root element. */
+ className?: string;
+ /** Override or extend the styles applied to the component. */
+ classes?: Partial;
+ /** The system prop that allows defining system overrides as well as additional CSS styles. */
+ sx?: SxProps;
+ /** Heading text. */
+ heading?: string;
+ /** Subheading text. */
+ subheading?: string;
+ /** Icon to display with heading. */
+ icon?: ReactNode;
+ /** Allow multiple files. */
+ multiple?: boolean;
+ /** Accepted file types.
+ * @default '*'
+ */
+ accept?: string;
+ /** Maximum file size (in bytes). */
+ maxSize?: number;
+ /** Callback fired when files were accepted. */
+ onChange?: (event: ChangeEvent, files: File[]) => void;
+ /** Callback fired when files were rejected. */
+ onReject?: (event: ChangeEvent, rejections: DropzoneOverlayFileRejection[]) => void;
+ /** Props applied to the transition element. */
+ TransitionProps?: TransitionProps;
+}
diff --git a/packages/react/src/components/DropzoneOverlay/index.ts b/packages/react/src/components/DropzoneOverlay/index.ts
new file mode 100644
index 000000000..4413ceafc
--- /dev/null
+++ b/packages/react/src/components/DropzoneOverlay/index.ts
@@ -0,0 +1,3 @@
+export { DropzoneOverlay } from './DropzoneOverlay';
+export { DropzoneOverlayClasses, dropzoneOverlayClasses, DropzoneOverlayClassKey } from './DropzoneOverlay.classes';
+export { DropzoneOverlayFileError, DropzoneOverlayFileRejection, DropzoneOverlayProps } from './DropzoneOverlay.types';
diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts
index 21e6de9e8..bff2a06d5 100644
--- a/packages/react/src/components/index.ts
+++ b/packages/react/src/components/index.ts
@@ -20,6 +20,7 @@ export * from './Dialog';
export * from './DialogStack';
export * from './Divider';
export * from './Dropzone';
+export * from './DropzoneOverlay';
export * from './EmptyState';
export * from './EmptyStateCompact';
export * from './ErrorPage';
diff --git a/packages/react/src/overrides.d.ts b/packages/react/src/overrides.d.ts
index 4ded9188a..1d6c34ff7 100644
--- a/packages/react/src/overrides.d.ts
+++ b/packages/react/src/overrides.d.ts
@@ -87,6 +87,7 @@ import {
} from './components/Dialog';
import { DividerClassKey, DividerProps } from './components/Divider';
import { DropzoneClassKey, DropzoneProps } from './components/Dropzone';
+import { DropzoneOverlayClassKey, DropzoneOverlayProps } from './components/DropzoneOverlay';
import { EmptyStateClassKey, EmptyStateProps } from './components/EmptyState';
import { EmptyStateCompactClassKey, EmptyStateCompactProps } from './components/EmptyStateCompact';
import {
@@ -369,6 +370,7 @@ declare module '@mui/material/styles/props' {
ESDialogTitle: DialogTitleProps;
ESDivider: DividerProps;
ESDropzone: DropzoneProps;
+ ESDropzoneOverlay: DropzoneOverlayProps;
ESEmptyState: EmptyStateProps;
ESEmptyStateCompact: EmptyStateCompactProps;
ESErrorPage: ErrorPageProps;
@@ -505,6 +507,7 @@ declare module '@mui/material/styles/overrides' {
ESDialogTitle: DialogTitleClassKey;
ESDivider: DividerClassKey;
ESDropzone: DropzoneClassKey;
+ ESDropzoneOverlay: DropzoneOverlayClassKey;
ESEmptyState: EmptyStateClassKey;
ESEmptyStateCompact: EmptyStateCompactClassKey;
ESErrorPage: ErrorPageClassKey;
@@ -856,6 +859,10 @@ declare module '@mui/material/styles/components' {
defaultProps?: ComponentsProps['ESDropzone'];
styleOverrides?: ComponentsOverrides['ESDropzone'];
};
+ ESDropzoneOverlay?: {
+ defaultProps?: ComponentsProps['ESDropzoneOverlay'];
+ styleOverrides?: ComponentsOverrides['ESDropzoneOverlay'];
+ };
ESFileIcon?: {
defaultProps?: ComponentsProps['ESFileIcon'];
styleOverrides?: ComponentsOverrides['ESFileIcon'];