From 7bad6ee16791be6f87e6d2edb9f342f0d1ed6b4e Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Mon, 4 May 2026 16:25:27 -0400 Subject: [PATCH] feat(Page): add dynamic sticky section support --- packages/react-core/package.json | 2 +- .../src/components/Page/PageGroup.tsx | 10 ++ .../src/components/Page/PageSection.tsx | 10 ++ .../Page/__tests__/PageGroup.test.tsx | 52 +++++++++ .../Page/__tests__/PageSection.test.tsx | 64 +++++++++++ .../src/components/Page/examples/Page.md | 12 +- .../examples/PageDynamicStickySection.tsx | 108 ++++++++++++++++++ packages/react-docs/package.json | 2 +- packages/react-icons/package.json | 2 +- packages/react-styles/package.json | 2 +- packages/react-tokens/package.json | 2 +- yarn.lock | 18 +-- 12 files changed, 269 insertions(+), 15 deletions(-) create mode 100644 packages/react-core/src/components/Page/examples/PageDynamicStickySection.tsx diff --git a/packages/react-core/package.json b/packages/react-core/package.json index ccab7d13453..7b2d80119b4 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.5.0-prerelease.78", + "@patternfly/patternfly": "6.5.0-prerelease.80", "case-anything": "^3.1.2", "css": "^3.0.0", "fs-extra": "^11.3.3" diff --git a/packages/react-core/src/components/Page/PageGroup.tsx b/packages/react-core/src/components/Page/PageGroup.tsx index 71cebd170ed..ae0181c1b4b 100644 --- a/packages/react-core/src/components/Page/PageGroup.tsx +++ b/packages/react-core/src/components/Page/PageGroup.tsx @@ -17,6 +17,10 @@ export interface PageGroupProps extends React.HTMLProps { xl?: 'top' | 'bottom'; '2xl'?: 'top' | 'bottom'; }; + /** @beta Applies the base sticky positioning to the top or bottom of the scroll parent container. */ + stickyBase?: 'top' | 'bottom'; + /** @beta Flag indicating if the group has stuck styling, applied when the group is not at the edge of the scroll parent container. */ + isStickyStuck?: boolean; /** Enables the page group to fill the available vertical space if true, or disable filling if false. */ isFilled?: boolean; /** Modifier indicating if PageGroup should have a shadow at the top */ @@ -37,6 +41,8 @@ export const PageGroup = ({ className = '', children, stickyOnBreakpoint, + stickyBase, + isStickyStuck = false, isFilled, hasShadowTop = false, hasShadowBottom = false, @@ -61,6 +67,10 @@ export const PageGroup = ({ className={css( styles.pageMainGroup, formatBreakpointMods(stickyOnBreakpoint, styles, 'sticky-', getVerticalBreakpoint(height), true), + stickyBase === 'top' && styles.modifiers.stickyTopBase, + stickyBase === 'bottom' && styles.modifiers.stickyBottomBase, + isStickyStuck && stickyBase === 'top' && styles.modifiers.stickyTopStuck, + isStickyStuck && stickyBase === 'bottom' && styles.modifiers.stickyBottomStuck, isFilled === false && styles.modifiers.noFill, isFilled === true && styles.modifiers.fill, hasShadowTop && styles.modifiers.shadowTop, diff --git a/packages/react-core/src/components/Page/PageSection.tsx b/packages/react-core/src/components/Page/PageSection.tsx index 08956ae0aa2..99d453cfddb 100644 --- a/packages/react-core/src/components/Page/PageSection.tsx +++ b/packages/react-core/src/components/Page/PageSection.tsx @@ -51,6 +51,10 @@ export interface PageSectionProps extends React.HTMLProps { xl?: 'top' | 'bottom'; '2xl'?: 'top' | 'bottom'; }; + /** @beta Applies the base sticky positioning to the top or bottom of the scroll parent container. */ + stickyBase?: 'top' | 'bottom'; + /** @beta Flag indicating if the section has stuck styling, applied when the section is not at the edge of the scroll parent container. */ + isStickyStuck?: boolean; /** Modifier indicating if PageSection should have a shadow at the top */ hasShadowTop?: boolean; /** Modifier indicating if PageSection should have a shadow at the bottom */ @@ -96,6 +100,8 @@ export const PageSection: React.FunctionComponent = ({ isWidthLimited = false, isCenterAligned = false, stickyOnBreakpoint, + stickyBase, + isStickyStuck = false, hasShadowTop = false, hasShadowBottom = false, hasOverflowScroll = false, @@ -124,6 +130,10 @@ export const PageSection: React.FunctionComponent = ({ variantType[type], formatBreakpointMods(padding, styles), formatBreakpointMods(stickyOnBreakpoint, styles, 'sticky-', getVerticalBreakpoint(height), true), + stickyBase === 'top' && styles.modifiers.stickyTopBase, + stickyBase === 'bottom' && styles.modifiers.stickyBottomBase, + isStickyStuck && stickyBase === 'top' && styles.modifiers.stickyTopStuck, + isStickyStuck && stickyBase === 'bottom' && styles.modifiers.stickyBottomStuck, type === PageSectionTypes.default && variantStyle[variant], isFilled === false && styles.modifiers.noFill, isFilled === true && styles.modifiers.fill, diff --git a/packages/react-core/src/components/Page/__tests__/PageGroup.test.tsx b/packages/react-core/src/components/Page/__tests__/PageGroup.test.tsx index 5377adacfb6..4d557e29912 100644 --- a/packages/react-core/src/components/Page/__tests__/PageGroup.test.tsx +++ b/packages/react-core/src/components/Page/__tests__/PageGroup.test.tsx @@ -101,3 +101,55 @@ test(`Renders with ${styles.modifiers.noPlainOnGlass} class when isNoPlainOnGlas expect(screen.getByText('test')).toHaveClass(styles.modifiers.noPlainOnGlass); }); + +test(`Does not add sticky base or sticky stuck classes by default`, () => { + render(test); + const group = screen.getByText('test'); + expect(group).not.toHaveClass(styles.modifiers.stickyTopBase); + expect(group).not.toHaveClass(styles.modifiers.stickyBottomBase); + expect(group).not.toHaveClass(styles.modifiers.stickyTopStuck); + expect(group).not.toHaveClass(styles.modifiers.stickyBottomStuck); +}); + +test(`Adds ${styles.modifiers.stickyTopBase} without stuck class when stickyBase="top"`, () => { + render(test); + const group = screen.getByText('test'); + expect(group).toHaveClass(styles.modifiers.stickyTopBase); + expect(group).not.toHaveClass(styles.modifiers.stickyTopStuck); +}); + +test(`Adds ${styles.modifiers.stickyBottomBase} without stuck class when stickyBase="bottom"`, () => { + render(test); + const group = screen.getByText('test'); + expect(group).toHaveClass(styles.modifiers.stickyBottomBase); + expect(group).not.toHaveClass(styles.modifiers.stickyBottomStuck); +}); + +test(`Adds ${styles.modifiers.stickyTopStuck} when stickyBase="top" and isStickyStuck`, () => { + render( + + test + + ); + const group = screen.getByText('test'); + expect(group).toHaveClass(styles.modifiers.stickyTopBase); + expect(group).toHaveClass(styles.modifiers.stickyTopStuck); +}); + +test(`Adds ${styles.modifiers.stickyBottomStuck} when stickyBase="bottom" and isStickyStuck`, () => { + render( + + test + + ); + const group = screen.getByText('test'); + expect(group).toHaveClass(styles.modifiers.stickyBottomBase); + expect(group).toHaveClass(styles.modifiers.stickyBottomStuck); +}); + +test(`Does not add stuck class when isStickyStuck is true but stickyBase is not set`, () => { + render(test); + const group = screen.getByText('test'); + expect(group).not.toHaveClass(styles.modifiers.stickyTopStuck); + expect(group).not.toHaveClass(styles.modifiers.stickyBottomStuck); +}); diff --git a/packages/react-core/src/components/Page/__tests__/PageSection.test.tsx b/packages/react-core/src/components/Page/__tests__/PageSection.test.tsx index 4cd665c49d2..a1711ce7e04 100644 --- a/packages/react-core/src/components/Page/__tests__/PageSection.test.tsx +++ b/packages/react-core/src/components/Page/__tests__/PageSection.test.tsx @@ -199,3 +199,67 @@ test(`Renders with ${styles.modifiers.noPlainOnGlass} class when isNoPlainOnGlas expect(screen.getByText('test')).toHaveClass(styles.modifiers.noPlainOnGlass); }); + +test(`Does not add sticky base or sticky stuck classes by default`, () => { + render(test); + const section = screen.getByRole('main'); + expect(section).not.toHaveClass(styles.modifiers.stickyTopBase); + expect(section).not.toHaveClass(styles.modifiers.stickyBottomBase); + expect(section).not.toHaveClass(styles.modifiers.stickyTopStuck); + expect(section).not.toHaveClass(styles.modifiers.stickyBottomStuck); +}); + +test(`Adds ${styles.modifiers.stickyTopBase} without stuck class when stickyBase="top"`, () => { + render( + + test + + ); + const section = screen.getByRole('main'); + expect(section).toHaveClass(styles.modifiers.stickyTopBase); + expect(section).not.toHaveClass(styles.modifiers.stickyTopStuck); +}); + +test(`Adds ${styles.modifiers.stickyBottomBase} without stuck class when stickyBase="bottom"`, () => { + render( + + test + + ); + const section = screen.getByRole('main'); + expect(section).toHaveClass(styles.modifiers.stickyBottomBase); + expect(section).not.toHaveClass(styles.modifiers.stickyBottomStuck); +}); + +test(`Adds ${styles.modifiers.stickyTopStuck} when stickyBase="top" and isStickyStuck`, () => { + render( + + test + + ); + const section = screen.getByRole('main'); + expect(section).toHaveClass(styles.modifiers.stickyTopBase); + expect(section).toHaveClass(styles.modifiers.stickyTopStuck); +}); + +test(`Adds ${styles.modifiers.stickyBottomStuck} when stickyBase="bottom" and isStickyStuck`, () => { + render( + + test + + ); + const section = screen.getByRole('main'); + expect(section).toHaveClass(styles.modifiers.stickyBottomBase); + expect(section).toHaveClass(styles.modifiers.stickyBottomStuck); +}); + +test(`Does not add stuck class when isStickyStuck is true but stickyBase is not set`, () => { + render( + + test + + ); + const section = screen.getByRole('main'); + expect(section).not.toHaveClass(styles.modifiers.stickyTopStuck); + expect(section).not.toHaveClass(styles.modifiers.stickyBottomStuck); +}); diff --git a/packages/react-core/src/components/Page/examples/Page.md b/packages/react-core/src/components/Page/examples/Page.md index 67c8fae40a2..9407de0f856 100644 --- a/packages/react-core/src/components/Page/examples/Page.md +++ b/packages/react-core/src/components/Page/examples/Page.md @@ -6,7 +6,7 @@ propComponents: ['Page', 'PageSidebar', 'PageSidebarBody', 'PageSection', 'PageGroup', 'PageBreadcrumb', 'PageToggleButton'] --- -import { useState } from 'react'; +import { useState, useLayoutEffect, useRef } from 'react'; import BarsIcon from '@patternfly/react-icons/dist/js/icons/bars-icon'; import pageSectionWidthLimitMaxWidth from '@patternfly/react-tokens/dist/esm/c_page_section_m_limit_width_MaxWidth'; @@ -131,3 +131,13 @@ To remove the default background color from a page section or group, use the `is ```ts file="./PagePlainSections.tsx" ``` + +### Dynamic sticky section + +A page section may be made sticky with separate control of its sticky positioning and stuck styling using the `stickyBase` and `isStickyStuck` properties. The `stickyBase` property accepts a value of `"top"` or `"bottom"` and applies the base sticky positioning in the given direction. The `isStickyStuck` property applies visual "stuck" styling such as a background, box shadow, and border, and should be toggled based on the scroll position of the scroll parent container. + +In this example, a scroll event listener on the scroll parent container toggles `isStickyStuck` when `scrollTop > 0`, so the stuck styling appears only when the content is scrolled. + +```ts file="./PageDynamicStickySection.tsx" + +``` diff --git a/packages/react-core/src/components/Page/examples/PageDynamicStickySection.tsx b/packages/react-core/src/components/Page/examples/PageDynamicStickySection.tsx new file mode 100644 index 00000000000..0e5f0b1125c --- /dev/null +++ b/packages/react-core/src/components/Page/examples/PageDynamicStickySection.tsx @@ -0,0 +1,108 @@ +import { useLayoutEffect, useState, useRef } from 'react'; +import { + Page, + Masthead, + MastheadMain, + MastheadBrand, + MastheadLogo, + MastheadContent, + PageSection, + Toolbar, + ToolbarContent, + ToolbarItem, + Breadcrumb, + BreadcrumbItem, + Content +} from '@patternfly/react-core'; + +const useIsStuckFromScrollParent = ({ + shouldTrack, + scrollParentRef +}: { + shouldTrack: boolean; + scrollParentRef: React.RefObject; +}): boolean => { + const [isStuck, setIsStuck] = useState(false); + + useLayoutEffect(() => { + if (!shouldTrack) { + setIsStuck(false); + return; + } + + const scrollElement = scrollParentRef.current; + if (!scrollElement) { + setIsStuck(false); + return; + } + + const syncFromScroll = () => { + setIsStuck(scrollElement.scrollTop > 0); + }; + syncFromScroll(); + scrollElement.addEventListener('scroll', syncFromScroll, { passive: true }); + return () => scrollElement.removeEventListener('scroll', syncFromScroll); + }, [shouldTrack, scrollParentRef]); + + return isStuck; +}; + +export const PageDynamicStickySection: React.FunctionComponent = () => { + const scrollParentRef = useRef(null); + const isStickyStuck = useIsStuckFromScrollParent({ shouldTrack: true, scrollParentRef }); + + const headerToolbar = ( + + + header-tools + + + ); + + const masthead = ( + + + + + Logo + + + + {headerToolbar} + + ); + + return ( + +
+ + + Section home + Section title + + Section landing + + + + + +

Main title

+

+ Scroll the container to see the breadcrumb section above dynamically apply its stuck styling. The section + uses stickyBase="top" to remain fixed at the top of the scroll parent, and{' '} + isStickyStuck is toggled via a scroll event listener to apply visual styling when the section + is no longer at the top edge. +

+
+
+ {Array.from({ length: 30 }, (_, i) => ( + + +

{`Section ${i + 1} content`}

+
+
+ ))} +
+
+ ); +}; diff --git a/packages/react-docs/package.json b/packages/react-docs/package.json index 769a3f226db..37df5cb4cac 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.5.0-prerelease.78", + "@patternfly/patternfly": "6.5.0-prerelease.80", "@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 83586707784..fd26f2d124d 100644 --- a/packages/react-icons/package.json +++ b/packages/react-icons/package.json @@ -35,7 +35,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.5.0-prerelease.78", + "@patternfly/patternfly": "6.5.0-prerelease.80", "@rhds/icons": "^2.2.0", "fs-extra": "^11.3.3", "tslib": "^2.8.1" diff --git a/packages/react-styles/package.json b/packages/react-styles/package.json index a729362ae29..af9db87ad3c 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.5.0-prerelease.78", + "@patternfly/patternfly": "6.5.0-prerelease.80", "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 a3674a38ceb..43e00b14678 100644 --- a/packages/react-tokens/package.json +++ b/packages/react-tokens/package.json @@ -30,7 +30,7 @@ }, "devDependencies": { "@adobe/css-tools": "^4.4.4", - "@patternfly/patternfly": "6.5.0-prerelease.78", + "@patternfly/patternfly": "6.5.0-prerelease.80", "fs-extra": "^11.3.3" } } diff --git a/yarn.lock b/yarn.lock index c4e6dfe74d4..acd0030fdc2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5070,10 +5070,10 @@ __metadata: languageName: node linkType: hard -"@patternfly/patternfly@npm:6.5.0-prerelease.78": - version: 6.5.0-prerelease.78 - resolution: "@patternfly/patternfly@npm:6.5.0-prerelease.78" - checksum: 10c0/fd9b94594bf3a51d2127338225b28852eef1bd1f51c23564b64e17006a9cf613200e2b673e6aaa0801cfbb96064c055e94bda9274366b5e89a0d8f03d3de4ab4 +"@patternfly/patternfly@npm:6.5.0-prerelease.80": + version: 6.5.0-prerelease.80 + resolution: "@patternfly/patternfly@npm:6.5.0-prerelease.80" + checksum: 10c0/18b3fa8eead7edd9e235d7d9f99af71dbd3dff2637cddbb9f3f10be2b2ef41918a9ed6484a6f4637d73f4b687921e7c04b21341af5e97df0e3d58bf0b2200cd2 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.5.0-prerelease.78" + "@patternfly/patternfly": "npm:6.5.0-prerelease.80" "@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.36.8" - "@patternfly/patternfly": "npm:6.5.0-prerelease.78" + "@patternfly/patternfly": "npm:6.5.0-prerelease.80" "@patternfly/patternfly-a11y": "npm:5.1.0" "@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.5.0-prerelease.78" + "@patternfly/patternfly": "npm:6.5.0-prerelease.80" "@rhds/icons": "npm:^2.2.0" 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.5.0-prerelease.78" + "@patternfly/patternfly": "npm:6.5.0-prerelease.80" 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.4.4" - "@patternfly/patternfly": "npm:6.5.0-prerelease.78" + "@patternfly/patternfly": "npm:6.5.0-prerelease.80" fs-extra: "npm:^11.3.3" languageName: unknown linkType: soft