From b1093bf0ba066a56c80667d299750cf49eb0641f Mon Sep 17 00:00:00 2001 From: kmcfaul <45077788+kmcfaul@users.noreply.github.com> Date: Fri, 1 Sep 2023 13:27:23 -0400 Subject: [PATCH] feat(Toolbar): allow multiple toggle groups (#9329) * feat(Toolbar): allow multiple toggle groups * remove dupe id * multiple groups second pass * toggle group with poppers * swap back to createPortal * update integration --- .../src/components/Toolbar/ToolbarContent.tsx | 18 +- .../Toolbar/ToolbarExpandableContent.tsx | 10 +- .../src/components/Toolbar/ToolbarFilter.tsx | 14 +- .../components/Toolbar/ToolbarToggleGroup.tsx | 139 ++++++---- .../src/components/Toolbar/ToolbarUtils.tsx | 7 +- .../ToolbarContent.test.tsx.snap | 8 - .../Toolbar/__tests__/Toolbar.test.tsx | 7 +- .../__tests__/ToolbarToggleGroup.test.tsx | 23 +- .../__snapshots__/Toolbar.test.tsx.snap | 59 ---- .../src/components/Toolbar/examples/Test.tsx | 258 ++++++++++++++++++ .../components/Toolbar/examples/Toolbar.md | 18 +- .../src/components/Toolbar/index.ts | 1 + .../cypress/integration/toolbar.spec.ts | 4 +- 13 files changed, 413 insertions(+), 153 deletions(-) create mode 100644 packages/react-core/src/components/Toolbar/examples/Test.tsx diff --git a/packages/react-core/src/components/Toolbar/ToolbarContent.tsx b/packages/react-core/src/components/Toolbar/ToolbarContent.tsx index 40c5d44d05d..51f19cc15a4 100644 --- a/packages/react-core/src/components/Toolbar/ToolbarContent.tsx +++ b/packages/react-core/src/components/Toolbar/ToolbarContent.tsx @@ -3,7 +3,6 @@ import styles from '@patternfly/react-styles/css/components/Toolbar/toolbar'; import { css } from '@patternfly/react-styles'; import { ToolbarContentContext, ToolbarContext } from './ToolbarUtils'; import { formatBreakpointMods } from '../../helpers/util'; -import { ToolbarExpandableContent } from './ToolbarExpandableContent'; import { PageContext } from '../Page/PageContext'; export interface ToolbarContentProps extends React.HTMLProps { @@ -70,6 +69,7 @@ class ToolbarContent extends React.Component { formatBreakpointMods(visibility, styles, '', getBreakpoint(width)), className )} + ref={this.expandableContentRef} {...props} > @@ -77,6 +77,7 @@ class ToolbarContent extends React.Component { clearAllFilters: clearAllFiltersContext, clearFiltersButtonText: clearFiltersButtonContext, showClearFiltersButton: showClearFiltersButtonContext, + isExpanded: isExpandedContext, toolbarId: toolbarIdContext }) => { const expandableContentId = `${ @@ -87,7 +88,11 @@ class ToolbarContent extends React.Component { value={{ expandableContentRef: this.expandableContentRef, expandableContentId, - chipContainerRef: this.chipContainerRef + chipContainerRef: this.chipContainerRef, + isExpanded: isExpanded || isExpandedContext, + clearAllFilters: clearAllFilters || clearAllFiltersContext, + clearFiltersButtonText: clearFiltersButtonText || clearFiltersButtonContext, + showClearFiltersButton: showClearFiltersButton || showClearFiltersButtonContext }} >
{ > {children}
- ); }} diff --git a/packages/react-core/src/components/Toolbar/ToolbarExpandableContent.tsx b/packages/react-core/src/components/Toolbar/ToolbarExpandableContent.tsx index 463db0c5b29..e8bf6907566 100644 --- a/packages/react-core/src/components/Toolbar/ToolbarExpandableContent.tsx +++ b/packages/react-core/src/components/Toolbar/ToolbarExpandableContent.tsx @@ -37,10 +37,10 @@ class ToolbarExpandableContent extends React.Component - +
+ {children} {numberOfFilters > 0 && ( diff --git a/packages/react-core/src/components/Toolbar/ToolbarFilter.tsx b/packages/react-core/src/components/Toolbar/ToolbarFilter.tsx index 4c71e7ce245..c3d3aacc14e 100644 --- a/packages/react-core/src/components/Toolbar/ToolbarFilter.tsx +++ b/packages/react-core/src/components/Toolbar/ToolbarFilter.tsx @@ -21,6 +21,8 @@ export interface ToolbarChip { } export interface ToolbarFilterProps extends ToolbarItemProps { + /** Flag indicating when toolbar toggle group is expanded for non-managed toolbar toggle groups. */ + isExpanded?: boolean; /** An array of strings to be displayed as chips in the expandable content */ chips?: (string | ToolbarChip)[]; /** Callback passed by consumer used to close the entire chip group */ @@ -37,6 +39,8 @@ export interface ToolbarFilterProps extends ToolbarItemProps { categoryName: string | ToolbarChipGroup; /** Flag to show the toolbar item */ showToolbarItem?: boolean; + /** Reference to a chip container created with a custom expandable content group, for non-managed multiple toolbar toggle groups. */ + expandableChipContainerRef?: React.RefObject; } interface ToolbarFilterState { @@ -90,9 +94,12 @@ class ToolbarFilter extends React.Component ) : null; - if (!isExpanded && this.state.isMounted) { + if (!_isExpanded && this.state.isMounted) { return ( {showToolbarItem && {children}} @@ -138,6 +145,9 @@ class ToolbarFilter extends React.Component {showToolbarItem && {children}} {chipContainerRef.current && ReactDOM.createPortal(chipGroup, chipContainerRef.current)} + {expandableChipContainerRef && + expandableChipContainerRef.current && + ReactDOM.createPortal(chipGroup, expandableChipContainerRef.current)} )} diff --git a/packages/react-core/src/components/Toolbar/ToolbarToggleGroup.tsx b/packages/react-core/src/components/Toolbar/ToolbarToggleGroup.tsx index 672419b75f5..3a69a25ad7b 100644 --- a/packages/react-core/src/components/Toolbar/ToolbarToggleGroup.tsx +++ b/packages/react-core/src/components/Toolbar/ToolbarToggleGroup.tsx @@ -8,8 +8,13 @@ import { Button } from '../Button'; import globalBreakpointLg from '@patternfly/react-tokens/dist/esm/global_breakpoint_lg'; import { formatBreakpointMods, toCamel, canUseDOM } from '../../helpers/util'; import { PageContext } from '../Page/PageContext'; +import { ToolbarExpandableContent } from './ToolbarExpandableContent'; export interface ToolbarToggleGroupProps extends ToolbarGroupProps { + /** Flag indicating when toggle group is expanded for non-managed toolbar toggle groups. */ + isExpanded?: boolean; + /** Callback for toggle group click event for non-managed toolbar toggle groups. */ + onToggle?: (event: React.MouseEvent) => void; /** An icon to be rendered when the toggle group has collapsed down */ toggleIcon: React.ReactNode; /** Controls when filters are shown and when the toggle button is hidden. */ @@ -46,10 +51,21 @@ export interface ToolbarToggleGroupProps extends ToolbarGroupProps { xl?: 'spaceItemsNone' | 'spaceItemsSm' | 'spaceItemsMd' | 'spaceItemsLg'; '2xl'?: 'spaceItemsNone' | 'spaceItemsSm' | 'spaceItemsMd' | 'spaceItemsLg'; }; + /** Reference to a chip container group for filters inside the toolbar toggle group */ + chipContainerRef?: React.RefObject; + /** Optional callback for clearing all filters in the toolbar toggle group */ + clearAllFilters?: () => void; + /** Flag indicating that the clear all filters button should be visible in the toolbar toggle group */ + showClearFiltersButton?: boolean; + /** Text to display in the clear all filters button of the toolbar toggle group */ + clearFiltersButtonText?: string; } class ToolbarToggleGroup extends React.Component { static displayName = 'ToolbarToggleGroup'; + toggleRef = React.createRef(); + expandableContentRef = React.createRef(); + isContentPopup = () => { const viewportSize = canUseDOM ? window.innerWidth : 1200; const lgBreakpointValue = parseInt(globalBreakpointLg.value); @@ -67,6 +83,12 @@ class ToolbarToggleGroup extends React.Component { spaceItems, className, children, + isExpanded, + onToggle, + chipContainerRef, + clearAllFilters, + showClearFiltersButton, + clearFiltersButtonText, ...props } = this.props; @@ -79,64 +101,87 @@ class ToolbarToggleGroup extends React.Component { {({ width, getBreakpoint }) => ( - {({ isExpanded, toggleIsExpanded }) => ( - - {({ expandableContentRef, expandableContentId }) => { - if (expandableContentRef.current && expandableContentRef.current.classList) { - if (isExpanded) { - expandableContentRef.current.classList.add(styles.modifiers.expanded); - } else { - expandableContentRef.current.classList.remove(styles.modifiers.expanded); - } - } + {({ toggleIsExpanded: managedOnToggle }) => { + const _onToggle = onToggle !== undefined ? onToggle : managedOnToggle; + + return ( + + {({ + expandableContentRef, + expandableContentId, + chipContainerRef: managedChipContainerRef, + isExpanded: managedIsExpanded, + clearAllFilters: clearAllFiltersContext, + clearFiltersButtonText: clearFiltersButtonContext, + showClearFiltersButton: showClearFiltersButtonContext + }) => { + const _isExpanded = isExpanded !== undefined ? isExpanded : managedIsExpanded; + const _chipContainerRef = + chipContainerRef !== undefined ? chipContainerRef : managedChipContainerRef; + + const breakpointMod: { + md?: 'show'; + lg?: 'show'; + xl?: 'show'; + '2xl'?: 'show'; + } = {}; + breakpointMod[breakpoint] = 'show'; - const breakpointMod: { - md?: 'show'; - lg?: 'show'; - xl?: 'show'; - '2xl'?: 'show'; - } = {}; - breakpointMod[breakpoint] = 'show'; + const expandableContent = ( + + {children} + + ); - return ( -
+ const toggleButton = (
- {isExpanded - ? (ReactDOM.createPortal( - children, - expandableContentRef.current.firstElementChild - ) as React.ReactElement) - : children} -
- ); - }} -
- )} + ); + + return ( +
+ {toggleButton} + {_isExpanded && ReactDOM.createPortal(expandableContent, expandableContentRef.current)} + {!_isExpanded && children} +
+ ); + }} +
+ ); + }}
)}
diff --git a/packages/react-core/src/components/Toolbar/ToolbarUtils.tsx b/packages/react-core/src/components/Toolbar/ToolbarUtils.tsx index 9ee37c99f85..6aec87bf74b 100644 --- a/packages/react-core/src/components/Toolbar/ToolbarUtils.tsx +++ b/packages/react-core/src/components/Toolbar/ToolbarUtils.tsx @@ -31,12 +31,17 @@ interface ToolbarContentContextProps { expandableContentRef: RefObject; expandableContentId: string; chipContainerRef: RefObject; + isExpanded?: boolean; + clearAllFilters?: () => void; + clearFiltersButtonText?: string; + showClearFiltersButton?: boolean; } export const ToolbarContentContext = React.createContext({ expandableContentRef: null, expandableContentId: '', - chipContainerRef: null + chipContainerRef: null, + clearAllFilters: () => {} }); export const globalBreakpoints = { diff --git a/packages/react-core/src/components/Toolbar/__tests__/Generated/__snapshots__/ToolbarContent.test.tsx.snap b/packages/react-core/src/components/Toolbar/__tests__/Generated/__snapshots__/ToolbarContent.test.tsx.snap index 044e3353e87..a6d292adf1f 100644 --- a/packages/react-core/src/components/Toolbar/__tests__/Generated/__snapshots__/ToolbarContent.test.tsx.snap +++ b/packages/react-core/src/components/Toolbar/__tests__/Generated/__snapshots__/ToolbarContent.test.tsx.snap @@ -12,14 +12,6 @@ exports[`ToolbarContent should match snapshot (auto-generated) 1`] = ` ReactNode
-
-
-
`; diff --git a/packages/react-core/src/components/Toolbar/__tests__/Toolbar.test.tsx b/packages/react-core/src/components/Toolbar/__tests__/Toolbar.test.tsx index 582485299aa..57c83cdce8a 100644 --- a/packages/react-core/src/components/Toolbar/__tests__/Toolbar.test.tsx +++ b/packages/react-core/src/components/Toolbar/__tests__/Toolbar.test.tsx @@ -65,7 +65,7 @@ describe('Toolbar', () => { {}} - deleteChipGroup={category => {}} + deleteChipGroup={(category) => {}} categoryName="Status" > test content @@ -102,8 +102,7 @@ describe('Toolbar', () => { ); expect(asFragment()).toMatchSnapshot(); - // Expecting 2 matches for text because the buttons also exist in hidden expandable content for mobile view - expect(screen.getAllByRole('button', { name: 'Save filters' }).length).toBe(2); - expect(screen.getAllByRole('button', { name: 'Clear all filters' }).length).toBe(2); + expect(screen.getAllByRole('button', { name: 'Save filters' }).length).toBe(1); + expect(screen.getAllByRole('button', { name: 'Clear all filters' }).length).toBe(1); }); }); diff --git a/packages/react-core/src/components/Toolbar/__tests__/ToolbarToggleGroup.test.tsx b/packages/react-core/src/components/Toolbar/__tests__/ToolbarToggleGroup.test.tsx index a86b1da1c86..91aa6e4b217 100644 --- a/packages/react-core/src/components/Toolbar/__tests__/ToolbarToggleGroup.test.tsx +++ b/packages/react-core/src/components/Toolbar/__tests__/ToolbarToggleGroup.test.tsx @@ -1,23 +1,26 @@ import React from 'react'; import { render } from '@testing-library/react'; import { ToolbarToggleGroup } from '../ToolbarToggleGroup'; -import { ToolbarContentContext } from '../ToolbarUtils'; +import { Toolbar } from '../Toolbar'; +import { ToolbarContent } from '../ToolbarContent'; describe('ToolbarToggleGroup', () => { it('should warn on bad props', () => { const myMock = jest.fn() as any; global.console = { error: myMock } as any; + const items = ( + + + test + + + ); + render( - - - + + {items} + ); expect(myMock).toHaveBeenCalled(); diff --git a/packages/react-core/src/components/Toolbar/__tests__/__snapshots__/Toolbar.test.tsx.snap b/packages/react-core/src/components/Toolbar/__tests__/__snapshots__/Toolbar.test.tsx.snap index 31e809b78b6..13b971baed6 100644 --- a/packages/react-core/src/components/Toolbar/__tests__/__snapshots__/Toolbar.test.tsx.snap +++ b/packages/react-core/src/components/Toolbar/__tests__/__snapshots__/Toolbar.test.tsx.snap @@ -34,14 +34,6 @@ exports[`Toolbar should render inset 1`] = ` Test 3 -
-
-
-
-
-
-
-
- -
-
- -
-
-
-
-
-
{ + const statusChipContainerRef = React.useRef(); + const riskChipContainerRef = React.useRef(); + + const [isStatusGroupExpanded, setIsStatusGroupExpanded] = React.useState(false); + const [isRiskGroupExpanded, setIsRiskGroupExpanded] = React.useState(false); + + const [isStatusMenuExpanded, setIsStatusExpanded] = React.useState(false); + const [isRiskMenuExpanded, setIsRiskExpanded] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + const [filters, setFilters] = React.useState({ + risk: ['Low'], + status: ['New', 'Pending'] + }); + + const closeToggleGroups = () => { + setIsStatusGroupExpanded(false); + setIsRiskGroupExpanded(false); + }; + + React.useEffect(() => { + window.addEventListener('resize', closeToggleGroups); // Resize observer to toggle off expand groups is required to properly reformat toolbar when growing + return () => { + window.removeEventListener('resize', closeToggleGroups); + }; + }, []); + + const onInputChange = (newValue: string) => { + setInputValue(newValue); + }; + + const onStatusToggle = () => { + setIsStatusExpanded(!isStatusMenuExpanded); + }; + + const onRiskToggle = () => { + setIsRiskExpanded(!isRiskMenuExpanded); + }; + + const onSelect = (type: string, event: React.MouseEvent | React.ChangeEvent | undefined, selection: string) => { + const checked = (event?.target as HTMLInputElement).checked; + setFilters((prev) => { + const prevSelections = prev[type]; + return { + ...prev, + [type]: checked ? [...prevSelections, selection] : prevSelections.filter((value) => value !== selection) + }; + }); + }; + + const onStatusSelect = (event: React.MouseEvent | React.ChangeEvent | undefined, selection: string) => { + onSelect('status', event, selection); + }; + + const onRiskSelect = (event: React.MouseEvent | React.ChangeEvent | undefined, selection: string) => { + onSelect('risk', event, selection); + }; + + const onDelete = (type: string, id: string) => { + if (type === 'Risk') { + setFilters({ risk: filters.risk.filter((fil: string) => fil !== id), status: filters.status }); + } else if (type === 'Status') { + setFilters({ risk: filters.risk, status: filters.status.filter((fil: string) => fil !== id) }); + } else { + setFilters({ risk: [], status: [] }); + } + }; + + const onDeleteGroup = (type: string) => { + if (type === 'Risk') { + setFilters({ risk: [], status: filters.status }); + } else if (type === 'Status') { + setFilters({ risk: filters.risk, status: [] }); + } + }; + + const statusToggleGroupItems = ( + + + onInputChange(value)} + value={inputValue} + onClear={() => { + onInputChange(''); + }} + /> + + + onDelete(category as string, chip as string)} + deleteChipGroup={(category) => onDeleteGroup(category as string)} + categoryName="Status" + isExpanded={isStatusGroupExpanded} + expandableChipContainerRef={statusChipContainerRef} // Required to link the toolbar filter chip group to the custom expandable group + > + + + + + ); + + const riskToggleGroupItems = ( + + onDelete(category as string, chip as string)} + categoryName="Risk" + isExpanded={isRiskGroupExpanded} + expandableChipContainerRef={riskChipContainerRef} // Required to link the toolbar filter chip group to the custom expandable group + > + + + + ); + + return ( + + + { + setIsStatusGroupExpanded(!isStatusGroupExpanded); + setIsRiskGroupExpanded(false); + }} // Required to control expanded state + toggleIcon={} + breakpoint="md" + chipContainerRef={statusChipContainerRef} + showClearFiltersButton + clearAllFilters={() => onDeleteGroup('Status')} + clearFiltersButtonText="Clear status filter" + > + {statusToggleGroupItems} + + { + setIsRiskGroupExpanded(!isRiskGroupExpanded); + setIsStatusGroupExpanded(false); + }} // Required to control expanded state + toggleIcon={} + breakpoint="xl" + chipContainerRef={riskChipContainerRef} + showClearFiltersButton + clearAllFilters={() => onDeleteGroup('Risk')} + clearFiltersButtonText={'Clear risk filter'} + > + {riskToggleGroupItems} + + + + ); +}; diff --git a/packages/react-core/src/components/Toolbar/examples/Toolbar.md b/packages/react-core/src/components/Toolbar/examples/Toolbar.md index 6028f3d4e59..de3011cd7f3 100644 --- a/packages/react-core/src/components/Toolbar/examples/Toolbar.md +++ b/packages/react-core/src/components/Toolbar/examples/Toolbar.md @@ -14,6 +14,12 @@ import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-ico ## Examples +### TEST - remove before merging + +```ts file="./Test.tsx" + +``` + ### Toolbar items A toolbar can contain multiple toolbar items, like filters and buttons. @@ -28,7 +34,7 @@ Note: This example does not demonstrate responsive toolbar behavior. Responsive You may adjust the space between toolbar items to arrange them into groups. Read our spacers documentation to learn more about using spacers. -Items are spaced “16px” apart by default. To adjust the size of the space between items, use the `spacer` property of each ``. You can set the `spacer` value at multiple breakpoints, including "default", "md", "lg", "xl", and "2xl". Available `spacer` values include "spacerNone", "spacerSm", "spacerMd", or "spacerLg" into each breakpoint. +Items are spaced “16px” apart by default. To adjust the size of the space between items, use the `spacer` property of each ``. You can set the `spacer` value at multiple breakpoints, including "default", "md", "lg", "xl", and "2xl". Available `spacer` values include "spacerNone", "spacerSm", "spacerMd", or "spacerLg" into each breakpoint. ```ts file="./ToolbarSpacers.tsx" @@ -54,27 +60,28 @@ To adjust a toolbar’s inset, use the `inset` property. You can set the inset v ### Sticky toolbar -To lock a toolbar and prevent it from scrolling with other content, use a sticky toolbar. +To lock a toolbar and prevent it from scrolling with other content, use a sticky toolbar. In the following example, toggle the "is toolbar sticky" checkbox to see the difference between a sticky and non-sticky toolbar. ```ts file="./ToolbarSticky.tsx" + ``` ### With groups of items -You can group similar items together to create desired associations and to enable items to respond to changes in viewport width together. +You can group similar items together to create desired associations and to enable items to respond to changes in viewport width together. Note: This example does not demonstrate responsive toolbar behavior. Responsive toolbars are shown in the [examples with toggle groups and filters](/components/toolbar#examples-with-toggle-groups-and-filters). ```ts file="./ToolbarGroups.tsx" ``` + ## Examples with toggle groups and filters The following examples use toggle groups to allow for more responsive and complex toolbars with multiple items and groups of items. To visualize responsive toolbar behavior in the following examples, resize the browser to a smaller screen width. - ### Component managed toggle groups A toggle group allows you to collapse a set of items into an overlay panel at a certain breakpoint. For example, when a toggle group contains filter controls, its contents will collapse into an overlay panel when the toolbar adapts to a change in the viewport size. The contents can be toggled by selecting the filter icon in the overlay panel. @@ -102,7 +109,6 @@ You can add filters to a toolbar to let users filter the content that a toolbar The `` component expects applied filters and a delete chip handler to be passed in as properties. Pass in a `deleteChipGroup` property to close the entire chip group. Once close, the rendering of chips will be handled responsively by the toolbar. - ```ts file="./ToolbarWithFilters.tsx" ``` @@ -120,5 +126,5 @@ To customize the chip groups generated by toolbar filters, use the `customChipGr When all of a toolbar's required elements cannot fit in a single line, you can split toolbar items into multiple rows. ```ts file="./ToolbarStacked.tsx" -``` +``` diff --git a/packages/react-core/src/components/Toolbar/index.ts b/packages/react-core/src/components/Toolbar/index.ts index 14a3b4ad0ae..e95ce798717 100644 --- a/packages/react-core/src/components/Toolbar/index.ts +++ b/packages/react-core/src/components/Toolbar/index.ts @@ -1,6 +1,7 @@ export * from './Toolbar'; export * from './ToolbarContent'; export * from './ToolbarExpandIconWrapper'; +export * from './ToolbarExpandableContent'; export * from './ToolbarGroup'; export * from './ToolbarItem'; export * from './ToolbarFilter'; diff --git a/packages/react-integration/cypress/integration/toolbar.spec.ts b/packages/react-integration/cypress/integration/toolbar.spec.ts index 50955bcf95a..35ff79a7402 100644 --- a/packages/react-integration/cypress/integration/toolbar.spec.ts +++ b/packages/react-integration/cypress/integration/toolbar.spec.ts @@ -45,7 +45,7 @@ describe('Data Toolbar Demo Test', () => { it('displays toggle group contents', () => { cy.get('#demo-toggle-group #toolbar-demo-search').should('be.visible'); cy.get('#demo-toggle-group #toolbar-demo-filters').should('be.visible'); - cy.get('.pf-v5-c-toolbar__expandable-content').should('not.be.visible'); + cy.get('.pf-v5-c-toolbar__expandable-content').should('not.exist'); }); it('displays filter chips', () => { @@ -66,7 +66,7 @@ describe('Data Toolbar Demo Test', () => { cy.get('#demo-toggle-group .pf-v5-c-toolbar__toggle').should('be.visible'); cy.get('#demo-toggle-group #toolbar-demo-search').should('not.be.visible'); cy.get('#demo-toggle-group #toolbar-demo-filters').should('not.be.visible'); - cy.get('.pf-v5-c-toolbar__expandable-content').should('not.be.visible'); + cy.get('.pf-v5-c-toolbar__expandable-content').should('not.exist'); }); it('displays X filters applied message', () => {