UNPKG

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