1 | import * as React from 'react';
|
2 | import * as ReactDOM from 'react-dom';
|
3 | import { getTranslateOffset, transformItem, setItemTransition, binarySearch, schd, isTouchEvent, checkIfInteractive } from './utils.js';
|
4 | const AUTOSCROLL_ACTIVE_OFFSET = 200;
|
5 | const AUTOSCROLL_SPEED_RATIO = 10;
|
6 | const AUTOSCROLL_DELTA_THRESHOLD = 10;
|
7 | class 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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
205 | if (height + 20 < this.listRef.current.scrollHeight) {
|
206 | let scrollingSpeed = 0;
|
207 |
|
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 |
|
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 |
|
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 |
|
285 | -(window.pageYOffset - this.lastYOffset) +
|
286 |
|
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 |
|
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 |
|
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 | }
|
503 | List.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 | };
|
515 | export default List;
|