diff --git a/packages/react-core/package.json b/packages/react-core/package.json index c62b4eee4de..0a668e3db3d 100644 --- a/packages/react-core/package.json +++ b/packages/react-core/package.json @@ -54,7 +54,7 @@ "tslib": "^2.8.1" }, "devDependencies": { - "@patternfly/patternfly": "6.6.0-prerelease.50", + "@patternfly/patternfly": "6.7.0-prerelease.1", "case-anything": "^3.1.2", "css": "^3.0.0", "fs-extra": "^11.3.3" diff --git a/packages/react-core/src/components/Toolbar/Toolbar.tsx b/packages/react-core/src/components/Toolbar/Toolbar.tsx index 5a0cf48de75..5ee2246ee60 100644 --- a/packages/react-core/src/components/Toolbar/Toolbar.tsx +++ b/packages/react-core/src/components/Toolbar/Toolbar.tsx @@ -3,7 +3,8 @@ import styles from '@patternfly/react-styles/css/components/Toolbar/toolbar'; import { css } from '@patternfly/react-styles'; import { ToolbarContext } from './ToolbarUtils'; import { ToolbarLabelGroupContent } from './ToolbarLabelGroupContent'; -import { formatBreakpointMods, canUseDOM } from '../../helpers/util'; +import { formatBreakpointMods, canUseDOM, getBreakpoint } from '../../helpers/util'; +import { getResizeObserver } from '../../helpers/resizeObserver'; import { getOUIAProps, OUIAProps } from '../../helpers'; import { SSRSafeIds } from '../../helpers/SSRSafeIds/SSRSafeIds'; import { PageContext } from '../Page/PageContext'; @@ -34,6 +35,8 @@ export interface ToolbarProps extends React.HTMLProps, OUIAProps children?: React.ReactNode; /** Id of the data toolbar */ id?: string; + /** Flag indicating if the toolbar is a container for CSS container queries */ + isContainer?: boolean; /** Flag indicating the toolbar height should expand to the full height of the container */ isFullHeight?: boolean; /** Flag indicating the toolbar is static */ @@ -84,6 +87,9 @@ interface FilterInfo { class Toolbar extends Component { static displayName = 'Toolbar'; labelGroupContentRef = createRef(); + toolbarRef = createRef(); + resizeObserver: () => void = () => {}; + containerBreakpoint: ReturnType; staticFilterInfo = {}; hasNoPadding = false; state = { @@ -100,8 +106,8 @@ class Toolbar extends Component { })); }; - closeExpandableContent = (e: any) => { - if (e.target.innerWidth !== this.state.windowWidth) { + closeExpandableContent = (e?: any) => { + if (e && e.target.innerWidth !== this.state.windowWidth) { this.setState(() => ({ isManagedToggleExpanded: false, windowWidth: e.target.innerWidth @@ -109,19 +115,58 @@ class Toolbar extends Component { } }; + closeExpandableContentOnContainerResize = () => { + const containerWidth = this.toolbarRef.current?.clientWidth; + if (!containerWidth) { + return; + } + + const breakpoint = getBreakpoint(containerWidth); + const isInitialMeasurement = this.containerBreakpoint === undefined; + if (breakpoint !== this.containerBreakpoint) { + this.containerBreakpoint = breakpoint; + if (!isInitialMeasurement && this.state.isManagedToggleExpanded) { + this.setState({ isManagedToggleExpanded: false }); + } + } + }; + + setupResizeHandling = () => { + if (!this.isToggleManaged() || !canUseDOM) { + return; + } + + this.containerBreakpoint = undefined; + const reference = this.props.isContainer ? this.toolbarRef.current : undefined; + const handler = this.props.isContainer ? this.closeExpandableContentOnContainerResize : this.closeExpandableContent; + + this.resizeObserver = getResizeObserver(reference, handler, true); + }; + + cleanupResizeHandling = () => { + this.resizeObserver(); + this.resizeObserver = () => {}; + }; + componentDidMount() { if (canUseDOM) { this.setState({ windowWidth: window.innerWidth }); } - if (this.isToggleManaged() && canUseDOM) { - window.addEventListener('resize', this.closeExpandableContent); + this.setupResizeHandling(); + } + + componentDidUpdate(prevProps: ToolbarProps) { + const wasToggleManaged = !(prevProps.isExpanded || !!prevProps.toggleIsExpanded); + const isToggleManaged = this.isToggleManaged(); + + if (prevProps.isContainer !== this.props.isContainer || wasToggleManaged !== isToggleManaged) { + this.cleanupResizeHandling(); + this.setupResizeHandling(); } } componentWillUnmount() { - if (this.isToggleManaged() && canUseDOM) { - window.removeEventListener('resize', this.closeExpandableContent); - } + this.cleanupResizeHandling(); } updateNumberFilters = (categoryName: string, numberOfFilters: number) => { @@ -146,6 +191,7 @@ class Toolbar extends Component { toggleIsExpanded, className, children, + isContainer, isFullHeight, isStatic, isStickyBase, @@ -169,56 +215,73 @@ class Toolbar extends Component { return ( - {({ width, getBreakpoint }) => ( -
- { + const toolbarContent = ( +
- {children} - - -
- )} + + {children} + + +
+ ); + + return isContainer ? ( + {toolbarContent} + ) : ( + toolbarContent + ); + }}
); }; diff --git a/packages/react-core/src/components/Toolbar/ToolbarItem.tsx b/packages/react-core/src/components/Toolbar/ToolbarItem.tsx index 66778c13451..beec76f4272 100644 --- a/packages/react-core/src/components/Toolbar/ToolbarItem.tsx +++ b/packages/react-core/src/components/Toolbar/ToolbarItem.tsx @@ -21,6 +21,7 @@ export interface ToolbarItemProps extends React.HTMLProps { /** Visibility at various width breakpoints. */ visibility?: { default?: 'hidden' | 'visible'; + sm?: 'hidden' | 'visible'; md?: 'hidden' | 'visible'; lg?: 'hidden' | 'visible'; xl?: 'hidden' | 'visible'; diff --git a/packages/react-core/src/components/Toolbar/ToolbarToggleGroup.tsx b/packages/react-core/src/components/Toolbar/ToolbarToggleGroup.tsx index d5caed8a187..27c83c5745b 100644 --- a/packages/react-core/src/components/Toolbar/ToolbarToggleGroup.tsx +++ b/packages/react-core/src/components/Toolbar/ToolbarToggleGroup.tsx @@ -3,12 +3,11 @@ import * as ReactDOM from 'react-dom'; import styles from '@patternfly/react-styles/css/components/Toolbar/toolbar'; import { css } from '@patternfly/react-styles'; import { ToolbarGroupProps } from './ToolbarGroup'; -import { ToolbarContext, ToolbarContentContext } from './ToolbarUtils'; +import { globalBreakpoints, ToolbarContext, ToolbarContentContext } from './ToolbarUtils'; import { Button } from '../Button'; -import globalBreakpointLg from '@patternfly/react-tokens/dist/esm/t_global_breakpoint_lg'; import { formatBreakpointMods, toCamel } from '../../helpers/util'; -import { PageContext } from '../Page/PageContext'; import { ToolbarExpandableContent } from './ToolbarExpandableContent'; +import { PageContext } from '../Page/PageContext'; export interface ToolbarToggleGroupProps extends ToolbarGroupProps { /** Flag indicating when toggle group is expanded for non-managed toolbar toggle groups. */ @@ -156,10 +155,9 @@ class ToolbarToggleGroup extends Component { toggleRef = createRef(); expandableContentRef = createRef(); - isContentPopup = () => { - const viewportSize = typeof window !== 'undefined' ? window.innerWidth : 1200; - const lgBreakpointValue = parseInt(globalBreakpointLg.value); - return viewportSize < lgBreakpointValue; + isContentPopup = (width?: number) => { + const size = width ?? (typeof window !== 'undefined' ? window.innerWidth : 1200); + return size < globalBreakpoints.lg; }; render() { @@ -191,7 +189,7 @@ class ToolbarToggleGroup extends Component { {({ width, getBreakpoint }) => ( - {({ toggleIsExpanded: managedOnToggle }) => { + {({ toggleIsExpanded: managedOnToggle, isContainer, toolbarRef }) => { const _onToggle = onToggle !== undefined ? onToggle : managedOnToggle; return ( @@ -238,7 +236,10 @@ class ToolbarToggleGroup extends Component { onClick={_onToggle} aria-label="Show Filters" {...(_isExpanded && { 'aria-expanded': true })} - aria-haspopup={_isExpanded && this.isContentPopup()} + aria-haspopup={ + _isExpanded && + this.isContentPopup(isContainer ? toolbarRef?.current?.clientWidth : undefined) + } aria-controls={_isExpanded ? expandableContentId : undefined} ref={this.toggleRef} icon={toggleIcon} diff --git a/packages/react-core/src/components/Toolbar/ToolbarUtils.tsx b/packages/react-core/src/components/Toolbar/ToolbarUtils.tsx index fbddb1ee6cd..7445da7cf27 100644 --- a/packages/react-core/src/components/Toolbar/ToolbarUtils.tsx +++ b/packages/react-core/src/components/Toolbar/ToolbarUtils.tsx @@ -15,6 +15,10 @@ export interface ToolbarContextProps { showClearFiltersButton?: boolean; toolbarId?: string; customLabelGroupContent?: React.ReactNode; + /** Flag indicating if the toolbar is a container */ + isContainer?: boolean; + /** Ref to the toolbar root element */ + toolbarRef?: RefObject; } export const ToolbarContext = createContext({ 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 7e7494e525b..49d398ee745 100644 --- a/packages/react-core/src/components/Toolbar/__tests__/Toolbar.test.tsx +++ b/packages/react-core/src/components/Toolbar/__tests__/Toolbar.test.tsx @@ -299,4 +299,42 @@ describe('Toolbar', () => { ); expect(screen.getByTestId('toolbar-sticky-stuck')).toHaveClass(styles.modifiers.stickyStuck); }); + + it('renders the container modifier when isContainer is true', () => { + render(); + + expect(screen.getByTestId('toolbar-container')).toHaveClass(styles.modifiers.container); + }); + + it('renders all responsive visibility modifiers', () => { + render(); + + const item = screen.getByTestId('toolbar-item'); + + expect(item).toHaveClass('pf-m-hidden-on-md'); + expect(item).toHaveClass('pf-m-visible-on-xl'); + }); + + it('renders all responsive row wrap modifiers', () => { + render(); + + const contentSection = screen.getByTestId('toolbar-content').querySelector('div'); + + expect(contentSection).toHaveClass('pf-m-nowrap-on-md'); + expect(contentSection).toHaveClass('pf-m-wrap-on-xl'); + }); + + it('renders the toggle group breakpoint modifier', () => { + render( + + + }> + Content + + + + ); + + expect(screen.getByTestId('toolbar-toggle-group')).toHaveClass('pf-m-show-on-xl'); + }); }); 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 da837642654..b2169bb5cb8 100644 --- a/packages/react-core/src/components/Toolbar/__tests__/ToolbarToggleGroup.test.tsx +++ b/packages/react-core/src/components/Toolbar/__tests__/ToolbarToggleGroup.test.tsx @@ -1,5 +1,6 @@ import { Fragment } from 'react'; -import { render } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; import { ToolbarToggleGroup } from '../ToolbarToggleGroup'; import { Toolbar } from '../Toolbar'; import { ToolbarContent } from '../ToolbarContent'; @@ -25,4 +26,24 @@ describe('ToolbarToggleGroup', () => { expect(myMock).toHaveBeenCalled(); }); + + it('sets aria-haspopup when expandable content is a popup in a narrow toolbar container', () => { + const clientWidthMock = jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(600); + + render( + + + }> + Filter controls + + + + ); + + const toggle = screen.getByRole('button', { name: 'Show Filters' }); + fireEvent.click(toggle); + + expect(toggle).toHaveAttribute('aria-haspopup', 'true'); + clientWidthMock.mockRestore(); + }); }); diff --git a/packages/react-core/src/components/Toolbar/examples/Toolbar.md b/packages/react-core/src/components/Toolbar/examples/Toolbar.md index 9b00cd5132f..8355501c234 100644 --- a/packages/react-core/src/components/Toolbar/examples/Toolbar.md +++ b/packages/react-core/src/components/Toolbar/examples/Toolbar.md @@ -6,6 +6,7 @@ section: components --- import { Fragment, useState, useLayoutEffect, useRef } from 'react'; +import './ToolbarContainerQuery.css'; import EditIcon from '@patternfly/react-icons/dist/esm/icons/edit-icon'; import RhUiCopyFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-copy-fill-icon'; @@ -78,6 +79,21 @@ To change the background color of a toolbar, use the `colorVariant` property on ``` +## Responsive toolbar behavior + +Toolbar width-based responsive modifiers use `@container` CSS queries with the global PatternFly breakpoints. Responsive inset modifiers (via the `inset` property) are an exception and use viewport media queries. Insets are an exception and rely on viewport media queries. + +By default, the PatternFly CSS establishes the `container-name` property with a value of `pf-v6-contain-toolbar` on `:root`, so the breakpoints behave like viewport breakpoints. + +Alternatively, if another wrapping element creates a container in the CSS with a `container-name` property value of `pf-v6-contain-toolbar` and `container-type` value of `inline-size` or `size` , the breakpoints will apply to that container's width instead. + +### Toolbar as container +Add `isContainer` to the toolbar so width-based modifiers respond to the toolbar's width. Resize the dashed box in this example to see items and groups show and hide at the global breakpoints. + +```ts file="./ToolbarContainerQuery.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. diff --git a/packages/react-core/src/components/Toolbar/examples/ToolbarContainerQuery.css b/packages/react-core/src/components/Toolbar/examples/ToolbarContainerQuery.css new file mode 100644 index 00000000000..8ee1c2e4c29 --- /dev/null +++ b/packages/react-core/src/components/Toolbar/examples/ToolbarContainerQuery.css @@ -0,0 +1,8 @@ +.toolbar-container-query-resize { + width: 100%; + min-width: 300px; + padding: var(--pf-t--global--spacer--sm); + overflow: auto; + resize: horizontal; + border: var(--pf-t--global--border--width--extra-strong) dashed var(--pf-t--global--border--color--default); +} diff --git a/packages/react-core/src/components/Toolbar/examples/ToolbarContainerQuery.tsx b/packages/react-core/src/components/Toolbar/examples/ToolbarContainerQuery.tsx new file mode 100644 index 00000000000..728edd14492 --- /dev/null +++ b/packages/react-core/src/components/Toolbar/examples/ToolbarContainerQuery.tsx @@ -0,0 +1,35 @@ +import { Button, Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem } from '@patternfly/react-core'; + +export const ToolbarContainerQuery: React.FunctionComponent = () => ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+); diff --git a/packages/react-docs/package.json b/packages/react-docs/package.json index bc009e76fe3..2c07d22e5c6 100644 --- a/packages/react-docs/package.json +++ b/packages/react-docs/package.json @@ -23,7 +23,7 @@ "test:a11y": "patternfly-a11y --config patternfly-a11y.config" }, "dependencies": { - "@patternfly/patternfly": "6.6.0-prerelease.50", + "@patternfly/patternfly": "6.7.0-prerelease.1", "@patternfly/react-charts": "workspace:^", "@patternfly/react-code-editor": "workspace:^", "@patternfly/react-core": "workspace:^", diff --git a/packages/react-icons/package.json b/packages/react-icons/package.json index 7ae49e4c7d4..50d99deef06 100644 --- a/packages/react-icons/package.json +++ b/packages/react-icons/package.json @@ -38,7 +38,7 @@ "@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-regular-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", - "@patternfly/patternfly": "6.6.0-prerelease.50", + "@patternfly/patternfly": "6.7.0-prerelease.1", "@rhds/icons": "^2.3.1", "fs-extra": "^11.3.3" }, diff --git a/packages/react-styles/package.json b/packages/react-styles/package.json index d786889fb7e..20c63052060 100644 --- a/packages/react-styles/package.json +++ b/packages/react-styles/package.json @@ -19,7 +19,7 @@ "clean": "rimraf dist css" }, "devDependencies": { - "@patternfly/patternfly": "6.6.0-prerelease.50", + "@patternfly/patternfly": "6.7.0-prerelease.1", "change-case": "^5.4.4", "fs-extra": "^11.3.3" }, diff --git a/packages/react-tokens/package.json b/packages/react-tokens/package.json index 47cb52aa4e0..e4a4aa8d954 100644 --- a/packages/react-tokens/package.json +++ b/packages/react-tokens/package.json @@ -30,7 +30,7 @@ }, "devDependencies": { "@adobe/css-tools": "^4.5.0", - "@patternfly/patternfly": "6.6.0-prerelease.50", + "@patternfly/patternfly": "6.7.0-prerelease.1", "fs-extra": "^11.3.3" } } diff --git a/yarn.lock b/yarn.lock index 189bdeb6339..56cb21c653a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5070,10 +5070,10 @@ __metadata: languageName: node linkType: hard -"@patternfly/patternfly@npm:6.6.0-prerelease.50": - version: 6.6.0-prerelease.50 - resolution: "@patternfly/patternfly@npm:6.6.0-prerelease.50" - checksum: 10c0/f4d711a87c0094dd9d0115f21f7c2e4ff027ea68b439a342e0331b6b90a38cd0cb24d1b9f002409fe30e271be79588fdb760927c5e008e299603eeb56f299a16 +"@patternfly/patternfly@npm:6.7.0-prerelease.1": + version: 6.7.0-prerelease.1 + resolution: "@patternfly/patternfly@npm:6.7.0-prerelease.1" + checksum: 10c0/d96430d6e95e500029a9b1eeb12fca980e9ccd65b4cae6f27efa3c01bdf33fb75355222c825ca1d7bac1477754703815e99931cb50b4931223f552cb86bdeb7c languageName: node linkType: hard @@ -5171,7 +5171,7 @@ __metadata: version: 0.0.0-use.local resolution: "@patternfly/react-core@workspace:packages/react-core" dependencies: - "@patternfly/patternfly": "npm:6.6.0-prerelease.50" + "@patternfly/patternfly": "npm:6.7.0-prerelease.1" "@patternfly/react-icons": "workspace:^" "@patternfly/react-styles": "workspace:^" "@patternfly/react-tokens": "workspace:^" @@ -5192,7 +5192,7 @@ __metadata: resolution: "@patternfly/react-docs@workspace:packages/react-docs" dependencies: "@patternfly/documentation-framework": "npm:^6.40.0" - "@patternfly/patternfly": "npm:6.6.0-prerelease.50" + "@patternfly/patternfly": "npm:6.7.0-prerelease.1" "@patternfly/patternfly-a11y": "npm:5.2.1" "@patternfly/react-charts": "workspace:^" "@patternfly/react-code-editor": "workspace:^" @@ -5232,7 +5232,7 @@ __metadata: "@fortawesome/free-brands-svg-icons": "npm:^5.15.4" "@fortawesome/free-regular-svg-icons": "npm:^5.15.4" "@fortawesome/free-solid-svg-icons": "npm:^5.15.4" - "@patternfly/patternfly": "npm:6.6.0-prerelease.50" + "@patternfly/patternfly": "npm:6.7.0-prerelease.1" "@rhds/icons": "npm:^2.3.1" fs-extra: "npm:^11.3.3" tslib: "npm:^2.8.1" @@ -5319,7 +5319,7 @@ __metadata: version: 0.0.0-use.local resolution: "@patternfly/react-styles@workspace:packages/react-styles" dependencies: - "@patternfly/patternfly": "npm:6.6.0-prerelease.50" + "@patternfly/patternfly": "npm:6.7.0-prerelease.1" change-case: "npm:^5.4.4" fs-extra: "npm:^11.3.3" languageName: unknown @@ -5361,7 +5361,7 @@ __metadata: resolution: "@patternfly/react-tokens@workspace:packages/react-tokens" dependencies: "@adobe/css-tools": "npm:^4.5.0" - "@patternfly/patternfly": "npm:6.6.0-prerelease.50" + "@patternfly/patternfly": "npm:6.7.0-prerelease.1" fs-extra: "npm:^11.3.3" languageName: unknown linkType: soft