UNPKG

23.2 kBJavaScriptView Raw
1import * as React from 'react';
2import * as ReactDOM from 'react-dom';
3import { getTranslateOffset, transformItem, setItemTransition, binarySearch, schd, isTouchEvent, checkIfInteractive } from './utils';
4const AUTOSCROLL_ACTIVE_OFFSET = 200;
5const AUTOSCROLL_SPEED_RATIO = 10;
6class List extends React.Component {
7 constructor(props) {
8 super(props);
9 this.listRef = React.createRef();
10 this.ghostRef = React.createRef();
11 this.topOffsets = [];
12 this.itemTranslateOffsets = [];
13 this.initialYOffset = 0;
14 this.lastScroll = 0;
15 this.lastYOffset = 0;
16 this.lastListYOffset = 0;
17 this.needle = -1;
18 this.afterIndex = -2;
19 this.state = {
20 itemDragged: -1,
21 itemDraggedOutOfBounds: -1,
22 selectedItem: -1,
23 initialX: 0,
24 initialY: 0,
25 targetX: 0,
26 targetY: 0,
27 targetHeight: 0,
28 targetWidth: 0,
29 liveText: '',
30 scrollingSpeed: 0,
31 scrollWindow: false
32 };
33 this.doScrolling = () => {
34 const { scrollingSpeed, scrollWindow } = this.state;
35 const listEl = this.listRef.current;
36 window.requestAnimationFrame(() => {
37 if (scrollWindow) {
38 window.scrollTo(window.pageXOffset, window.pageYOffset + scrollingSpeed * 1.5);
39 }
40 else {
41 listEl.scrollTop += scrollingSpeed;
42 }
43 if (scrollingSpeed !== 0) {
44 this.doScrolling();
45 }
46 });
47 };
48 this.getChildren = () => {
49 if (this.listRef && this.listRef.current) {
50 return Array.from(this.listRef.current.children);
51 }
52 console.warn('No items found in the List container. Did you forget to pass & spread the `props` param in renderList?');
53 return [];
54 };
55 this.calculateOffsets = () => {
56 this.topOffsets = this.getChildren().map((item) => item.getBoundingClientRect().top);
57 this.itemTranslateOffsets = this.getChildren().map((item) => getTranslateOffset(item));
58 };
59 this.getTargetIndex = (e) => {
60 return this.getChildren().findIndex((child) => child === e.target || child.contains(e.target));
61 };
62 this.onMouseOrTouchStart = (e) => {
63 if (this.dropTimeout && this.state.itemDragged > -1) {
64 window.clearTimeout(this.dropTimeout);
65 this.finishDrop();
66 }
67 const isTouch = isTouchEvent(e);
68 if (!isTouch && e.button !== 0)
69 return;
70 const index = this.getTargetIndex(e);
71 if (index === -1 ||
72 // @ts-ignore
73 (this.props.values[index] && this.props.values[index].disabled)) {
74 if (this.state.selectedItem !== -1) {
75 this.setState({ selectedItem: -1 });
76 this.finishDrop();
77 }
78 return;
79 }
80 const listItemTouched = this.getChildren()[index];
81 const handle = listItemTouched.querySelector('[data-movable-handle]');
82 if (handle && !handle.contains(e.target)) {
83 return;
84 }
85 if (checkIfInteractive(e.target, listItemTouched)) {
86 return;
87 }
88 e.preventDefault();
89 this.props.beforeDrag &&
90 this.props.beforeDrag({
91 elements: this.getChildren(),
92 index
93 });
94 if (isTouch) {
95 const opts = { passive: false };
96 listItemTouched.style.touchAction = 'none';
97 document.addEventListener('touchend', this.schdOnEnd, opts);
98 document.addEventListener('touchmove', this.schdOnTouchMove, opts);
99 document.addEventListener('touchcancel', this.schdOnEnd, opts);
100 }
101 else {
102 document.addEventListener('mousemove', this.schdOnMouseMove);
103 document.addEventListener('mouseup', this.schdOnEnd);
104 const listItemDragged = this.getChildren()[this.state.itemDragged];
105 if (listItemDragged && listItemDragged.style) {
106 listItemDragged.style.touchAction = '';
107 }
108 }
109 this.onStart(listItemTouched, isTouch ? e.touches[0].clientX : e.clientX, isTouch ? e.touches[0].clientY : e.clientY, index);
110 };
111 this.getYOffset = () => {
112 const listScroll = this.listRef.current
113 ? this.listRef.current.scrollTop
114 : 0;
115 return window.pageYOffset + listScroll;
116 };
117 this.onStart = (target, clientX, clientY, index) => {
118 if (this.state.selectedItem > -1) {
119 this.setState({ selectedItem: -1 });
120 this.needle = -1;
121 }
122 const targetRect = target.getBoundingClientRect();
123 const targetStyles = window.getComputedStyle(target);
124 this.calculateOffsets();
125 this.initialYOffset = this.getYOffset();
126 this.lastYOffset = window.pageYOffset;
127 this.lastListYOffset = this.listRef.current.scrollTop;
128 this.setState({
129 itemDragged: index,
130 targetX: targetRect.left - parseInt(targetStyles['margin-left'], 10),
131 targetY: targetRect.top - parseInt(targetStyles['margin-top'], 10),
132 targetHeight: targetRect.height,
133 targetWidth: targetRect.width,
134 initialX: clientX,
135 initialY: clientY
136 });
137 };
138 this.onMouseMove = (e) => {
139 e.cancelable && e.preventDefault();
140 this.onMove(e.clientX, e.clientY);
141 };
142 this.onTouchMove = (e) => {
143 e.cancelable && e.preventDefault();
144 this.onMove(e.touches[0].clientX, e.touches[0].clientY);
145 };
146 this.onWheel = (e) => {
147 if (this.state.itemDragged < 0)
148 return;
149 this.lastScroll = this.listRef.current.scrollTop += e.deltaY;
150 this.moveOtherItems();
151 };
152 this.onMove = (clientX, clientY) => {
153 if (this.state.itemDragged === -1)
154 return null;
155 transformItem(this.ghostRef.current, clientY - this.state.initialY, this.props.lockVertically ? 0 : clientX - this.state.initialX);
156 this.autoScrolling(clientY);
157 this.moveOtherItems();
158 };
159 this.moveOtherItems = () => {
160 const targetRect = this.ghostRef.current.getBoundingClientRect();
161 const itemVerticalCenter = targetRect.top + targetRect.height / 2;
162 const offset = getTranslateOffset(this.getChildren()[this.state.itemDragged]);
163 const currentYOffset = this.getYOffset();
164 // adjust offsets if scrolling happens during the item movement
165 if (this.initialYOffset !== currentYOffset) {
166 this.topOffsets = this.topOffsets.map((offset) => offset - (currentYOffset - this.initialYOffset));
167 this.initialYOffset = currentYOffset;
168 }
169 if (this.isDraggedItemOutOfBounds() && this.props.removableByMove) {
170 this.afterIndex = this.topOffsets.length + 1;
171 }
172 else {
173 this.afterIndex = binarySearch(this.topOffsets, itemVerticalCenter);
174 }
175 this.animateItems(this.afterIndex === -1 ? 0 : this.afterIndex, this.state.itemDragged, offset);
176 };
177 this.autoScrolling = (clientY) => {
178 const { top, bottom, height } = this.listRef.current.getBoundingClientRect();
179 const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
180 // autoscrolling for the window (down)
181 if (bottom > viewportHeight &&
182 viewportHeight - clientY < AUTOSCROLL_ACTIVE_OFFSET) {
183 this.setState({
184 scrollingSpeed: Math.round((AUTOSCROLL_ACTIVE_OFFSET - (viewportHeight - clientY)) /
185 AUTOSCROLL_SPEED_RATIO),
186 scrollWindow: true
187 });
188 // autoscrolling for the window (up)
189 }
190 else if (top < 0 && clientY < AUTOSCROLL_ACTIVE_OFFSET) {
191 this.setState({
192 scrollingSpeed: Math.round((AUTOSCROLL_ACTIVE_OFFSET - clientY) / -AUTOSCROLL_SPEED_RATIO),
193 scrollWindow: true
194 });
195 }
196 else {
197 if (this.state.scrollWindow && this.state.scrollingSpeed !== 0) {
198 this.setState({ scrollingSpeed: 0, scrollWindow: false });
199 }
200 // autoscrolling for containers with overflow
201 if (height + 20 < this.listRef.current.scrollHeight) {
202 let scrollingSpeed = 0;
203 if (clientY - top < AUTOSCROLL_ACTIVE_OFFSET) {
204 scrollingSpeed = Math.round((AUTOSCROLL_ACTIVE_OFFSET - (clientY - top)) /
205 -AUTOSCROLL_SPEED_RATIO);
206 }
207 else if (bottom - clientY < AUTOSCROLL_ACTIVE_OFFSET) {
208 scrollingSpeed = Math.round((AUTOSCROLL_ACTIVE_OFFSET - (bottom - clientY)) /
209 AUTOSCROLL_SPEED_RATIO);
210 }
211 if (this.state.scrollingSpeed !== scrollingSpeed) {
212 this.setState({ scrollingSpeed });
213 }
214 }
215 }
216 };
217 this.animateItems = (needle, movedItem, offset, animateMovedItem = false) => {
218 this.getChildren().forEach((item, i) => {
219 setItemTransition(item, this.props.transitionDuration);
220 if (movedItem === i && animateMovedItem) {
221 if (movedItem === needle) {
222 return transformItem(item, null);
223 }
224 transformItem(item, movedItem < needle
225 ? this.itemTranslateOffsets
226 .slice(movedItem + 1, needle + 1)
227 .reduce((a, b) => a + b, 0)
228 : this.itemTranslateOffsets
229 .slice(needle, movedItem)
230 .reduce((a, b) => a + b, 0) * -1);
231 }
232 else if (movedItem < needle && i > movedItem && i <= needle) {
233 transformItem(item, -offset);
234 }
235 else if (i < movedItem && movedItem > needle && i >= needle) {
236 transformItem(item, offset);
237 }
238 else {
239 transformItem(item, null);
240 }
241 });
242 };
243 this.isDraggedItemOutOfBounds = () => {
244 const initialRect = this.getChildren()[this.state.itemDragged].getBoundingClientRect();
245 const targetRect = this.ghostRef.current.getBoundingClientRect();
246 if (Math.abs(initialRect.left - targetRect.left) > targetRect.width) {
247 if (this.state.itemDraggedOutOfBounds === -1) {
248 this.setState({ itemDraggedOutOfBounds: this.state.itemDragged });
249 }
250 return true;
251 }
252 if (this.state.itemDraggedOutOfBounds > -1) {
253 this.setState({ itemDraggedOutOfBounds: -1 });
254 }
255 return false;
256 };
257 this.onEnd = (e) => {
258 e.cancelable && e.preventDefault();
259 document.removeEventListener('mousemove', this.schdOnMouseMove);
260 document.removeEventListener('touchmove', this.schdOnTouchMove);
261 document.removeEventListener('mouseup', this.schdOnEnd);
262 document.removeEventListener('touchup', this.schdOnEnd);
263 document.removeEventListener('touchcancel', this.schdOnEnd);
264 const removeItem = this.props.removableByMove && this.isDraggedItemOutOfBounds();
265 if (!removeItem &&
266 this.props.transitionDuration > 0 &&
267 this.afterIndex !== -2) {
268 // animate drop
269 schd(() => {
270 setItemTransition(this.ghostRef.current, this.props.transitionDuration, 'cubic-bezier(.2,1,.1,1)');
271 if (this.afterIndex < 1 && this.state.itemDragged === 0) {
272 transformItem(this.ghostRef.current, 0, 0);
273 }
274 else {
275 transformItem(this.ghostRef.current,
276 // compensate window scroll
277 -(window.pageYOffset - this.lastYOffset) +
278 // compensate container scroll
279 -(this.listRef.current.scrollTop - this.lastListYOffset) +
280 (this.state.itemDragged < this.afterIndex
281 ? this.itemTranslateOffsets
282 .slice(this.state.itemDragged + 1, this.afterIndex + 1)
283 .reduce((a, b) => a + b, 0)
284 : this.itemTranslateOffsets
285 .slice(this.afterIndex < 0 ? 0 : this.afterIndex, this.state.itemDragged)
286 .reduce((a, b) => a + b, 0) * -1), 0);
287 }
288 })();
289 }
290 this.dropTimeout = window.setTimeout(this.finishDrop, removeItem || this.afterIndex === -2 ? 0 : this.props.transitionDuration);
291 };
292 this.finishDrop = () => {
293 const removeItem = this.props.removableByMove && this.isDraggedItemOutOfBounds();
294 if (removeItem ||
295 (this.afterIndex > -2 && this.state.itemDragged !== this.afterIndex)) {
296 this.props.onChange({
297 oldIndex: this.state.itemDragged,
298 newIndex: removeItem ? -1 : Math.max(this.afterIndex, 0),
299 targetRect: this.ghostRef.current.getBoundingClientRect()
300 });
301 }
302 this.getChildren().forEach((item) => {
303 setItemTransition(item, 0);
304 transformItem(item, null);
305 item.style.touchAction = '';
306 });
307 this.setState({ itemDragged: -1, scrollingSpeed: 0 });
308 this.afterIndex = -2;
309 // sometimes the scroll gets messed up after the drop, fix:
310 if (this.lastScroll > 0) {
311 this.listRef.current.scrollTop = this.lastScroll;
312 this.lastScroll = 0;
313 }
314 };
315 this.onKeyDown = (e) => {
316 const selectedItem = this.state.selectedItem;
317 const index = this.getTargetIndex(e);
318 if (checkIfInteractive(e.target, e.currentTarget)) {
319 return;
320 }
321 if (index === -1)
322 return;
323 if (e.key === ' ') {
324 e.preventDefault();
325 if (selectedItem === index) {
326 if (selectedItem !== this.needle) {
327 this.getChildren().forEach((item) => {
328 setItemTransition(item, 0);
329 transformItem(item, null);
330 });
331 this.props.onChange({
332 oldIndex: selectedItem,
333 newIndex: this.needle,
334 targetRect: this.getChildren()[this.needle].getBoundingClientRect()
335 });
336 this.getChildren()[this.needle].focus();
337 }
338 this.setState({
339 selectedItem: -1,
340 liveText: this.props.voiceover.dropped(selectedItem + 1, this.needle + 1)
341 });
342 this.needle = -1;
343 }
344 else {
345 this.setState({
346 selectedItem: index,
347 liveText: this.props.voiceover.lifted(index + 1)
348 });
349 this.needle = index;
350 this.calculateOffsets();
351 }
352 }
353 if ((e.key === 'ArrowDown' || e.key === 'j') &&
354 selectedItem > -1 &&
355 this.needle < this.props.values.length - 1) {
356 e.preventDefault();
357 const offset = getTranslateOffset(this.getChildren()[selectedItem]);
358 this.needle++;
359 this.animateItems(this.needle, selectedItem, offset, true);
360 this.setState({
361 liveText: this.props.voiceover.moved(this.needle + 1, false)
362 });
363 }
364 if ((e.key === 'ArrowUp' || e.key === 'k') &&
365 selectedItem > -1 &&
366 this.needle > 0) {
367 e.preventDefault();
368 const offset = getTranslateOffset(this.getChildren()[selectedItem]);
369 this.needle--;
370 this.animateItems(this.needle, selectedItem, offset, true);
371 this.setState({
372 liveText: this.props.voiceover.moved(this.needle + 1, true)
373 });
374 }
375 if (e.key === 'Escape' && selectedItem > -1) {
376 this.getChildren().forEach((item) => {
377 setItemTransition(item, 0);
378 transformItem(item, null);
379 });
380 this.setState({
381 selectedItem: -1,
382 liveText: this.props.voiceover.canceled(selectedItem + 1)
383 });
384 this.needle = -1;
385 }
386 if ((e.key === 'Tab' || e.key === 'Enter') && selectedItem > -1) {
387 e.preventDefault();
388 }
389 };
390 this.schdOnMouseMove = schd(this.onMouseMove);
391 this.schdOnTouchMove = schd(this.onTouchMove);
392 this.schdOnEnd = schd(this.onEnd);
393 }
394 componentDidMount() {
395 this.calculateOffsets();
396 document.addEventListener('touchstart', this.onMouseOrTouchStart, {
397 passive: false,
398 capture: false
399 });
400 document.addEventListener('mousedown', this.onMouseOrTouchStart);
401 }
402 componentDidUpdate(_prevProps, prevState) {
403 if (prevState.scrollingSpeed !== this.state.scrollingSpeed &&
404 prevState.scrollingSpeed === 0) {
405 this.doScrolling();
406 }
407 }
408 componentWillUnmount() {
409 document.removeEventListener('touchstart', this.onMouseOrTouchStart);
410 document.removeEventListener('mousedown', this.onMouseOrTouchStart);
411 if (this.dropTimeout) {
412 window.clearTimeout(this.dropTimeout);
413 }
414 this.schdOnMouseMove.cancel();
415 this.schdOnTouchMove.cancel();
416 this.schdOnEnd.cancel();
417 }
418 render() {
419 const baseStyle = {
420 userSelect: 'none',
421 WebkitUserSelect: 'none',
422 MozUserSelect: 'none',
423 msUserSelect: 'none',
424 boxSizing: 'border-box',
425 position: 'relative'
426 };
427 const ghostStyle = {
428 ...baseStyle,
429 top: this.state.targetY,
430 left: this.state.targetX,
431 width: this.state.targetWidth,
432 height: this.state.targetHeight,
433 position: 'fixed',
434 marginTop: 0
435 };
436 return (React.createElement(React.Fragment, null,
437 this.props.renderList({
438 children: this.props.values.map((value, index) => {
439 const isHidden = index === this.state.itemDragged;
440 const isSelected = index === this.state.selectedItem;
441 const isDisabled =
442 // @ts-ignore
443 this.props.values[index] && this.props.values[index].disabled;
444 const props = {
445 key: index,
446 tabIndex: isDisabled ? -1 : 0,
447 'aria-roledescription': this.props.voiceover.item(index + 1),
448 onKeyDown: this.onKeyDown,
449 style: {
450 ...baseStyle,
451 visibility: isHidden ? 'hidden' : undefined,
452 zIndex: isSelected ? 5000 : 0
453 }
454 };
455 return this.props.renderItem({
456 value,
457 props,
458 index,
459 isDragged: false,
460 isSelected,
461 isOutOfBounds: false
462 });
463 }),
464 isDragged: this.state.itemDragged > -1,
465 props: {
466 ref: this.listRef
467 }
468 }),
469 this.state.itemDragged > -1 &&
470 ReactDOM.createPortal(this.props.renderItem({
471 value: this.props.values[this.state.itemDragged],
472 props: {
473 ref: this.ghostRef,
474 style: ghostStyle,
475 onWheel: this.onWheel
476 },
477 index: this.state.itemDragged,
478 isDragged: true,
479 isSelected: false,
480 isOutOfBounds: this.state.itemDraggedOutOfBounds > -1
481 }), this.props.container || document.body),
482 React.createElement("div", { "aria-live": "assertive", role: "log", "aria-atomic": "true", style: {
483 position: 'absolute',
484 width: '1px',
485 height: '1px',
486 margin: '-1px',
487 border: '0px',
488 padding: '0px',
489 overflow: 'hidden',
490 clip: 'rect(0px, 0px, 0px, 0px)',
491 clipPath: 'inset(100%)'
492 } }, this.state.liveText)));
493 }
494}
495List.defaultProps = {
496 transitionDuration: 300,
497 lockVertically: false,
498 removableByMove: false,
499 voiceover: {
500 item: (position) => `You are currently at a draggable item at position ${position}. Press space bar to lift.`,
501 lifted: (position) => `You have lifted item at position ${position}. Press j to move down, k to move up, space bar to drop and escape to cancel.`,
502 moved: (position, up) => `You have moved the lifted item ${up ? 'up' : 'down'} to position ${position}. Press j to move down, k to move up, space bar to drop and escape to cancel.`,
503 dropped: (from, to) => `You have dropped the item. It has moved from position ${from} to ${to}.`,
504 canceled: (position) => `You have cancelled the movement. The item has returned to its starting position of ${position}.`
505 }
506};
507export default List;