UNPKG

@shopify/polaris

Version:

Shopify’s product component library

437 lines (436 loc) • 20.5 kB
import React from 'react'; import debounce from 'lodash/debounce'; import { EnableSelectionMinor } from '@shopify/polaris-icons'; import { classNames } from '../../utilities/css'; import { Button } from '../Button'; import { EventListener } from '../EventListener'; import { Sticky } from '../Sticky'; import { Spinner } from '../Spinner'; import { withAppProvider, } from '../../utilities/with-app-provider'; import { ResourceListContext, SELECT_ALL_ITEMS, } from '../../utilities/resource-list'; import { Select } from '../Select'; import { EmptySearchResult } from '../EmptySearchResult'; import { ResourceItem } from '../ResourceItem'; import { BulkActions, CheckableButton, // eslint-disable-next-line import/no-deprecated FilterControl, } from './components'; import styles from './ResourceList.scss'; const SMALL_SCREEN_WIDTH = 458; const SMALL_SPINNER_HEIGHT = 28; const LARGE_SPINNER_HEIGHT = 45; class ResourceList extends React.Component { constructor(props) { super(props); this.listRef = React.createRef(); this.handleResize = debounce(() => { const { selectedItems } = this.props; const { selectMode, smallScreen } = this.state; const newSmallScreen = isSmallScreen(); if (selectedItems && selectedItems.length === 0 && selectMode && !newSmallScreen) { this.handleSelectMode(false); } if (smallScreen !== newSmallScreen) { this.setState({ smallScreen: newSmallScreen }); } }, 50, { leading: true, trailing: true, maxWait: 50 }); this.setLoadingPosition = () => { if (this.listRef.current != null) { if (typeof window === 'undefined') { return; } const overlay = this.listRef.current.getBoundingClientRect(); const viewportHeight = Math.max(document.documentElement ? document.documentElement.clientHeight : 0, window.innerHeight || 0); const overflow = viewportHeight - overlay.height; const spinnerHeight = this.props.items.length === 1 ? SMALL_SPINNER_HEIGHT : LARGE_SPINNER_HEIGHT; const spinnerPosition = overflow > 0 ? (overlay.height - spinnerHeight) / 2 : (viewportHeight - overlay.top - spinnerHeight) / 2; this.setState({ loadingPosition: spinnerPosition }); } }; this.handleSelectAllItemsInStore = () => { const { onSelectionChange, selectedItems, items, idForItem = defaultIdForItem, } = this.props; const newlySelectedItems = selectedItems === SELECT_ALL_ITEMS ? getAllItemsOnPage(items, idForItem) : SELECT_ALL_ITEMS; if (onSelectionChange) { onSelectionChange(newlySelectedItems); } }; this.renderItem = (item, index) => { const { renderItem, idForItem = defaultIdForItem } = this.props; const id = idForItem(item, index); return (<li key={id} className={styles.ItemWrapper}> {renderItem(item, id, index)} </li>); }; this.handleMultiSelectionChange = (lastSelected, currentSelected, resolveItemId) => { const min = Math.min(lastSelected, currentSelected); const max = Math.max(lastSelected, currentSelected); return this.props.items.slice(min, max + 1).map(resolveItemId); }; this.handleCheckableButtonRegistration = (key, button) => { this.setState(({ checkableButtons }) => { return { checkableButtons: new Map(checkableButtons).set(key, button), }; }); }; this.handleSelectionChange = (selected, id, sortOrder, shiftKey) => { const { onSelectionChange, selectedItems, items, idForItem = defaultIdForItem, resolveItemId, } = this.props; const { lastSelected } = this.state; if (selectedItems == null || onSelectionChange == null) { return; } let newlySelectedItems = selectedItems === SELECT_ALL_ITEMS ? getAllItemsOnPage(items, idForItem) : [...selectedItems]; if (sortOrder !== undefined) { this.setState({ lastSelected: sortOrder }); } let selectedIds = [id]; if (shiftKey && lastSelected != null && sortOrder !== undefined && resolveItemId) { selectedIds = this.handleMultiSelectionChange(lastSelected, sortOrder, resolveItemId); } newlySelectedItems = [...new Set([...newlySelectedItems, ...selectedIds])]; if (!selected) { for (let i = 0; i < selectedIds.length; i++) { newlySelectedItems.splice(newlySelectedItems.indexOf(selectedIds[i]), 1); } } if (newlySelectedItems.length === 0 && !isSmallScreen()) { this.handleSelectMode(false); } else if (newlySelectedItems.length > 0) { this.handleSelectMode(true); } if (onSelectionChange) { onSelectionChange(newlySelectedItems); } }; this.handleSelectMode = (selectMode) => { const { onSelectionChange } = this.props; this.setState({ selectMode }); if (!selectMode && onSelectionChange) { onSelectionChange([]); } }; this.handleToggleAll = () => { const { onSelectionChange, selectedItems, items, idForItem = defaultIdForItem, } = this.props; const { checkableButtons } = this.state; let newlySelectedItems = []; if ((Array.isArray(selectedItems) && selectedItems.length === items.length) || selectedItems === SELECT_ALL_ITEMS) { newlySelectedItems = []; } else { newlySelectedItems = items.map((item, index) => { const id = idForItem(item, index); return id; }); } if (newlySelectedItems.length === 0 && !isSmallScreen()) { this.handleSelectMode(false); } else if (newlySelectedItems.length > 0) { this.handleSelectMode(true); } let checkbox; if (isSmallScreen()) { checkbox = checkableButtons.get('bulkSm'); } else if (newlySelectedItems.length === 0) { checkbox = checkableButtons.get('plain'); } else { checkbox = checkableButtons.get('bulkLg'); } if (onSelectionChange) { onSelectionChange(newlySelectedItems); } // setTimeout ensures execution after the Transition on BulkActions setTimeout(() => { checkbox && checkbox.focus(); }, 0); }; const { selectedItems, polaris: { intl }, } = props; this.defaultResourceName = { singular: intl.translate('Polaris.ResourceList.defaultItemSingular'), plural: intl.translate('Polaris.ResourceList.defaultItemPlural'), }; // eslint-disable-next-line react/state-in-constructor this.state = { selectMode: Boolean(selectedItems && selectedItems.length > 0), loadingPosition: 0, lastSelected: null, smallScreen: isSmallScreen(), checkableButtons: new Map(), }; } get selectable() { const { promotedBulkActions, bulkActions, selectable } = this.props; return Boolean((promotedBulkActions && promotedBulkActions.length > 0) || (bulkActions && bulkActions.length > 0) || selectable); } get bulkSelectState() { const { selectedItems, items } = this.props; let selectState = 'indeterminate'; if (!selectedItems || (Array.isArray(selectedItems) && selectedItems.length === 0)) { selectState = false; } else if (selectedItems === SELECT_ALL_ITEMS || (Array.isArray(selectedItems) && selectedItems.length === items.length)) { selectState = true; } return selectState; } get headerTitle() { const { resourceName = this.defaultResourceName, items, polaris: { intl }, loading, totalItemsCount, } = this.props; const itemsCount = items.length; const resource = itemsCount === 1 && !loading ? resourceName.singular : resourceName.plural; if (loading) { return intl.translate('Polaris.ResourceList.loading', { resource }); } else if (totalItemsCount) { return intl.translate('Polaris.ResourceList.showingTotalCount', { itemsCount, totalItemsCount, resource, }); } else { return intl.translate('Polaris.ResourceList.showing', { itemsCount, resource, }); } } get bulkActionsLabel() { const { selectedItems = [], items, polaris: { intl }, } = this.props; const selectedItemsCount = selectedItems === SELECT_ALL_ITEMS ? `${items.length}+` : selectedItems.length; return intl.translate('Polaris.ResourceList.selected', { selectedItemsCount, }); } get bulkActionsAccessibilityLabel() { const { resourceName = this.defaultResourceName, selectedItems = [], items, polaris: { intl }, } = this.props; const selectedItemsCount = selectedItems.length; const totalItemsCount = items.length; const allSelected = selectedItemsCount === totalItemsCount; if (totalItemsCount === 1 && allSelected) { return intl.translate('Polaris.ResourceList.a11yCheckboxDeselectAllSingle', { resourceNameSingular: resourceName.singular }); } else if (totalItemsCount === 1) { return intl.translate('Polaris.ResourceList.a11yCheckboxSelectAllSingle', { resourceNameSingular: resourceName.singular, }); } else if (allSelected) { return intl.translate('Polaris.ResourceList.a11yCheckboxDeselectAllMultiple', { itemsLength: items.length, resourceNamePlural: resourceName.plural, }); } else { return intl.translate('Polaris.ResourceList.a11yCheckboxSelectAllMultiple', { itemsLength: items.length, resourceNamePlural: resourceName.plural, }); } } get paginatedSelectAllText() { const { hasMoreItems, selectedItems, items, resourceName = this.defaultResourceName, polaris: { intl }, } = this.props; if (!this.selectable || !hasMoreItems) { return; } if (selectedItems === SELECT_ALL_ITEMS) { return intl.translate('Polaris.ResourceList.allItemsSelected', { itemsLength: items.length, resourceNamePlural: resourceName.plural, }); } } get paginatedSelectAllAction() { const { hasMoreItems, selectedItems, items, resourceName = this.defaultResourceName, polaris: { intl }, } = this.props; if (!this.selectable || !hasMoreItems) { return; } const actionText = selectedItems === SELECT_ALL_ITEMS ? intl.translate('Polaris.Common.undo') : intl.translate('Polaris.ResourceList.selectAllItems', { itemsLength: items.length, resourceNamePlural: resourceName.plural, }); return { content: actionText, onAction: this.handleSelectAllItemsInStore, }; } get emptySearchResultText() { const { polaris: { intl }, resourceName = this.defaultResourceName, } = this.props; return { title: intl.translate('Polaris.ResourceList.emptySearchResultTitle', { resourceNamePlural: resourceName.plural, }), description: intl.translate('Polaris.ResourceList.emptySearchResultDescription'), }; } componentDidMount() { this.forceUpdate(); if (this.props.loading) { this.setLoadingPosition(); } } componentDidUpdate({ loading: prevLoading, items: prevItems, selectedItems: prevSelectedItems, }) { const { selectedItems, loading } = this.props; if (this.listRef.current && this.itemsExist() && !this.itemsExist(prevItems)) { this.forceUpdate(); } if (loading && !prevLoading) { this.setLoadingPosition(); } if (selectedItems && selectedItems.length > 0 && !this.state.selectMode) { // eslint-disable-next-line react/no-did-update-set-state this.setState({ selectMode: true }); return; } if (prevSelectedItems && prevSelectedItems.length > 0 && (!selectedItems || selectedItems.length === 0) && !isSmallScreen()) { // eslint-disable-next-line react/no-did-update-set-state this.setState({ selectMode: false }); } } render() { const { items, promotedBulkActions, bulkActions, filterControl, loading, showHeader = false, sortOptions, sortValue, alternateTool, selectedItems, resourceName = this.defaultResourceName, onSortChange, polaris: { intl }, } = this.props; const { selectMode, loadingPosition, smallScreen } = this.state; const filterControlMarkup = filterControl ? (<div className={styles.FiltersWrapper}>{filterControl}</div>) : null; const bulkActionsMarkup = this.selectable ? (<div className={styles.BulkActionsWrapper}> <BulkActions label={this.bulkActionsLabel} accessibilityLabel={this.bulkActionsAccessibilityLabel} selected={this.bulkSelectState} onToggleAll={this.handleToggleAll} selectMode={selectMode} onSelectModeToggle={this.handleSelectMode} promotedActions={promotedBulkActions} paginatedSelectAllAction={this.paginatedSelectAllAction} paginatedSelectAllText={this.paginatedSelectAllText} actions={bulkActions} disabled={loading} smallScreen={smallScreen}/> </div>) : null; const sortingSelectMarkup = sortOptions && sortOptions.length > 0 && !alternateTool ? (<div className={styles.SortWrapper}> <Select label={intl.translate('Polaris.ResourceList.sortingLabel')} labelInline={!smallScreen} labelHidden={smallScreen} options={sortOptions} onChange={onSortChange} value={sortValue} disabled={selectMode}/> </div>) : null; const alternateToolMarkup = alternateTool && !sortingSelectMarkup ? (<div className={styles.AlternateToolWrapper}>{alternateTool}</div>) : null; const headerTitleMarkup = (<div className={styles.HeaderTitleWrapper} testID="headerTitleWrapper"> {this.headerTitle} </div>); const selectButtonMarkup = this.selectable ? (<div className={styles.SelectButtonWrapper}> <Button disabled={selectMode} icon={EnableSelectionMinor} onClick={this.handleSelectMode.bind(this, true)}> {intl.translate('Polaris.ResourceList.selectButtonText')} </Button> </div>) : null; const checkableButtonMarkup = this.selectable ? (<div className={styles.CheckableButtonWrapper}> <CheckableButton accessibilityLabel={this.bulkActionsAccessibilityLabel} label={this.headerTitle} onToggleAll={this.handleToggleAll} plain disabled={loading}/> </div>) : null; const needsHeader = this.selectable || (sortOptions && sortOptions.length > 0) || alternateTool; const headerWrapperOverlay = loading ? (<div className={styles['HeaderWrapper-overlay']}/>) : null; const showEmptyState = filterControl && !this.itemsExist() && !loading; const headerMarkup = !showEmptyState && (showHeader || needsHeader) && this.listRef.current && (<div className={styles.HeaderOuterWrapper}> <Sticky boundingElement={this.listRef.current}> {(isSticky) => { const headerClassName = classNames(styles.HeaderWrapper, sortOptions && sortOptions.length > 0 && !alternateTool && styles['HeaderWrapper-hasSort'], alternateTool && styles['HeaderWrapper-hasAlternateTool'], this.selectable && styles['HeaderWrapper-hasSelect'], loading && styles['HeaderWrapper-disabled'], this.selectable && selectMode && styles['HeaderWrapper-inSelectMode'], isSticky && styles['HeaderWrapper-isSticky']); return (<div className={headerClassName} testID="ResourceList-Header"> <EventListener event="resize" handler={this.handleResize}/> {headerWrapperOverlay} <div className={styles.HeaderContentWrapper}> {headerTitleMarkup} {checkableButtonMarkup} {alternateToolMarkup} {sortingSelectMarkup} {selectButtonMarkup} </div> {bulkActionsMarkup} </div>); }} </Sticky> </div>); const emptyStateMarkup = showEmptyState ? (<div className={styles.EmptySearchResultWrapper}> <EmptySearchResult {...this.emptySearchResultText} withIllustration/> </div>) : null; const defaultTopPadding = 8; const topPadding = loadingPosition > 0 ? loadingPosition : defaultTopPadding; const spinnerStyle = { paddingTop: `${topPadding}px` }; const spinnerSize = items.length < 2 ? 'small' : 'large'; const loadingOverlay = loading ? (<React.Fragment> <div className={styles.SpinnerContainer} style={spinnerStyle}> <Spinner size={spinnerSize} accessibilityLabel="Items are loading"/> </div> <div className={styles.LoadingOverlay}/> </React.Fragment>) : null; const className = classNames(styles.ItemWrapper, loading && styles['ItemWrapper-isLoading']); const loadingWithoutItemsMarkup = loading && !this.itemsExist() ? (<div className={className} tabIndex={-1}> {loadingOverlay} </div>) : null; const resourceListClassName = classNames(styles.ResourceList, loading && styles.disabledPointerEvents, selectMode && styles.disableTextSelection); const listMarkup = this.itemsExist() ? (<ul className={resourceListClassName} ref={this.listRef} aria-live="polite" aria-busy={loading}> {loadingOverlay} {items.map(this.renderItem)} </ul>) : (emptyStateMarkup); const context = { selectable: this.selectable, selectedItems, selectMode, resourceName, loading, onSelectionChange: this.handleSelectionChange, registerCheckableButtons: this.handleCheckableButtonRegistration, }; return (<ResourceListContext.Provider value={context}> <div className={styles.ResourceListWrapper}> {filterControlMarkup} {headerMarkup} {listMarkup} {loadingWithoutItemsMarkup} </div> </ResourceListContext.Provider>); } itemsExist(items) { return (items || this.props.items).length > 0; } } ResourceList.Item = ResourceItem; // eslint-disable-next-line import/no-deprecated ResourceList.FilterControl = FilterControl; function getAllItemsOnPage(items, idForItem) { return items.map((item, index) => { return idForItem(item, index); }); } function defaultIdForItem(item, index) { return item.hasOwnProperty('id') ? item.id : index.toString(); } function isSmallScreen() { return typeof window === 'undefined' ? false : window.innerWidth <= SMALL_SCREEN_WIDTH; } // Use named export once withAppProvider is refactored away // eslint-disable-next-line import/no-default-export export default withAppProvider()(ResourceList);