@cloud-ru/ds-dropzone 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/LICENSE +201 -0
- package/README.md +356 -0
- package/dist/cjs/components/Dropzone/Dropzone.d.ts +25 -0
- package/dist/cjs/components/Dropzone/Dropzone.js +11 -0
- package/dist/cjs/components/Dropzone/index.d.ts +1 -0
- package/dist/cjs/components/Dropzone/index.js +17 -0
- package/dist/cjs/components/FileUpload/FileUpload.d.ts +45 -0
- package/dist/cjs/components/FileUpload/FileUpload.js +56 -0
- package/dist/cjs/components/FileUpload/index.d.ts +1 -0
- package/dist/cjs/components/FileUpload/index.js +17 -0
- package/dist/cjs/components/FileUpload/styles.module.css +3 -0
- package/dist/cjs/components/HiddenDropZone/HiddenDropZone.d.ts +10 -0
- package/dist/cjs/components/HiddenDropZone/HiddenDropZone.js +19 -0
- package/dist/cjs/components/HiddenDropZone/index.d.ts +1 -0
- package/dist/cjs/components/HiddenDropZone/index.js +17 -0
- package/dist/cjs/components/HiddenDropZone/styles.module.css +20 -0
- package/dist/cjs/components/PrivateDropZone/PrivateDropZone.d.ts +2 -0
- package/dist/cjs/components/PrivateDropZone/PrivateDropZone.js +62 -0
- package/dist/cjs/components/PrivateDropZone/constants.d.ts +1 -0
- package/dist/cjs/components/PrivateDropZone/constants.js +5 -0
- package/dist/cjs/components/PrivateDropZone/index.d.ts +2 -0
- package/dist/cjs/components/PrivateDropZone/index.js +5 -0
- package/dist/cjs/components/PrivateDropZone/styles.module.css +405 -0
- package/dist/cjs/components/PrivateDropZone/types.d.ts +23 -0
- package/dist/cjs/components/PrivateDropZone/types.js +2 -0
- package/dist/cjs/components/index.d.ts +3 -0
- package/dist/cjs/components/index.js +19 -0
- package/dist/cjs/constants.d.ts +17 -0
- package/dist/cjs/constants.js +20 -0
- package/dist/cjs/hooks/useDrag.d.ts +7 -0
- package/dist/cjs/hooks/useDrag.js +30 -0
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.js +20 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/style.css +428 -0
- package/dist/cjs/types.d.ts +33 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/utils.d.ts +37 -0
- package/dist/cjs/utils.js +70 -0
- package/dist/esm/components/Dropzone/Dropzone.d.ts +25 -0
- package/dist/esm/components/Dropzone/Dropzone.js +8 -0
- package/dist/esm/components/Dropzone/index.d.ts +1 -0
- package/dist/esm/components/Dropzone/index.js +1 -0
- package/dist/esm/components/FileUpload/FileUpload.d.ts +45 -0
- package/dist/esm/components/FileUpload/FileUpload.js +50 -0
- package/dist/esm/components/FileUpload/index.d.ts +1 -0
- package/dist/esm/components/FileUpload/index.js +1 -0
- package/dist/esm/components/FileUpload/styles.module.css +3 -0
- package/dist/esm/components/HiddenDropZone/HiddenDropZone.d.ts +10 -0
- package/dist/esm/components/HiddenDropZone/HiddenDropZone.js +13 -0
- package/dist/esm/components/HiddenDropZone/index.d.ts +1 -0
- package/dist/esm/components/HiddenDropZone/index.js +1 -0
- package/dist/esm/components/HiddenDropZone/styles.module.css +20 -0
- package/dist/esm/components/PrivateDropZone/PrivateDropZone.d.ts +2 -0
- package/dist/esm/components/PrivateDropZone/PrivateDropZone.js +56 -0
- package/dist/esm/components/PrivateDropZone/constants.d.ts +1 -0
- package/dist/esm/components/PrivateDropZone/constants.js +1 -0
- package/dist/esm/components/PrivateDropZone/index.d.ts +2 -0
- package/dist/esm/components/PrivateDropZone/index.js +1 -0
- package/dist/esm/components/PrivateDropZone/styles.module.css +405 -0
- package/dist/esm/components/PrivateDropZone/types.d.ts +23 -0
- package/dist/esm/components/PrivateDropZone/types.js +1 -0
- package/dist/esm/components/index.d.ts +3 -0
- package/dist/esm/components/index.js +3 -0
- package/dist/esm/constants.d.ts +17 -0
- package/dist/esm/constants.js +17 -0
- package/dist/esm/hooks/useDrag.d.ts +7 -0
- package/dist/esm/hooks/useDrag.js +27 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/style.css +428 -0
- package/dist/esm/types.d.ts +33 -0
- package/dist/esm/types.js +1 -0
- package/dist/esm/utils.d.ts +37 -0
- package/dist/esm/utils.js +64 -0
- package/dist/tsconfig.cjs.tsbuildinfo +1 -0
- package/dist/tsconfig.esm.tsbuildinfo +1 -0
- package/package.json +43 -0
- package/src/components/Dropzone/Dropzone.tsx +40 -0
- package/src/components/Dropzone/index.ts +1 -0
- package/src/components/FileUpload/FileUpload.tsx +145 -0
- package/src/components/FileUpload/index.ts +1 -0
- package/src/components/FileUpload/styles.module.scss +3 -0
- package/src/components/HiddenDropZone/HiddenDropZone.tsx +39 -0
- package/src/components/HiddenDropZone/index.ts +1 -0
- package/src/components/HiddenDropZone/styles.module.scss +20 -0
- package/src/components/PrivateDropZone/PrivateDropZone.tsx +122 -0
- package/src/components/PrivateDropZone/constants.ts +1 -0
- package/src/components/PrivateDropZone/index.ts +2 -0
- package/src/components/PrivateDropZone/styles.module.scss +126 -0
- package/src/components/PrivateDropZone/types.ts +29 -0
- package/src/components/index.ts +3 -0
- package/src/constants.ts +19 -0
- package/src/hooks/useDrag.ts +49 -0
- package/src/index.ts +4 -0
- package/src/types.ts +36 -0
- package/src/utils.ts +89 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Dropzone';
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { extractDataTestProps, WithSupportProps } from '@cloud-ru/ds-utils';
|
|
2
|
+
import {
|
|
3
|
+
ChangeEvent,
|
|
4
|
+
ChangeEventHandler,
|
|
5
|
+
cloneElement,
|
|
6
|
+
InputHTMLAttributes,
|
|
7
|
+
MouseEvent,
|
|
8
|
+
MouseEventHandler,
|
|
9
|
+
MutableRefObject,
|
|
10
|
+
ReactElement,
|
|
11
|
+
Ref,
|
|
12
|
+
useRef,
|
|
13
|
+
} from 'react';
|
|
14
|
+
|
|
15
|
+
import { TEST_IDS, UPLOAD_MODE } from '../../constants';
|
|
16
|
+
import { UploadMode } from '../../types';
|
|
17
|
+
import { AcceptInput, buildAcceptAttribute, FileRejection, partitionFiles } from '../../utils';
|
|
18
|
+
import styles from './styles.module.scss';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Нативные атрибуты `<input type="file">`, проксируемые на скрытый input:
|
|
22
|
+
* `name`, `id`, `required`, `capture`, `form`, `onBlur` и т.п. — всё, что нужно
|
|
23
|
+
* для работы в HTML-форме и интеграции с react-hook-form (`{...register(name)}`).
|
|
24
|
+
* Управляемые самим компонентом поля исключены.
|
|
25
|
+
*/
|
|
26
|
+
type NativeInputProps = Omit<
|
|
27
|
+
InputHTMLAttributes<HTMLInputElement>,
|
|
28
|
+
'type' | 'accept' | 'multiple' | 'disabled' | 'value' | 'defaultValue' | 'onChange' | 'children'
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
export type FileUploadProps = WithSupportProps<
|
|
32
|
+
{
|
|
33
|
+
/** Триггер открытия диалога выбора файлов (кнопка / лейбл). Клонируется, к нему добавляется `onClick`. */
|
|
34
|
+
children: ReactElement;
|
|
35
|
+
/** Колбек с принятыми файлами (прошедшими проверку `maxSize` и `accept`). */
|
|
36
|
+
onFilesUpload(files: File[]): void;
|
|
37
|
+
/**
|
|
38
|
+
* Колбек с отклонёнными файлами и причиной отказа (`maxSize` / `mime`).
|
|
39
|
+
* Вызывается только когда задан `maxSize` и/или `accept` и есть отклонённые файлы.
|
|
40
|
+
*/
|
|
41
|
+
onFilesReject?(rejections: FileRejection[]): void;
|
|
42
|
+
/**
|
|
43
|
+
* Нативный `onChange` скрытого input. Вызывается до валидации с исходным событием —
|
|
44
|
+
* точка интеграции с react-hook-form и другими form-библиотеками.
|
|
45
|
+
*/
|
|
46
|
+
onChange?: ChangeEventHandler<HTMLInputElement>;
|
|
47
|
+
/**
|
|
48
|
+
* Режим выбора файлов
|
|
49
|
+
* @default multiple
|
|
50
|
+
*/
|
|
51
|
+
mode?: UploadMode;
|
|
52
|
+
/**
|
|
53
|
+
* Разрешённые типы файлов: MIME-тип (`image/png`), шаблон (`image/*`) или расширение (`.pdf`).
|
|
54
|
+
* Строка (через запятую, как нативный `accept`) или массив. Задаёт нативный `accept` и валидацию.
|
|
55
|
+
*/
|
|
56
|
+
accept?: AcceptInput;
|
|
57
|
+
/** Максимальный размер одного файла в байтах. Превысившие уходят в `onFilesReject`. */
|
|
58
|
+
maxSize?: number;
|
|
59
|
+
/** Деактивирует триггер и input. */
|
|
60
|
+
disabled?: boolean;
|
|
61
|
+
/** Ссылка на нативный `<input type="file">` — для интеграции с формами (react-hook-form `ref`). */
|
|
62
|
+
innerRef?: Ref<HTMLInputElement>;
|
|
63
|
+
} & NativeInputProps
|
|
64
|
+
>;
|
|
65
|
+
|
|
66
|
+
// eslint-disable-next-line ds/require-inner-ref-support -- innerRef ведёт на скрытый <input type="file">, а не на корень: маркер соврал бы поповеру.
|
|
67
|
+
export function FileUpload({
|
|
68
|
+
mode = UPLOAD_MODE.Multiple,
|
|
69
|
+
onFilesUpload,
|
|
70
|
+
onFilesReject,
|
|
71
|
+
onChange,
|
|
72
|
+
accept,
|
|
73
|
+
maxSize,
|
|
74
|
+
disabled = false,
|
|
75
|
+
innerRef,
|
|
76
|
+
children,
|
|
77
|
+
...rest
|
|
78
|
+
}: FileUploadProps) {
|
|
79
|
+
const hiddenFileInput = useRef<HTMLInputElement | null>(null);
|
|
80
|
+
|
|
81
|
+
const setInputRef = (node: HTMLInputElement | null) => {
|
|
82
|
+
hiddenFileInput.current = node;
|
|
83
|
+
if (typeof innerRef === 'function') {
|
|
84
|
+
innerRef(node);
|
|
85
|
+
} else if (innerRef) {
|
|
86
|
+
(innerRef as MutableRefObject<HTMLInputElement | null>).current = node;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleButtonClick = (cb?: MouseEventHandler<HTMLElement>) => (e: MouseEvent<HTMLElement>) => {
|
|
91
|
+
if (disabled) return;
|
|
92
|
+
|
|
93
|
+
hiddenFileInput.current?.click();
|
|
94
|
+
cb?.(e);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
|
|
98
|
+
onChange?.(e);
|
|
99
|
+
|
|
100
|
+
const { files } = e.target;
|
|
101
|
+
if (!files) return;
|
|
102
|
+
|
|
103
|
+
const filesArray = Array.from(files);
|
|
104
|
+
|
|
105
|
+
if (typeof maxSize === 'number' || accept) {
|
|
106
|
+
const { accepted, rejected } = partitionFiles(filesArray, { maxSize, accept });
|
|
107
|
+
if (rejected.length) {
|
|
108
|
+
onFilesReject?.(rejected);
|
|
109
|
+
}
|
|
110
|
+
onFilesUpload(accepted);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onFilesUpload(filesArray);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const handleClick = (e: MouseEvent<HTMLInputElement>) => {
|
|
118
|
+
(e.target as HTMLInputElement).value = '';
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const childProps = children.props as { onClick?: MouseEventHandler<HTMLElement>; disabled?: boolean };
|
|
122
|
+
const mergedProps = {
|
|
123
|
+
onClick: handleButtonClick(childProps?.onClick),
|
|
124
|
+
disabled: childProps?.disabled ?? disabled,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<>
|
|
129
|
+
{cloneElement(children, mergedProps)}
|
|
130
|
+
<input
|
|
131
|
+
{...rest}
|
|
132
|
+
onChange={handleFileSelect}
|
|
133
|
+
multiple={mode === UPLOAD_MODE.Multiple}
|
|
134
|
+
ref={setInputRef}
|
|
135
|
+
className={styles.hiddenInput}
|
|
136
|
+
type='file'
|
|
137
|
+
accept={buildAcceptAttribute(accept)}
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
onClick={handleClick}
|
|
140
|
+
data-test-id={TEST_IDS.fileUpload.nativeInput}
|
|
141
|
+
{...extractDataTestProps(rest)}
|
|
142
|
+
/>
|
|
143
|
+
</>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './FileUpload';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { extractSupportProps } from '@cloud-ru/ds-utils';
|
|
2
|
+
import cn from 'classnames';
|
|
3
|
+
import { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useDrag } from '../../hooks/useDrag';
|
|
6
|
+
import { DropzoneProps } from '../Dropzone';
|
|
7
|
+
import { PrivateDropZone } from '../PrivateDropZone';
|
|
8
|
+
import styles from './styles.module.scss';
|
|
9
|
+
|
|
10
|
+
export type HiddenDropZoneProps = Omit<DropzoneProps, 'children'> & {
|
|
11
|
+
/** Контент dropzone при drag (overlay) */
|
|
12
|
+
content?: ReactNode;
|
|
13
|
+
/** Дочерний контент, поверх которого отображается dropzone при drag */
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Компонент — скрытая дропзона, накрывающая произвольный контент (формы, карточки) */
|
|
18
|
+
export function HiddenDropZone(props: HiddenDropZoneProps) {
|
|
19
|
+
const { disabled = false, children, className, content, 'data-test-id': dataTestId, ...rest } = props;
|
|
20
|
+
|
|
21
|
+
const { events, isOver } = useDrag(disabled);
|
|
22
|
+
const showOverlay = isOver && !disabled;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={cn(styles.wrapper, className)}
|
|
27
|
+
{...extractSupportProps({ ...rest, 'data-test-id': dataTestId })}
|
|
28
|
+
{...events}
|
|
29
|
+
>
|
|
30
|
+
{showOverlay && (
|
|
31
|
+
<PrivateDropZone className={styles.dropZone} {...events} {...rest} isOver disabled={disabled}>
|
|
32
|
+
{content}
|
|
33
|
+
</PrivateDropZone>
|
|
34
|
+
)}
|
|
35
|
+
|
|
36
|
+
<div className={cn(styles.children, showOverlay && styles.hidden)}>{children}</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './HiddenDropZone';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
.wrapper {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: inline-block;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.dropZone {
|
|
7
|
+
position: absolute;
|
|
8
|
+
width: 100%;
|
|
9
|
+
height: 100%;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.hidden * {
|
|
13
|
+
visibility: hidden;
|
|
14
|
+
opacity: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.children {
|
|
18
|
+
width: 100%;
|
|
19
|
+
height: 100%;
|
|
20
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { extractSupportProps } from '@cloud-ru/ds-utils';
|
|
2
|
+
import cn from 'classnames';
|
|
3
|
+
import { ChangeEvent, DragEvent, MutableRefObject, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import { TEST_IDS } from '../../constants';
|
|
6
|
+
import { buildAcceptAttribute, partitionFiles } from '../../utils';
|
|
7
|
+
import { UPLOAD_MODE } from './constants';
|
|
8
|
+
import styles from './styles.module.scss';
|
|
9
|
+
import { PrivateDropZoneProps } from './types';
|
|
10
|
+
|
|
11
|
+
// eslint-disable-next-line ds/require-inner-ref-support -- innerRef ведёт на скрытый <input type="file">, а не на корень: маркер соврал бы поповеру.
|
|
12
|
+
export function PrivateDropZone({
|
|
13
|
+
disabled = false,
|
|
14
|
+
className,
|
|
15
|
+
isOver,
|
|
16
|
+
onDragLeave,
|
|
17
|
+
onDragOver,
|
|
18
|
+
onDrop,
|
|
19
|
+
mode = UPLOAD_MODE.Multiple,
|
|
20
|
+
children,
|
|
21
|
+
onFilesUpload,
|
|
22
|
+
onFilesReject,
|
|
23
|
+
accept,
|
|
24
|
+
maxSize,
|
|
25
|
+
onChange,
|
|
26
|
+
innerRef,
|
|
27
|
+
name,
|
|
28
|
+
id,
|
|
29
|
+
required,
|
|
30
|
+
form,
|
|
31
|
+
capture,
|
|
32
|
+
size = 'm',
|
|
33
|
+
...rest
|
|
34
|
+
}: PrivateDropZoneProps) {
|
|
35
|
+
const hiddenFileInput = useRef<HTMLInputElement | null>(null);
|
|
36
|
+
|
|
37
|
+
const setInputRef = (node: HTMLInputElement | null) => {
|
|
38
|
+
hiddenFileInput.current = node;
|
|
39
|
+
if (typeof innerRef === 'function') {
|
|
40
|
+
innerRef(node);
|
|
41
|
+
} else if (innerRef) {
|
|
42
|
+
(innerRef as MutableRefObject<HTMLInputElement | null>).current = node;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const emitFiles = (files: File[]) => {
|
|
47
|
+
if (typeof maxSize === 'number' || accept) {
|
|
48
|
+
const { accepted, rejected } = partitionFiles(files, { maxSize, accept });
|
|
49
|
+
if (rejected.length) {
|
|
50
|
+
onFilesReject?.(rejected);
|
|
51
|
+
}
|
|
52
|
+
onFilesUpload(accepted);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
onFilesUpload(files);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleAttachFile = () => {
|
|
59
|
+
if (disabled) return;
|
|
60
|
+
hiddenFileInput.current?.click();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
|
|
64
|
+
onChange?.(e);
|
|
65
|
+
const { files } = e.target;
|
|
66
|
+
if (!files) return;
|
|
67
|
+
|
|
68
|
+
emitFiles(Array.from(files));
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleDrop = (e: DragEvent<HTMLButtonElement>) => {
|
|
72
|
+
if (disabled) return;
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
|
|
75
|
+
const filesArray = Array.from(e.dataTransfer.files);
|
|
76
|
+
onDrop?.(e);
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
78
|
+
emitFiles(mode === UPLOAD_MODE.Single ? [filesArray[0]!] : filesArray);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<button
|
|
83
|
+
className={cn(className, styles.container)}
|
|
84
|
+
type='button'
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
{...extractSupportProps(rest)}
|
|
87
|
+
data-over={isOver || undefined}
|
|
88
|
+
data-disabled={disabled || undefined}
|
|
89
|
+
data-size={size}
|
|
90
|
+
onClick={handleAttachFile}
|
|
91
|
+
tabIndex={disabled ? -1 : 0}
|
|
92
|
+
onDrop={handleDrop}
|
|
93
|
+
onDragLeave={onDragLeave}
|
|
94
|
+
onDragOver={onDragOver}
|
|
95
|
+
>
|
|
96
|
+
<span className={styles.acrylic} aria-hidden />
|
|
97
|
+
<span className={styles.borderLayer} data-state-layer aria-hidden data-state='borderOnBackground' />
|
|
98
|
+
<span className={styles.overLayer} data-state-layer aria-hidden data-state='activatedOnBackground' />
|
|
99
|
+
|
|
100
|
+
{children && <div className={styles.content}>{children}</div>}
|
|
101
|
+
|
|
102
|
+
<input
|
|
103
|
+
data-test-id={TEST_IDS.dropzone.nativeInput}
|
|
104
|
+
className={styles.hidden}
|
|
105
|
+
onChange={handleFileSelect}
|
|
106
|
+
multiple={mode === UPLOAD_MODE.Multiple}
|
|
107
|
+
ref={setInputRef}
|
|
108
|
+
type='file'
|
|
109
|
+
accept={buildAcceptAttribute(accept)}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
name={name}
|
|
112
|
+
id={id}
|
|
113
|
+
required={required}
|
|
114
|
+
form={form}
|
|
115
|
+
capture={capture}
|
|
116
|
+
onClick={e => {
|
|
117
|
+
(e.target as HTMLInputElement).value = '';
|
|
118
|
+
}}
|
|
119
|
+
/>
|
|
120
|
+
</button>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UPLOAD_MODE } from '../../constants';
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
@use '@cloud-ru/ds-figma-variables/build/scss/styles/styles.module' as base;
|
|
2
|
+
@use '@cloud-ru/ds-figma-variables/build/scss/components/dropzone.module' as dropzone;
|
|
3
|
+
@use '@cloud-ru/ds-materials' as m;
|
|
4
|
+
|
|
5
|
+
$sizes: 's', 'm', 'l';
|
|
6
|
+
|
|
7
|
+
.hidden {
|
|
8
|
+
display: none;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.content {
|
|
12
|
+
z-index: 1;
|
|
13
|
+
|
|
14
|
+
display: flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
justify-content: center;
|
|
17
|
+
|
|
18
|
+
width: 100%;
|
|
19
|
+
height: 100%;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.overLayer {
|
|
23
|
+
// Заливка drag-over (`data-state='activatedOnBackground'`) — absolute-оверлей поверх
|
|
24
|
+
// контейнера под контентом (`.content` z-index:1), показывается на `[data-over]`.
|
|
25
|
+
// Фон и раньше отсутствовал (миксин state-layer применялся только к `.borderLayer`),
|
|
26
|
+
// поэтому подсветка перетаскивания не работала. Absolute убирает overLayer из потока —
|
|
27
|
+
// иначе он (в потоке flex-колонки) смещал `.content` с центра.
|
|
28
|
+
pointer-events: none;
|
|
29
|
+
|
|
30
|
+
position: absolute;
|
|
31
|
+
inset: 0;
|
|
32
|
+
|
|
33
|
+
border-radius: inherit;
|
|
34
|
+
|
|
35
|
+
visibility: hidden;
|
|
36
|
+
background-color: base.$sn-theme-color-material-stateLayer-activated-hovered-filled;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.borderLayer {
|
|
40
|
+
visibility: initial;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.acrylic {
|
|
44
|
+
pointer-events: none;
|
|
45
|
+
position: absolute;
|
|
46
|
+
inset: 0;
|
|
47
|
+
border-radius: inherit;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.container {
|
|
51
|
+
cursor: pointer;
|
|
52
|
+
|
|
53
|
+
position: relative;
|
|
54
|
+
z-index: 0;
|
|
55
|
+
|
|
56
|
+
display: flex;
|
|
57
|
+
flex-direction: column;
|
|
58
|
+
align-items: center;
|
|
59
|
+
justify-content: center;
|
|
60
|
+
|
|
61
|
+
box-sizing: border-box;
|
|
62
|
+
width: 100%;
|
|
63
|
+
margin: 0;
|
|
64
|
+
padding: 0;
|
|
65
|
+
border: none;
|
|
66
|
+
|
|
67
|
+
font-family: inherit;
|
|
68
|
+
text-decoration: none;
|
|
69
|
+
|
|
70
|
+
background: transparent;
|
|
71
|
+
background-color: base.$sn-theme-color-neutral-background1Level;
|
|
72
|
+
outline: 0;
|
|
73
|
+
outline-offset: 0;
|
|
74
|
+
|
|
75
|
+
@include m.with-material('acrylic', #{acrylic});
|
|
76
|
+
|
|
77
|
+
// Типографику компонент не задаёт: в Figma это слот целиком, её приносит содержимое.
|
|
78
|
+
// Цвет задаёт базовый: ресет `<button>` ставил `color: transparent`, и текст,
|
|
79
|
+
// переданный в слот строкой, наследовал прозрачный — рендерился невидимым.
|
|
80
|
+
.content {
|
|
81
|
+
color: base.$sn-theme-color-available-version-textMain;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Макет: focusedFrame/primary/outside — рамка вплотную к боксу (bbox линии равен боксу
|
|
85
|
+
// компонента), толщина из focusLine/field, как у остальных полеподобных контролов.
|
|
86
|
+
// outline-offset выводится явно: без него Chromium подставляет свой отступ в 1px.
|
|
87
|
+
&:focus-visible {
|
|
88
|
+
outline: base.$sn-brand-anatomy-focusLine-field-strokeWeight solid base.$sn-theme-color-primary-accent;
|
|
89
|
+
outline-offset: 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
&[data-over] {
|
|
93
|
+
.overLayer {
|
|
94
|
+
visibility: inherit;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@each $size in $sizes {
|
|
99
|
+
&[data-size='#{$size}'] {
|
|
100
|
+
@include base.composite-var(dropzone.$dropzone, 'anatomy', 'size', $size);
|
|
101
|
+
|
|
102
|
+
.content {
|
|
103
|
+
@include base.composite-var(dropzone.$dropzone, 'anatomy', 'size', $size, 'contentWrapper');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
&[data-disabled] {
|
|
109
|
+
cursor: not-allowed;
|
|
110
|
+
|
|
111
|
+
// В макете у disabled-варианта своя заливка контейнера, а не только приглушённый контент.
|
|
112
|
+
background-color: base.$sn-theme-color-neutral-background;
|
|
113
|
+
|
|
114
|
+
.content {
|
|
115
|
+
opacity: base.$sn-theme-effect-opacity-disabled;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@include m.has-state-layer-as-child(#{borderLayer});
|
|
120
|
+
|
|
121
|
+
// Пунктир — только бордер-слою: overLayer'у миксин задаёт лишь фон, и `dashed` включил
|
|
122
|
+
// бы ему рамку с initial-значениями (`medium` + `currentColor`).
|
|
123
|
+
> [data-state-layer][data-state='borderOnBackground'] {
|
|
124
|
+
border-style: dashed !important;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { WithSupportProps } from '@cloud-ru/ds-utils';
|
|
2
|
+
import { ButtonHTMLAttributes, DetailedHTMLProps, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { FileFieldProps, Size } from '../../types';
|
|
5
|
+
import { UPLOAD_MODE } from './constants';
|
|
6
|
+
|
|
7
|
+
export type DragAndDropEventNames = 'onDragLeave' | 'onDragOver' | 'onDrop';
|
|
8
|
+
|
|
9
|
+
export type PrivateDropZoneProps = WithSupportProps<
|
|
10
|
+
{
|
|
11
|
+
isOver: boolean;
|
|
12
|
+
/** Колбек с принятыми файлами (прошедшими проверку `maxSize` и `accept`). */
|
|
13
|
+
onFilesUpload(files: File[]): void;
|
|
14
|
+
/** Контент dropzone */
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
/** Деактивирован ли компонент */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Режим
|
|
20
|
+
* @default multiple
|
|
21
|
+
*/
|
|
22
|
+
mode?: (typeof UPLOAD_MODE)[keyof typeof UPLOAD_MODE];
|
|
23
|
+
/** Размер компонента */
|
|
24
|
+
size?: Size;
|
|
25
|
+
/** CSS-класс */
|
|
26
|
+
className?: string;
|
|
27
|
+
} & FileFieldProps
|
|
28
|
+
> &
|
|
29
|
+
Pick<DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, DragAndDropEventNames>;
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const UPLOAD_MODE = {
|
|
2
|
+
Single: 'single',
|
|
3
|
+
Multiple: 'multiple',
|
|
4
|
+
} as const;
|
|
5
|
+
|
|
6
|
+
export const SIZE = {
|
|
7
|
+
S: 's',
|
|
8
|
+
M: 'm',
|
|
9
|
+
L: 'l',
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
export const TEST_IDS = {
|
|
13
|
+
dropzone: {
|
|
14
|
+
nativeInput: 'file-input',
|
|
15
|
+
},
|
|
16
|
+
fileUpload: {
|
|
17
|
+
nativeInput: 'file-upload__native-input',
|
|
18
|
+
},
|
|
19
|
+
} as const;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { debounce } from '@cloud-ru/ds-utils';
|
|
2
|
+
import { DragEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
type UseDragResult = {
|
|
5
|
+
events: Record<'onDragLeave' | 'onDragOver' | 'onDrop', DragEventHandler<HTMLElement>>;
|
|
6
|
+
isOver: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function useDrag(disabled: boolean): UseDragResult {
|
|
10
|
+
const [isOver, setIsOver] = useState(false);
|
|
11
|
+
|
|
12
|
+
const debouncedSetIsOver = useMemo(() => debounce(setIsOver, 5), []);
|
|
13
|
+
|
|
14
|
+
const handleDragLeave = useCallback<DragEventHandler<HTMLElement>>(
|
|
15
|
+
e => {
|
|
16
|
+
if (!disabled) {
|
|
17
|
+
e.preventDefault();
|
|
18
|
+
debouncedSetIsOver(false);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
[debouncedSetIsOver, disabled],
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const handleDragOver = useCallback<DragEventHandler<HTMLElement>>(
|
|
25
|
+
e => {
|
|
26
|
+
if (!disabled) {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
debouncedSetIsOver(true);
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
[debouncedSetIsOver, disabled],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const handleDrop = useCallback<DragEventHandler<HTMLElement>>(
|
|
35
|
+
e => {
|
|
36
|
+
if (!disabled) {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
debouncedSetIsOver(false);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
[debouncedSetIsOver, disabled],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
useEffect(() => () => debouncedSetIsOver.cancel(), [debouncedSetIsOver]);
|
|
45
|
+
|
|
46
|
+
const events = { onDragLeave: handleDragLeave, onDragOver: handleDragOver, onDrop: handleDrop };
|
|
47
|
+
|
|
48
|
+
return { events, isOver };
|
|
49
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ValueOf } from '@cloud-ru/ds-utils';
|
|
2
|
+
import { ChangeEventHandler, Ref } from 'react';
|
|
3
|
+
|
|
4
|
+
import { SIZE, UPLOAD_MODE } from './constants';
|
|
5
|
+
import { AcceptInput, FileRejection } from './utils';
|
|
6
|
+
|
|
7
|
+
export type UploadMode = ValueOf<typeof UPLOAD_MODE>;
|
|
8
|
+
export type Size = ValueOf<typeof SIZE>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Пропы, превращающие компонент загрузки в полноценное поле формы: нативные
|
|
12
|
+
* атрибуты скрытого `<input type="file">`, ссылка на него, валидация файлов.
|
|
13
|
+
* Общий контракт для FileUpload, Dropzone и HiddenDropZone.
|
|
14
|
+
*/
|
|
15
|
+
export type FileFieldProps = {
|
|
16
|
+
/** Разрешённые типы: MIME-тип (`image/png`), шаблон (`image/*`) или расширение (`.pdf`). Строка или массив. */
|
|
17
|
+
accept?: AcceptInput;
|
|
18
|
+
/** Максимальный размер одного файла в байтах. Превысившие уходят в `onFilesReject`. */
|
|
19
|
+
maxSize?: number;
|
|
20
|
+
/** Колбек с отклонёнными файлами и причиной (`maxSize` / `mime`). */
|
|
21
|
+
onFilesReject?(rejections: FileRejection[]): void;
|
|
22
|
+
/** Нативный `name` скрытого input — для отправки формы и интеграции с form-библиотеками. */
|
|
23
|
+
name?: string;
|
|
24
|
+
/** Нативный `id` скрытого input — для связи с `<label htmlFor>`. */
|
|
25
|
+
id?: string;
|
|
26
|
+
/** Нативный `required` скрытого input. */
|
|
27
|
+
required?: boolean;
|
|
28
|
+
/** Нативный `form` скрытого input — привязка к форме по `id`. */
|
|
29
|
+
form?: string;
|
|
30
|
+
/** Нативный `capture` скрытого input — источник захвата на мобильных (камера/микрофон). */
|
|
31
|
+
capture?: boolean | 'user' | 'environment';
|
|
32
|
+
/** Нативный `onChange` скрытого input. Вызывается до валидации с исходным событием. */
|
|
33
|
+
onChange?: ChangeEventHandler<HTMLInputElement>;
|
|
34
|
+
/** Ссылка на нативный `<input type="file">` — для интеграции с формами (react-hook-form `ref`). */
|
|
35
|
+
innerRef?: Ref<HTMLInputElement>;
|
|
36
|
+
};
|