@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +201 -0
  3. package/README.md +356 -0
  4. package/dist/cjs/components/Dropzone/Dropzone.d.ts +25 -0
  5. package/dist/cjs/components/Dropzone/Dropzone.js +11 -0
  6. package/dist/cjs/components/Dropzone/index.d.ts +1 -0
  7. package/dist/cjs/components/Dropzone/index.js +17 -0
  8. package/dist/cjs/components/FileUpload/FileUpload.d.ts +45 -0
  9. package/dist/cjs/components/FileUpload/FileUpload.js +56 -0
  10. package/dist/cjs/components/FileUpload/index.d.ts +1 -0
  11. package/dist/cjs/components/FileUpload/index.js +17 -0
  12. package/dist/cjs/components/FileUpload/styles.module.css +3 -0
  13. package/dist/cjs/components/HiddenDropZone/HiddenDropZone.d.ts +10 -0
  14. package/dist/cjs/components/HiddenDropZone/HiddenDropZone.js +19 -0
  15. package/dist/cjs/components/HiddenDropZone/index.d.ts +1 -0
  16. package/dist/cjs/components/HiddenDropZone/index.js +17 -0
  17. package/dist/cjs/components/HiddenDropZone/styles.module.css +20 -0
  18. package/dist/cjs/components/PrivateDropZone/PrivateDropZone.d.ts +2 -0
  19. package/dist/cjs/components/PrivateDropZone/PrivateDropZone.js +62 -0
  20. package/dist/cjs/components/PrivateDropZone/constants.d.ts +1 -0
  21. package/dist/cjs/components/PrivateDropZone/constants.js +5 -0
  22. package/dist/cjs/components/PrivateDropZone/index.d.ts +2 -0
  23. package/dist/cjs/components/PrivateDropZone/index.js +5 -0
  24. package/dist/cjs/components/PrivateDropZone/styles.module.css +405 -0
  25. package/dist/cjs/components/PrivateDropZone/types.d.ts +23 -0
  26. package/dist/cjs/components/PrivateDropZone/types.js +2 -0
  27. package/dist/cjs/components/index.d.ts +3 -0
  28. package/dist/cjs/components/index.js +19 -0
  29. package/dist/cjs/constants.d.ts +17 -0
  30. package/dist/cjs/constants.js +20 -0
  31. package/dist/cjs/hooks/useDrag.d.ts +7 -0
  32. package/dist/cjs/hooks/useDrag.js +30 -0
  33. package/dist/cjs/index.d.ts +4 -0
  34. package/dist/cjs/index.js +20 -0
  35. package/dist/cjs/package.json +3 -0
  36. package/dist/cjs/style.css +428 -0
  37. package/dist/cjs/types.d.ts +33 -0
  38. package/dist/cjs/types.js +2 -0
  39. package/dist/cjs/utils.d.ts +37 -0
  40. package/dist/cjs/utils.js +70 -0
  41. package/dist/esm/components/Dropzone/Dropzone.d.ts +25 -0
  42. package/dist/esm/components/Dropzone/Dropzone.js +8 -0
  43. package/dist/esm/components/Dropzone/index.d.ts +1 -0
  44. package/dist/esm/components/Dropzone/index.js +1 -0
  45. package/dist/esm/components/FileUpload/FileUpload.d.ts +45 -0
  46. package/dist/esm/components/FileUpload/FileUpload.js +50 -0
  47. package/dist/esm/components/FileUpload/index.d.ts +1 -0
  48. package/dist/esm/components/FileUpload/index.js +1 -0
  49. package/dist/esm/components/FileUpload/styles.module.css +3 -0
  50. package/dist/esm/components/HiddenDropZone/HiddenDropZone.d.ts +10 -0
  51. package/dist/esm/components/HiddenDropZone/HiddenDropZone.js +13 -0
  52. package/dist/esm/components/HiddenDropZone/index.d.ts +1 -0
  53. package/dist/esm/components/HiddenDropZone/index.js +1 -0
  54. package/dist/esm/components/HiddenDropZone/styles.module.css +20 -0
  55. package/dist/esm/components/PrivateDropZone/PrivateDropZone.d.ts +2 -0
  56. package/dist/esm/components/PrivateDropZone/PrivateDropZone.js +56 -0
  57. package/dist/esm/components/PrivateDropZone/constants.d.ts +1 -0
  58. package/dist/esm/components/PrivateDropZone/constants.js +1 -0
  59. package/dist/esm/components/PrivateDropZone/index.d.ts +2 -0
  60. package/dist/esm/components/PrivateDropZone/index.js +1 -0
  61. package/dist/esm/components/PrivateDropZone/styles.module.css +405 -0
  62. package/dist/esm/components/PrivateDropZone/types.d.ts +23 -0
  63. package/dist/esm/components/PrivateDropZone/types.js +1 -0
  64. package/dist/esm/components/index.d.ts +3 -0
  65. package/dist/esm/components/index.js +3 -0
  66. package/dist/esm/constants.d.ts +17 -0
  67. package/dist/esm/constants.js +17 -0
  68. package/dist/esm/hooks/useDrag.d.ts +7 -0
  69. package/dist/esm/hooks/useDrag.js +27 -0
  70. package/dist/esm/index.d.ts +4 -0
  71. package/dist/esm/index.js +4 -0
  72. package/dist/esm/style.css +428 -0
  73. package/dist/esm/types.d.ts +33 -0
  74. package/dist/esm/types.js +1 -0
  75. package/dist/esm/utils.d.ts +37 -0
  76. package/dist/esm/utils.js +64 -0
  77. package/dist/tsconfig.cjs.tsbuildinfo +1 -0
  78. package/dist/tsconfig.esm.tsbuildinfo +1 -0
  79. package/package.json +43 -0
  80. package/src/components/Dropzone/Dropzone.tsx +40 -0
  81. package/src/components/Dropzone/index.ts +1 -0
  82. package/src/components/FileUpload/FileUpload.tsx +145 -0
  83. package/src/components/FileUpload/index.ts +1 -0
  84. package/src/components/FileUpload/styles.module.scss +3 -0
  85. package/src/components/HiddenDropZone/HiddenDropZone.tsx +39 -0
  86. package/src/components/HiddenDropZone/index.ts +1 -0
  87. package/src/components/HiddenDropZone/styles.module.scss +20 -0
  88. package/src/components/PrivateDropZone/PrivateDropZone.tsx +122 -0
  89. package/src/components/PrivateDropZone/constants.ts +1 -0
  90. package/src/components/PrivateDropZone/index.ts +2 -0
  91. package/src/components/PrivateDropZone/styles.module.scss +126 -0
  92. package/src/components/PrivateDropZone/types.ts +29 -0
  93. package/src/components/index.ts +3 -0
  94. package/src/constants.ts +19 -0
  95. package/src/hooks/useDrag.ts +49 -0
  96. package/src/index.ts +4 -0
  97. package/src/types.ts +36 -0
  98. 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,3 @@
1
+ .hiddenInput {
2
+ display: none;
3
+ }
@@ -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,2 @@
1
+ export { PrivateDropZone } from './PrivateDropZone';
2
+ export type { PrivateDropZoneProps } from './types';
@@ -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>;
@@ -0,0 +1,3 @@
1
+ export * from './Dropzone';
2
+ export * from './FileUpload';
3
+ export * from './HiddenDropZone';
@@ -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
@@ -0,0 +1,4 @@
1
+ export * from './components';
2
+ export * from './constants';
3
+ export * from './types';
4
+ export * from './utils';
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
+ };