UNPKG

wix-style-react

Version:
338 lines 15.4 kB
import React from 'react'; import PropTypes from 'prop-types'; import SelectorListContent from './Content'; import Box from '../Box'; import Search from '../Search'; import Text from '../Text'; import ToggleAllCheckbox from './ToggleAllCheckbox'; import { dataHooks } from './SelectorList.helpers'; import { DEFAULT_IMAGE_SIZE_BY_SIZE, SEARCH_SIZE_BY_SIZE } from './constants'; import { st, classes } from './SelectorList.st.css'; /** * Use this component when needed to select one / multiple items having complex descriptions. * E.g.: choosing products to promote via ShoutOuts */ class SelectorList extends React.PureComponent { constructor() { super(...arguments); this.state = { isLoaded: false, isSearching: false, items: [], searchValue: '', selectedItems: [], indeterminateItems: [], noResultsFound: false, isEmpty: false, }; this._renderList = () => { const { dataHook, emptyState, renderNoResults, height, maxHeight, size, imageSize, imageShape, multiple, showDivider, } = this.props; const { items, isLoaded, isEmpty, isSearching, searchValue, noResultsFound, selectedItems, } = this.state; const hasMore = this._hasMore(); const contentProps = { items, selectedItems, onToggle: this._onToggle, emptyState, renderNoResults, isEmpty, isLoading: !isLoaded || isSearching, noResultsFound, size, imageSize: imageSize || DEFAULT_IMAGE_SIZE_BY_SIZE[size], imageShape, multiple, showDivider, loadMore: this._loadMore, hasMore, checkIsSelected: this._checkIsSelected, checkIndeterminate: this._checkIndeterminate, searchValue, }; const shouldRenderSubheader = isLoaded && !isEmpty; return (React.createElement(Box, { direction: "vertical", overflow: "hidden", dataHook, height, maxHeight }, shouldRenderSubheader && this._renderSubheader(), React.createElement(SelectorListContent, { ...contentProps }))); }; this._renderSubheader = () => { const { subtitle, withSearch, size, searchDebounceMs, searchPlaceholder } = this.props; const { searchValue } = this.state; return (React.createElement("div", { className: st(classes.subheaderWrapper, { withSearch, size, }) }, subtitle && (React.createElement("div", { className: classes.subtitleWrapper }, typeof subtitle === 'string' ? (React.createElement(Text, { dataHook: dataHooks.subtitle }, subtitle)) : (subtitle))), withSearch && (React.createElement(Search, { dataHook: dataHooks.search, placeholder: searchPlaceholder, onChange: this._onSearchChange, onClear: this._onClear, debounceMs: searchDebounceMs, value: searchValue, size: SEARCH_SIZE_BY_SIZE[size] })))); }; this._renderToggleAllCheckbox = () => { const { selectAllText, deselectAllText, size } = this.props; const { items, selectedItems } = this.state; const enabledItemsAmount = this._getEnabledItems(items).length; const selectedEnabledItemsAmount = this._getEnabledItems(selectedItems).length; const checkboxProps = { selectAllText, deselectAllText, size, enabledItemsAmount, selectedEnabledItemsAmount, selectAll: this._selectAll, deselectAll: this._deselectAll, }; return React.createElement(ToggleAllCheckbox, { ...checkboxProps }); }; this._updateSearchValue = searchValue => this.setState({ searchValue, isSearching: true, items: [], }, () => this._loadInitialItems(searchValue)); this._onSearchChange = event => this._updateSearchValue(event.target.value); this._onClear = () => { const searchValue = ''; this.setState({ searchValue, isSearching: true, items: [], }, () => { this._getInitialData(searchValue).then(dataSourceProps => { const { items, selectedItems, indeterminateItems } = this.state; const newItems = [...items, ...dataSourceProps.items]; const newSelectedItems = selectedItems; const newIndeterminateItems = indeterminateItems; const noResultsFound = newItems.length === 0 && Boolean(searchValue); const isEmpty = newItems.length === 0 && !searchValue; this.setState({ items: newItems, selectedItems: newSelectedItems, indeterminateItems: newIndeterminateItems, isLoaded: true, isEmpty, isSearching: false, noResultsFound, totalCount: dataSourceProps.totalCount, }); }); }); }; this._checkIsSelected = item => { const { selectedItems } = this.state; return !!selectedItems.find(({ id }) => item.id === id); }; this._checkIndeterminate = item => { const { indeterminateItems } = this.state; return !!indeterminateItems.find(({ id }) => item.id === id); }; this._toggleItem = item => { const { multiple } = this.props; this.setState(({ selectedItems, indeterminateItems }) => ({ selectedItems: multiple ? this._checkIsSelected(item) ? selectedItems.filter(({ id }) => item.id !== id) : selectedItems.concat(item) : [item], indeterminateItems: indeterminateItems.filter(({ id }) => item.id !== id), })); }; this._onToggle = item => { const { onSelect } = this.props; this._toggleItem(item); if (onSelect) { onSelect(item); } }; this._selectAll = () => { const { selectedItems, items } = this.state; const enabledItems = this._getEnabledItems(items); this.setState({ selectedItems: selectedItems.concat(enabledItems), indeterminateItems: [], }); }; this._deselectAll = () => this.setState(({ selectedItems }) => ({ selectedItems: selectedItems.filter(({ disabled }) => disabled), indeterminateItems: [], })); this._updateItems = ({ resetItems, items: nextPageItems, totalCount, searchValue, }) => { const { items, selectedItems, indeterminateItems } = this.state; // react only to the resolve of the relevant search if (searchValue !== this.state.searchValue) { return; } const newItems = [...(resetItems ? [] : items), ...nextPageItems]; const newSelectedItems = selectedItems .concat(nextPageItems.filter(({ selected }) => selected)) .filter((value, index, self) => self.findIndex(({ id }) => id === value.id) === index); const newIndeterminateItems = indeterminateItems .concat(nextPageItems.filter(({ indeterminate }) => indeterminate)) .filter((value, index, self) => self.findIndex(({ id }) => id === value.id) === index); const noResultsFound = newItems.length === 0 && Boolean(searchValue); const isEmpty = newItems.length === 0 && !searchValue; this.setState({ items: newItems, selectedItems: newSelectedItems, indeterminateItems: newIndeterminateItems, isLoaded: true, isEmpty, isSearching: false, totalCount, noResultsFound, }); }; this._loadInitialItems = (searchValue = '', { resetItems } = {}) => { this._getInitialData(searchValue).then(dataSourceProps => { return this._updateItems({ resetItems, ...dataSourceProps, searchValue, }); }); }; this._getInitialData = (searchValue = '') => { const { dataSource, itemsPerPage } = this.props; const initialAmountToLoad = this.props.initialAmountToLoad || itemsPerPage; return dataSource(searchValue, 0, initialAmountToLoad); }; this._loadMore = () => { const { dataSource, itemsPerPage } = this.props; const { items, searchValue } = this.state; dataSource(searchValue, items.length, itemsPerPage).then(dataSourceProps => this._updateItems({ ...dataSourceProps, searchValue, })); }; this._getEnabledItems = items => items.filter(({ disabled }) => !disabled); } componentDidMount() { this._loadInitialItems(); } /** Resets list items and loads first page from dataSource while persisting searchValue */ reloadInitialItems() { const searchValue = this.state.searchValue; this.setState({ searchValue, isSearching: Boolean(searchValue), isLoaded: false, }); this._loadInitialItems(searchValue, { resetItems: true }); } render() { const { children } = this.props; const { selectedItems } = this.state; if (typeof children === 'function') { return children({ renderList: this._renderList, renderToggleAllCheckbox: this._renderToggleAllCheckbox, selectedItems, }); } return this._renderList(); } _hasMore() { const { items, isLoaded, totalCount, isSearching } = this.state; return ((items.length === 0 && !isLoaded) || items.length < totalCount || isSearching); } } SelectorList.displayName = 'SelectorList'; SelectorList.propTypes = { /** Applies a data-hook HTML attribute to be used in the tests */ dataHook: PropTypes.string, /** * Returns data source for the list described in a following structure: * * ```typescript * (searchQuery: string, offset: number, limit: number) => * Promise<{ * items: Array<{ * id: number | string, // sets the unique item ID (required) * title: node, defines // an item’s title (required) * subtitle?: string, // defines an item’s subtitle * extraText?: string, // contains any text at the end of an item * extraNode?: node, // contains any component at the end of an item * disabled?: boolean, // controls if an item is disabled for selection or not * selected?: boolean, // sets an item as selected * indeterminate?: boolean, // sets an item as indeterminate * image?: node, // contains <Image/> or other component to illustrate an item * subtitleNode?: node, // contains any component below an item’s subtitle * belowNode?: node, // contains any component below the item, to be shown after an item is selected * showBelowNodeOnSelect?: boolean, // allows to show belowNode content when an item is selected * }>, * offset: number, // specifies the item index in the data source to start fetching from<br> * limit: number, // sets a max amount of items to load from the data source<br> * totalCount: number, // sets a max amount of items to load from the data source on user’s search query * }> * ``` * */ dataSource: PropTypes.func.isRequired, /** Controls the size of component paddings and list items */ size: PropTypes.oneOf(['small', 'medium']), /** Controls the size of item images. Note: `portrait` and `cinema` sizes are only compatible with `rectangular` image shape. */ imageSize: PropTypes.oneOf([ 'tiny', 'small', 'portrait', 'large', 'cinema', ]), /** * Controls the shape of item images * */ imageShape: PropTypes.oneOf(['rectangular', 'circle']), /** * Use to display a divider between items */ showDivider: PropTypes.bool, /** Defines placeholder value shown in the search input */ searchPlaceholder: PropTypes.string, /** * Contains a component which is shown when there are no items to display in the selector list. * * i.e. empty `{items:[], totalCount: 0}` was returned on the first call to `dataSource`. Render `<EmptyState/>` component in `section` theme for this purpose. * */ emptyState: PropTypes.node, /** * Defines a function that gets the current `searchQuery` and returns the component that is shown when no items are found. Render `<EmptyState />` component in `section` theme for this purpose. * */ renderNoResults: PropTypes.func, /** Sets the number of items to be loaded each time users scroll down to the end of the list */ itemsPerPage: PropTypes.number, /** Controls whether to display the search input */ withSearch: PropTypes.bool, /** Sets search debounce in milliseconds */ searchDebounceMs: PropTypes.number, /** Sets the height of the component in % or px */ height: PropTypes.string, /** Sets the maximum height of the component in % or px */ maxHeight: PropTypes.string, /** Renders checkboxes instead of radio buttons and allows users to select multiple items */ multiple: PropTypes.bool, /** Defines callback that triggers on select and return selected item object */ onSelect: PropTypes.func, /** Sets the label for the checkbox which allows to select all items */ selectAllText: PropTypes.string, /** Sets the label for the checkbox which allows to deselect all selected items */ deselectAllText: PropTypes.string, /** Sets the number of items to load on initial render or after search. If not defined, it will be equal to `itemsPerPage` value. */ initialAmountToLoad: PropTypes.number, /** Contains text or other component in a fixed position at the top of the list */ subtitle: PropTypes.node, /** Displays a checkbox which allows to select and deselect all items at once */ renderToggleAllCheckbox: PropTypes.func, }; SelectorList.defaultProps = { searchPlaceholder: 'Search...', size: 'medium', imageShape: 'rectangular', showDivider: false, itemsPerPage: 50, withSearch: true, height: '100%', maxHeight: '100%', deselectAllText: 'Deselect all', multiple: false, selectAllText: 'Select all', }; export default SelectorList; //# sourceMappingURL=SelectorList.js.map