Skip to content

Commit 8173352

Browse files
committed
feat: add FileDropZone component
1 parent f4a6a7a commit 8173352

File tree

44 files changed

+842
-5
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+842
-5
lines changed
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import * as React from 'react';
2+
3+
import {useActionHandlers, useDropZone, useFileInput} from '../../hooks';
4+
import type {UseDropZoneState, UseFileInputResult} from '../../hooks';
5+
6+
import type {FileDropZoneProps} from './FileDropZone';
7+
import {cnFileDropZone} from './FileDropZone.classname';
8+
9+
interface FileDropZoneContextValue
10+
extends Pick<
11+
FileDropZoneProps,
12+
| 'title'
13+
| 'description'
14+
| 'buttonText'
15+
| 'multiple'
16+
| 'icon'
17+
| 'errorIcon'
18+
| 'errorMessage'
19+
| 'validationState'
20+
>,
21+
UseFileInputResult,
22+
UseDropZoneState {}
23+
24+
const FileDropZoneContext = React.createContext<FileDropZoneContextValue | null>(null);
25+
26+
export const FileDropZoneProvider = ({
27+
accept,
28+
onAdd,
29+
title,
30+
description,
31+
buttonText,
32+
icon,
33+
errorIcon,
34+
multiple,
35+
disabled,
36+
className,
37+
errorMessage,
38+
validationState,
39+
children,
40+
}: FileDropZoneProps) => {
41+
const handleDrop = React.useCallback(
42+
(items: DataTransferItemList): void => {
43+
const files: File[] = [];
44+
45+
for (const item of items) {
46+
const file = item.getAsFile();
47+
48+
if (!file) {
49+
continue;
50+
}
51+
52+
files.push(file);
53+
}
54+
55+
onAdd(files);
56+
},
57+
[onAdd],
58+
);
59+
60+
const {isDraggingOver, getDroppableProps} = useDropZone({
61+
accept,
62+
disabled,
63+
onDrop: handleDrop,
64+
});
65+
66+
const onUpdate = React.useCallback(
67+
(files: File[]) => {
68+
onAdd(files);
69+
},
70+
[onAdd],
71+
);
72+
73+
const {controlProps, triggerProps} = useFileInput({onUpdate, multiple});
74+
75+
const {onKeyDown} = useActionHandlers(triggerProps.onClick);
76+
77+
const contextValue = React.useMemo(
78+
() => ({
79+
title,
80+
description,
81+
buttonText,
82+
multiple,
83+
icon,
84+
errorIcon,
85+
errorMessage,
86+
validationState,
87+
controlProps,
88+
triggerProps,
89+
isDraggingOver,
90+
getDroppableProps,
91+
}),
92+
[
93+
title,
94+
description,
95+
buttonText,
96+
multiple,
97+
icon,
98+
errorIcon,
99+
errorMessage,
100+
validationState,
101+
controlProps,
102+
triggerProps,
103+
isDraggingOver,
104+
getDroppableProps,
105+
],
106+
);
107+
108+
const hasError = Boolean(errorMessage);
109+
110+
/* eslint-disable jsx-a11y/no-static-element-interactions */
111+
return (
112+
<FileDropZoneContext.Provider value={contextValue}>
113+
<div
114+
{...getDroppableProps()}
115+
onKeyDown={onKeyDown}
116+
className={cnFileDropZone(
117+
{
118+
'drag-hover': isDraggingOver,
119+
disabled: disabled,
120+
error: hasError || validationState === 'invalid',
121+
},
122+
className,
123+
)}
124+
>
125+
{children}
126+
</div>
127+
</FileDropZoneContext.Provider>
128+
);
129+
/* eslint-enable jsx-a11y/no-static-element-interactions */
130+
};
131+
132+
export const useFileZoneContext = (): FileDropZoneContextValue => {
133+
const contextValue = React.useContext(FileDropZoneContext);
134+
135+
if (contextValue === null) {
136+
throw new Error('FileDropZone context not found');
137+
}
138+
139+
return contextValue;
140+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import {block} from '../utils/cn';
2+
3+
export const cnFileDropZone = block('file-drop-zone');
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
@use '../variables';
2+
3+
$block: '.#{variables.$ns}file-drop-zone';
4+
5+
#{$block} {
6+
background-color: var(--g-color-base-background);
7+
border: 1px dashed var(--g-color-line-generic-active);
8+
border-radius: 8px;
9+
box-sizing: border-box;
10+
padding: 16px;
11+
12+
cursor: pointer;
13+
outline: none;
14+
15+
&_default-layout {
16+
display: flex;
17+
flex-direction: column;
18+
justify-content: center;
19+
align-items: center;
20+
text-align: center;
21+
22+
#{$block}__button {
23+
margin-block-start: 10px;
24+
}
25+
}
26+
27+
&:hover {
28+
border-color: var(--g-color-text-info);
29+
}
30+
31+
&:focus {
32+
border-color: var(--g-color-text-info);
33+
}
34+
35+
&:focus:not(:focus-visible) {
36+
border-color: none;
37+
}
38+
39+
&_drag-hover {
40+
background-color: var(--g-color-base-info-light);
41+
border-color: var(--g-color-text-info);
42+
}
43+
44+
&_error {
45+
border-color: var(--g-color-line-danger);
46+
47+
&:hover {
48+
border-color: var(--g-color-line-danger);
49+
}
50+
51+
#{$block}__icon {
52+
color: var(--g-color-text-danger);
53+
}
54+
}
55+
56+
&_disabled {
57+
cursor: not-allowed;
58+
pointer-events: none;
59+
60+
#{$block}__icon {
61+
color: var(--g-color-text-hint);
62+
}
63+
}
64+
65+
&__icon {
66+
flex-shrink: 0;
67+
color: var(--g-color-text-info);
68+
margin-block-end: 5px;
69+
}
70+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import * as React from 'react';
2+
3+
import type {IconData} from '../..';
4+
import type {UseDropZoneAccept} from '../../hooks/useDropZone';
5+
import type {BaseInputControlProps} from '../controls/types';
6+
7+
import {FileDropZoneProvider} from './FileDropZone.Provider';
8+
import {cnFileDropZone} from './FileDropZone.classname';
9+
import {FileDropZoneButton} from './parts/FileDropZone.Button';
10+
import {FileDropZoneDescription} from './parts/FileDropZone.Description';
11+
import {FileDropZoneIcon} from './parts/FileDropZone.Icon';
12+
import {FileDropZoneTitle} from './parts/FileDropZone.Title';
13+
14+
import './FileDropZone.scss';
15+
16+
export interface FileDropZoneProps extends Pick<BaseInputControlProps, 'validationState'> {
17+
accept: UseDropZoneAccept;
18+
onAdd: (files: File[]) => void;
19+
title?: string;
20+
description?: string;
21+
buttonText?: string;
22+
icon?: IconData | null;
23+
errorIcon?: IconData | null;
24+
className?: string;
25+
multiple?: boolean;
26+
disabled?: boolean;
27+
errorMessage?: string;
28+
children?: React.ReactNode;
29+
}
30+
31+
const BaseFileDropZone = React.memo<FileDropZoneProps>(
32+
({children, ...restProps}: FileDropZoneProps) => {
33+
const content = React.useMemo(() => {
34+
if (typeof children !== 'undefined') {
35+
return children;
36+
}
37+
38+
return (
39+
<React.Fragment>
40+
<FileDropZoneIcon />
41+
<FileDropZoneTitle />
42+
<FileDropZoneDescription />
43+
<FileDropZoneButton />
44+
</React.Fragment>
45+
);
46+
}, [children]);
47+
48+
return (
49+
<FileDropZoneProvider
50+
{...restProps}
51+
className={cnFileDropZone({
52+
'default-layout': typeof children === 'undefined',
53+
})}
54+
>
55+
{content}
56+
</FileDropZoneProvider>
57+
);
58+
},
59+
);
60+
61+
BaseFileDropZone.displayName = 'FileDropZone';
62+
63+
export const FileDropZone = Object.assign(BaseFileDropZone, {
64+
Icon: FileDropZoneIcon,
65+
Title: FileDropZoneTitle,
66+
Description: FileDropZoneDescription,
67+
Button: FileDropZoneButton,
68+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<!--GITHUB_BLOCK-->
2+
3+
# FileDropZone
4+
5+
<!--/GITHUB_BLOCK-->
6+
7+
```tsx
8+
import {FileDropZone} from '@gravity-ui/uikit';
9+
```
10+
11+
### Базовое использование
12+
13+
```tsx
14+
const accept = ['image/*'];
15+
const handleAdd = (files: File[]) => {
16+
// Do something with files
17+
};
18+
19+
<FileDropZone accept={accept} onAdd={handleAdd} />;
20+
```
21+
22+
### Кастомные тексты и иконки
23+
24+
```tsx
25+
import {DatabaseFill, HeartCrack} from '@gravity-ui/icons';
26+
27+
const accept = ['image/*'];
28+
const handleAdd = (files: File[]) => {
29+
// Do something with files
30+
};
31+
32+
<FileDropZone
33+
accept={accept}
34+
onAdd={handleAdd}
35+
title="Lorem ipsum dolor sit amet"
36+
description="Duis consequat commodo eros sit"
37+
buttonText="Upload"
38+
icon={DatabaseFill}
39+
errorIcon={HeartCrack}
40+
/>;
41+
```
42+
43+
### Кастомный лейаут
44+
45+
Паттерн Compound Component позволяет рендерить произвольный лейаут. Все пропсы передаются только родительскому компоненту и шарятся через React-контекст, а компоненты-части могут получать только `className`.
46+
47+
```tsx
48+
const accept = ['image/*'];
49+
const handleAdd = (files: File[]) => {
50+
// Do something with files
51+
};
52+
53+
<FileDropZone
54+
accept={accept}
55+
onAdd={handleAdd}
56+
title="Lorem ipsum dolor sit amet"
57+
description="Duis consequat commodo eros sit"
58+
buttonText="Upload"
59+
icon={DatabaseFill}
60+
errorIcon={HeartCrack}
61+
>
62+
<div
63+
style={{
64+
flexGrow: '1',
65+
display: 'flex',
66+
flexDirection: 'row',
67+
justifyContent: 'space-between',
68+
alignItems: 'center',
69+
}}
70+
>
71+
<div
72+
style={{
73+
display: 'flex',
74+
flexDirection: 'row',
75+
alignItems: 'center',
76+
gap: '32px',
77+
}}
78+
>
79+
<FileDropZone.Icon className={iconClassName} />
80+
<div style={{display: 'flex', flexDirection: 'column', alignItems: 'flex-start'}}>
81+
<FileDropZone.Title className={titleClassName} />
82+
<FileDropZone.Description className={descriptionClassName} />
83+
</div>
84+
</div>
85+
<div style={{marginLeft: '16px'}}>
86+
<FileDropZone.Button className={buttonClassName} />
87+
</div>
88+
</div>
89+
</FileDropZone>;
90+
```
91+
92+
## Properties
93+
94+
| Name | Description | Type | Default |
95+
| :----------- | :------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------------------------: |
96+
| accept | Список допустимых MIME-типов | `string[]` | |
97+
| onAdd | Коллбэк, вызываемый при добавлении файлов. Не вызывается, если тип файла недопустим. | `(files: File[]) => void` | |
98+
| title | Заголовок, отображаемый под иконкой | `string` | "Drag the file(s) here or select it(them)" |
99+
| description | Описание, отображаемое под заголовком | `string` | |
100+
| buttonText | Подпись кнопки загрузки | `string` | "Select a file(s)" |
101+
| icon | Пользовательский компонент иконки из `@gravity-ui/icons`. Если передан `null`, иконка не отображается. | `@gravity-ui/icons/IconData` | |
102+
| errorIcon | Пользовательский компонент иконки ошибки из `@gravity-ui/icons`. Если передан `null`, иконка ошибки не отображается. | `@gravity-ui/icons/IconData` | |
103+
| className | Класс корневого элемента | `string` | |
104+
| multiple | Булево значение, которое определяет, разрешена ли множественная загрузка файлов | | |
105+
| disabled | Булево значение, определяющее, отключена ли загрузка файлов | | |
106+
| errorMessage | Сообщение об ошибке. Если указано, также применяются стили ошибки. | `string` | |

0 commit comments

Comments
 (0)