diff --git a/src/components/FileDropZone/FileDropZone.Provider.tsx b/src/components/FileDropZone/FileDropZone.Provider.tsx new file mode 100644 index 0000000000..b79bd588aa --- /dev/null +++ b/src/components/FileDropZone/FileDropZone.Provider.tsx @@ -0,0 +1,140 @@ +import * as React from 'react'; + +import {useActionHandlers, useDropZone, useFileInput} from '../../hooks'; +import type {UseDropZoneState, UseFileInputResult} from '../../hooks'; + +import type {FileDropZoneProps} from './FileDropZone'; +import {cnFileDropZone} from './FileDropZone.classname'; + +interface FileDropZoneContextValue + extends Pick< + FileDropZoneProps, + | 'title' + | 'description' + | 'buttonText' + | 'multiple' + | 'icon' + | 'errorIcon' + | 'errorMessage' + | 'validationState' + >, + UseFileInputResult, + UseDropZoneState {} + +const FileDropZoneContext = React.createContext(null); + +export const FileDropZoneProvider = ({ + accept, + onAdd, + title, + description, + buttonText, + icon, + errorIcon, + multiple, + disabled, + className, + errorMessage, + validationState, + children, +}: FileDropZoneProps) => { + const handleDrop = React.useCallback( + (items: DataTransferItemList): void => { + const files: File[] = []; + + for (const item of items) { + const file = item.getAsFile(); + + if (!file) { + continue; + } + + files.push(file); + } + + onAdd(files); + }, + [onAdd], + ); + + const {isDraggingOver, getDroppableProps} = useDropZone({ + accept, + disabled, + onDrop: handleDrop, + }); + + const onUpdate = React.useCallback( + (files: File[]) => { + onAdd(files); + }, + [onAdd], + ); + + const {controlProps, triggerProps} = useFileInput({onUpdate, multiple}); + + const {onKeyDown} = useActionHandlers(triggerProps.onClick); + + const contextValue = React.useMemo( + () => ({ + title, + description, + buttonText, + multiple, + icon, + errorIcon, + errorMessage, + validationState, + controlProps, + triggerProps, + isDraggingOver, + getDroppableProps, + }), + [ + title, + description, + buttonText, + multiple, + icon, + errorIcon, + errorMessage, + validationState, + controlProps, + triggerProps, + isDraggingOver, + getDroppableProps, + ], + ); + + const hasError = Boolean(errorMessage); + + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( + +
+ {children} +
+
+ ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ +}; + +export const useFileZoneContext = (): FileDropZoneContextValue => { + const contextValue = React.useContext(FileDropZoneContext); + + if (contextValue === null) { + throw new Error('FileDropZone context not found'); + } + + return contextValue; +}; diff --git a/src/components/FileDropZone/FileDropZone.classname.ts b/src/components/FileDropZone/FileDropZone.classname.ts new file mode 100644 index 0000000000..a196c50331 --- /dev/null +++ b/src/components/FileDropZone/FileDropZone.classname.ts @@ -0,0 +1,3 @@ +import {block} from '../utils/cn'; + +export const cnFileDropZone = block('file-drop-zone'); diff --git a/src/components/FileDropZone/FileDropZone.scss b/src/components/FileDropZone/FileDropZone.scss new file mode 100644 index 0000000000..3a32eda3c2 --- /dev/null +++ b/src/components/FileDropZone/FileDropZone.scss @@ -0,0 +1,70 @@ +@use '../variables'; + +$block: '.#{variables.$ns}file-drop-zone'; + +#{$block} { + background-color: var(--g-color-base-background); + border: 1px dashed var(--g-color-line-generic-active); + border-radius: 8px; + box-sizing: border-box; + padding: 16px; + + cursor: pointer; + outline: none; + + &_default-layout { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + + #{$block}__button { + margin-block-start: 10px; + } + } + + &:hover { + border-color: var(--g-color-text-info); + } + + &:focus { + border-color: var(--g-color-text-info); + } + + &:focus:not(:focus-visible) { + border-color: none; + } + + &_drag-hover { + background-color: var(--g-color-base-info-light); + border-color: var(--g-color-text-info); + } + + &_error { + border-color: var(--g-color-line-danger); + + &:hover { + border-color: var(--g-color-line-danger); + } + + #{$block}__icon { + color: var(--g-color-text-danger); + } + } + + &_disabled { + cursor: not-allowed; + pointer-events: none; + + #{$block}__icon { + color: var(--g-color-text-hint); + } + } + + &__icon { + flex-shrink: 0; + color: var(--g-color-text-info); + margin-block-end: 5px; + } +} diff --git a/src/components/FileDropZone/FileDropZone.tsx b/src/components/FileDropZone/FileDropZone.tsx new file mode 100644 index 0000000000..0b53e9bbec --- /dev/null +++ b/src/components/FileDropZone/FileDropZone.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; + +import type {IconData} from '../..'; +import type {UseDropZoneAccept} from '../../hooks/useDropZone'; +import type {BaseInputControlProps} from '../controls/types'; + +import {FileDropZoneProvider} from './FileDropZone.Provider'; +import {cnFileDropZone} from './FileDropZone.classname'; +import {FileDropZoneButton} from './parts/FileDropZone.Button'; +import {FileDropZoneDescription} from './parts/FileDropZone.Description'; +import {FileDropZoneIcon} from './parts/FileDropZone.Icon'; +import {FileDropZoneTitle} from './parts/FileDropZone.Title'; + +import './FileDropZone.scss'; + +export interface FileDropZoneProps extends Pick { + accept: UseDropZoneAccept; + onAdd: (files: File[]) => void; + title?: string; + description?: string; + buttonText?: string; + icon?: IconData | null; + errorIcon?: IconData | null; + className?: string; + multiple?: boolean; + disabled?: boolean; + errorMessage?: string; + children?: React.ReactNode; +} + +const BaseFileDropZone = React.memo( + ({children, ...restProps}: FileDropZoneProps) => { + const content = React.useMemo(() => { + if (typeof children !== 'undefined') { + return children; + } + + return ( + + + + + + + ); + }, [children]); + + return ( + + {content} + + ); + }, +); + +BaseFileDropZone.displayName = 'FileDropZone'; + +export const FileDropZone = Object.assign(BaseFileDropZone, { + Icon: FileDropZoneIcon, + Title: FileDropZoneTitle, + Description: FileDropZoneDescription, + Button: FileDropZoneButton, +}); diff --git a/src/components/FileDropZone/README-ru.md b/src/components/FileDropZone/README-ru.md new file mode 100644 index 0000000000..1780b963f9 --- /dev/null +++ b/src/components/FileDropZone/README-ru.md @@ -0,0 +1,106 @@ + + +# FileDropZone + + + +```tsx +import {FileDropZone} from '@gravity-ui/uikit'; +``` + +### Базовое использование + +```tsx +const accept = ['image/*']; +const handleAdd = (files: File[]) => { + // Do something with files +}; + +; +``` + +### Кастомные тексты и иконки + +```tsx +import {DatabaseFill, HeartCrack} from '@gravity-ui/icons'; + +const accept = ['image/*']; +const handleAdd = (files: File[]) => { + // Do something with files +}; + +; +``` + +### Кастомный лейаут + +Паттерн Compound Component позволяет рендерить произвольный лейаут. Все пропсы передаются только родительскому компоненту и шарятся через React-контекст, а компоненты-части могут получать только `className`. + +```tsx +const accept = ['image/*']; +const handleAdd = (files: File[]) => { + // Do something with files +}; + + +
+
+ +
+ + +
+
+
+ +
+
+
; +``` + +## Properties + +| Name | Description | Type | Default | +| :----------- | :------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------------------------: | +| accept | Список допустимых MIME-типов | `string[]` | | +| onAdd | Коллбэк, вызываемый при добавлении файлов. Не вызывается, если тип файла недопустим. | `(files: File[]) => void` | | +| title | Заголовок, отображаемый под иконкой | `string` | "Drag the file(s) here or select it(them)" | +| description | Описание, отображаемое под заголовком | `string` | | +| buttonText | Подпись кнопки загрузки | `string` | "Select a file(s)" | +| icon | Пользовательский компонент иконки из `@gravity-ui/icons`. Если передан `null`, иконка не отображается. | `@gravity-ui/icons/IconData` | | +| errorIcon | Пользовательский компонент иконки ошибки из `@gravity-ui/icons`. Если передан `null`, иконка ошибки не отображается. | `@gravity-ui/icons/IconData` | | +| className | Класс корневого элемента | `string` | | +| multiple | Булево значение, которое определяет, разрешена ли множественная загрузка файлов | | | +| disabled | Булево значение, определяющее, отключена ли загрузка файлов | | | +| errorMessage | Сообщение об ошибке. Если указано, также применяются стили ошибки. | `string` | | diff --git a/src/components/FileDropZone/README.md b/src/components/FileDropZone/README.md new file mode 100644 index 0000000000..df51d8a8b9 --- /dev/null +++ b/src/components/FileDropZone/README.md @@ -0,0 +1,106 @@ + + +# FileDropZone + + + +```tsx +import {FileDropZone} from '@gravity-ui/uikit'; +``` + +### Basic Usage + +```tsx +const accept = ['image/*']; +const handleAdd = (files: File[]) => { + // Do something with files +}; + +; +``` + +### Custom Texts And Icons + +```tsx +import {DatabaseFill, HeartCrack} from '@gravity-ui/icons'; + +const accept = ['image/*']; +const handleAdd = (files: File[]) => { + // Do something with files +}; + +; +``` + +### Custom Layout + +The Compound Component pattern allows rendering of an arbitrary layout. All props are passed only to the parent and shared via React context, while the subcomponents can only receive a `className`. + +```tsx +const accept = ['image/*']; +const handleAdd = (files: File[]) => { + // Do something with files +}; + + +
+
+ +
+ + +
+
+
+ +
+
+
; +``` + +## Properties + +| Name | Description | Type | Default | +| :----------- | :---------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------------------------: | +| accept | A list of MIME types for allowed files | `string[]` | | +| onAdd | A callback invoked when files are added. Won't be called if a type is not acceptable | `(files: File[]) => void` | | +| title | A title displayed under the icon | `string` | "Drag the file(s) here or select it(them)" | +| description | A description displayed under the title | `string` | | +| buttonText | An upload button label | `string` | "Select a file(s)" | +| icon | A custom icon component from `@gravity-ui/icons`. When null is passed, the icon is not rendered | `@gravity-ui/icons/IconData` | | +| errorIcon | A custom error icon component from `@gravity-ui/icons`. When null is passed, the error icon is not rendered | `@gravity-ui/icons/IconData` | | +| className | A root element className | `string` | | +| multiple | A boolean value that determines whether multiple file uploads are allowed | | | +| disabled | A boolean value that determines whether file uploading is disabled | | | +| errorMessage | An error message. If provided, error styles are also rendered | `string` | | diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomLayout-dark-chromium-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomLayout-dark-chromium-linux.png new file mode 100644 index 0000000000..df731c9e1b Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomLayout-dark-chromium-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomLayout-dark-webkit-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomLayout-dark-webkit-linux.png new file mode 100644 index 0000000000..18ce00919a Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomLayout-dark-webkit-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomLayout-light-chromium-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomLayout-light-chromium-linux.png new file mode 100644 index 0000000000..4d4c7da4d8 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomLayout-light-chromium-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomLayout-light-webkit-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomLayout-light-webkit-linux.png new file mode 100644 index 0000000000..0674ad5c1f Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomLayout-light-webkit-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomTexts-dark-chromium-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomTexts-dark-chromium-linux.png new file mode 100644 index 0000000000..cf8c0e767b Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomTexts-dark-chromium-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomTexts-dark-webkit-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomTexts-dark-webkit-linux.png new file mode 100644 index 0000000000..3f9eacd004 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomTexts-dark-webkit-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomTexts-light-chromium-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomTexts-light-chromium-linux.png new file mode 100644 index 0000000000..2a22c5bc28 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomTexts-light-chromium-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomTexts-light-webkit-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomTexts-light-webkit-linux.png new file mode 100644 index 0000000000..3cdaf63d22 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-CustomTexts-light-webkit-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Default-dark-chromium-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Default-dark-chromium-linux.png new file mode 100644 index 0000000000..5577361bd6 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Default-dark-chromium-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Default-dark-webkit-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Default-dark-webkit-linux.png new file mode 100644 index 0000000000..2b77bf0f20 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Default-dark-webkit-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Default-light-chromium-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Default-light-chromium-linux.png new file mode 100644 index 0000000000..a0f0d3706c Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Default-light-chromium-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Default-light-webkit-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Default-light-webkit-linux.png new file mode 100644 index 0000000000..52258c12b0 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Default-light-webkit-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Disabled-dark-chromium-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Disabled-dark-chromium-linux.png new file mode 100644 index 0000000000..bac4090b54 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Disabled-dark-chromium-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Disabled-dark-webkit-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Disabled-dark-webkit-linux.png new file mode 100644 index 0000000000..ac7feb78f2 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Disabled-dark-webkit-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Disabled-light-chromium-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Disabled-light-chromium-linux.png new file mode 100644 index 0000000000..8d5669b7c6 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Disabled-light-chromium-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Disabled-light-webkit-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Disabled-light-webkit-linux.png new file mode 100644 index 0000000000..2f44d965c8 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Disabled-light-webkit-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Errors-dark-chromium-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Errors-dark-chromium-linux.png new file mode 100644 index 0000000000..81c2c0e4f3 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Errors-dark-chromium-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Errors-dark-webkit-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Errors-dark-webkit-linux.png new file mode 100644 index 0000000000..c9fe454c4c Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Errors-dark-webkit-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Errors-light-chromium-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Errors-light-chromium-linux.png new file mode 100644 index 0000000000..d322cb911b Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Errors-light-chromium-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Errors-light-webkit-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Errors-light-webkit-linux.png new file mode 100644 index 0000000000..f036af4275 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Errors-light-webkit-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Icons-dark-chromium-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Icons-dark-chromium-linux.png new file mode 100644 index 0000000000..a64e48e044 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Icons-dark-chromium-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Icons-dark-webkit-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Icons-dark-webkit-linux.png new file mode 100644 index 0000000000..aa4b77aecc Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Icons-dark-webkit-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Icons-light-chromium-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Icons-light-chromium-linux.png new file mode 100644 index 0000000000..3134117fef Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Icons-light-chromium-linux.png differ diff --git a/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Icons-light-webkit-linux.png b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Icons-light-webkit-linux.png new file mode 100644 index 0000000000..49b3247f87 Binary files /dev/null and b/src/components/FileDropZone/__snapshots__/FileDropZone.visual.test.tsx-snapshots/FileDropZone-render-story-Icons-light-webkit-linux.png differ diff --git a/src/components/FileDropZone/__stories__/FileDropZone.stories.tsx b/src/components/FileDropZone/__stories__/FileDropZone.stories.tsx new file mode 100644 index 0000000000..8f105d3c12 --- /dev/null +++ b/src/components/FileDropZone/__stories__/FileDropZone.stories.tsx @@ -0,0 +1,145 @@ +import {DatabaseFill, FolderOpenFill, HeartCrack} from '@gravity-ui/icons'; +import type {Meta, StoryFn} from '@storybook/react-webpack5'; + +import {Text} from '../..'; +import {FileDropZone} from '../FileDropZone'; +import type {FileDropZoneProps} from '../FileDropZone'; + +export default { + title: 'Components/Inputs/FileDropZone', + component: FileDropZone, + args: {}, + argTypes: { + states: {table: {disable: true}}, + }, +} as Meta; + +const handleAdd = (files: File[]) => { + const msg = `Files: ${files.map(({name}) => name).join(', ')}`; + + alert(msg); +}; + +const BASE_ARGS = { + accept: ['image/*'], + onAdd: handleAdd, +}; + +const DefaultTemplate: StoryFn = (args) => { + return ; +}; + +export const Default: StoryFn = DefaultTemplate.bind({}); +Default.args = BASE_ARGS; + +export const CustomTexts: StoryFn = DefaultTemplate.bind({}); +CustomTexts.args = { + ...BASE_ARGS, + title: 'Lorem ipsum dolor sit amet', + description: 'Duis consequat commodo eros sit', + buttonText: 'Upload', +}; + +export const Disabled: StoryFn = DefaultTemplate.bind({}); +Disabled.args = { + ...BASE_ARGS, + disabled: true, +}; + +const MultipleStatesTemplate = ({ + states, + ...props +}: FileDropZoneProps & {states: {title: string; itemProps: Partial}[]}) => { + return ( +
+ {states.map(({title, itemProps}) => ( +
+ {title} + +
+ ))} +
+ ); +}; + +export const Errors: StoryFn = MultipleStatesTemplate.bind({}); +Errors.args = { + ...BASE_ARGS, + states: [ + { + title: 'With Error Message', + itemProps: {errorMessage: 'Unknown error has occurred'}, + }, + { + title: 'With Validation State Error', + itemProps: {validationState: 'invalid'}, + }, + ], +}; + +export const Icons: StoryFn = MultipleStatesTemplate.bind({}); +Icons.args = { + ...BASE_ARGS, + states: [ + { + title: 'No Icon', + itemProps: {icon: null}, + }, + { + title: 'Custom Icon', + itemProps: {icon: DatabaseFill}, + }, + { + title: 'No Error Icon', + itemProps: {errorIcon: null, errorMessage: 'Unknown error has occurred'}, + }, + { + title: 'Custom Error Icon', + itemProps: {errorIcon: HeartCrack, errorMessage: 'Unknown error has occurred'}, + }, + ], +}; + +const CustomLayoutTemplate: StoryFn = (args) => { + return ( + +
+
+ +
+ + +
+
+
+ +
+
+
+ ); +}; +export const CustomLayout = CustomLayoutTemplate.bind({}); +CustomLayout.args = { + ...BASE_ARGS, + title: 'Lorem ipsum dolor sit amet', + description: 'Duis consequat commodo eros sit', + buttonText: 'Upload', + icon: FolderOpenFill, +}; diff --git a/src/components/FileDropZone/__tests__/FileDropZone.visual.test.tsx b/src/components/FileDropZone/__tests__/FileDropZone.visual.test.tsx new file mode 100644 index 0000000000..8993b85a57 --- /dev/null +++ b/src/components/FileDropZone/__tests__/FileDropZone.visual.test.tsx @@ -0,0 +1,41 @@ +import {test} from '~playwright/core'; + +import {FileDropZoneStories} from './helpersPlaywright'; + +test.describe('FileDropZone', {tag: '@FileDropZone'}, () => { + test('render story: ', async ({mount, expectScreenshot}) => { + await mount(); + + await expectScreenshot(); + }); + + test('render story: ', async ({mount, expectScreenshot}) => { + await mount(); + + await expectScreenshot(); + }); + + test('render story: ', async ({mount, expectScreenshot}) => { + await mount(); + + await expectScreenshot(); + }); + + test('render story: ', async ({mount, expectScreenshot}) => { + await mount(); + + await expectScreenshot(); + }); + + test('render story: ', async ({mount, expectScreenshot}) => { + await mount(); + + await expectScreenshot(); + }); + + test('render story: ', async ({mount, expectScreenshot}) => { + await mount(); + + await expectScreenshot(); + }); +}); diff --git a/src/components/FileDropZone/__tests__/helpersPlaywright.tsx b/src/components/FileDropZone/__tests__/helpersPlaywright.tsx new file mode 100644 index 0000000000..17677c1285 --- /dev/null +++ b/src/components/FileDropZone/__tests__/helpersPlaywright.tsx @@ -0,0 +1,5 @@ +import {composeStories} from '@storybook/react-webpack5'; + +import * as DefaultFileDropZoneStories from '../__stories__/FileDropZone.stories'; + +export const FileDropZoneStories = composeStories(DefaultFileDropZoneStories); diff --git a/src/components/FileDropZone/i18n/en.json b/src/components/FileDropZone/i18n/en.json new file mode 100644 index 0000000000..bb0f8b23bc --- /dev/null +++ b/src/components/FileDropZone/i18n/en.json @@ -0,0 +1,6 @@ +{ + "label_title-single": "Drag the file here or select it", + "button_select-file-single": "Select a file", + "label_title-multiple": "Drag the files here or select them", + "button_select-file-multiple": "Select files" +} diff --git a/src/components/FileDropZone/i18n/index.ts b/src/components/FileDropZone/i18n/index.ts new file mode 100644 index 0000000000..e6975d6380 --- /dev/null +++ b/src/components/FileDropZone/i18n/index.ts @@ -0,0 +1,8 @@ +import {addComponentKeysets} from '../../../i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'FileDropZone'; + +export default addComponentKeysets({en, ru}, COMPONENT); diff --git a/src/components/FileDropZone/i18n/ru.json b/src/components/FileDropZone/i18n/ru.json new file mode 100644 index 0000000000..2ed247f00b --- /dev/null +++ b/src/components/FileDropZone/i18n/ru.json @@ -0,0 +1,6 @@ +{ + "label_title-single": "Перетащите файл сюда или выберите его", + "button_select-file-single": "Выбрать файл", + "label_title-multiple": "Перетащите файлы сюда или выберите их", + "button_select-file-multiple": "Выбрать файлы" +} diff --git a/src/components/FileDropZone/index.ts b/src/components/FileDropZone/index.ts new file mode 100644 index 0000000000..fff878632d --- /dev/null +++ b/src/components/FileDropZone/index.ts @@ -0,0 +1 @@ +export * from './FileDropZone'; diff --git a/src/components/FileDropZone/parts/FileDropZone.Button.tsx b/src/components/FileDropZone/parts/FileDropZone.Button.tsx new file mode 100644 index 0000000000..91c1ca954f --- /dev/null +++ b/src/components/FileDropZone/parts/FileDropZone.Button.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; + +import {Button} from '../..'; +import {useFileZoneContext} from '../FileDropZone.Provider'; +import {cnFileDropZone} from '../FileDropZone.classname'; +import i18n from '../i18n'; + +type FileDropZoneButtonProps = { + className?: string; +}; + +export const FileDropZoneButton = ({className}: FileDropZoneButtonProps) => { + const {buttonText, triggerProps, controlProps, multiple} = useFileZoneContext(); + + const {t} = i18n.useTranslation(); + + const postfix = multiple ? 'multiple' : 'single'; + const displayLabel = buttonText || t(`button_select-file-${postfix}`); + + return ( + + + + + ); +}; diff --git a/src/components/FileDropZone/parts/FileDropZone.Description.tsx b/src/components/FileDropZone/parts/FileDropZone.Description.tsx new file mode 100644 index 0000000000..bdc2fa6c96 --- /dev/null +++ b/src/components/FileDropZone/parts/FileDropZone.Description.tsx @@ -0,0 +1,20 @@ +import {Text} from '../..'; +import {useFileZoneContext} from '../FileDropZone.Provider'; + +type FileDropZoneDescriptionProps = { + className?: string; +}; + +export const FileDropZoneDescription = ({className}: FileDropZoneDescriptionProps) => { + const {description} = useFileZoneContext(); + + if (!description) { + return null; + } + + return ( + + {description} + + ); +}; diff --git a/src/components/FileDropZone/parts/FileDropZone.Icon.tsx b/src/components/FileDropZone/parts/FileDropZone.Icon.tsx new file mode 100644 index 0000000000..9a7a220a82 --- /dev/null +++ b/src/components/FileDropZone/parts/FileDropZone.Icon.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; + +import { + CircleExclamation as DefaultErrorIcon, + CloudArrowUpIn as DefaultIcon, +} from '@gravity-ui/icons'; + +import {Icon} from '../..'; +import {useFileZoneContext} from '../FileDropZone.Provider'; +import {cnFileDropZone} from '../FileDropZone.classname'; + +type FileDropZoneIconProps = { + className?: string; +}; + +export const FileDropZoneIcon = ({className}: FileDropZoneIconProps) => { + const {errorMessage, validationState, icon, errorIcon} = useFileZoneContext(); + const isError = Boolean(errorMessage) || validationState === 'invalid'; + + const DisplayIcon = React.useMemo(() => { + if (isError) { + if (errorIcon === null) { + return null; + } + + return errorIcon || DefaultErrorIcon; + } + + if (icon === null) { + return null; + } + + return icon || DefaultIcon; + }, [errorIcon, icon, isError]); + + if (DisplayIcon === null) { + return null; + } + + return ( + + ); +}; diff --git a/src/components/FileDropZone/parts/FileDropZone.Title.tsx b/src/components/FileDropZone/parts/FileDropZone.Title.tsx new file mode 100644 index 0000000000..435539988a --- /dev/null +++ b/src/components/FileDropZone/parts/FileDropZone.Title.tsx @@ -0,0 +1,24 @@ +import {Text} from '../..'; +import {useFileZoneContext} from '../FileDropZone.Provider'; +import i18n from '../i18n'; + +type FileDropZoneTitleProps = { + className?: string; +}; + +export const FileDropZoneTitle = ({className}: FileDropZoneTitleProps) => { + const {title, multiple, errorMessage} = useFileZoneContext(); + + const {t} = i18n.useTranslation(); + + const postfix = multiple ? 'multiple' : 'single'; + const defaultTitle = title || t(`label_title-${postfix}`); + const isError = Boolean(errorMessage); + const displayTitle = isError ? errorMessage : defaultTitle; + + return ( + + {displayTitle} + + ); +}; diff --git a/src/components/index.ts b/src/components/index.ts index 3427cb59fd..15c6c45fc0 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -21,6 +21,7 @@ export * from './Dialog'; export * from './Disclosure'; export * from './Divider'; export * from './DropdownMenu'; +export * from './FileDropZone'; export * from './FilePreview'; export * from './HelpMark'; export * from './Hotkey'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e5e67492c2..3ea29b075c 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -14,3 +14,4 @@ export * from './useTimeout'; export * from './useViewportSize'; export * from './useVirtualElementRef'; export * from './useUniqId'; +export * from './useDropZone'; diff --git a/src/hooks/useDropZone/README.md b/src/hooks/useDropZone/README.md new file mode 100644 index 0000000000..4358f3e320 --- /dev/null +++ b/src/hooks/useDropZone/README.md @@ -0,0 +1,26 @@ + + +# useDropZone + + + +```tsx +import {useDropZone} from '@gravity-ui/uikit'; +``` + +The `useDropZone` hook provides props for an element to act as a drop zone and also gives access to the dragging-over state. +Additionally, the hook supports a more imperative approach: it can accept a `ref` for cases where you don't have direct access to the HTML element you want to make a drop zone. + +## Properties + +| Name | Description | Type | Default | +| :------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------: | :-----: | +| accept | A list of MIME types that will be accepted by the drop zone (e.g., `['text/*', 'image/png']`) | `string[]` | | +| disabled | Disables the drop zone | `boolean` | | +| ref | A ref object pointing to the element that will be provided with drop zone behavior | `React.RefObject` | | +| onDrop | A callback triggered when something is successfully dropped into the drop zone. Won't be called if the item's type does not match those provided in `accept` | `(items: DataTransferItemList) => void` | | + +## Result + +- `getDroppableProps` - returns props to provide an element with drop zone behavior +- `isDraggingOver` - returns `true` when an element is being dragged over the zone, and `false` otherwise diff --git a/src/hooks/useDropZone/__stories__/UseDropZone.stories.tsx b/src/hooks/useDropZone/__stories__/UseDropZone.stories.tsx new file mode 100644 index 0000000000..7907d458ee --- /dev/null +++ b/src/hooks/useDropZone/__stories__/UseDropZone.stories.tsx @@ -0,0 +1,56 @@ +import {FileArrowDown} from '@gravity-ui/icons'; +import type {Meta, StoryFn} from '@storybook/react-webpack5'; + +import {Icon, Text} from '../../../components'; +import {useDropZone} from '../useDropZone'; +import type {UseDropZoneParams} from '../useDropZone'; + +export default {title: 'Hooks/useDropZone'} as Meta; + +const ACCEPT = ['text/plain', 'image/*']; + +const DefaultTemplate: StoryFn = () => { + const handleDrop: UseDropZoneParams['onDrop'] = (items) => { + for (const item of items) { + if (item.kind === 'string') { + item.getAsString((text) => { + alert(`String: ${text}`); + }); + } + + if (item.kind === 'file') { + const file = item.getAsFile(); + + alert(`File: name: ${file?.name}, size: ${file?.size}, type: ${file?.type}`); + } + } + }; + + const {isDraggingOver, getDroppableProps} = useDropZone({ + accept: ACCEPT, + onDrop: handleDrop, + }); + + return ( +
+ Drop Something Here + +
+ ); +}; + +export const Default = DefaultTemplate.bind({}); diff --git a/src/hooks/useDropZone/index.ts b/src/hooks/useDropZone/index.ts new file mode 100644 index 0000000000..db87693300 --- /dev/null +++ b/src/hooks/useDropZone/index.ts @@ -0,0 +1,7 @@ +export {useDropZone} from './useDropZone'; +export type { + UseDropZoneAccept, + UseDropZoneParams, + DroppableProps, + UseDropZoneState, +} from './useDropZone'; diff --git a/src/hooks/useDropZone/useDropZone.ts b/src/hooks/useDropZone/useDropZone.ts new file mode 100644 index 0000000000..f08b67b764 --- /dev/null +++ b/src/hooks/useDropZone/useDropZone.ts @@ -0,0 +1,191 @@ +import * as React from 'react'; + +export type UseDropZoneAccept = string[]; + +export interface UseDropZoneParams { + accept: UseDropZoneAccept; + ref?: React.RefObject; + disabled?: boolean; + onDrop: (items: DataTransferItemList) => void; +} + +const DROP_ZONE_BASE_ATTRIBUTES = { + 'aria-dropeffect': 'copy' as DataTransfer['dropEffect'], + tabIndex: 0, + role: 'button', +}; + +export interface DroppableProps extends Required { + onDragEnter: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDragLeave: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent) => void; +} + +export interface UseDropZoneState { + isDraggingOver: boolean; + getDroppableProps: () => DroppableProps; +} + +function typeMatchesPattern(actualMimeType: string, expectedMimeTypePattern: string): boolean { + const actualMimeTypeParts = actualMimeType.split('/'); + + if (actualMimeTypeParts.length !== 2) { + return false; + } + + const [actualType] = actualMimeTypeParts; + const [expectedType, expectedSubtype] = expectedMimeTypePattern.split('/'); + + if (expectedSubtype === '*') { + return actualType === expectedType; + } + + return actualMimeType === expectedMimeTypePattern; +} + +function eventItemTypesAcceptable(accept: UseDropZoneAccept, event: DragEvent): boolean { + const items = event.dataTransfer?.items; + + if (!items) { + return false; + } + + for (const {type} of items) { + if (accept.some((acceptedTypePattern) => typeMatchesPattern(type, acceptedTypePattern))) { + return true; + } + } + + return false; +} + +export function useDropZone({accept, disabled, onDrop, ref}: UseDropZoneParams): UseDropZoneState { + const [isDraggingOver, setIsDraggingOver] = React.useState(false); + const nestingCounterRef = React.useRef(0); + + const handleDragEnterNative = React.useCallback( + (event: DragEvent) => { + if (disabled || !event.dataTransfer || !eventItemTypesAcceptable(accept, event)) { + return; + } + + nestingCounterRef.current++; + + event.dataTransfer.dropEffect = DROP_ZONE_BASE_ATTRIBUTES['aria-dropeffect']; + + setIsDraggingOver(true); + }, + [accept, disabled], + ); + + const handleDragEnter = React.useCallback( + (event: React.DragEvent) => { + handleDragEnterNative(event.nativeEvent); + }, + [handleDragEnterNative], + ); + + const handleDragOverNative = React.useCallback( + (event: DragEvent) => { + if (disabled || !event.dataTransfer || !eventItemTypesAcceptable(accept, event)) { + return; + } + + event.dataTransfer.dropEffect = DROP_ZONE_BASE_ATTRIBUTES['aria-dropeffect']; + + event.preventDefault(); + }, + [accept, disabled], + ); + + const handleDragOver = React.useCallback( + (event: React.DragEvent) => { + handleDragOverNative(event.nativeEvent); + }, + [handleDragOverNative], + ); + + const handleDragLeaveNative = React.useCallback( + (event: DragEvent) => { + if (disabled || !eventItemTypesAcceptable(accept, event)) { + return; + } + + nestingCounterRef.current--; + + if (nestingCounterRef.current !== 0) { + return; + } + + setIsDraggingOver(false); + }, + [accept, disabled], + ); + + const handleDragLeave = React.useCallback( + (event: React.DragEvent) => { + handleDragLeaveNative(event.nativeEvent); + }, + [handleDragLeaveNative], + ); + + const handleDropNative = React.useCallback( + (event: DragEvent) => { + if (disabled || !eventItemTypesAcceptable(accept, event)) { + return; + } + + setIsDraggingOver(false); + + const items = event.dataTransfer?.items; + + if (!items) { + return; + } + + onDrop(items); + }, + [accept, disabled, onDrop], + ); + + const handleDrop = React.useCallback( + (event: React.DragEvent) => { + handleDropNative(event.nativeEvent); + }, + [handleDropNative], + ); + + React.useEffect(() => { + const dropZoneElement = ref?.current; + + dropZoneElement?.addEventListener('dragenter', handleDragEnterNative); + dropZoneElement?.addEventListener('dragover', handleDragOverNative); + dropZoneElement?.addEventListener('dragleave', handleDragLeaveNative); + dropZoneElement?.addEventListener('drop', handleDropNative); + + for (const [attribute, value] of Object.entries(DROP_ZONE_BASE_ATTRIBUTES)) { + dropZoneElement?.setAttribute(attribute, value.toString()); + } + + return () => { + dropZoneElement?.removeEventListener('dragenter', handleDragEnterNative); + dropZoneElement?.removeEventListener('dragover', handleDragOverNative); + dropZoneElement?.removeEventListener('dragleave', handleDragLeaveNative); + dropZoneElement?.removeEventListener('drop', handleDropNative); + }; + }, [handleDragEnterNative, handleDragLeaveNative, handleDragOverNative, handleDropNative, ref]); + + return { + isDraggingOver, + getDroppableProps: () => { + return { + ...DROP_ZONE_BASE_ATTRIBUTES, + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }; + }, + }; +} diff --git a/src/hooks/useFileInput/useFileInput.ts b/src/hooks/useFileInput/useFileInput.ts index 53e1ba3cf8..1b3df5f463 100644 --- a/src/hooks/useFileInput/useFileInput.ts +++ b/src/hooks/useFileInput/useFileInput.ts @@ -3,6 +3,7 @@ import * as React from 'react'; export type UseFileInputProps = { onUpdate?: (files: File[]) => void; onChange?: (event: React.ChangeEvent) => void; + multiple?: boolean; }; export type UseFileInputResult = { @@ -22,11 +23,11 @@ export type UseFileInputResult = { ```tsx import * as React from 'react'; import {Button, useFileInput} from '@gravity-ui/uikit'; - + const Component = () => { const onUpdate = React.useCallback((files: File[]) => console.log(files), []); const {controlProps, triggerProps} = useFileInput({onUpdate}); - + return ( @@ -35,8 +36,12 @@ export type UseFileInputResult = { ); }; ``` -*/ -export function useFileInput({onUpdate, onChange}: UseFileInputProps): UseFileInputResult { + */ +export function useFileInput({ + onUpdate, + onChange, + multiple, +}: UseFileInputProps): UseFileInputResult { const ref = React.useRef(null); const handleChange = React.useCallback( @@ -60,6 +65,7 @@ export function useFileInput({onUpdate, onChange}: UseFileInputProps): UseFileIn type: 'file', tabIndex: -1, style: {opacity: 0, position: 'absolute', width: 1, height: 1}, + multiple, onChange: handleChange, 'aria-hidden': true, }, @@ -67,7 +73,7 @@ export function useFileInput({onUpdate, onChange}: UseFileInputProps): UseFileIn onClick: openDeviceStorage, }, }), - [handleChange, openDeviceStorage], + [handleChange, multiple, openDeviceStorage], ); return result;