diff --git a/.changeset/rare-kings-repeat.md b/.changeset/rare-kings-repeat.md new file mode 100644 index 00000000000..72f1a293cd0 --- /dev/null +++ b/.changeset/rare-kings-repeat.md @@ -0,0 +1,5 @@ +--- +'@shopify/polaris': minor +--- + +Updated the BulkActions component to include logic to handling selecting and deselecting rows diff --git a/polaris-react/src/components/BulkActions/BulkActions.module.scss b/polaris-react/src/components/BulkActions/BulkActions.module.scss index a8717b6b58c..ece03ac20ec 100644 --- a/polaris-react/src/components/BulkActions/BulkActions.module.scss +++ b/polaris-react/src/components/BulkActions/BulkActions.module.scss @@ -5,43 +5,56 @@ $bulk-actions-button-stacking-order: ( focused: 20, ); -.Group { - // stylelint-disable-next-line -- generated by polaris-migrator DO NOT COPY - @include text-style-input; +.BulkActionsOuterLayout { + position: relative; + flex: 1; + width: 100%; +} + +.BulkActionsSelectAllWrapper { + min-height: 24px; display: flex; align-items: center; - flex-wrap: wrap; - opacity: 0; - width: 100%; - justify-content: flex-start; - transition: var(--p-motion-duration-100) var(--p-motion-ease); - transition-property: opacity; - will-change: opacity; + gap: var(--p-space-200); +} - &.Group-not-sticky { - opacity: 1; - } +.BulkActionsPromotedActionsWrapper { + flex: 1; +} - &.Group-entering, - &.Group-exiting { - opacity: 0; - display: flex; - } +.BulkActionsLayout { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + flex: 1 1 auto; + gap: var(--p-space-100); - &.Group-entered { - opacity: 1; - display: flex; + > * { + flex: 0 0 auto; } +} - &.Group-exited { - opacity: 0; - display: none; - } +.BulkActionsLayout--measuring { + visibility: hidden; + height: 0; +} - &.Group-measuring { - transition: none; - display: flex; - opacity: 0; +.BulkActionsMeasurerLayout { + position: absolute; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + flex: 1 1 auto; + gap: 0; + padding: 0; + visibility: hidden; + height: 0; + width: 100%; + + > * { + flex: 0 0 auto; } } @@ -83,3 +96,37 @@ $bulk-actions-button-stacking-order: ( cursor: default; pointer-events: none; } + +.PaginatedSelectAll { + font-size: var(--p-text-body-sm-font-size); + font-weight: var(--p-font-weight-medium); + line-height: var(--p-text-body-sm-font-line-height); +} + +.AllAction { + font-size: var(--p-text-body-sm-font-size); + font-weight: var(--p-font-weight-medium); + line-height: var(--p-text-body-sm-font-line-height); + border: 0; + background: none; + padding: 0; + cursor: pointer; + color: var(--p-color-text-emphasis); + outline: none; + // stylelint-disable-next-line -- needed for focus ring + @include focus-ring; + + &:hover, + &:focus { + color: var(--p-color-text-emphasis-hover); + } + + &:active { + color: var(--p-color-text-emphasis-active); + } + + &:focus-visible:not(:active) { + // stylelint-disable-next-line -- needed for focus ring + @include focus-ring($style: 'focused'); + } +} diff --git a/polaris-react/src/components/BulkActions/BulkActions.tsx b/polaris-react/src/components/BulkActions/BulkActions.tsx index 827be54a3ca..a88082a726e 100644 --- a/polaris-react/src/components/BulkActions/BulkActions.tsx +++ b/polaris-react/src/components/BulkActions/BulkActions.tsx @@ -1,10 +1,13 @@ -import React, {PureComponent, createRef, forwardRef} from 'react'; -import {Transition} from 'react-transition-group'; +import React, { + forwardRef, + useReducer, + useState, + useEffect, + useCallback, +} from 'react'; -import {debounce} from '../../utilities/debounce'; import {classNames} from '../../utilities/css'; import {useI18n} from '../../utilities/i18n'; -import {clamp} from '../../utilities/clamp'; import type { BadgeAction, DisableableAction, @@ -16,25 +19,31 @@ import {ActionList} from '../ActionList'; import {Popover} from '../Popover'; import {InlineStack} from '../InlineStack'; import {CheckableButton} from '../CheckableButton'; -// eslint-disable-next-line import/no-deprecated -import {EventListener} from '../EventListener'; +import {UnstyledButton} from '../UnstyledButton'; import type {ButtonProps} from '../Button'; -import {BulkActionButton, BulkActionMenu} from './components'; +import { + BulkActionButton, + BulkActionMenu, + BulkActionsMeasurer, +} from './components'; +import type {ActionsMeasurements} from './components'; +import { + getVisibleAndHiddenActionsIndices, + isNewBadgeInBadgeActions, + instanceOfMenuGroupDescriptor, + instanceOfBulkActionListSection, + getActionSections, +} from './utilities'; import styles from './BulkActions.module.scss'; export type BulkAction = DisableableAction & BadgeAction; type BulkActionListSection = ActionListSection; -type TransitionStatus = 'entering' | 'entered' | 'exiting' | 'exited'; type AriaLive = 'off' | 'polite' | undefined; -const BUTTONS_NODE_ADDITIONAL_WIDTH = 64; - export interface BulkActionsProps { - /** List is in a selectable state */ - selectMode?: boolean; /** Visually hidden text for screen readers */ accessibilityLabel?: string; /** State of the bulk actions checkbox */ @@ -51,408 +60,306 @@ export interface BulkActionsProps { actions?: (BulkAction | BulkActionListSection)[]; /** Disables bulk actions */ disabled?: boolean; - /** Callback when selectable state of list is changed */ - onSelectModeToggle?(selectMode: boolean): void; /** Callback when more actions button is toggled */ onMoreActionPopoverToggle?(isOpen: boolean): void; - /** Used for forwarding the ref */ - innerRef?: React.Ref; /** The size of the buttons to render */ buttonSize?: Extract; + /** Label for the bulk actions */ + label?: string; + /** @deprecated List is in a selectable state. No longer needed due to removal of Transition */ + selectMode?: boolean; + /** @deprecated Used for forwarding the ref. Use `ref` prop instead */ + innerRef?: React.Ref; + /** @deprecated Callback when selectable state of list is changed. Unused callback */ + onSelectModeToggle?(selectMode: boolean): void; /** @deprecated If the BulkActions is currently sticky in view */ isSticky?: boolean; /** @deprecated The width of the BulkActions */ width?: number; } -type CombinedProps = BulkActionsProps & { - i18n: ReturnType; -}; - -interface State { - popoverVisible: boolean; +interface BulkActionsState { + visiblePromotedActions: number[]; + hiddenPromotedActions: number[]; + actionsWidths: number[]; containerWidth: number; - measuring: boolean; + disclosureWidth: number; + hasMeasured: boolean; } -class BulkActionsInner extends PureComponent { - state: State = { - popoverVisible: false, - containerWidth: 0, - measuring: true, - }; - - private containerNode: HTMLElement | null = null; - private buttonsNode: HTMLElement | null = null; - private moreActionsNode: HTMLElement | null = null; - private groupNode = createRef(); - private promotedActionsWidths: number[] = []; - private bulkActionsWidth = 0; - private addedMoreActionsWidthForMeasuring = 0; - - private handleResize = debounce( - () => { - const {popoverVisible} = this.state; - - if (this.containerNode) { - const containerWidth = this.containerNode.getBoundingClientRect().width; - if (containerWidth > 0) { - this.setState({containerWidth}); - } - } - - if (popoverVisible) { - this.setState({ - popoverVisible: false, - }); - } +export const BulkActions = forwardRef(function BulkActions( + { + promotedActions, + actions, + disabled, + buttonSize, + paginatedSelectAllAction, + paginatedSelectAllText, + label, + accessibilityLabel, + selected, + onToggleAll, + onMoreActionPopoverToggle, + width, + }: BulkActionsProps, + ref, +) { + const i18n = useI18n(); + const [popoverActive, setPopoverActive] = useState(false); + + const [state, setState] = useReducer( + ( + data: BulkActionsState, + partialData: Partial, + ): BulkActionsState => { + return {...data, ...partialData}; + }, + { + disclosureWidth: 0, + containerWidth: Infinity, + actionsWidths: [], + visiblePromotedActions: [], + hiddenPromotedActions: [], + hasMeasured: false, }, - 50, - {trailing: true}, ); - private numberOfPromotedActionsToRender(): number { - const {promotedActions} = this.props; - const {containerWidth, measuring} = this.state; - - if (!promotedActions) { - return 0; - } - - const containerWidthMinusAdditionalWidth = Math.max( - 0, - containerWidth - BUTTONS_NODE_ADDITIONAL_WIDTH, - ); - - if ( - containerWidthMinusAdditionalWidth >= this.bulkActionsWidth || - measuring - ) { - return promotedActions.length; - } - - let sufficientSpace = false; - let counter = promotedActions.length - 1; - let totalWidth = 0; - - while (!sufficientSpace && counter >= 0) { - totalWidth += this.promotedActionsWidths[counter]; - const widthWithRemovedAction = - this.bulkActionsWidth - - totalWidth + - this.addedMoreActionsWidthForMeasuring; - if (containerWidthMinusAdditionalWidth >= widthWithRemovedAction) { - sufficientSpace = true; - } else { - counter--; - } - } - - return clamp(counter, 0, promotedActions.length); - } - - private actionSections(): BulkActionListSection[] | undefined { - const {actions} = this.props; - - if (!actions || actions.length === 0) { - return; - } - - if (instanceOfBulkActionListSectionArray(actions)) { - return actions; - } - - if (instanceOfBulkActionArray(actions)) { - return [ - { - items: actions, - }, - ]; - } - } - - private rolledInPromotedActions() { - const {promotedActions} = this.props; - const numberOfPromotedActionsToRender = - this.numberOfPromotedActionsToRender(); + const { + visiblePromotedActions, + hiddenPromotedActions, + containerWidth, + disclosureWidth, + actionsWidths, + hasMeasured, + } = state; + useEffect(() => { if ( + containerWidth === 0 || !promotedActions || - promotedActions.length === 0 || - numberOfPromotedActionsToRender >= promotedActions.length + promotedActions.length === 0 ) { - return []; + return; } - - const rolledInPromotedActions = promotedActions.map((action) => { - if (instanceOfMenuGroupDescriptor(action)) { - return {items: [...action.actions]}; - } - return {items: [action]}; + const {visiblePromotedActions, hiddenPromotedActions} = + getVisibleAndHiddenActionsIndices( + promotedActions, + disclosureWidth, + actionsWidths, + containerWidth, + ); + setState({ + visiblePromotedActions, + hiddenPromotedActions, + hasMeasured: containerWidth !== Infinity, }); + }, [containerWidth, disclosureWidth, promotedActions, actionsWidths]); + + const activatorLabel = + !promotedActions || (promotedActions && visiblePromotedActions.length === 0) + ? i18n.translate('Polaris.ResourceList.BulkActions.actionsActivatorLabel') + : i18n.translate( + 'Polaris.ResourceList.BulkActions.moreActionsActivatorLabel', + ); + + const paginatedSelectAllActionMarkup = paginatedSelectAllAction ? ( + + {paginatedSelectAllAction.content} + + ) : null; + + const hasTextAndAction = paginatedSelectAllText && paginatedSelectAllAction; + + const paginatedSelectAllMarkup = paginatedSelectAllActionMarkup ? ( +
+ {paginatedSelectAllActionMarkup} +
+ ) : null; + + const ariaLive: AriaLive = hasTextAndAction ? 'polite' : undefined; + + const checkableButtonProps = { + accessibilityLabel, + label: hasTextAndAction ? paginatedSelectAllText : label, + selected, + onToggleAll, + disabled, + ariaLive, + ref, + }; - return rolledInPromotedActions.slice(numberOfPromotedActionsToRender); - } - - // eslint-disable-next-line @typescript-eslint/member-ordering - componentDidMount() { - const {actions, promotedActions} = this.props; - - if (promotedActions && !actions && this.moreActionsNode) { - this.addedMoreActionsWidthForMeasuring = - this.moreActionsNode.getBoundingClientRect().width; - } - - this.bulkActionsWidth = this.buttonsNode - ? this.buttonsNode.getBoundingClientRect().width - - this.addedMoreActionsWidthForMeasuring - : 0; + const togglePopover = useCallback(() => { + onMoreActionPopoverToggle?.(popoverActive); + setPopoverActive((popoverActive) => !popoverActive); + }, [onMoreActionPopoverToggle, popoverActive]); + + const handleMeasurement = useCallback( + (measurements: ActionsMeasurements) => { + const { + hiddenActionsWidths: actionsWidths, + containerWidth, + disclosureWidth, + } = measurements; + if (!promotedActions || promotedActions.length === 0) { + return; + } - if (this.containerNode) { - this.setState({ - containerWidth: this.containerNode.getBoundingClientRect().width, - measuring: false, + const {visiblePromotedActions, hiddenPromotedActions} = + getVisibleAndHiddenActionsIndices( + promotedActions, + disclosureWidth, + actionsWidths, + containerWidth, + ); + + setState({ + visiblePromotedActions, + hiddenPromotedActions, + actionsWidths, + containerWidth, + disclosureWidth, + hasMeasured: true, }); - } - } - - // eslint-disable-next-line @typescript-eslint/member-ordering - render() { - const { - selectMode, - disabled, - promotedActions, - i18n, - paginatedSelectAllText, - paginatedSelectAllAction, - accessibilityLabel, - onToggleAll, - selected, - innerRef, - buttonSize = 'micro', - width, - isSticky, - } = this.props; - const actionSections = this.actionSections(); - - const {popoverVisible, measuring} = this.state; - - const numberOfPromotedActionsToRender = - this.numberOfPromotedActionsToRender(); - - const promotedActionsMarkup = - promotedActions && numberOfPromotedActionsToRender > 0 - ? [...promotedActions] - .slice(0, numberOfPromotedActionsToRender) - .map((action, index) => { - if (instanceOfMenuGroupDescriptor(action)) { - return ( - - ); - } - return ( - - ); - }) - : null; - - const rolledInPromotedActions = this.rolledInPromotedActions(); - - const activatorLabel = - !promotedActions || - (promotedActions && numberOfPromotedActionsToRender === 0 && !measuring) - ? i18n.translate( - 'Polaris.ResourceList.BulkActions.actionsActivatorLabel', - ) - : i18n.translate( - 'Polaris.ResourceList.BulkActions.moreActionsActivatorLabel', - ); - - let combinedActions: ActionListSection[] = []; - - if (actionSections && rolledInPromotedActions.length > 0) { - combinedActions = [...rolledInPromotedActions, ...actionSections]; - } else if (actionSections) { - combinedActions = actionSections; - } else if (rolledInPromotedActions.length > 0) { - combinedActions = [...rolledInPromotedActions]; - } + }, + [promotedActions], + ); - const hasTextAndAction = paginatedSelectAllText && paginatedSelectAllAction; - - const ariaLive: AriaLive = hasTextAndAction ? 'polite' : undefined; - - const checkableButtonProps = { - accessibilityLabel, - selected, - onToggleAll, - disabled, - ariaLive, - ref: innerRef, - }; - - const actionsPopover = - actionSections || rolledInPromotedActions.length > 0 || measuring ? ( -
- { + if (!visiblePromotedActions.includes(index)) { + return false; + } + + return true; + }) + .map((action, index) => { + if (instanceOfMenuGroupDescriptor(action)) { + return ( + - } - preferredAlignment="right" - onClose={this.togglePopover} - > - - -
- ) : null; - - const groupContent = - promotedActionsMarkup || actionsPopover ? ( - - - - {promotedActionsMarkup} - {actionsPopover} - - - ) : null; - - if (!groupContent) { - return null; - } - - const group = ( - - {(status: TransitionStatus) => { - const groupClassName = classNames( - styles.Group, - !isSticky && styles['Group-not-sticky'], - status && styles[`Group-${status}`], - ); + ); + } return ( -
- -
-
{groupContent}
-
-
+ ); - }} -
- ); - - return
{group}
; - } + }) + : null; - private isNewBadgeInBadgeActions() { - const actions = this.actionSections(); - if (!actions) return false; + const hiddenPromotedActionObjects = hiddenPromotedActions.map( + (index) => promotedActions?.[index], + ); - for (const action of actions) { - for (const item of action.items) { - if (item.badge?.tone === 'new') return true; + const mergedHiddenPromotedActions = hiddenPromotedActionObjects.reduce( + (memo, action) => { + if (!action) return memo; + if (instanceOfMenuGroupDescriptor(action)) { + return memo.concat(action.actions); } - } - - return false; - } - - private setButtonsNode = (node: HTMLElement | null) => { - this.buttonsNode = node; - }; - - private setContainerNode = (node: HTMLElement | null) => { - this.containerNode = node; - }; - - private setMoreActionsNode = (node: HTMLElement | null) => { - this.moreActionsNode = node; - }; - - private togglePopover = () => { - if (this.props.onMoreActionPopoverToggle) { - this.props.onMoreActionPopoverToggle(this.state.popoverVisible); - } - - this.setState(({popoverVisible}) => ({ - popoverVisible: !popoverVisible, - })); - }; + return memo.concat(action); + }, + [] as (BulkAction | MenuGroupDescriptor)[], + ); - private handleMeasurement = (width: number) => { - const {measuring} = this.state; - if (measuring) { - this.promotedActionsWidths.push(width); - } + const hiddenPromotedSection = { + items: mergedHiddenPromotedActions, }; -} - -function instanceOfBulkActionListSectionArray( - actions: (BulkAction | BulkActionListSection)[], -): actions is BulkActionListSection[] { - const validList = actions.filter((action: any) => { - return action.items; - }); - - return actions.length === validList.length; -} - -function instanceOfBulkActionArray( - actions: (BulkAction | BulkActionListSection)[], -): actions is BulkAction[] { - const validList = actions.filter((action: any) => { - return !action.items; - }); - return actions.length === validList.length; -} - -function instanceOfMenuGroupDescriptor( - action: MenuGroupDescriptor | BulkAction, -): action is MenuGroupDescriptor { - return 'title' in action; -} + const allHiddenActions = actions + ? actions + .filter((action) => action) + .map( + ( + action: BulkAction | MenuGroupDescriptor | BulkActionListSection, + ) => { + if (instanceOfBulkActionListSection(action)) { + return {items: [...action.items]}; + } else if (instanceOfMenuGroupDescriptor(action)) { + return {items: [...action.actions]}; + } + return {items: [action]}; + }, + ) + : []; + + const activator = ( + + ); -export const BulkActions = forwardRef(function BulkActions( - props: BulkActionsProps, - ref, -) { - const i18n = useI18n(); + const actionsMarkup = + allHiddenActions.length > 0 ? ( + + + + ) : null; + + const measurerMarkup = ( + + ); - return ; + return ( +
+ +
+ + {paginatedSelectAllMarkup} +
+
+ +
+ {measurerMarkup} +
+ {promotedActionsMarkup} +
+
+ {actionsMarkup} +
+
+
+
+ ); }); diff --git a/polaris-react/src/components/BulkActions/components/BulkActionsMeasurer/BulkActionsMeasurer.tsx b/polaris-react/src/components/BulkActions/components/BulkActionsMeasurer/BulkActionsMeasurer.tsx new file mode 100644 index 00000000000..c5aed2f38e4 --- /dev/null +++ b/polaris-react/src/components/BulkActions/components/BulkActionsMeasurer/BulkActionsMeasurer.tsx @@ -0,0 +1,96 @@ +import React, {useCallback, useRef, useEffect} from 'react'; + +import {useI18n} from '../../../../utilities/i18n'; +import {BulkActionButton} from '../BulkActionButton'; +import {useEventListener} from '../../../../utilities/use-event-listener'; +import styles from '../../BulkActions.module.scss'; +import type {BulkActionsProps} from '../../BulkActions'; +import {instanceOfMenuGroupDescriptor} from '../../utilities'; + +export interface ActionsMeasurements { + containerWidth: number; + disclosureWidth: number; + hiddenActionsWidths: number[]; +} + +export interface ActionsMeasurerProps { + /** Collection of page-level action groups */ + promotedActions?: BulkActionsProps['promotedActions']; + disabled?: BulkActionsProps['disabled']; + buttonSize?: BulkActionsProps['buttonSize']; + handleMeasurement(measurements: ActionsMeasurements): void; +} + +const ACTION_SPACING = 4; + +export function BulkActionsMeasurer({ + promotedActions = [], + disabled, + buttonSize, + handleMeasurement: handleMeasurementProp, +}: ActionsMeasurerProps) { + const i18n = useI18n(); + const containerNode = useRef(null); + + const activatorLabel = i18n.translate( + 'Polaris.ResourceList.BulkActions.moreActionsActivatorLabel', + ); + + const activator = ; + + const handleMeasurement = useCallback(() => { + if (!containerNode.current) { + return; + } + + const containerWidth = containerNode.current.offsetWidth; + const hiddenActionNodes = containerNode.current.children; + const hiddenActionNodesArray = Array.from(hiddenActionNodes); + const hiddenActionsWidths = hiddenActionNodesArray.map((node) => { + const buttonWidth = Math.ceil(node.getBoundingClientRect().width); + return buttonWidth + ACTION_SPACING; + }); + const disclosureWidth = hiddenActionsWidths.pop() || 0; + + handleMeasurementProp({ + containerWidth, + disclosureWidth, + hiddenActionsWidths, + }); + }, [handleMeasurementProp]); + + useEffect(() => { + handleMeasurement(); + }, [handleMeasurement, promotedActions]); + + const promotedActionsMarkup = promotedActions.map((action, index) => { + if (instanceOfMenuGroupDescriptor(action)) { + return ( + + ); + } + return ( + + ); + }); + + useEventListener('resize', handleMeasurement); + + return ( +
+ {promotedActionsMarkup} + {activator} +
+ ); +} diff --git a/polaris-react/src/components/BulkActions/components/BulkActionsMeasurer/index.ts b/polaris-react/src/components/BulkActions/components/BulkActionsMeasurer/index.ts new file mode 100644 index 00000000000..5cb2e5bb42c --- /dev/null +++ b/polaris-react/src/components/BulkActions/components/BulkActionsMeasurer/index.ts @@ -0,0 +1,2 @@ +export {BulkActionsMeasurer} from './BulkActionsMeasurer'; +export type {ActionsMeasurements} from './BulkActionsMeasurer'; diff --git a/polaris-react/src/components/BulkActions/components/index.ts b/polaris-react/src/components/BulkActions/components/index.ts index ea2d0c2e60c..9ee53127693 100644 --- a/polaris-react/src/components/BulkActions/components/index.ts +++ b/polaris-react/src/components/BulkActions/components/index.ts @@ -1,2 +1,4 @@ export * from './BulkActionButton'; export * from './BulkActionMenu'; +export {BulkActionsMeasurer} from './BulkActionsMeasurer'; +export type {ActionsMeasurements} from './BulkActionsMeasurer'; diff --git a/polaris-react/src/components/BulkActions/tests/BulkActions.test.tsx b/polaris-react/src/components/BulkActions/tests/BulkActions.test.tsx index 70386c9601d..c3c14b298b2 100644 --- a/polaris-react/src/components/BulkActions/tests/BulkActions.test.tsx +++ b/polaris-react/src/components/BulkActions/tests/BulkActions.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {Transition, CSSTransition} from 'react-transition-group'; +import type {CustomRoot} from 'tests/utilities'; import {mountWithApp} from 'tests/utilities'; import {ActionList} from '../../ActionList'; @@ -9,8 +9,30 @@ import {BulkActionButton, BulkActionMenu} from '../components'; import type {BulkActionButtonProps} from '../components'; import {BulkActions} from '../BulkActions'; import type {BulkAction, BulkActionsProps} from '../BulkActions'; +import type {getVisibleAndHiddenActionsIndices} from '../utilities'; import styles from '../BulkActions.module.scss'; +jest.mock('../components', () => ({ + ...jest.requireActual('../components'), + BulkActionsMeasurer: () => { + return null; + }, +})); + +jest.mock('../utilities', () => ({ + ...jest.requireActual('../utilities'), + getVisibleAndHiddenActionsIndices: jest.fn(), +})); + +function mockGetVisibleAndHiddenActionsIndices( + args: ReturnType, +) { + const getVisibleAndHiddenActionsIndices: jest.Mock = + jest.requireMock('../utilities').getVisibleAndHiddenActionsIndices; + + getVisibleAndHiddenActionsIndices.mockReturnValue(args); +} + interface Props { bulkActions: BulkActionButtonProps['content'][]; promotedActions: NonNullable; @@ -37,6 +59,13 @@ const bulkActionProps: Props = { }; describe('', () => { + beforeEach(() => { + mockGetVisibleAndHiddenActionsIndices({ + visiblePromotedActions: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + hiddenPromotedActions: [], + }); + }); + describe('actions', () => { it('indicator is passed to BulkActionButton when actions contain a new tone for badge', () => { const bulkActions = mountWithApp( @@ -174,31 +203,6 @@ describe('', () => { }); }); - describe('selectMode', () => { - it('is passed down to Transition', () => { - const bulkActions = mountWithApp( - , - ); - - expect(bulkActions).toContainReactComponent(Transition, { - in: true, - }); - }); - - it('is passed down to CSSTransition', () => { - const bulkActions = mountWithApp( - , - ); - - const cssTransition = bulkActions.findAll(CSSTransition, { - appear: true, - }); - cssTransition.forEach((cssTransitionComponent) => { - expect(cssTransitionComponent).toHaveReactProps({in: true}); - }); - }); - }); - describe('promotedActions', () => { let warnSpy: jest.SpyInstance; @@ -242,6 +246,42 @@ describe('', () => { expect(bulkActions).toContainReactComponentTimes(BulkActionButton, 3); }); + it('will not render promotedActions that are hidden', () => { + mockGetVisibleAndHiddenActionsIndices({ + visiblePromotedActions: [0, 1], + hiddenPromotedActions: [2], + }); + const bulkActionProps: Props = { + accessibilityLabel: 'A11y label', + label: 'Label', + selected: false, + bulkActions: [], + promotedActions: [ + { + title: 'button1', + actions: [ + { + content: 'action1', + }, + { + content: 'action2', + }, + ], + }, + { + content: 'button 2', + }, + { + content: 'button 3', + }, + ], + disabled: false, + }; + const bulkActions = mountWithApp(); + const wrapper = findWrapper(bulkActions); + expect(wrapper).toContainReactComponentTimes(BulkActionButton, 2); + }); + it('renders a BulkActionMenu when promotedActions are menus', () => { const bulkActionProps: Props = { accessibilityLabel: 'A11y label', @@ -281,11 +321,12 @@ describe('', () => { disabled: false, }; const bulkActions = mountWithApp(); - const bulkActionButtons = bulkActions.findAll(BulkActionButton); + const wrapper = findWrapper(bulkActions); + const bulkActionButtons = wrapper!.findAll(BulkActionButton); expect(bulkActionButtons).toHaveLength(4); expect(bulkActionButtons[0].text()).toBe('button1'); expect(bulkActionButtons[1].text()).toBe('button2'); - const bulkActionMenus = bulkActions.findAll(BulkActionMenu); + const bulkActionMenus = wrapper!.findAll(BulkActionMenu); expect(bulkActionMenus).toHaveLength(2); }); @@ -315,10 +356,15 @@ describe('', () => { disabled: false, }; const bulkActions = mountWithApp(); + const wrapper = findWrapper(bulkActions); - bulkActions.find(BulkActionButton)?.trigger('onAction'); + wrapper! + .find(BulkActionButton, { + content: promotedActionToBeClicked.title, + }) + ?.trigger('onAction'); - const actionList = bulkActions.find(ActionList); + const actionList = bulkActions!.find(ActionList); expect(actionList!.prop('items')).toBe( promotedActionToBeClicked.actions, ); @@ -357,7 +403,9 @@ describe('', () => { }; const bulkActions = mountWithApp(); - expect(bulkActions).toContainReactComponentTimes(BulkActionButton, 1, { + const wrapper = findWrapper(bulkActions); + + expect(wrapper).toContainReactComponentTimes(BulkActionButton, 1, { disabled: true, }); }); @@ -365,6 +413,10 @@ describe('', () => { describe('onMoreActionPopoverToggle', () => { it('is invoked when the popover is toggled', () => { + mockGetVisibleAndHiddenActionsIndices({ + visiblePromotedActions: [], + hiddenPromotedActions: [], + }); const spy = jest.fn(); const bulkActions = mountWithApp( ', () => { />, ); - bulkActions.find(BulkActionButton)?.trigger('onAction'); + bulkActions! + .find(BulkActionButton, { + content: 'Actions', + }) + ?.trigger('onAction'); expect(spy).toHaveBeenCalledTimes(1); }); @@ -403,28 +459,6 @@ describe('', () => { }); describe('deprecated props', () => { - describe('isSticky', () => { - it('adds the not-sticky class name if isSticky is falsy', () => { - const bulkActions = mountWithApp( - , - ); - - expect(bulkActions).toContainReactComponent('div', { - className: expect.stringContaining(styles['Group-not-sticky']), - }); - }); - - it('does not add the not-sticky class name if isSticky is truthy', () => { - const bulkActions = mountWithApp( - , - ); - - expect(bulkActions).not.toContainReactComponent('div', { - className: expect.stringContaining(styles['Group-not-sticky']), - }); - }); - }); - describe('width', () => { it('adds an inline style width if present', () => { const bulkActions = mountWithApp( @@ -446,3 +480,15 @@ describe('', () => { }); }); }); + +function findWrapper(wrapper: CustomRoot) { + const wrappingDiv = wrapper.findWhere<'div'>((node) => { + return ( + node.is('div') && + Boolean(node.prop('className')) && + node.prop('className')!.includes(styles.BulkActionsLayout) + ); + }); + + return wrappingDiv; +} diff --git a/polaris-react/src/components/BulkActions/tests/utilities.test.tsx b/polaris-react/src/components/BulkActions/tests/utilities.test.tsx new file mode 100644 index 00000000000..e7aaabfae33 --- /dev/null +++ b/polaris-react/src/components/BulkActions/tests/utilities.test.tsx @@ -0,0 +1,80 @@ +import {getVisibleAndHiddenActionsIndices} from '../utilities'; + +describe('bulk actions utilities', () => { + describe('getVisibleAndHiddenActionsIndices', () => { + const promotedActions = [ + {content: 'Promoted Action 1'}, + {content: 'Promoted Action 2'}, + {content: 'Promoted Action 3'}, + {content: 'Promoted Action 4'}, + {content: 'Promoted Action 5'}, + ]; + const disclosureWidth = 20; + const actionsWidths = [50, 60, 70, 80, 90]; + const containerWidth = 400; + + it('returns all promotedActions as visible when container width is greater than the sum of tab widths', () => { + const result = getVisibleAndHiddenActionsIndices( + promotedActions, + disclosureWidth, + actionsWidths, + containerWidth, + ); + + expect(result.visiblePromotedActions).toStrictEqual([0, 1, 2, 3, 4]); + expect(result.hiddenPromotedActions).toStrictEqual([]); + }); + + it('hides promotedActions that exceed the container width', () => { + const customContainerWidth = 100; + const result = getVisibleAndHiddenActionsIndices( + promotedActions, + disclosureWidth, + actionsWidths, + customContainerWidth, + ); + + expect(result.visiblePromotedActions).toStrictEqual([0]); + expect(result.hiddenPromotedActions).toStrictEqual([1, 2, 3, 4]); + }); + + it('hides all promotedActions when container width is less than the width of the first action', () => { + const customContainerWidth = 40; + const result = getVisibleAndHiddenActionsIndices( + promotedActions, + disclosureWidth, + actionsWidths, + customContainerWidth, + ); + + expect(result.visiblePromotedActions).toStrictEqual([]); + expect(result.hiddenPromotedActions).toStrictEqual([0, 1, 2, 3, 4]); + }); + + it('will not show not-in-order promotedActions if the other action widths do not fit', () => { + const customActionWidths = [50, 400, 400, 60, 350]; + const result = getVisibleAndHiddenActionsIndices( + promotedActions, + disclosureWidth, + customActionWidths, + containerWidth, + ); + + expect(result.visiblePromotedActions).toStrictEqual([0]); + expect(result.hiddenPromotedActions).toStrictEqual([1, 2, 3, 4]); + }); + + it('hides all promotedActions and actions when actionsWidths is larger than container width', () => { + const customActionsWidths = [500, 400, 500, 600, 700]; + const result = getVisibleAndHiddenActionsIndices( + promotedActions, + disclosureWidth, + customActionsWidths, + containerWidth, + ); + + expect(result.visiblePromotedActions).toStrictEqual([]); + expect(result.hiddenPromotedActions).toStrictEqual([0, 1, 2, 3, 4]); + }); + }); +}); diff --git a/polaris-react/src/components/BulkActions/utilities.ts b/polaris-react/src/components/BulkActions/utilities.ts new file mode 100644 index 00000000000..b200fcf9e0c --- /dev/null +++ b/polaris-react/src/components/BulkActions/utilities.ts @@ -0,0 +1,121 @@ +import type { + BadgeAction, + DisableableAction, + ActionListSection, + MenuGroupDescriptor, +} from '../../types'; + +import type {BulkActionsProps} from './BulkActions'; + +type BulkActionListSection = ActionListSection; + +export function getVisibleAndHiddenActionsIndices( + promotedActions: any[] = [], + disclosureWidth: number, + actionsWidths: number[], + containerWidth: number, +) { + const sumTabWidths = actionsWidths.reduce((sum, width) => sum + width, 0); + const arrayOfPromotedActionsIndices = promotedActions.map((_, index) => { + return index; + }); + + const visiblePromotedActions: number[] = []; + const hiddenPromotedActions: number[] = []; + + if (containerWidth > sumTabWidths) { + visiblePromotedActions.push(...arrayOfPromotedActionsIndices); + } else { + let accumulatedWidth = 0; + let hasReturned = false; + + arrayOfPromotedActionsIndices.forEach((currentPromotedActionsIndex) => { + const currentActionsWidth = actionsWidths[currentPromotedActionsIndex]; + const notEnoughSpace = + accumulatedWidth + currentActionsWidth >= + containerWidth - disclosureWidth; + + if (notEnoughSpace || hasReturned) { + hiddenPromotedActions.push(currentPromotedActionsIndex); + hasReturned = true; + return; + } + + visiblePromotedActions.push(currentPromotedActionsIndex); + accumulatedWidth += currentActionsWidth; + }); + } + + return { + visiblePromotedActions, + hiddenPromotedActions, + }; +} + +export function instanceOfBulkActionListSectionArray( + actions: (BulkAction | BulkActionListSection)[], +): actions is BulkActionListSection[] { + const validList = actions.filter((action: any) => { + return action.items; + }); + + return actions.length === validList.length; +} + +export function instanceOfBulkActionArray( + actions: (BulkAction | BulkActionListSection)[], +): actions is BulkAction[] { + const validList = actions.filter((action: any) => { + return !action.items; + }); + + return actions.length === validList.length; +} + +export type BulkAction = DisableableAction & BadgeAction; + +export function instanceOfMenuGroupDescriptor( + action: MenuGroupDescriptor | BulkAction, +): action is MenuGroupDescriptor { + return 'title' in action && 'actions' in action; +} + +export function instanceOfBulkActionListSection( + action: BulkAction | BulkActionListSection, +): action is BulkActionListSection { + return 'items' in action; +} + +export function getActionSections( + actions: BulkActionsProps['actions'], +): BulkActionListSection[] | undefined { + if (!actions || actions.length === 0) { + return; + } + + if (instanceOfBulkActionListSectionArray(actions)) { + return actions; + } + + if (instanceOfBulkActionArray(actions)) { + return [ + { + items: actions, + }, + ]; + } +} + +export function isNewBadgeInBadgeActions( + actionSections?: BulkActionListSection[], +) { + if (!actionSections) return false; + + for (const action of actionSections) { + for (const item of action.items) { + if (item.badge?.tone === 'new') return true; + } + } + + return false; +} diff --git a/polaris-react/src/components/IndexTable/IndexTable.module.scss b/polaris-react/src/components/IndexTable/IndexTable.module.scss index 580af2cb450..06bb6cf19dd 100644 --- a/polaris-react/src/components/IndexTable/IndexTable.module.scss +++ b/polaris-react/src/components/IndexTable/IndexTable.module.scss @@ -804,7 +804,7 @@ $loading-panel-height: 53px; .TableHeading { // stylelint-disable -- Polaris component custom properties --pc-index-table-heading-padding-x: var(--p-space-150); - --pc-index-table-heading-padding-y: var(--p-space-150); + --pc-index-table-heading-padding-y: var(--p-space-200); --pc-index-table-checkbox-offset-left: var(--p-space-300); --pc-index-table-checkbox-offset-right: var(--p-space-200); background: var(--p-color-bg-surface-secondary); @@ -1396,7 +1396,7 @@ $loading-panel-height: 53px; position: relative; // stylelint-disable-next-line -- generated by polaris-migrator DO NOT COPY z-index: var(--pc-index-table-bulk-actions); - padding: var(--p-space-100) var(--p-space-200) var(--p-space-100) + padding: var(--p-space-150) var(--p-space-200) var(--p-space-150) var(--p-space-300); line-height: var(--p-font-line-height-500); background-color: var(--p-color-bg-surface); @@ -1420,11 +1420,6 @@ $loading-panel-height: 53px; } } -.PaginationWrapperWithSelectAllActions { - position: sticky; - bottom: 0; -} - .PaginationWrapperScrolledPastTop { @media (--p-breakpoints-md-up) { position: absolute; @@ -1433,14 +1428,6 @@ $loading-panel-height: 53px; top: var(--pc-index-table-pagination-top-offset); width: 100%; } - - &.PaginationWrapperWithSelectAllActions { - position: absolute; - bottom: auto; - /* stylelint-disable-next-line -- set from the use-is-select-all-actions-sticky hook */ - top: var(--pc-index-table-pagination-top-offset); - width: 100%; - } } $scroll-bar-size: var(--p-space-200); diff --git a/polaris-react/src/components/IndexTable/IndexTable.stories.tsx b/polaris-react/src/components/IndexTable/IndexTable.stories.tsx index 616da97dd0a..4ab6e4e48de 100644 --- a/polaris-react/src/components/IndexTable/IndexTable.stories.tsx +++ b/polaris-react/src/components/IndexTable/IndexTable.stories.tsx @@ -26,6 +26,12 @@ import { Thumbnail, Badge, } from '@shopify/polaris'; +import { + AffiliateIcon, + EditIcon, + ExportIcon, + ProductIcon, +} from '@shopify/polaris-icons'; import {IndexTable} from './IndexTable'; @@ -1271,27 +1277,55 @@ export function WithBulkActionsAndSelectionAcrossPages() { const promotedBulkActions = [ { - content: 'Edit customers', - onAction: () => console.log('Todo: implement bulk edit'), + content: 'Rename customers', + onAction: () => console.log('Todo: implement bulk rename'), }, { - content: 'Delete customers', - onAction: () => console.log('Todo: implement bulk delete'), + title: 'Edit customers', + actions: [ + { + content: 'Add customers', + onAction: () => console.log('Todo: implement adding customers'), + }, + { + content: 'Delete customers', + onAction: () => console.log('Todo: implement deleting customers'), + }, + ], }, { - content: 'Rename customers', - onAction: () => console.log('Todo: implement bulk rename'), + title: 'Export', + actions: [ + { + content: 'Export as PDF', + onAction: () => console.log('Todo: implement PDF exporting'), + }, + { + content: 'Export as CSV', + onAction: () => console.log('Todo: implement CSV exporting'), + }, + ], }, ]; const bulkActions = [ - { - content: 'Add tags', - onAction: () => console.log('Todo: implement bulk add tags'), - }, { content: 'Remove tags', onAction: () => console.log('Todo: implement bulk remove tags'), }, + { + title: 'Bulk action section', + items: [ + { + content: 'Delete data', + }, + { + content: 'Edit data', + }, + { + content: 'Manage data', + }, + ], + }, { content: 'Delete customers', onAction: () => console.log('Todo: implement bulk delete'), diff --git a/polaris-react/src/components/IndexTable/IndexTable.tsx b/polaris-react/src/components/IndexTable/IndexTable.tsx index 4b36c9e17fc..b3b5664c45b 100644 --- a/polaris-react/src/components/IndexTable/IndexTable.tsx +++ b/polaris-react/src/components/IndexTable/IndexTable.tsx @@ -13,10 +13,6 @@ import {Checkbox as PolarisCheckbox} from '../Checkbox'; import {EmptySearchResult} from '../EmptySearchResult'; // eslint-disable-next-line import/no-deprecated import {EventListener} from '../EventListener'; -import { - SelectAllActions, - useIsSelectAllActionsSticky, -} from '../SelectAllActions'; // eslint-disable-next-line import/no-deprecated import {LegacyStack} from '../LegacyStack'; import {Pagination} from '../Pagination'; @@ -218,50 +214,6 @@ function IndexTableBase({ hasSelected.current = true; } - const { - selectAllActionsIntersectionRef, - tableMeasurerRef, - isSelectAllActionsSticky, - selectAllActionsAbsoluteOffset, - selectAllActionsMaxWidth, - selectAllActionsOffsetLeft, - selectAllActionsOffsetBottom, - computeTableDimensions, - isScrolledPastTop, - selectAllActionsPastTopOffset, - scrollbarPastTopOffset, - } = useIsSelectAllActionsSticky({ - selectMode, - hasPagination: Boolean(pagination), - tableType: 'index-table', - }); - - useEffect(() => { - computeTableDimensions(); - }, [computeTableDimensions, itemCount]); - - useEffect(() => { - const callback = (mutationList: MutationRecord[]) => { - const hasChildList = mutationList.some( - (mutation) => mutation.type === 'childList', - ); - if (hasChildList) { - computeTableDimensions(); - } - }; - const mutationObserver = new MutationObserver(callback); - - if (tableBodyElement.current) { - mutationObserver.observe(tableBodyElement.current, { - childList: true, - }); - - return () => { - mutationObserver.disconnect(); - }; - } - }, [computeTableDimensions]); - const tableBodyRef = useCallback( (node: Element | null) => { if (node !== null && !tableInitialized) { @@ -518,10 +470,6 @@ function IndexTableBase({ .map(renderHeading) .reduce((acc, heading) => acc.concat(heading), []); - const bulkActionsSelectable = Boolean( - promotedBulkActions.length > 0 || bulkActions.length > 0, - ); - const stickyColumnHeaderStyle = tableHeadingRects.current && tableHeadingRects.current.length > 0 ? { @@ -635,52 +583,10 @@ function IndexTableBase({ condensed && styles['StickyTable-condensed'], ); - const shouldShowBulkActions = bulkActionsSelectable; - - const selectAllActionsClassNames = classNames( - styles.SelectAllActionsWrapper, - isSelectAllActionsSticky && styles.SelectAllActionsWrapperSticky, - !isSelectAllActionsSticky && - !pagination && - styles.SelectAllActionsWrapperAtEnd, - selectMode && - !isSelectAllActionsSticky && - !pagination && - styles.SelectAllActionsWrapperAtEndAppear, - ); - const shouldShowActions = !condensed || selectedItemsCount; const promotedActions = shouldShowActions ? promotedBulkActions : []; const actions = shouldShowActions ? bulkActions : []; - const selectAllActionsMarkup = - shouldShowActions && !condensed ? ( -
- -
- ) : null; - const stickyHeaderMarkup = (
@@ -698,7 +604,7 @@ function IndexTableBase({ ); const bulkActionsMarkup = - shouldShowBulkActions && !condensed ? ( + shouldShowActions && !condensed ? (
) : null; @@ -752,22 +660,14 @@ function IndexTableBase({ ); }}
- {selectAllActionsMarkup}
); const scrollBarWrapperClassNames = classNames( styles.ScrollBarContainer, pagination && styles.ScrollBarContainerWithPagination, - shouldShowBulkActions && styles.ScrollBarContainerWithSelectAllActions, - selectMode && - isSelectAllActionsSticky && - styles.ScrollBarContainerSelectAllActionsSticky, condensed && styles.scrollBarContainerCondensed, hideScrollContainer && styles.scrollBarContainerHidden, - isScrolledPastTop && - (pagination || shouldShowBulkActions) && - styles.ScrollBarContainerScrolledPastTop, ); const scrollBarClassNames = classNames( @@ -780,11 +680,6 @@ function IndexTableBase({
{emptyStateMarkup}
); - const tableWrapperClassNames = classNames( - styles.IndexTableWrapper, - Boolean(selectAllActionsMarkup) && - selectMode && - !pagination && - styles.IndexTableWrapperWithSelectAllActions, - ); - - const paginationWrapperClassNames = classNames( - styles.PaginationWrapper, - shouldShowBulkActions && styles.PaginationWrapperWithSelectAllActions, - isScrolledPastTop && styles.PaginationWrapperScrolledPastTop, - ); - const paginationMarkup = pagination ? ( -
+
) : null; @@ -902,13 +776,12 @@ function IndexTableBase({ return ( <>
-
- {!shouldShowBulkActions && !condensed && loadingMarkup} +
+ {!condensed && loadingMarkup} {tableContentMarkup} {scrollBarMarkup} {paginationMarkup}
-
); diff --git a/polaris-react/src/components/IndexTable/tests/IndexTable.test.tsx b/polaris-react/src/components/IndexTable/tests/IndexTable.test.tsx index 429bbaa54b6..8d523175e9b 100644 --- a/polaris-react/src/components/IndexTable/tests/IndexTable.test.tsx +++ b/polaris-react/src/components/IndexTable/tests/IndexTable.test.tsx @@ -12,8 +12,6 @@ import {Checkbox} from '../../Checkbox'; import {Badge} from '../../Badge'; import {Text} from '../../Text'; import {BulkActions} from '../../BulkActions'; -import type {useIsSelectAllActionsSticky} from '../../SelectAllActions'; -import {SelectAllActions} from '../../SelectAllActions'; import {IndexTable} from '../IndexTable'; import type {IndexTableProps, IndexTableSortDirection} from '../IndexTable'; import {ScrollContainer} from '../components'; @@ -36,21 +34,6 @@ jest.mock('../../../utilities/debounce', () => ({ }, })); -jest.mock('../../SelectAllActions', () => ({ - ...jest.requireActual('../../SelectAllActions'), - useIsSelectAllActionsSticky: jest.fn(), -})); - -function mockUseIsSelectAllActionsSticky( - args: ReturnType, -) { - const useIsSelectAllActionsSticky: jest.Mock = jest.requireMock( - '../../SelectAllActions', - ).useIsSelectAllActionsSticky; - - useIsSelectAllActionsSticky.mockReturnValue(args); -} - const mockTableItems = [ { id: 'item-1', @@ -102,19 +85,6 @@ describe('', () => { beforeEach(() => { jest.resetAllMocks(); (getTableHeadingsBySelector as jest.Mock).mockReturnValue([]); - mockUseIsSelectAllActionsSticky({ - selectAllActionsIntersectionRef: {current: null}, - tableMeasurerRef: {current: null}, - isSelectAllActionsSticky: false, - selectAllActionsAbsoluteOffset: 0, - selectAllActionsMaxWidth: 0, - selectAllActionsOffsetLeft: 0, - selectAllActionsOffsetBottom: 0, - computeTableDimensions: jest.fn(), - isScrolledPastTop: false, - scrollbarPastTopOffset: 0, - selectAllActionsPastTopOffset: 0, - }); }); it('renders an if no items are passed', () => { @@ -401,7 +371,7 @@ describe('', () => { }); }); - describe('SelectAllActions', () => { + describe('BulkActions', () => { const originalInnerWidth = window.innerWidth; afterEach(() => { @@ -427,7 +397,7 @@ describe('', () => { ); index - .find(SelectAllActions)! + .find(BulkActions)! .triggerKeypath('paginatedSelectAllAction.onAction'); expect(onSelectionChangeSpy).toHaveBeenCalledWith( @@ -453,7 +423,7 @@ describe('', () => { {mockTableItems.map(mockRenderRow)} , ); - expect(index.find(SelectAllActions)).toContainReactText(customString); + expect(index.find(BulkActions)).toContainReactText(customString); }); it('toggles all page resources when onToggleAll is triggered', () => { @@ -507,14 +477,14 @@ describe('', () => { expect(index).not.toContainReactComponent(BulkActions); }); - it('does not render SelectAllActions', () => { + it('does not render BulkActions', () => { const index = mountWithApp( {mockTableItems.map(mockRenderCondensedRow)} , ); - expect(index).not.toContainReactComponent(SelectAllActions); + expect(index).not.toContainReactComponent(BulkActions); }); it('does not render bulk actions with onSelectModeToggle when condensed is false', () => { @@ -781,78 +751,6 @@ describe('', () => { }); }); - describe('computeTableDimensions', () => { - it('invokes the computeTableDimensions callback when the number of items changes', () => { - const computeTableDimensions = jest.fn(); - mockUseIsSelectAllActionsSticky({ - selectAllActionsIntersectionRef: {current: null}, - tableMeasurerRef: {current: null}, - isSelectAllActionsSticky: false, - selectAllActionsAbsoluteOffset: 0, - selectAllActionsMaxWidth: 0, - selectAllActionsOffsetLeft: 0, - selectAllActionsOffsetBottom: 0, - computeTableDimensions, - isScrolledPastTop: false, - scrollbarPastTopOffset: 0, - selectAllActionsPastTopOffset: 0, - }); - const index = mountWithApp( - - {mockTableItems.map(mockRenderRow)} - , - ); - expect(computeTableDimensions).toHaveBeenCalledTimes(1); - - index.setProps({itemCount: 60}); - - expect(computeTableDimensions).toHaveBeenCalledTimes(2); - }); - }); - - describe('mutation observer', () => { - let mutationObserverObserveSpy: jest.SpyInstance; - let mutationObserverDisconnectSpy: jest.SpyInstance; - - beforeEach(() => { - mutationObserverObserveSpy = jest.spyOn( - MutationObserver.prototype, - 'observe', - ); - mutationObserverDisconnectSpy = jest.spyOn( - MutationObserver.prototype, - 'disconnect', - ); - }); - - afterEach(() => { - mutationObserverObserveSpy.mockRestore(); - mutationObserverDisconnectSpy.mockRestore(); - }); - - it('observes the activator', () => { - mountWithApp( - - {mockTableItems.map(mockRenderRow)} - , - ); - - expect(mutationObserverObserveSpy).toHaveBeenCalledTimes(1); - }); - - it('disconnects the observer when componentWillUnMount', () => { - const overlay = mountWithApp( - - {mockTableItems.map(mockRenderRow)} - , - ); - - overlay.unmount(); - - expect(mutationObserverDisconnectSpy).toHaveBeenCalled(); - }); - }); - describe('pagination', () => { it('does not render Pagination when pagination props are not provided', () => { const index = mountWithApp( diff --git a/polaris-react/src/components/ResourceList/ResourceList.module.scss b/polaris-react/src/components/ResourceList/ResourceList.module.scss index 6489156689f..970276b0f58 100644 --- a/polaris-react/src/components/ResourceList/ResourceList.module.scss +++ b/polaris-react/src/components/ResourceList/ResourceList.module.scss @@ -200,16 +200,6 @@ $item-wrapper-loading-height: 64px; z-index: resource-list(bulk-actions-wrapper-stacking-order); width: 100%; - /* - We do not want to worry about setting the height and width explicitly. - We want to inherits the height and width of its child (in this case the SelectAllActions component). - - We do this using flexbox because "the cross size of each flex container is the minimum size necessary to contain the flex items" as per the spec. - https://www.w3.org/TR/css-flexbox-1/#flex-lines - */ - display: flex; - align-self: auto; - visibility: hidden; opacity: 0; transition: opacity var(--p-motion-duration-100) var(--p-motion-ease), @@ -221,13 +211,6 @@ $item-wrapper-loading-height: 64px; } } -.ResourceListWrapperWithBulkActions { - // stylelint-disable-next-line -- Polaris component custom properties - --pc-resource-list-bulk-actions-offset: 41px; - // stylelint-disable-next-line -- Polaris component custom properties - padding-bottom: var(--pc-resource-list-bulk-actions-offset); -} - .PaginationWrapper { /* stylelint-disable-next-line -- custom z index to work with the z-indexes in the IndexTable */ --pc-pagination-index: 30; @@ -239,29 +222,6 @@ $item-wrapper-loading-height: 64px; } } -.PaginationWrapperWithSelectAllActions { - position: sticky; - bottom: 0; -} - -.PaginationWrapperScrolledPastTop { - @media (--p-breakpoints-md-up) { - position: absolute; - bottom: auto; - /* stylelint-disable-next-line -- set from the use-is-select-all-actions-sticky hook */ - top: var(--pc-index-table-pagination-top-offset); - width: 100%; - } - - &.PaginationWrapperWithSelectAllActions { - position: absolute; - bottom: auto; - /* stylelint-disable-next-line -- set from the use-is-select-all-actions-sticky hook */ - top: var(--pc-index-table-pagination-top-offset); - width: 100%; - } -} - .CheckableButtonWrapper { display: none; diff --git a/polaris-react/src/components/ResourceList/ResourceList.tsx b/polaris-react/src/components/ResourceList/ResourceList.tsx index c9019a97f3b..ea5271ce7d6 100644 --- a/polaris-react/src/components/ResourceList/ResourceList.tsx +++ b/polaris-react/src/components/ResourceList/ResourceList.tsx @@ -5,6 +5,7 @@ import React, { useRef, useState, Children, + useMemo, } from 'react'; import {CheckboxIcon} from '@shopify/polaris-icons'; import {themeDefault, toPx} from '@shopify/polaris-tokens'; @@ -29,10 +30,6 @@ import {useLazyRef} from '../../utilities/use-lazy-ref'; import {useEventListener} from '../../utilities/use-event-listener'; import {BulkActions} from '../BulkActions'; import type {BulkActionsProps} from '../BulkActions'; -import { - SelectAllActions, - useIsSelectAllActionsSticky, -} from '../SelectAllActions'; import {CheckableButton} from '../CheckableButton'; import {Pagination} from '../Pagination'; import type {PaginationProps} from '../Pagination'; @@ -172,27 +169,6 @@ export function ResourceList({ )[1]; const checkableButtonRef = useRef(null); - const { - selectAllActionsIntersectionRef, - tableMeasurerRef, - isSelectAllActionsSticky, - selectAllActionsAbsoluteOffset, - selectAllActionsMaxWidth, - selectAllActionsOffsetLeft, - selectAllActionsOffsetBottom, - computeTableDimensions, - isScrolledPastTop, - selectAllActionsPastTopOffset, - } = useIsSelectAllActionsSticky({ - selectMode, - hasPagination: Boolean(pagination), - tableType: 'resource-list', - }); - - useEffect(() => { - computeTableDimensions(); - }, [computeTableDimensions, items.length]); - const defaultResourceName = useLazyRef(() => ({ singular: i18n.translate('Polaris.ResourceList.defaultItemSingular'), plural: i18n.translate('Polaris.ResourceList.defaultItemPlural'), @@ -235,7 +211,7 @@ export function ResourceList({ selectable, ) && !smallScreen; - const selectAllSelectState = (): boolean | 'indeterminate' => { + const selectAllSelectState = useMemo((): boolean | 'indeterminate' => { let selectState: boolean | 'indeterminate' = 'indeterminate'; if ( !selectedItems || @@ -249,7 +225,7 @@ export function ResourceList({ selectState = true; } return selectState; - }; + }, [items.length, selectedItems]); const resourceName = resourceNameProp ? resourceNameProp @@ -304,7 +280,7 @@ export function ResourceList({ }, ); - const bulkActionsAccessibilityLabel = () => { + const bulkActionsAccessibilityLabel = useMemo(() => { const selectedItemsCount = selectedItems.length; const totalItemsCount = items.length; const allSelected = selectedItemsCount === totalItemsCount; @@ -340,9 +316,15 @@ export function ResourceList({ }, ); } - }; - - const paginatedSelectAllText = () => { + }, [ + i18n, + items.length, + resourceName.singular, + resourceName.plural, + selectedItems.length, + ]); + + const paginatedSelectAllText = useMemo(() => { if (!isSelectable || !hasMoreItems) { return; } @@ -358,9 +340,28 @@ export function ResourceList({ }, ); } - }; + }, [ + hasMoreItems, + i18n, + isFiltered, + isSelectable, + items, + resourceName.plural, + selectedItems, + ]); + + const handleSelectAllItemsInStore = useCallback(() => { + const newlySelectedItems = + selectedItems === SELECT_ALL_ITEMS + ? getAllItemsOnPage(items, idForItem) + : SELECT_ALL_ITEMS; - const paginatedSelectAllAction = () => { + if (onSelectionChange) { + onSelectionChange(newlySelectedItems); + } + }, [idForItem, items, onSelectionChange, selectedItems]); + + const paginatedSelectAllAction = useMemo(() => { if (!isSelectable || !hasMoreItems) { return; } @@ -382,7 +383,16 @@ export function ResourceList({ content: actionText, onAction: handleSelectAllItemsInStore, }; - }; + }, [ + handleSelectAllItemsInStore, + hasMoreItems, + i18n, + isFiltered, + isSelectable, + items.length, + resourceName.plural, + selectedItems, + ]); const emptySearchResultText = { title: i18n.translate('Polaris.ResourceList.emptySearchResultTitle', { @@ -393,17 +403,6 @@ export function ResourceList({ ), }; - const handleSelectAllItemsInStore = () => { - const newlySelectedItems = - selectedItems === SELECT_ALL_ITEMS - ? getAllItemsOnPage(items, idForItem) - : SELECT_ALL_ITEMS; - - if (onSelectionChange) { - onSelectionChange(newlySelectedItems); - } - }; - const setLoadingPosition = useCallback(() => { if (listRef.current != null) { if (typeof window === 'undefined') { @@ -563,67 +562,31 @@ export function ResourceList({ }, 0); }; - const selectAllActionsClassNames = classNames( - styles.SelectAllActionsWrapper, - isSelectAllActionsSticky && styles.SelectAllActionsWrapperSticky, - !isSelectAllActionsSticky && - !pagination && - styles.SelectAllActionsWrapperAtEnd, - selectMode && - !isSelectAllActionsSticky && - !pagination && - styles.SelectAllActionsWrapperAtEndAppear, + const bulkActionClassNames = classNames( + styles.BulkActionsWrapper, + selectMode && styles.BulkActionsWrapperVisible, ); - const selectAllActionsMarkup = isSelectable ? ( -
- +
) : null; - const bulkActionClassNames = classNames( - styles.BulkActionsWrapper, - selectMode && styles.BulkActionsWrapperVisible, - ); - - const bulkActionsMarkup = - isSelectable && (bulkActions || promotedBulkActions) ? ( -
- -
- ) : null; - const filterControlMarkup = filterControl ? (
{filterControl} @@ -669,12 +632,12 @@ export function ResourceList({ const checkableButtonMarkup = isSelectable ? (
) : null; @@ -729,7 +692,6 @@ export function ResourceList({ ); }} - {selectAllActionsMarkup}
); @@ -787,23 +749,8 @@ export function ResourceList({ ) : null; - const paginationWrapperClassNames = classNames( - styles.PaginationWrapper, - selectedItems && - selectedItems.length && - styles.PaginationWrapperWithSelectAllActions, - isScrolledPastTop && styles.PaginationWrapperScrolledPastTop, - ); - const paginationMarkup = pagination ? ( -
+
) : null; @@ -820,19 +767,10 @@ export function ResourceList({ onSelectionChange: handleSelectionChange, }; - const resourceListWrapperClasses = classNames( - styles.ResourceListWrapper, - Boolean(selectAllActionsMarkup) && - selectMode && - // bulkActions && - !pagination && - styles.ResourceListWrapperWithBulkActions, - ); - return ( {filterControlMarkup} -
+
{headerMarkup} {listMarkup} {emptySearchStateMarkup} @@ -840,7 +778,6 @@ export function ResourceList({ {loadingWithoutItemsMarkup} {paginationMarkup}
-
); } diff --git a/polaris-react/src/components/ResourceList/tests/ResourceList.test.tsx b/polaris-react/src/components/ResourceList/tests/ResourceList.test.tsx index 14ace66394e..7e4cd484eef 100644 --- a/polaris-react/src/components/ResourceList/tests/ResourceList.test.tsx +++ b/polaris-react/src/components/ResourceList/tests/ResourceList.test.tsx @@ -4,9 +4,6 @@ import type {Root, Node, Element} from '@shopify/react-testing'; import {matchMedia} from '@shopify/jest-dom-mocks'; import {BulkActions} from '../../BulkActions'; -import type {useIsSelectAllActionsSticky} from '../../SelectAllActions'; -import {SelectAllActions} from '../../SelectAllActions'; -import {Button} from '../../Button'; import {CheckableButton} from '../../CheckableButton'; import {EmptySearchResult} from '../../EmptySearchResult'; import {EmptyState} from '../../EmptyState'; @@ -16,23 +13,9 @@ import {ResourceItem} from '../../ResourceItem'; import {Pagination} from '../../Pagination'; import {SELECT_ALL_ITEMS} from '../../../utilities/resource-list'; import {ResourceList} from '../ResourceList'; +import {UnstyledButton} from '../../UnstyledButton'; import styles from '../ResourceList.module.scss'; -jest.mock('../../SelectAllActions', () => ({ - ...jest.requireActual('../../SelectAllActions'), - useIsSelectAllActionsSticky: jest.fn(), -})); - -function mockUseIsSelectAllActionsSticky( - args: ReturnType, -) { - const useIsSelectAllActionsSticky: jest.Mock = jest.requireMock( - '../../SelectAllActions', - ).useIsSelectAllActionsSticky; - - useIsSelectAllActionsSticky.mockReturnValue(args); -} - function getResourceItemCheckbox | Element>( wrapper: T, ) { @@ -72,20 +55,6 @@ const alternateTool =
Alternate Tool
; const defaultWindowWidth = window.innerWidth; describe('', () => { - mockUseIsSelectAllActionsSticky({ - selectAllActionsIntersectionRef: {current: null}, - tableMeasurerRef: {current: null}, - isSelectAllActionsSticky: false, - selectAllActionsAbsoluteOffset: 0, - selectAllActionsMaxWidth: 0, - selectAllActionsOffsetLeft: 0, - selectAllActionsOffsetBottom: 0, - computeTableDimensions: jest.fn(), - scrollbarPastTopOffset: 0, - selectAllActionsPastTopOffset: 0, - isScrolledPastTop: false, - }); - beforeEach(() => { matchMedia.mock(); }); @@ -134,14 +103,14 @@ describe('', () => { expect(resourceList).not.toContainReactComponent(CheckableButton); }); - it('does not render a `SelectAllActions` if the `selectable` prop is not provided', () => { + it('does not render a `BulkActions` if the `selectable` prop is not provided', () => { const resourceList = mountWithApp( , ); - expect(resourceList).not.toContainReactComponent(SelectAllActions); + expect(resourceList).not.toContainReactComponent(BulkActions); }); - it('does render SelectAllActions if the promotedBulkActions prop is provided', () => { + it('does render BulkActions if the promotedBulkActions prop is provided', () => { const resourceList = mountWithApp( ', () => { promotedBulkActions={promotedBulkActions} />, ); - expect(resourceList).toContainReactComponent(SelectAllActions); + expect(resourceList).toContainReactComponent(BulkActions); }); it('renders bulk actions if the bulkActions prop is provided', () => { @@ -160,7 +129,7 @@ describe('', () => { bulkActions={bulkActions} />, ); - expect(resourceList).toContainReactComponent(SelectAllActions); + expect(resourceList).toContainReactComponent(BulkActions); }); it('renders a `CheckableButton` if the `selectable` prop is true', () => { @@ -170,16 +139,16 @@ describe('', () => { expect(resourceList).toContainReactComponent(CheckableButton); }); - it('renders a `SelectAllActions` if the `selectable` prop is true', () => { + it('renders a `BulkActions` if the `selectable` prop is true', () => { const resourceList = mountWithApp( , ); - expect(resourceList).toContainReactComponent(SelectAllActions); + expect(resourceList).toContainReactComponent(BulkActions); }); }); describe('hasMoreItems', () => { - it('does not add a prop of paginatedSelectAllAction to SelectAllActions if omitted', () => { + it('does not add a prop of paginatedSelectAllAction to BulkActions if omitted', () => { const resourceList = mountWithApp( ', () => { bulkActions={bulkActions} />, ); - expect(resourceList).toContainReactComponent(SelectAllActions, { + expect(resourceList).toContainReactComponent(BulkActions, { paginatedSelectAllAction: undefined, }); }); - it('adds a prop of paginatedSelectAllAction to SelectAllActions if included', () => { + it('adds a prop of paginatedSelectAllAction to BulkActions if included', () => { const resourceList = mountWithApp( ', () => { />, ); expect( - resourceList.find(SelectAllActions)!.props.paginatedSelectAllAction, + resourceList.find(BulkActions)!.props.paginatedSelectAllAction, ).toBeDefined(); }); }); @@ -977,11 +946,11 @@ describe('', () => { />, ); - expect(resourceList).toContainReactComponent(SelectAllActions, { + expect(resourceList).toContainReactComponent(BulkActions, { selectMode: false, }); resourceList.setProps({selectedItems: ['1']}); - expect(resourceList).toContainReactComponent(SelectAllActions, { + expect(resourceList).toContainReactComponent(BulkActions, { selectMode: true, }); }); @@ -996,11 +965,11 @@ describe('', () => { />, ); - expect(resourceList).toContainReactComponent(SelectAllActions, { + expect(resourceList).toContainReactComponent(BulkActions, { selectMode: true, }); resourceList.setProps({selectedItems: []}); - expect(resourceList).toContainReactComponent(SelectAllActions, { + expect(resourceList).toContainReactComponent(BulkActions, { selectMode: false, }); }); @@ -1081,7 +1050,6 @@ describe('', () => { />, ); - expect(resourceList).not.toContainReactComponent(SelectAllActions); expect(resourceList).not.toContainReactComponent(BulkActions); }); }); @@ -1260,7 +1228,7 @@ describe('', () => { />, ); - expect(resourceList).toContainReactComponent(SelectAllActions, { + expect(resourceList).toContainReactComponent(BulkActions, { paginatedSelectAllAction: { content: 'Select all 2+ customers in this filter', onAction: expect.any(Function), @@ -1282,9 +1250,9 @@ describe('', () => { />, ); - resourceList.find(BulkActions)!.find(Button)!.trigger('onClick'); + resourceList.find(BulkActions)!.find(UnstyledButton)!.trigger('onClick'); - expect(resourceList).toContainReactComponent(SelectAllActions, { + expect(resourceList).toContainReactComponent(BulkActions, { paginatedSelectAllText: 'All 2+ customers in this filter are selected', }); }); @@ -1301,7 +1269,7 @@ describe('', () => { />, ); - expect(resourceList).toContainReactComponent(SelectAllActions, { + expect(resourceList).toContainReactComponent(BulkActions, { paginatedSelectAllAction: { content: 'Select all 2+ customers in your store', onAction: expect.any(Function), @@ -1322,43 +1290,14 @@ describe('', () => { />, ); - resourceList.find(BulkActions)!.find(Button)!.trigger('onClick'); + resourceList.find(BulkActions)!.find(UnstyledButton)!.trigger('onClick'); - expect(resourceList).toContainReactComponent(SelectAllActions, { + expect(resourceList).toContainReactComponent(BulkActions, { paginatedSelectAllText: 'All 2+ customers in your store are selected', }); }); }); - describe('computeTableDimensions', () => { - it('invokes the computeTableDimensions callback when the number of items changes', () => { - const computeTableDimensions = jest.fn(); - mockUseIsSelectAllActionsSticky({ - selectAllActionsIntersectionRef: {current: null}, - tableMeasurerRef: {current: null}, - isSelectAllActionsSticky: false, - selectAllActionsAbsoluteOffset: 0, - selectAllActionsMaxWidth: 0, - selectAllActionsOffsetLeft: 0, - selectAllActionsOffsetBottom: 0, - computeTableDimensions, - scrollbarPastTopOffset: 0, - selectAllActionsPastTopOffset: 0, - isScrolledPastTop: false, - }); - const newItems = [...itemsWithID, {id: 12, url: '//shopify.com'}]; - const resourceList = mountWithApp( - , - ); - - expect(computeTableDimensions).toHaveBeenCalledTimes(1); - - resourceList.setProps({items: newItems}); - - expect(computeTableDimensions).toHaveBeenCalledTimes(2); - }); - }); - describe('pagination', () => { it('does not render Pagination when pagination props are not provided', () => { const resourceList = mountWithApp( diff --git a/polaris-react/src/components/SelectAllActions/SelectAllActions.stories.tsx b/polaris-react/src/components/SelectAllActions/SelectAllActions.stories.tsx index ce6459c4c5f..2191ab9041b 100644 --- a/polaris-react/src/components/SelectAllActions/SelectAllActions.stories.tsx +++ b/polaris-react/src/components/SelectAllActions/SelectAllActions.stories.tsx @@ -1,10 +1,13 @@ import React from 'react'; import type {ComponentMeta} from '@storybook/react'; +// eslint-disable-next-line import/no-deprecated import {SelectAllActions} from './SelectAllActions'; export default { + // eslint-disable-next-line import/no-deprecated component: SelectAllActions, + // eslint-disable-next-line import/no-deprecated } as ComponentMeta; export function Default() { diff --git a/polaris-react/src/components/SelectAllActions/SelectAllActions.tsx b/polaris-react/src/components/SelectAllActions/SelectAllActions.tsx index cc17d9da64e..cb8dda7710e 100644 --- a/polaris-react/src/components/SelectAllActions/SelectAllActions.tsx +++ b/polaris-react/src/components/SelectAllActions/SelectAllActions.tsx @@ -37,6 +37,9 @@ export interface SelectAllActionsProps { onToggleAll?(): void; } +/** + * @deprecated Use `BulkActions` instead. + */ export const SelectAllActions = forwardRef(function SelectAllActions( { label, diff --git a/polaris-react/src/components/SelectAllActions/hooks/index.ts b/polaris-react/src/components/SelectAllActions/hooks/index.ts deleted file mode 100644 index 5ef64f0b03f..00000000000 --- a/polaris-react/src/components/SelectAllActions/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {useIsSelectAllActionsSticky} from './use-is-select-all-actions-sticky'; diff --git a/polaris-react/src/components/SelectAllActions/hooks/tests/use-is-select-all-actions-sticky.test.tsx b/polaris-react/src/components/SelectAllActions/hooks/tests/use-is-select-all-actions-sticky.test.tsx deleted file mode 100644 index 8548a8a2fa9..00000000000 --- a/polaris-react/src/components/SelectAllActions/hooks/tests/use-is-select-all-actions-sticky.test.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import React from 'react'; -import {intersectionObserver} from '@shopify/jest-dom-mocks'; -import {mountWithApp} from 'tests/utilities'; - -import {Scrollable} from '../../../Scrollable'; -import {useIsSelectAllActionsSticky} from '../use-is-select-all-actions-sticky'; -import type {UseIsSelectAllActionsStickyProps} from '../use-is-select-all-actions-sticky'; - -function Component({ - selectMode = false, - hasPagination, - tableType = 'index-table', -}: Partial) { - const { - selectAllActionsIntersectionRef, - tableMeasurerRef, - isSelectAllActionsSticky, - selectAllActionsAbsoluteOffset, - selectAllActionsMaxWidth, - selectAllActionsOffsetLeft, - selectAllActionsOffsetBottom, - } = useIsSelectAllActionsSticky({selectMode, hasPagination, tableType}); - - return ( -
-

{isSelectAllActionsSticky ? 'true' : 'false'}

- {selectAllActionsAbsoluteOffset} - {selectAllActionsMaxWidth} - {selectAllActionsOffsetLeft} - {selectAllActionsOffsetBottom} - - -
- ); -} - -describe('useIsSelectAllActionsSticky', () => { - let getBoundingClientRectSpy: jest.SpyInstance; - let getComputedStyleSpy: jest.SpyInstance; - - beforeEach(() => { - getComputedStyleSpy = jest.spyOn(window, 'getComputedStyle'); - - getBoundingClientRectSpy = jest.spyOn( - Element.prototype, - 'getBoundingClientRect', - ); - - setGetBoundingClientRect({ - width: 600, - height: 400, - left: 20, - y: 18, - }); - - intersectionObserver.mock(); - }); - - afterEach(() => { - getComputedStyleSpy.mockRestore(); - getBoundingClientRectSpy.mockRestore(); - intersectionObserver.restore(); - }); - - describe('when measuring', () => { - it('returns the offset correctly when select mode is false', () => { - const component = mountWithApp(); - const result = component.findAll('span')[0]?.text(); - expect(result).toBe('400'); - }); - - it('returns the offset correctly when select mode is true', () => { - const component = mountWithApp(); - const result = component.findAll('span')[0]?.text(); - expect(result).toBe('359'); - }); - - it('returns the width value correctly', () => { - const component = mountWithApp(); - const result = component.findAll('span')[1]?.text(); - expect(result).toBe('600'); - }); - - it('returns the width value correctly when hasPagination is true', () => { - const component = mountWithApp(); - const result = component.findAll('span')[1]?.text(); - expect(result).toBe('536'); - }); - - it('returns the left value correctly', () => { - const component = mountWithApp(); - const result = component.findAll('span')[2]?.text(); - expect(result).toBe('20'); - }); - - it('returns the bottom value correctly when not in a scroll container', () => { - setGetComputedStyle({ - overflow: 'visible', - overflowX: 'visible', - overflowY: 'visible', - }); - - const component = mountWithApp(); - const result = component.findAll('span')[3]?.text(); - expect(result).toBe('0'); - }); - - it('returns the bottom value correctly when in a scroll container', () => { - setGetComputedStyle({ - overflow: 'auto', - overflowX: 'auto', - overflowY: 'auto', - }); - - const component = mountWithApp( - - - , - ); - const result = component.findAll('span')[3]?.text(); - expect(result).toBe('26'); - }); - }); - - describe('when isIntersecting', () => { - it('sets the isSelectAllActionsSticky value to false', () => { - const component = mountWithApp(); - - const intersector = component.find('i'); - - component.act(() => { - intersectionObserver.simulate({ - isIntersecting: true, - target: intersector!.domNode!, - }); - }); - - const result = component.find('p')?.text(); - expect(result).toBe('false'); - }); - }); - - describe('when not isIntersecting', () => { - it('sets the isSelectAllActionsSticky value to true', () => { - const component = mountWithApp(); - - const intersector = component.find('i'); - - component.act(() => { - intersectionObserver.simulate({ - isIntersecting: false, - target: intersector!.domNode!, - }); - }); - - const result = component.find('p')?.text(); - expect(result).toBe('true'); - }); - }); - - describe('when the table is off screen', () => { - it('will set isSelectAllActionsSticky to true if we are intersecting', () => { - const component = mountWithApp(); - - const table = component.find('div'); - - component.act(() => { - intersectionObserver.simulate({ - isIntersecting: true, - target: table!.domNode!, - boundingClientRect: { - top: 100, - height: 100, - width: 0, - bottom: 0, - left: 0, - right: 0, - x: 0, - y: 0, - toJSON: jest.fn(), - }, - rootBounds: { - height: 150, - top: 0, - width: 0, - bottom: 0, - left: 0, - right: 0, - x: 0, - y: 0, - toJSON: jest.fn(), - }, - }); - }); - - const result = component.find('p')?.text(); - expect(result).toBe('true'); - }); - - it('will not set isSelectAllActionsSticky to true if we are intersecting but the rootBounds is large enough', () => { - const component = mountWithApp(); - - const table = component.find('div'); - - component.act(() => { - intersectionObserver.simulate({ - isIntersecting: true, - target: table!.domNode!, - boundingClientRect: { - top: 100, - height: 100, - width: 0, - bottom: 0, - left: 0, - right: 0, - x: 0, - y: 0, - toJSON: jest.fn(), - }, - rootBounds: { - height: 250, - top: 0, - width: 0, - bottom: 0, - left: 0, - right: 0, - x: 0, - y: 0, - toJSON: jest.fn(), - }, - }); - }); - - const result = component.find('p')?.text(); - expect(result).toBe('false'); - }); - }); - - function setGetComputedStyle({ - overflow, - overflowX, - overflowY, - }: { - overflow: string; - overflowX: string; - overflowY: string; - }) { - getComputedStyleSpy.mockImplementation(() => { - return { - overflow, - overflowX, - overflowY, - toJSON() {}, - }; - }); - } - - function setGetBoundingClientRect({ - width, - height, - left, - y, - }: { - width: number; - height: number; - left: number; - y: number; - }) { - getBoundingClientRectSpy.mockImplementation(() => { - return { - height, - width, - top: 0, - left, - bottom: 0, - right: 0, - x: 0, - y, - toJSON() {}, - }; - }); - } -}); diff --git a/polaris-react/src/components/SelectAllActions/hooks/use-is-select-all-actions-sticky.ts b/polaris-react/src/components/SelectAllActions/hooks/use-is-select-all-actions-sticky.ts deleted file mode 100644 index 72824030fae..00000000000 --- a/polaris-react/src/components/SelectAllActions/hooks/use-is-select-all-actions-sticky.ts +++ /dev/null @@ -1,224 +0,0 @@ -import {useEffect, useRef, useState, useCallback} from 'react'; - -import {debounce} from '../../../utilities/debounce'; - -const DEBOUNCE_PERIOD = 250; - -const SELECT_ALL_ACTIONS_HEIGHT = 41; -const PAGINATION_WIDTH_OFFSET = 64; -const SCROLL_BAR_CONTAINER_HEIGHT = 13; -const SCROLL_BAR_HEIGHT = 8; -const INDEX_TABLE_INITIAL_OFFSET = 32; -const RESOURCE_LIST_INITIAL_OFFSET = 48; - -type TableType = 'index-table' | 'resource-list'; - -export interface UseIsSelectAllActionsStickyProps { - selectMode: boolean; - hasPagination?: boolean; - tableType: TableType; -} - -export interface SelectAllActionsStickyCalculations { - selectAllActionsIntersectionRef: React.RefObject; - tableMeasurerRef: React.RefObject; - isSelectAllActionsSticky: boolean; - selectAllActionsAbsoluteOffset: number; - selectAllActionsMaxWidth: number; - selectAllActionsOffsetLeft: number; - selectAllActionsOffsetBottom: number; - computeTableDimensions(): - | { - maxWidth: number; - offsetHeight: number; - offsetLeft: number; - offsetBottom: number; - } - | undefined; - isScrolledPastTop: boolean; - selectAllActionsPastTopOffset: number; - scrollbarPastTopOffset: number; -} - -export function useIsSelectAllActionsSticky({ - selectMode, - hasPagination, - tableType, -}: UseIsSelectAllActionsStickyProps): SelectAllActionsStickyCalculations { - const hasIOSupport = - typeof window !== 'undefined' && Boolean(window.IntersectionObserver); - const [isSelectAllActionsSticky, setIsSticky] = useState(false); - const [isScrolledPastTop, setIsScrolledPastTop] = useState(false); - const [selectAllActionsAbsoluteOffset, setSelectAllActionsAbsoluteOffset] = - useState(0); - const [selectAllActionsMaxWidth, setSelectAllActionsMaxWidth] = useState(0); - const [selectAllActionsOffsetLeft, setSelectAllActionsOffsetLeft] = - useState(0); - const [selectAllActionsOffsetBottom, setSelectAllActionsOffsetBottom] = - useState(0); - - const selectAllActionsIntersectionRef = useRef(null); - const tableMeasurerRef = useRef(null); - - const widthOffset = hasPagination ? PAGINATION_WIDTH_OFFSET : 0; - - const initialPostOffset = - tableType === 'index-table' - ? INDEX_TABLE_INITIAL_OFFSET + SCROLL_BAR_CONTAINER_HEIGHT - : RESOURCE_LIST_INITIAL_OFFSET; - - const postScrollOffset = initialPostOffset + SELECT_ALL_ACTIONS_HEIGHT; - - const handleIntersect = (entries: IntersectionObserverEntry[]) => { - entries.forEach((entry: IntersectionObserverEntry) => { - setIsSticky(!entry.isIntersecting); - }); - }; - - const handleTableIntersect = (entries: IntersectionObserverEntry[]) => { - entries.forEach((entry: IntersectionObserverEntry) => { - const isScrolledPastTop = - entry.boundingClientRect.top > 0 && !entry.isIntersecting; - const rootBoundsHeight = entry.rootBounds?.height || 0; - - const hasTableOffscreen = - entry.boundingClientRect.top + entry.boundingClientRect.height > - rootBoundsHeight; - if (hasTableOffscreen && entry.rootBounds) { - setIsSticky(entry.isIntersecting); - } - setIsScrolledPastTop(isScrolledPastTop); - }); - }; - - const options = { - root: null, - rootMargin: '0px', - threshold: 0, - }; - const observerRef = useRef( - hasIOSupport ? new IntersectionObserver(handleIntersect, options) : null, - ); - - const tableOptions = { - root: null, - rootMargin: `0px 0px -${postScrollOffset}px 0px`, - threshold: 0, - }; - const tableObserverRef = useRef( - hasIOSupport - ? new IntersectionObserver(handleTableIntersect, tableOptions) - : null, - ); - - const getClosestScrollContainer = (node: HTMLElement) => { - let container: HTMLElement | null = node; - - while (container && container !== document.body) { - const style = window.getComputedStyle(container); - const isScrollContainer = - style.overflow === 'auto' || - style.overflowX === 'auto' || - style.overflowY === 'auto' || - style.overflow === 'scroll' || - style.overflowX === 'scroll' || - style.overflowY === 'scroll'; - - if (isScrollContainer) return container; - container = container.parentElement; - } - - return null; - }; - - const computeTableDimensions = useCallback(() => { - const node = tableMeasurerRef.current; - if (!node) { - return { - maxWidth: 0, - offsetHeight: 0, - offsetLeft: 0, - offsetBottom: 0, - }; - } - - const scrollContainer = - getClosestScrollContainer(node)?.getBoundingClientRect(); - const box = node.getBoundingClientRect(); - const paddingHeight = selectMode ? SELECT_ALL_ACTIONS_HEIGHT : 0; - const offsetHeight = box.height - paddingHeight; - const maxWidth = box.width - widthOffset; - const offsetLeft = box.left; - const offsetBottomScrollable = scrollContainer - ? Math.round(scrollContainer.y + SCROLL_BAR_HEIGHT) - : 0; - - setSelectAllActionsAbsoluteOffset(offsetHeight); - setSelectAllActionsMaxWidth(maxWidth); - setSelectAllActionsOffsetLeft(offsetLeft); - setSelectAllActionsOffsetBottom(offsetBottomScrollable); - }, [selectMode, widthOffset]); - - const computeDimensionsPastScroll = useCallback(() => { - setSelectAllActionsAbsoluteOffset(initialPostOffset); - }, [initialPostOffset]); - - useEffect(() => { - if (isScrolledPastTop) { - computeDimensionsPastScroll(); - } else { - computeTableDimensions(); - } - - const debouncedComputeTableHeight = debounce( - computeTableDimensions, - DEBOUNCE_PERIOD, - { - trailing: true, - }, - ); - - window.addEventListener('resize', debouncedComputeTableHeight); - - return () => - window.removeEventListener('resize', debouncedComputeTableHeight); - }, [isScrolledPastTop, computeDimensionsPastScroll, computeTableDimensions]); - - useEffect(() => { - const observer = observerRef.current; - const tableObserver = tableObserverRef.current; - if (!observer || !tableObserver) { - return; - } - - const node = selectAllActionsIntersectionRef.current; - const tableNode = tableMeasurerRef.current; - - if (node) { - observer.observe(node); - } - - if (tableNode) { - tableObserver.observe(tableNode); - } - - return () => { - observer?.disconnect(); - tableObserver?.disconnect(); - }; - }, [selectAllActionsIntersectionRef]); - - return { - selectAllActionsIntersectionRef, - tableMeasurerRef, - isSelectAllActionsSticky, - selectAllActionsAbsoluteOffset, - selectAllActionsMaxWidth, - selectAllActionsOffsetLeft, - selectAllActionsOffsetBottom, - computeTableDimensions, - isScrolledPastTop, - selectAllActionsPastTopOffset: initialPostOffset, - scrollbarPastTopOffset: initialPostOffset - SCROLL_BAR_CONTAINER_HEIGHT, - }; -} diff --git a/polaris-react/src/components/SelectAllActions/index.ts b/polaris-react/src/components/SelectAllActions/index.ts index c7950c10251..ab80555384b 100644 --- a/polaris-react/src/components/SelectAllActions/index.ts +++ b/polaris-react/src/components/SelectAllActions/index.ts @@ -1,2 +1 @@ export * from './SelectAllActions'; -export * from './hooks'; diff --git a/polaris-react/src/components/SelectAllActions/tests/SelectAllActions.test.tsx b/polaris-react/src/components/SelectAllActions/tests/SelectAllActions.test.tsx index f35c707e23a..b158d788173 100644 --- a/polaris-react/src/components/SelectAllActions/tests/SelectAllActions.test.tsx +++ b/polaris-react/src/components/SelectAllActions/tests/SelectAllActions.test.tsx @@ -4,6 +4,7 @@ import {mountWithApp} from 'tests/utilities'; import {CheckableButton} from '../../CheckableButton'; import {UnstyledButton} from '../../UnstyledButton'; +// eslint-disable-next-line import/no-deprecated import {SelectAllActions} from '../SelectAllActions'; import styles from '../SelectAllActions.module.scss';