@gravity-ui/uikit
Version: 
Gravity UI base styling and components
372 lines (371 loc) • 16.7 kB
JavaScript
'use client';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from 'react';
import isEqual from "lodash/isEqual.js";
import isObject from "lodash/isObject.js";
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import AutoSizer from 'react-virtualized-auto-sizer';
import { VariableSizeList } from 'react-window';
import { TextInput } from "../controls/index.js";
import { MobileContext } from "../mobile/index.js";
import { useDirection } from "../theme/index.js";
import { block } from "../utils/cn.js";
import { getUniqId } from "../utils/common.js";
import { ListLoadingIndicator } from "./ListLoadingIndicator.js";
import { ListItem, SimpleContainer, defaultRenderItem } from "./components/index.js";
import { listNavigationIgnoredKeys } from "./constants.js";
import "./List.css";
const b = block('list');
const DEFAULT_ITEM_HEIGHT = 28;
const DEFAULT_PAGE_SIZE = 10;
export const listDefaultProps = {
    items: [],
    itemClassName: '',
    filterable: true,
    sortable: false,
    virtualized: true,
    deactivateOnLeave: true,
};
const reorder = (list, startIndex, endIndex) => {
    const result = Array.from(list);
    const [removed] = result.splice(startIndex, 1);
    result.splice(endIndex, 0, removed);
    return result;
};
const ListContainer = React.forwardRef((props, ref) => {
    return _jsx(VariableSizeList, { ref: ref, ...props, direction: useDirection() });
});
ListContainer.displayName = 'ListContainer';
export class List extends React.Component {
    static defaultProps = listDefaultProps;
    static moveListElement(list, oldIndex, newIndex) {
        if (oldIndex !== newIndex) {
            const [item] = list.splice(oldIndex, 1);
            list.splice(newIndex, 0, item);
        }
        return list;
    }
    static findNextIndex(list, index, step) {
        const dataLength = list.length;
        let currentIndex = (index + dataLength) % dataLength;
        for (let i = 0; i < dataLength; i += 1) {
            if (list[currentIndex] && !list[currentIndex].disabled) {
                return currentIndex;
            }
            currentIndex = (currentIndex + dataLength + step) % dataLength;
        }
        return undefined;
    }
    state = {
        items: this.props.items,
        activeItem: this.props.activeItemIndex,
        filter: '',
    };
    refFilter = React.createRef();
    refContainer = React.createRef();
    blurTimer = null;
    loadingItem = { value: '__LIST_ITEM_LOADING__', disabled: false };
    uniqId = getUniqId();
    componentDidMount() {
        this.activateItem(this.props.activeItemIndex, true);
    }
    componentDidUpdate(prevProps, prevState) {
        if (!isEqual(this.props.items, prevProps.items)) {
            const filter = this.getFilter();
            const internalFiltering = filter && !this.props.onFilterUpdate;
            if (internalFiltering) {
                this.onUpdateFilterInternal(filter);
            }
            else {
                this.setState({ items: this.props.items });
            }
        }
        if (this.props.activeItemIndex !== prevProps.activeItemIndex) {
            this.activateItem(this.props.activeItemIndex);
        }
        if (this.props.onChangeActive && this.state.activeItem !== prevState.activeItem) {
            this.props.onChangeActive(this.state.activeItem);
        }
    }
    componentWillUnmount() {
        this.blurTimer = null;
    }
    render() {
        const { id, emptyPlaceholder, virtualized, className, itemsClassName, qa, role = 'list', } = this.props;
        const { items } = this.state;
        return (_jsx(MobileContext.Consumer, { children: ({ mobile }) => (
            // The event handler should only be used to capture bubbled events
            // eslint-disable-next-line jsx-a11y/no-static-element-interactions
            _jsxs("div", { className: b({ mobile }, className), "data-qa": qa, tabIndex: -1, onFocus: this.handleFocus, onBlur: this.handleBlur, onKeyDown: this.onKeyDown, children: [this.renderFilter(), _jsxs("div", { id: id, className: b('items', { virtualized }, itemsClassName), style: this.getItemsStyle(), onMouseLeave: this.onMouseLeave, role: role, children: [this.renderItems(), items.length === 0 && Boolean(emptyPlaceholder) && (_jsx("div", { className: b('empty-placeholder'), children: emptyPlaceholder }))] })] })) }));
    }
    getItems() {
        return this.state.items;
    }
    getItemsWithLoading() {
        if (this.props.sortable) {
            return this.getItems();
        }
        return this.props.loading ? [...this.state.items, this.loadingItem] : this.getItems();
    }
    getActiveItem() {
        return typeof this.state.activeItem === 'number' ? this.state.activeItem : null;
    }
    activateItem(index, scrollTo = true) {
        this.setState({ activeItem: index }, () => {
            if (typeof index === 'number' && scrollTo) {
                this.scrollToIndex(index);
            }
        });
    }
    onKeyDown = (event) => {
        const { activeItem, pageSize } = this.state;
        if (listNavigationIgnoredKeys.includes(event.key)) {
            return;
        }
        const isInputTarget = event.target instanceof HTMLInputElement;
        switch (event.key) {
            case 'ArrowDown': {
                this.handleKeyMove(event, 1, -1);
                break;
            }
            case 'ArrowUp': {
                this.handleKeyMove(event, -1);
                break;
            }
            case 'PageDown': {
                this.handleKeyMove(event, pageSize ?? DEFAULT_PAGE_SIZE);
                break;
            }
            case 'PageUp': {
                this.handleKeyMove(event, -(pageSize ?? DEFAULT_PAGE_SIZE));
                break;
            }
            case 'Home': {
                // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
                // ... if the combobox is editable, returns focus to the combobox and places the cursor on the first character (c)
                if (isInputTarget) {
                    return;
                }
                this.handleKeyMove(event, this.state.items.length - (activeItem || 0));
                break;
            }
            case 'End': {
                // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
                // ... if the combobox is editable, returns focus to the combobox and places the cursor after the last character (c)
                if (isInputTarget) {
                    return;
                }
                this.handleKeyMove(event, -(activeItem || 0) - 1);
                break;
            }
            case 'Enter': {
                if (typeof activeItem === 'number' && this.props.onItemClick) {
                    this.props.onItemClick(this.state.items[activeItem], activeItem, true, event);
                }
                break;
            }
            default: {
                if (this.refFilter.current) {
                    this.refFilter.current.focus();
                }
            }
        }
    };
    renderItemContent = (item, isItemActive, itemIndex) => {
        const { onLoadMore } = this.props;
        if (isObject(item) && 'value' in item && item.value === this.loadingItem.value) {
            return _jsx(ListLoadingIndicator, { onIntersect: itemIndex === 0 ? undefined : onLoadMore });
        }
        return this.props.renderItem
            ? this.props.renderItem(item, isItemActive, itemIndex)
            : defaultRenderItem(item);
    };
    renderItem = ({ index, style, height, provided, isDragging, }) => {
        const { sortHandleAlign, role } = this.props;
        const { items, activeItem } = this.state;
        const item = this.getItemsWithLoading()[index];
        const sortable = this.props.sortable && items.length > 1 && !this.getFilter();
        const active = index === activeItem || index === this.props.activeItemIndex;
        const selected = Array.isArray(this.props.selectedItemIndex)
            ? this.props.selectedItemIndex.includes(index)
            : index === this.props.selectedItemIndex;
        return (_jsx(ListItem, { style: style, height: height, itemIndex: index, item: item, sortable: sortable, sortHandleAlign: sortHandleAlign, renderItem: this.renderItemContent, itemClassName: this.props.itemClassName, active: active, selected: selected, onActivate: this.onItemActivate, onClick: this.props.onItemClick, role: role === 'listbox' ? 'option' : 'listitem', listId: this.props.id ?? this.uniqId, provided: provided, isDragging: isDragging }, index));
    };
    renderVirtualizedItem = ({ index, style, }) => {
        return (_jsx(Draggable, { draggableId: String(index), index: index, children: (provided) => this.renderItem({ index, style, provided }) }, `item-key-${index}`));
    };
    renderFilter() {
        const { size, filterable, filter = this.state.filter, filterPlaceholder, filterClassName = '', autoFocus, } = this.props;
        if (!filterable) {
            return null;
        }
        return (_jsx("div", { className: b('filter', filterClassName), children: _jsx(TextInput, { controlRef: this.refFilter, size: size, placeholder: filterPlaceholder, value: filter, hasClear: true, onUpdate: this.onFilterUpdate, autoFocus: autoFocus }) }));
    }
    renderSimpleContainer() {
        const { sortable } = this.props;
        const items = this.getItemsWithLoading();
        if (sortable) {
            return (_jsx(DragDropContext, { onDragStart: this.onSortStart, onDragEnd: this.onSortEnd, children: _jsx(Droppable, { droppableId: "droppable", renderClone: (provided, snapshot, rubric) => {
                        return this.renderItem({
                            index: rubric.source.index,
                            provided,
                            isDragging: snapshot.isDragging,
                        });
                    }, children: (droppableProvided) => (_jsx(SimpleContainer, { ref: this.refContainer, itemCount: items.length, provided: droppableProvided, onScrollToItem: this.props.onScrollToItem, children: items.map((_item, index) => {
                            return (_jsx(Draggable, { draggableId: String(index), index: index, children: (provided, snapshot) => {
                                    return this.renderItem({
                                        index,
                                        isDragging: snapshot.isDragging,
                                        provided,
                                        height: this.getItemHeight(index),
                                    });
                                } }, `item-key-${index}`));
                        }) })) }) }));
        }
        return (_jsx(SimpleContainer, { itemCount: items.length, ref: this.refContainer, onScrollToItem: this.props.onScrollToItem, children: items.map((_item, index) => this.renderItem({ index, height: this.getItemHeight(index) })) }));
    }
    renderVirtualizedContainer() {
        // Otherwise, react-window will not update the list items
        const items = [...this.getItemsWithLoading()];
        if (this.props.sortable) {
            return (_jsx(DragDropContext, { onDragStart: this.onSortStart, onDragEnd: this.onSortEnd, children: _jsx(Droppable, { droppableId: "droppable", mode: "virtual", renderClone: (provided, snapshot, rubric) => {
                        return this.renderItem({
                            index: rubric.source.index,
                            provided,
                            isDragging: snapshot.isDragging,
                        });
                    }, children: (droppableProvided) => (_jsx(AutoSizer, { children: ({ width, height }) => (_jsx(ListContainer, { ref: this.refContainer, outerRef: droppableProvided.innerRef, width: width, height: height, itemSize: this.getVirtualizedItemHeight, itemData: items, itemCount: items.length, overscanCount: 10, onItemsRendered: this.onItemsRendered, 
                            // this property used to rerender items in viewport
                            // must be last, typescript skips checks for all props behind ts-ignore/ts-expect-error
                            // @ts-expect-error
                            activeItem: this.state.activeItem, children: this.renderVirtualizedItem })) })) }) }));
        }
        return (_jsx(AutoSizer, { children: ({ width, height }) => (_jsx(ListContainer, { ref: this.refContainer, width: width, height: height, itemSize: this.getVirtualizedItemHeight, itemData: items, itemCount: items.length, overscanCount: 10, onItemsRendered: this.onItemsRendered, 
                // this property used to rerender items in viewport
                // must be last, typescript skips checks for all props behind ts-ignore/ts-expect-error
                // @ts-expect-error
                activeItem: this.state.activeItem, children: this.renderItem })) }));
    }
    renderItems() {
        if (this.props.virtualized) {
            return this.renderVirtualizedContainer();
        }
        else {
            return this.renderSimpleContainer();
        }
    }
    filterItem = (filter) => (item) => {
        return String(item).includes(filter);
    };
    getFilter() {
        const { filter = this.state.filter } = this.props;
        return filter;
    }
    getItemsStyle() {
        let { itemsHeight } = this.props;
        if (typeof itemsHeight === 'function') {
            itemsHeight = itemsHeight(this.state.items);
        }
        return itemsHeight ? { height: itemsHeight } : undefined;
    }
    scrollToIndex = (index) => {
        const container = this.refContainer.current;
        if (container) {
            container.scrollToItem(index);
        }
    };
    deactivate = () => {
        if (!this.blurTimer) {
            return;
        }
        this.blurTimer = null;
        if (this.props.deactivateOnLeave) {
            this.setState({ activeItem: undefined });
        }
    };
    handleKeyMove(event, step, defaultItemIndex = 0) {
        const { activeItem = defaultItemIndex } = this.state;
        event.preventDefault();
        const items = this.getItemsWithLoading();
        this.activateItem(List.findNextIndex(items, activeItem + step, Math.sign(step)));
    }
    handleFocus = () => {
        if (this.blurTimer) {
            clearTimeout(this.blurTimer);
            this.blurTimer = null;
        }
    };
    handleBlur = () => {
        if (!this.blurTimer) {
            this.blurTimer = setTimeout(this.deactivate, 50);
        }
    };
    onUpdateFilterInternal = (value) => {
        const { items, filterItem = this.filterItem, onFilterEnd } = this.props;
        this.setState({
            filter: value,
            items: value ? items.filter(filterItem(value)) : items,
        }, () => {
            if (onFilterEnd) {
                onFilterEnd({ items: this.state.items });
            }
        });
    };
    onFilterUpdate = (value) => {
        if (this.props.onFilterUpdate) {
            this.props.onFilterUpdate(value);
        }
        else {
            this.onUpdateFilterInternal(value);
        }
    };
    onItemsRendered = ({ visibleStartIndex, visibleStopIndex, }) => {
        this.setState({
            pageSize: visibleStopIndex - visibleStartIndex,
        });
    };
    onItemActivate = (index) => {
        if (!this.state.sorting) {
            this.activateItem(index, false);
        }
    };
    onMouseLeave = () => {
        this.handleBlur();
    };
    onSortStart = () => {
        this.setState({ sorting: true });
    };
    onSortEnd = (result) => {
        if (!result.destination) {
            this.setState({ sorting: false });
            return;
        }
        if (result.source.index === result.destination.index) {
            this.setState({ sorting: false });
            return;
        }
        const oldIndex = result.source.index;
        const newIndex = result.destination.index;
        if (this.props.onSortEnd) {
            this.props.onSortEnd({ oldIndex, newIndex });
        }
        const nextItems = reorder(this.getItems(), oldIndex, newIndex);
        this.setState({
            activeItem: newIndex,
            items: nextItems,
            sorting: false,
        });
    };
    getItemHeight = (index) => {
        const { itemHeight } = this.props;
        if (typeof itemHeight === 'function') {
            const { items } = this.state;
            return itemHeight(items[index], index);
        }
        return itemHeight;
    };
    getVirtualizedItemHeight = (index) => {
        return this.getItemHeight(index) || DEFAULT_ITEM_HEIGHT;
    };
}
//# sourceMappingURL=List.js.map