1 | import * as React from 'react';
|
2 | import * as ReactDOM from 'react-dom';
|
3 | import { getTranslateOffset, transformItem, setItemTransition, binarySearch, schd, isTouchEvent, checkIfInteractive } from './utils';
|
4 | const AUTOSCROLL_ACTIVE_OFFSET = 200;
|
5 | const AUTOSCROLL_SPEED_RATIO = 10;
|
6 | class 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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
277 | -(window.pageYOffset - this.lastYOffset) +
|
278 |
|
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 |
|
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 |
|
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 | }
|
495 | List.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 | };
|
507 | export default List;
|