Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions src/components/FileDropZone/FileDropZone.Provider.tsx
Original file line number Diff line number Diff line change
@@ -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<FileDropZoneContextValue | null>(null);

export const FileDropZoneProvider = ({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually give Provider postfix to entities that don't have a layout. For example: TabProvider, ToasterProvider, AccordionProvider, DisclosureProvider etc

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 (
<FileDropZoneContext.Provider value={contextValue}>
<div
{...getDroppableProps()}
onKeyDown={onKeyDown}
className={cnFileDropZone(
{
'drag-hover': isDraggingOver,
disabled: disabled,
error: hasError || validationState === 'invalid',
},
className,
)}
>
{children}
</div>
</FileDropZoneContext.Provider>
);
/* 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;
};
3 changes: 3 additions & 0 deletions src/components/FileDropZone/FileDropZone.classname.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {block} from '../utils/cn';

export const cnFileDropZone = block('file-drop-zone');
70 changes: 70 additions & 0 deletions src/components/FileDropZone/FileDropZone.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
68 changes: 68 additions & 0 deletions src/components/FileDropZone/FileDropZone.tsx
Original file line number Diff line number Diff line change
@@ -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<BaseInputControlProps, 'validationState'> {
accept: UseDropZoneAccept;
onAdd: (files: File[]) => void;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually use the name onUpdate for such callbacks, this is a familiar pattern for users of our library

title?: string;
description?: string;
buttonText?: string;
icon?: IconData | null;
errorIcon?: IconData | null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need more than one icon property?

className?: string;
multiple?: boolean;
disabled?: boolean;
errorMessage?: string;
children?: React.ReactNode;
}

const BaseFileDropZone = React.memo<FileDropZoneProps>(
({children, ...restProps}: FileDropZoneProps) => {
const content = React.useMemo(() => {
if (typeof children !== 'undefined') {
return children;
}

return (
<React.Fragment>
<FileDropZoneIcon />
<FileDropZoneTitle />
<FileDropZoneDescription />
<FileDropZoneButton />
</React.Fragment>
);
}, [children]);

return (
<FileDropZoneProvider
{...restProps}
className={cnFileDropZone({
'default-layout': typeof children === 'undefined',
})}
>
{content}
</FileDropZoneProvider>
);
},
);

BaseFileDropZone.displayName = 'FileDropZone';

export const FileDropZone = Object.assign(BaseFileDropZone, {
Icon: FileDropZoneIcon,
Title: FileDropZoneTitle,
Description: FileDropZoneDescription,
Button: FileDropZoneButton,
});
106 changes: 106 additions & 0 deletions src/components/FileDropZone/README-ru.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<!--GITHUB_BLOCK-->

# FileDropZone

<!--/GITHUB_BLOCK-->

```tsx
import {FileDropZone} from '@gravity-ui/uikit';
```

### Базовое использование

```tsx
const accept = ['image/*'];
const handleAdd = (files: File[]) => {
// Do something with files
};

<FileDropZone accept={accept} onAdd={handleAdd} />;
```

### Кастомные тексты и иконки

```tsx
import {DatabaseFill, HeartCrack} from '@gravity-ui/icons';

const accept = ['image/*'];
const handleAdd = (files: File[]) => {
// Do something with files
};

<FileDropZone
accept={accept}
onAdd={handleAdd}
title="Lorem ipsum dolor sit amet"
description="Duis consequat commodo eros sit"
buttonText="Upload"
icon={DatabaseFill}
errorIcon={HeartCrack}
/>;
```

### Кастомный лейаут

Паттерн Compound Component позволяет рендерить произвольный лейаут. Все пропсы передаются только родительскому компоненту и шарятся через React-контекст, а компоненты-части могут получать только `className`.

```tsx
const accept = ['image/*'];
const handleAdd = (files: File[]) => {
// Do something with files
};

<FileDropZone
accept={accept}
onAdd={handleAdd}
title="Lorem ipsum dolor sit amet"
description="Duis consequat commodo eros sit"
buttonText="Upload"
icon={DatabaseFill}
errorIcon={HeartCrack}
>
<div
style={{
flexGrow: '1',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '32px',
}}
>
<FileDropZone.Icon className={iconClassName} />
<div style={{display: 'flex', flexDirection: 'column', alignItems: 'flex-start'}}>
<FileDropZone.Title className={titleClassName} />
<FileDropZone.Description className={descriptionClassName} />
</div>
</div>
<div style={{marginLeft: '16px'}}>
<FileDropZone.Button className={buttonClassName} />
</div>
</div>
</FileDropZone>;
```

## 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` | |
Loading
Loading