Skip to content

Commit e93907d

Browse files
committed
feat: add useDropZone hook
1 parent 6caddab commit e93907d

File tree

5 files changed

+271
-0
lines changed

5 files changed

+271
-0
lines changed

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export * from './useTimeout';
1414
export * from './useViewportSize';
1515
export * from './useVirtualElementRef';
1616
export * from './useUniqId';
17+
export * from './useDropZone';

src/hooks/useDropZone/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<!--GITHUB_BLOCK-->
2+
3+
# useDropZone
4+
5+
<!--/GITHUB_BLOCK-->
6+
7+
```tsx
8+
import {useDropZone} from '@gravity-ui/uikit';
9+
```
10+
11+
The `useDropZone` hook provides props for an element to act as a drop zone and also gives access to the dragging-over state.
12+
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.
13+
14+
## Properties
15+
16+
| Name | Description | Type | Default |
17+
| :------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------: | :-----: |
18+
| accept | A list of MIME types that will be accepted by the drop zone (e.g., `['text/*', 'image/png']`) | `string[]` | |
19+
| disabled | Disables the drop zone | `boolean` | |
20+
| ref | A ref object pointing to the element that will be provided with drop zone behavior | `React.RefObject<HTMLElement>` | |
21+
| 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` | |
22+
23+
## Result
24+
25+
- `getDroppableProps` - returns props to provide an element with drop zone behavior
26+
- `isDraggingOver` - returns `true` when an element is being dragged over the zone, and `false` otherwise
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {FileArrowDown} from '@gravity-ui/icons';
2+
import type {Meta, StoryFn} from '@storybook/react-webpack5';
3+
4+
import {Icon, Text} from '../../../components';
5+
import {useDropZone} from '../useDropZone';
6+
import type {UseDropZoneParams} from '../useDropZone';
7+
8+
export default {title: 'Hooks/useDropZone'} as Meta;
9+
10+
const ACCEPT = ['text/plain', 'image/*'];
11+
12+
const DefaultTemplate: StoryFn = () => {
13+
const handleDrop: UseDropZoneParams['onDrop'] = (items) => {
14+
for (const item of items) {
15+
if (item.kind === 'string') {
16+
item.getAsString((text) => {
17+
alert(`String: ${text}`);
18+
});
19+
}
20+
21+
if (item.kind === 'file') {
22+
const file = item.getAsFile();
23+
24+
alert(`File: name: ${file?.name}, size: ${file?.size}, type: ${file?.type}`);
25+
}
26+
}
27+
};
28+
29+
const {isDraggingOver, getDroppableProps} = useDropZone({
30+
accept: ACCEPT,
31+
onDrop: handleDrop,
32+
});
33+
34+
return (
35+
<div
36+
style={{
37+
width: '400px',
38+
height: '400px',
39+
display: 'flex',
40+
justifyContent: 'center',
41+
alignItems: 'center',
42+
flexDirection: 'column',
43+
gap: '16px',
44+
border: isDraggingOver
45+
? '4px dashed var(--g-color-line-info)'
46+
: '4px dashed var(--g-color-line-misc)',
47+
}}
48+
{...getDroppableProps()}
49+
>
50+
<Text color={isDraggingOver ? 'primary' : 'secondary'}>Drop Something Here</Text>
51+
<Icon size="32" data={FileArrowDown} />
52+
</div>
53+
);
54+
};
55+
56+
export const Default = DefaultTemplate.bind({});

src/hooks/useDropZone/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export {useDropZone} from './useDropZone';
2+
export type {
3+
UseDropZoneAccept,
4+
UseDropZoneParams,
5+
DroppableProps,
6+
UseDropZoneState,
7+
} from './useDropZone';
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import * as React from 'react';
2+
3+
export type UseDropZoneAccept = string[];
4+
5+
export interface UseDropZoneParams {
6+
accept: UseDropZoneAccept;
7+
ref?: React.RefObject<HTMLElement>;
8+
disabled?: boolean;
9+
onDrop: (items: DataTransferItemList) => void;
10+
}
11+
12+
const DROP_ZONE_BASE_ATTRIBUTES = {
13+
'aria-dropeffect': 'copy' as DataTransfer['dropEffect'],
14+
tabIndex: 0,
15+
};
16+
17+
export interface DroppableProps extends Required<typeof DROP_ZONE_BASE_ATTRIBUTES> {
18+
onDragEnter: (e: React.DragEvent) => void;
19+
onDragOver: (e: React.DragEvent) => void;
20+
onDragLeave: (e: React.DragEvent) => void;
21+
onDrop: (e: React.DragEvent) => void;
22+
}
23+
24+
export interface UseDropZoneState {
25+
isDraggingOver: boolean;
26+
getDroppableProps: () => DroppableProps;
27+
}
28+
29+
function typeMatchesPattern(actualMimeType: string, expectedMimeTypePattern: string): boolean {
30+
const actualMimeTypeParts = actualMimeType.split('/');
31+
32+
if (actualMimeTypeParts.length !== 2) {
33+
return false;
34+
}
35+
36+
const [actualType] = actualMimeTypeParts;
37+
const [expectedType, expectedSubtype] = expectedMimeTypePattern.split('/');
38+
39+
if (expectedSubtype === '*') {
40+
return actualType === expectedType;
41+
}
42+
43+
return actualMimeType === expectedMimeTypePattern;
44+
}
45+
46+
function eventItemTypesAcceptable(accept: UseDropZoneAccept, event: DragEvent): boolean {
47+
const items = event.dataTransfer?.items;
48+
49+
if (!items) {
50+
return false;
51+
}
52+
53+
for (const {type} of items) {
54+
if (accept.some((acceptedTypePattern) => typeMatchesPattern(type, acceptedTypePattern))) {
55+
return true;
56+
}
57+
}
58+
59+
return false;
60+
}
61+
62+
export function useDropZone({accept, disabled, onDrop, ref}: UseDropZoneParams): UseDropZoneState {
63+
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
64+
65+
const handleDragEnterNative = React.useCallback(
66+
(event: DragEvent) => {
67+
if (disabled || !event.dataTransfer || !eventItemTypesAcceptable(accept, event)) {
68+
return;
69+
}
70+
71+
event.dataTransfer.dropEffect = DROP_ZONE_BASE_ATTRIBUTES['aria-dropeffect'];
72+
73+
setIsDraggingOver(true);
74+
},
75+
[accept, disabled],
76+
);
77+
78+
const handleDragEnter = React.useCallback(
79+
(event: React.DragEvent) => {
80+
handleDragEnterNative(event.nativeEvent);
81+
},
82+
[handleDragEnterNative],
83+
);
84+
85+
const handleDragOverNative = React.useCallback(
86+
(event: DragEvent) => {
87+
if (disabled || !event.dataTransfer || !eventItemTypesAcceptable(accept, event)) {
88+
return;
89+
}
90+
91+
event.dataTransfer.dropEffect = DROP_ZONE_BASE_ATTRIBUTES['aria-dropeffect'];
92+
93+
event.preventDefault();
94+
},
95+
[accept, disabled],
96+
);
97+
98+
const handleDragOver = React.useCallback(
99+
(event: React.DragEvent) => {
100+
handleDragOverNative(event.nativeEvent);
101+
},
102+
[handleDragOverNative],
103+
);
104+
105+
const handleDragLeaveNative = React.useCallback(
106+
(event: DragEvent) => {
107+
if (disabled || !eventItemTypesAcceptable(accept, event)) {
108+
return;
109+
}
110+
111+
setIsDraggingOver(false);
112+
},
113+
[accept, disabled],
114+
);
115+
116+
const handleDragLeave = React.useCallback(
117+
(event: React.DragEvent) => {
118+
handleDragLeaveNative(event.nativeEvent);
119+
},
120+
[handleDragLeaveNative],
121+
);
122+
123+
const handleDropNative = React.useCallback(
124+
(event: DragEvent) => {
125+
if (disabled || !eventItemTypesAcceptable(accept, event)) {
126+
return;
127+
}
128+
129+
setIsDraggingOver(false);
130+
131+
const items = event.dataTransfer?.items;
132+
133+
if (!items) {
134+
return;
135+
}
136+
137+
onDrop(items);
138+
},
139+
[accept, disabled, onDrop],
140+
);
141+
142+
const handleDrop = React.useCallback(
143+
(event: React.DragEvent) => {
144+
handleDropNative(event.nativeEvent);
145+
},
146+
[handleDropNative],
147+
);
148+
149+
React.useEffect(() => {
150+
const dropZoneElement = ref?.current;
151+
152+
dropZoneElement?.addEventListener('dragenter', handleDragEnterNative);
153+
dropZoneElement?.addEventListener('dragover', handleDragOverNative);
154+
dropZoneElement?.addEventListener('dragleave', handleDragLeaveNative);
155+
dropZoneElement?.addEventListener('drop', handleDropNative);
156+
157+
for (const [attribute, value] of Object.entries(DROP_ZONE_BASE_ATTRIBUTES)) {
158+
dropZoneElement?.setAttribute(attribute, value.toString());
159+
}
160+
161+
return () => {
162+
dropZoneElement?.removeEventListener('dragenter', handleDragEnterNative);
163+
dropZoneElement?.removeEventListener('dragover', handleDragOverNative);
164+
dropZoneElement?.removeEventListener('dragleave', handleDragLeaveNative);
165+
dropZoneElement?.removeEventListener('drop', handleDropNative);
166+
};
167+
}, [handleDragEnterNative, handleDragLeaveNative, handleDragOverNative, handleDropNative, ref]);
168+
169+
return {
170+
isDraggingOver,
171+
getDroppableProps: () => {
172+
return {
173+
...DROP_ZONE_BASE_ATTRIBUTES,
174+
onDragEnter: handleDragEnter,
175+
onDragOver: handleDragOver,
176+
onDragLeave: handleDragLeave,
177+
onDrop: handleDrop,
178+
};
179+
},
180+
};
181+
}

0 commit comments

Comments
 (0)