From e07ff5adce747774084946213cad4bf56ecd0b98 Mon Sep 17 00:00:00 2001 From: Svyatoslav Date: Thu, 4 Jul 2024 14:56:55 +0300 Subject: [PATCH] feat(DropzoneOverlay): add new component --- packages/configs/cspell-config/index.json | 1 + .../DropzoneOverlay/DropzoneOverlay.api.mdx | 37 +++ .../DropzoneOverlay.classes.ts | 43 +++ .../DropzoneOverlay.stories.tsx | 89 ++++++ .../DropzoneOverlay/DropzoneOverlay.tsx | 290 ++++++++++++++++++ .../DropzoneOverlay/DropzoneOverlay.types.ts | 43 +++ .../src/components/DropzoneOverlay/index.ts | 3 + packages/react/src/components/index.ts | 1 + packages/react/src/overrides.d.ts | 7 + 9 files changed, 514 insertions(+) create mode 100644 packages/react/src/components/DropzoneOverlay/DropzoneOverlay.api.mdx create mode 100644 packages/react/src/components/DropzoneOverlay/DropzoneOverlay.classes.ts create mode 100644 packages/react/src/components/DropzoneOverlay/DropzoneOverlay.stories.tsx create mode 100644 packages/react/src/components/DropzoneOverlay/DropzoneOverlay.tsx create mode 100644 packages/react/src/components/DropzoneOverlay/DropzoneOverlay.types.ts create mode 100644 packages/react/src/components/DropzoneOverlay/index.ts 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'];