UNPKG

20.4 kBJavaScriptView Raw
1/**
2 * @name List
3 * @category Components
4 * @tags Ring UI Language
5 * @description Displays a list of items.
6 */
7
8import 'dom4';
9import React, {Component, cloneElement} from 'react';
10import PropTypes from 'prop-types';
11import classNames from 'classnames';
12import VirtualizedList from 'react-virtualized/dist/commonjs/List';
13import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer';
14import WindowScroller from 'react-virtualized/dist/commonjs/WindowScroller';
15import {CellMeasurer, CellMeasurerCache} from 'react-virtualized/dist/commonjs/CellMeasurer';
16
17import dataTests from '../global/data-tests';
18import getUID from '../global/get-uid';
19import scheduleRAF from '../global/schedule-raf';
20import memoize from '../global/memoize';
21import {preventDefault} from '../global/dom';
22import Shortcuts from '../shortcuts/shortcuts';
23
24import styles from './list.css';
25import ListItem from './list__item';
26import ListCustom from './list__custom';
27import ListLink from './list__link';
28import ListTitle from './list__title';
29import ListSeparator from './list__separator';
30import ListHint from './list__hint';
31
32const scheduleScrollListener = scheduleRAF();
33const scheduleHoverListener = scheduleRAF();
34/**
35 * @enum {number}
36 */
37const Type = {
38 SEPARATOR: 0,
39 LINK: 1,
40 ITEM: 2,
41 HINT: 3,
42 CUSTOM: 4,
43 TITLE: 5,
44 MARGIN: 6
45};
46
47const Dimension = {
48 ITEM_PADDING: 16,
49 ITEM_HEIGHT: 32,
50 COMPACT_ITEM_HEIGHT: 24,
51 SEPARATOR_HEIGHT: 25,
52 SEPARATOR_FIRST_HEIGHT: 16,
53 SEPARATOR_TEXT_HEIGHT: 18,
54 TITLE_HEIGHT: 42,
55 INNER_PADDING: 8,
56 MARGIN: 8
57};
58
59const DEFAULT_ITEM_TYPE = Type.ITEM;
60
61function noop() {}
62
63/**
64 * @param {Type} listItemType
65 * @param {Object} item list item
66 */
67function isItemType(listItemType, item) {
68 let type = item.rgItemType;
69 if (type == null) {
70 type = DEFAULT_ITEM_TYPE;
71 }
72 return type === listItemType;
73}
74
75function isActivatable(item) {
76 return !(item.rgItemType === Type.HINT ||
77 item.rgItemType === Type.SEPARATOR ||
78 item.rgItemType === Type.TITLE ||
79 item.disabled);
80}
81
82/**
83 * @name List
84 * @constructor
85 * @extends {ReactComponent}
86 * @example-file ./list.examples.html
87 */
88// eslint-disable-next-line react/no-deprecated
89export default class List extends Component {
90 static isItemType = isItemType;
91
92 static ListHint = ListHint;
93
94 static ListProps = {
95 Type,
96 Dimension
97 };
98
99 static propTypes = {
100 className: PropTypes.string,
101 hint: PropTypes.string,
102 hintOnSelection: PropTypes.string,
103 data: PropTypes.array,
104 maxHeight: PropTypes.oneOfType([
105 PropTypes.string,
106 PropTypes.number
107 ]),
108 activeIndex: PropTypes.number,
109 restoreActiveIndex: PropTypes.bool,
110 activateSingleItem: PropTypes.bool,
111 activateFirstItem: PropTypes.bool,
112 shortcuts: PropTypes.bool,
113 onMouseOut: PropTypes.func,
114 onSelect: PropTypes.func,
115 onScrollToBottom: PropTypes.func,
116 onResize: PropTypes.func,
117 useMouseUp: PropTypes.bool,
118 visible: PropTypes.bool,
119 renderOptimization: PropTypes.bool,
120 disableMoveOverflow: PropTypes.bool,
121 disableMoveDownOverflow: PropTypes.bool,
122 compact: PropTypes.bool
123 };
124
125 static defaultProps = {
126 data: [],
127 restoreActiveIndex: false, // restore active item using its "key" property
128 activateSingleItem: false, // if there is only one item, activate it
129 activateFirstItem: false, // if there no active items, activate the first one
130 onMouseOut: noop,
131 onSelect: noop,
132 onScrollToBottom: noop,
133 onResize: noop,
134 shortcuts: false,
135 renderOptimization: true,
136 disableMoveDownOverflow: false
137 };
138
139 state = {
140 activeIndex: null,
141 activeItem: null,
142 needScrollToActive: false,
143 scrolling: false,
144 hasOverflow: false,
145 disabledHover: false,
146 scrolledToBottom: false
147 };
148
149 componentWillMount() {
150 const {data, activeIndex} = this.props;
151 this.checkActivatableItems(data);
152 if (activeIndex != null && data[this.props.activeIndex]) {
153 this.setState({
154 activeIndex,
155 activeItem: data[activeIndex],
156 needScrollToActive: true
157 });
158 } else if (
159 activeIndex == null &&
160 this.shouldActivateFirstItem(this.props) &&
161 this.hasActivatableItems()
162 ) {
163 const firstActivatableIndex = data.findIndex(isActivatable);
164 this.setState({
165 activeIndex: firstActivatableIndex,
166 activeItem: data[firstActivatableIndex],
167 needScrollToActive: true
168 });
169 }
170 }
171
172 componentDidMount() {
173 document.addEventListener('mousemove', this.onDocumentMouseMove);
174 document.addEventListener('keydown', this.onDocumentKeyDown, true);
175 }
176
177 componentWillReceiveProps(props) {
178 if (props.data) {
179 //TODO investigate (https://youtrack.jetbrains.com/issue/RG-772)
180 //props.data = props.data.map(normalizeListItemType);
181
182 this.checkActivatableItems(props.data);
183
184 this.setState(prevState => {
185 let activeIndex = null;
186 let activeItem = null;
187
188 if (
189 props.restoreActiveIndex &&
190 prevState.activeItem &&
191 prevState.activeItem.key != null
192 ) {
193 for (let i = 0; i < props.data.length; i++) {
194 // Restore active index if there is an item with the same "key" property
195 if (props.data[i].key !== undefined && props.data[i].key === prevState.activeItem.key) {
196 activeIndex = i;
197 activeItem = props.data[i];
198 break;
199 }
200 }
201 }
202
203 if (
204 activeIndex === null &&
205 this.shouldActivateFirstItem(props) &&
206 this.hasActivatableItems()
207 ) {
208 activeIndex = props.data.findIndex(isActivatable);
209 activeItem = props.data[activeIndex];
210 } else if (
211 props.activeIndex != null &&
212 props.activeIndex !== this.props.activeIndex &&
213 props.data[props.activeIndex]
214 ) {
215 activeIndex = props.activeIndex;
216 activeItem = props.data[props.activeIndex];
217 }
218
219 return {
220 activeIndex,
221 activeItem,
222 needScrollToActive:
223 activeIndex !== prevState.activeIndex ? true : prevState.needScrollToActive
224 };
225 });
226 }
227 }
228
229 shouldComponentUpdate(nextProps, nextState) {
230 return nextProps !== this.props ||
231 Object.keys(nextState).some(key => nextState[key] !== this.state[key]);
232 }
233
234 componentDidUpdate(prevProps) {
235 if (this.virtualizedList && prevProps.data !== this.props.data) {
236 this.virtualizedList.recomputeRowHeights();
237 }
238
239 this.checkOverflow();
240 }
241
242 componentWillUnmount() {
243 this.unmounted = true;
244 document.removeEventListener('mousemove', this.onDocumentMouseMove);
245 document.removeEventListener('keydown', this.onDocumentKeyDown, true);
246 }
247
248 hoverHandler = memoize(index => () =>
249 scheduleHoverListener(() => {
250 if (this.state.disabledHover) {
251 return;
252 }
253
254 if (this.container) {
255 this.setState({
256 activeIndex: index,
257 activeItem: this.props.data[index],
258 needScrollToActive: false
259 });
260 }
261 })
262 );
263
264 _activatableItems = false;
265
266 // eslint-disable-next-line no-magic-numbers
267 _bufferSize = 10; // keep X items above and below of the visible area
268 // reuse size cache for similar items
269 sizeCacheKey = index => {
270 if (index === 0 || index === this.props.data.length + 1) {
271 return Type.MARGIN;
272 }
273
274 const item = this.props.data[index - 1];
275 const isFirst = index === 1;
276 switch (item.rgItemType) {
277 case Type.SEPARATOR:
278 case Type.TITLE:
279 return `${item.rgItemType}${isFirst ? '_first' : ''}${item.description ? '_desc' : ''}`;
280 case Type.MARGIN:
281 return Type.MARGIN;
282 case Type.CUSTOM:
283 return `${Type.CUSTOM}_${item.key}`;
284 case Type.ITEM:
285 case Type.LINK:
286 default:
287 if (item.details) {
288 return `${Type.ITEM}_${item.details}`;
289 }
290 return Type.ITEM;
291 }
292 };
293
294 _cache = new CellMeasurerCache({
295 defaultHeight: this.defaultItemHeight(),
296 fixedWidth: true,
297 keyMapper: this.sizeCacheKey
298 });
299
300 hasActivatableItems() {
301 return this._activatableItems;
302 }
303
304 checkActivatableItems(items) {
305 this._activatableItems = false;
306 for (let i = 0; i < items.length; i++) {
307 if (isActivatable(items[i])) {
308 this._activatableItems = true;
309 return;
310 }
311 }
312 }
313
314 selectHandler = memoize(index => event => {
315 const item = this.props.data[index];
316 if (!this.props.useMouseUp && item.onClick) {
317 item.onClick(item, event);
318 } else if (this.props.useMouseUp && item.onMouseUp) {
319 item.onMouseUp(item, event);
320 }
321
322 if (this.props.onSelect) {
323 this.props.onSelect(item, event);
324 }
325 });
326
327 upHandler = e => {
328 const {data, disableMoveOverflow} = this.props;
329 const index = this.state.activeIndex;
330 let newIndex;
331
332 if (index === null || index === 0) {
333 if (!disableMoveOverflow) {
334 newIndex = data.length - 1;
335 } else {
336 return;
337 }
338 } else {
339 newIndex = index - 1;
340 }
341
342 this.moveHandler(newIndex, this.upHandler, e);
343 };
344
345 downHandler = e => {
346 const {data, disableMoveOverflow, disableMoveDownOverflow} = this.props;
347 const index = this.state.activeIndex;
348 let newIndex;
349
350 if (index === null) {
351 newIndex = 0;
352 } else if (index + 1 === data.length) {
353 if (!disableMoveOverflow && !disableMoveDownOverflow) {
354 newIndex = 0;
355 } else {
356 return;
357 }
358 } else {
359 newIndex = index + 1;
360 }
361
362 this.moveHandler(newIndex, this.downHandler, e);
363 };
364
365 homeHandler = e => {
366 this.moveHandler(0, this.downHandler, e);
367 };
368
369 endHandler = e => {
370 this.moveHandler(this.props.data.length - 1, this.upHandler, e);
371 };
372
373 onDocumentMouseMove = () => {
374 if (this.state.disabledHover) {
375 this.setState({disabledHover: false});
376 }
377 };
378
379 onDocumentKeyDown = e => {
380 const metaKeys = [16, 17, 18, 19, 20, 91]; // eslint-disable-line no-magic-numbers
381 if (!this.state.disabledHover && !metaKeys.includes(e.keyCode)) {
382 this.setState({disabledHover: true});
383 }
384 };
385
386 moveHandler(index, retryCallback, e) {
387 let correctedIndex;
388 if (this.props.data.length === 0 || !this.hasActivatableItems()) {
389 return;
390 } else if (this.props.data.length < index) {
391 correctedIndex = 0;
392 } else {
393 correctedIndex = index;
394 }
395
396 const item = this.props.data[correctedIndex];
397 this.setState(
398 {
399 activeIndex: correctedIndex,
400 activeItem: item,
401 needScrollToActive: true
402 },
403 function onSet() {
404 if (!isActivatable(item)) {
405 retryCallback(e);
406 return;
407 }
408
409 preventDefault(e);
410 }
411 );
412 }
413
414 mouseHandler = () => {
415 this.setState({scrolling: false});
416 };
417
418 scrollHandler = () => {
419 this.setState({scrolling: true}, this.scrollEndHandler);
420 };
421
422 enterHandler = (event, shortcut) => {
423 if (this.state.activeIndex !== null) {
424 const item = this.props.data[this.state.activeIndex];
425 this.selectHandler(this.state.activeIndex)(event);
426
427 if (item.href && !event.defaultPrevented) {
428 if (['command+enter', 'ctrl+enter'].includes(shortcut)) {
429 window.open(item.href, '_blank');
430 } else if (shortcut === 'shift+enter') {
431 window.open(item.href);
432 } else {
433 window.location.href = item.href;
434 }
435 }
436 return false; // do not propagate event
437 } else {
438 return true; // propagate event to the parent component (e.g., QueryAssist)
439 }
440 };
441
442 getFirst() {
443 return this.props.data.find(
444 item => item.rgItemType === Type.ITEM || item.rgItemType === Type.CUSTOM
445 );
446 }
447
448 getSelected() {
449 return this.props.data[this.state.activeIndex];
450 }
451
452 clearSelected = () => {
453 this.setState({
454 activeIndex: null,
455 needScrollToActive: false
456 });
457 };
458
459 defaultItemHeight() {
460 return this.props.compact ? Dimension.COMPACT_ITEM_HEIGHT : Dimension.ITEM_HEIGHT;
461 }
462
463 shouldActivateFirstItem(props) {
464 return props.activateFirstItem ||
465 props.activateSingleItem && props.length === 1;
466 }
467
468 scrollEndHandler = () => scheduleScrollListener(() => {
469 const innerContainer = this.inner;
470 if (innerContainer) {
471 const maxScrollingPosition = innerContainer.scrollHeight;
472 const sensitivity = this.defaultItemHeight() / 2;
473 const currentScrollingPosition =
474 innerContainer.scrollTop + innerContainer.clientHeight + sensitivity;
475 const scrolledToBottom =
476 maxScrollingPosition > 0 && currentScrollingPosition >= maxScrollingPosition;
477 if (!this.unmounted) {
478 this.setState({scrolledToBottom});
479 }
480 if (scrolledToBottom) {
481 this.props.onScrollToBottom();
482 }
483 }
484 });
485
486 checkOverflow = () => {
487 if (this.inner) {
488 this.setState({
489 hasOverflow: this.inner.scrollHeight - this.inner.clientHeight > 1
490 });
491 }
492 };
493
494 getVisibleListHeight(props) {
495 return props.maxHeight - this.defaultItemHeight() - Dimension.INNER_PADDING;
496 }
497
498 renderItem = ({index, style, isScrolling, parent, key}) => {
499 let itemKey;
500 let el;
501
502 const realIndex = index - 1;
503
504 const item = this.props.data[realIndex];
505
506 // top and bottom margins
507 if (index === 0 || index === this.props.data.length + 1 || item.rgItemType === Type.MARGIN) {
508 itemKey = key || `${Type.MARGIN}_${index}`;
509 el = <div style={{height: Dimension.MARGIN}}/>;
510 } else {
511
512 // Hack around SelectNG implementation
513 // eslint-disable-next-line no-unused-vars
514 const {selectedLabel, originalModel, ...cleanedProps} = item;
515 const itemProps = Object.assign({rgItemType: DEFAULT_ITEM_TYPE}, cleanedProps);
516
517 if (itemProps.url) {
518 itemProps.href = itemProps.url;
519 }
520 if (itemProps.href) {
521 itemProps.rgItemType = Type.LINK;
522 }
523
524 // Probably unique enough key
525 itemKey = key || itemProps.key ||
526 `${itemProps.rgItemType}_${itemProps.label || itemProps.description}`;
527
528 itemProps.hover = (realIndex === this.state.activeIndex);
529 itemProps.onMouseOver = this.hoverHandler(realIndex);
530 itemProps.tabIndex = -1;
531 itemProps.scrolling = isScrolling;
532
533 const selectHandler = this.selectHandler(realIndex);
534
535 if (this.props.useMouseUp) {
536 itemProps.onMouseUp = selectHandler;
537 } else {
538 itemProps.onClick = selectHandler;
539 }
540 itemProps.onCheckboxChange = selectHandler;
541
542 if (itemProps.compact == null) {
543 itemProps.compact = this.props.compact;
544 }
545
546 let ItemComponent;
547 const isFirst = index === 1;
548 switch (itemProps.rgItemType) {
549 case Type.SEPARATOR:
550 ItemComponent = ListSeparator;
551 itemProps.isFirst = isFirst;
552 break;
553 case Type.LINK:
554 ItemComponent = ListLink;
555 this.addItemDataTestToProp(itemProps);
556 break;
557 case Type.ITEM:
558 ItemComponent = ListItem;
559 this.addItemDataTestToProp(itemProps);
560 break;
561 case Type.CUSTOM:
562 ItemComponent = ListCustom;
563 this.addItemDataTestToProp(itemProps);
564 break;
565 case Type.TITLE:
566 itemProps.isFirst = isFirst;
567 ItemComponent = ListTitle;
568 break;
569 default:
570 throw new Error(`Unknown menu element type: ${itemProps.rgItemType}`);
571 }
572
573 el = <ItemComponent {...itemProps}/>;
574 }
575
576 return parent ? (
577 <CellMeasurer
578 cache={this._cache}
579 key={itemKey}
580 parent={parent}
581 rowIndex={index}
582 columnIndex={0}
583 >
584 <div style={style}>{el}</div>
585 </CellMeasurer>
586 ) : cloneElement(el, {key: itemKey});
587 };
588
589 addItemDataTestToProp = props => {
590 props['data-test'] = dataTests('ring-list-item', props['data-test']);
591 return props;
592 };
593
594 virtualizedListRef = el => {
595 this.virtualizedList = el;
596 };
597
598 containerRef = el => {
599 this.container = el;
600 };
601
602 get inner() {
603 if (!this._inner) {
604 this._inner = this.container && this.container.query('.ring-list__i');
605 }
606 return this._inner;
607 }
608
609 renderVirtualizedInner({
610 height,
611 maxHeight,
612 autoHeight = false,
613 rowCount,
614 isScrolling,
615 onChildScroll = noop,
616 scrollTop
617 }) {
618 const dirOverride = {direction: 'auto'}; // Virtualized sets "direction: ltr" by defaulthttps://github.com/bvaughn/react-virtualized/issues/457
619 return (
620 <AutoSizer disableHeight onResize={this.props.onResize}>
621 {({width}) => (
622 <VirtualizedList
623 ref={this.virtualizedListRef}
624 className="ring-list__i"
625 autoHeight={autoHeight}
626 style={maxHeight ? {maxHeight, height: 'auto', ...dirOverride} : dirOverride}
627 autoContainerWidth
628 height={height}
629 width={width}
630 isScrolling={isScrolling}
631 // eslint-disable-next-line react/jsx-no-bind
632 onScroll={e => {
633 onChildScroll(e);
634 this.scrollEndHandler(e);
635 }}
636 scrollTop={scrollTop}
637 rowCount={rowCount}
638 estimatedRowSize={this.defaultItemHeight()}
639 rowHeight={this._cache.rowHeight}
640 rowRenderer={this.renderItem}
641 overscanRowCount={this._bufferSize}
642
643 // ensure rerendering
644 // eslint-disable-next-line react/jsx-no-bind
645 noop={() => {}}
646
647 scrollToIndex={
648 this.state.needScrollToActive && this.state.activeIndex != null
649 ? this.state.activeIndex + 1
650 : undefined
651 }
652 scrollToAlignment="center"
653 deferredMeasurementCache={this._cache}
654 onRowsRendered={this.checkOverflow}
655 />
656 )}
657 </AutoSizer>
658 );
659 }
660
661 renderVirtualized(maxHeight, rowCount) {
662 if (maxHeight) {
663 return this.renderVirtualizedInner({height: maxHeight, maxHeight, rowCount});
664 }
665
666 return (
667 <WindowScroller>
668 {props => this.renderVirtualizedInner({...props, rowCount, autoHeight: true})}
669 </WindowScroller>
670 );
671 }
672
673 renderSimple(maxHeight, rowCount) {
674 const items = [];
675
676 for (let index = 0; index < rowCount; index++) {
677 items.push(this.renderItem({
678 index,
679 isScrolling: this.state.scrolling
680 }));
681 }
682
683 return (
684 <div
685 className={classNames('ring-list__i', styles.simpleInner)}
686 onScroll={this.scrollHandler}
687 onMouseMove={this.mouseHandler}
688 >
689 <div
690 style={maxHeight
691 ? {maxHeight: this.getVisibleListHeight(this.props)}
692 : null
693 }
694 >
695 {items}
696 </div>
697 </div>
698 );
699 }
700
701 shortcutsScope = getUID('list-');
702 shortcutsMap = {
703 up: this.upHandler,
704 down: this.downHandler,
705 home: this.homeHandler,
706 end: this.endHandler,
707 enter: this.enterHandler,
708 'meta+enter': this.enterHandler,
709 'ctrl+enter': this.enterHandler,
710 'command+enter': this.enterHandler,
711 'shift+enter': this.enterHandler
712 };
713
714 /** @override */
715 render() {
716 const hint = this.getSelected() && this.props.hintOnSelection || this.props.hint;
717 const fadeStyles = hint ? {bottom: Dimension.ITEM_HEIGHT} : null;
718
719 const rowCount = this.props.data.length + 2;
720
721 const maxHeight = this.props.maxHeight && this.getVisibleListHeight(this.props);
722
723 const classes = classNames(styles.list, this.props.className);
724
725 return (
726 <div
727 ref={this.containerRef}
728 className={classes}
729 onMouseOut={this.props.onMouseOut}
730 onMouseLeave={this.clearSelected}
731 data-test="ring-list"
732 >
733 {this.props.shortcuts &&
734 (
735 <Shortcuts
736 map={this.shortcutsMap}
737 scope={this.shortcutsScope}
738 />
739 )
740 }
741 {this.props.renderOptimization
742 ? this.renderVirtualized(maxHeight, rowCount)
743 : this.renderSimple(maxHeight, rowCount)
744 }
745 {this.state.hasOverflow && !this.state.scrolledToBottom && (
746 <div
747 className={styles.fade}
748 style={fadeStyles}
749 />
750 )}
751 {hint && (
752 <ListHint
753 label={hint}
754 />
755 )}
756 </div>
757 );
758 }
759}