From 10b8bcd99e4b6c2b97961841ecf708879130eb74 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Wed, 8 Apr 2026 09:53:35 -0400 Subject: [PATCH 1/5] feat(Page): added responsive docked nav --- .../src/components/Button/Button.tsx | 8 + .../src/components/Masthead/MastheadLogo.tsx | 9 +- .../src/components/MenuToggle/MenuToggle.tsx | 10 + .../react-core/src/components/Nav/Nav.tsx | 4 + .../react-core/src/components/Page/Page.tsx | 23 +- packages/react-core/src/demos/Page.md | 2 + .../src/demos/examples/Page/PageDockedNav.tsx | 430 +++++++++++++----- 7 files changed, 369 insertions(+), 117 deletions(-) diff --git a/packages/react-core/src/components/Button/Button.tsx b/packages/react-core/src/components/Button/Button.tsx index 86b40f66826..9bf88985114 100644 --- a/packages/react-core/src/components/Button/Button.tsx +++ b/packages/react-core/src/components/Button/Button.tsx @@ -109,6 +109,10 @@ export interface ButtonProps extends Omit, 'r hamburgerVariant?: 'expand' | 'collapse'; /** @beta Flag indicating the button is a circle button. Intended for buttons that only contain an icon.. */ isCircle?: boolean; + /** @beta Flag indicating the button is a dock variant button. For use in docked navigation. */ + isDock?: boolean; + /** @beta Flag indicating the dock button should display text. Only applies when isDock is true. */ + isTextExpanded?: boolean; /** @hide Forwarded ref */ innerRef?: React.Ref; /** Adds count number to button */ @@ -134,6 +138,8 @@ const ButtonBase: React.FunctionComponent = ({ isHamburger, hamburgerVariant, isCircle, + isDock = false, + isTextExpanded = false, spinnerAriaValueText, spinnerAriaLabelledBy, spinnerAriaLabel, @@ -265,6 +271,8 @@ const ButtonBase: React.FunctionComponent = ({ size === ButtonSize.sm && styles.modifiers.small, size === ButtonSize.lg && styles.modifiers.displayLg, isCircle && styles.modifiers.circle, + isDock && styles.modifiers.dock, + isTextExpanded && styles.modifiers.textExpanded, className )} disabled={isButtonElement ? isDisabled : null} diff --git a/packages/react-core/src/components/Masthead/MastheadLogo.tsx b/packages/react-core/src/components/Masthead/MastheadLogo.tsx index 0f9f8391e35..49e1f43d715 100644 --- a/packages/react-core/src/components/Masthead/MastheadLogo.tsx +++ b/packages/react-core/src/components/Masthead/MastheadLogo.tsx @@ -11,12 +11,15 @@ export interface MastheadLogoProps extends React.DetailedHTMLProps< className?: string; /** Component type of the masthead logo. */ component?: React.ElementType | React.ComponentType; + /** @beta Flag indicating the logo is a compact variant. Used in docked layouts. */ + isCompact?: boolean; } export const MastheadLogo: React.FunctionComponent = ({ children, className, component, + isCompact = false, ...props }: MastheadLogoProps) => { let Component = component as any; @@ -28,7 +31,11 @@ export const MastheadLogo: React.FunctionComponent = ({ } } return ( - + {children} ); diff --git a/packages/react-core/src/components/MenuToggle/MenuToggle.tsx b/packages/react-core/src/components/MenuToggle/MenuToggle.tsx index 565b478a928..4cdf5b4d609 100644 --- a/packages/react-core/src/components/MenuToggle/MenuToggle.tsx +++ b/packages/react-core/src/components/MenuToggle/MenuToggle.tsx @@ -51,6 +51,10 @@ export interface MenuToggleProps isCircle?: boolean; /** Flag indicating whether the toggle is a settings toggle. This will override the icon property */ isSettings?: boolean; + /** @beta Flag indicating the toggle is a dock variant. For use in docked navigation. */ + isDock?: boolean; + /** @beta Flag indicating the dock toggle should display text. Only applies when isDock is true. */ + isTextExpanded?: boolean; /** Elements to display before the toggle button. When included, renders the menu toggle as a split button. */ splitButtonItems?: React.ReactNode[]; /** Variant styles of the menu toggle */ @@ -85,6 +89,8 @@ class MenuToggleBase extends Component { isFullHeight: false, isPlaceholder: false, isCircle: false, + isDock: false, + isTextExpanded: false, size: 'default', ouiaSafe: true }; @@ -102,6 +108,8 @@ class MenuToggleBase extends Component { isPlaceholder, isCircle, isSettings, + isDock, + isTextExpanded, splitButtonItems, variant, status, @@ -185,6 +193,8 @@ class MenuToggleBase extends Component { isDisabled && styles.modifiers.disabled, isPlaceholder && styles.modifiers.placeholder, isSettings && styles.modifiers.settings, + isDock && styles.modifiers.dock, + isTextExpanded && styles.modifiers.textExpanded, size === MenuToggleSize.sm && styles.modifiers.small, className ); diff --git a/packages/react-core/src/components/Nav/Nav.tsx b/packages/react-core/src/components/Nav/Nav.tsx index 8a908a90584..28e32bf25b7 100644 --- a/packages/react-core/src/components/Nav/Nav.tsx +++ b/packages/react-core/src/components/Nav/Nav.tsx @@ -39,6 +39,8 @@ export interface NavProps 'aria-label'?: string; /** The nav variant to use. Docked is in beta. */ variant?: 'default' | 'horizontal' | 'horizontal-subnav' | 'docked'; + /** @beta Flag indicating the docked nav should display text. Only applies when variant is docked. */ + isTextExpanded?: boolean; /** Value to overwrite the randomly generated data-ouia-component-id.*/ ouiaId?: number | string; /** Set the value of data-ouia-safe. Only set to true when the component is in a static state, i.e. no animations are occurring. At all other times, this value must be false. */ @@ -119,6 +121,7 @@ class Nav extends Component { className?: string; /** @beta Indicates the layout variant */ variant?: 'default' | 'docked'; + /** @beta Flag indicating the docked nav is expanded on mobile. Only applies when variant is docked. */ + isDockExpanded?: boolean; + /** @beta Flag indicating the docked nav should display text. Only applies when variant is docked. */ + isDockTextExpanded?: boolean; /** Masthead component (e.g. ) */ masthead?: React.ReactNode; + dockedMasthead?: React.ReactNode; /** Sidebar component for a side nav, recommended to be a PageSidebar. If set to null, the page grid layout * will render without a sidebar. */ @@ -232,7 +237,10 @@ class Page extends Component { className, children, variant, + isDockExpanded = false, + isDockTextExpanded = false, masthead, + dockedMasthead, sidebar, notificationDrawer, isNotificationDrawerExpanded, @@ -349,9 +357,18 @@ class Page extends Component { > {skipToContent} {variant === 'docked' ? ( -
-
{masthead}
-
+ <> + {masthead} +
+
{dockedMasthead}
+
+ ) : ( masthead )} diff --git a/packages/react-core/src/demos/Page.md b/packages/react-core/src/demos/Page.md index 202cd65d792..b4acc662417 100644 --- a/packages/react-core/src/demos/Page.md +++ b/packages/react-core/src/demos/Page.md @@ -18,6 +18,8 @@ import CloudIcon from '@patternfly/react-icons/dist/esm/icons/cloud-icon'; import CodeIcon from '@patternfly/react-icons/dist/esm/icons/code-icon'; import pfLogo from '@patternfly/react-core/src/demos/assets/PF-HorizontalLogo-Color.svg'; import pfIconLogo from '@patternfly/react-core/src/demos/assets/PF-IconLogo-color.svg'; +import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; +import ThIcon from '@patternfly/react-icons/dist/esm/icons/th-icon'; - All examples set the `isManagedSidebar` prop on the Page component to have the sidebar automatically close for smaller screen widths. You can also manually control this behavior by not adding the `isManagedSidebar` prop and instead: 1. Add an onNavToggle callback to PageHeader diff --git a/packages/react-core/src/demos/examples/Page/PageDockedNav.tsx b/packages/react-core/src/demos/examples/Page/PageDockedNav.tsx index 962c2210af3..3f68cae39d9 100644 --- a/packages/react-core/src/demos/examples/Page/PageDockedNav.tsx +++ b/packages/react-core/src/demos/examples/Page/PageDockedNav.tsx @@ -1,18 +1,13 @@ import { useRef, useState } from 'react'; import { - Avatar, Brand, Breadcrumb, BreadcrumbItem, Button, - ButtonVariant, Card, CardBody, Content, Divider, - Dropdown, - DropdownItem, - DropdownList, Gallery, GalleryItem, Masthead, @@ -20,12 +15,14 @@ import { MastheadLogo, MastheadContent, MastheadBrand, + MastheadToggle, MenuToggle, Nav, NavItem, NavList, Page, PageSection, + PageToggleButton, SkipToContent, Toolbar, ToolbarContent, @@ -38,7 +35,8 @@ import CubeIcon from '@patternfly/react-icons/dist/esm/icons/cube-icon'; import FolderIcon from '@patternfly/react-icons/dist/esm/icons/folder-icon'; import CloudIcon from '@patternfly/react-icons/dist/esm/icons/cloud-icon'; import CodeIcon from '@patternfly/react-icons/dist/esm/icons/code-icon'; -import imgAvatar from '@patternfly/react-core/src/components/assets/avatarImg.svg'; +import ThIcon from '@patternfly/react-icons/dist/esm/icons/th-icon'; +import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; import pfIconLogo from '@patternfly/react-core/src/demos/assets/PF-IconLogo-color.svg'; interface NavOnSelectProps { @@ -48,20 +46,109 @@ interface NavOnSelectProps { } export const PageDockedNav: React.FunctionComponent = () => { - const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [activeItem, setActiveItem] = useState(1); + const [isDockExpanded, setIsDockExpanded] = useState(false); + const [isDockTextExpanded, setIsDockTextExpanded] = useState(false); const onNavSelect = (_event: React.FormEvent, selectedItem: NavOnSelectProps) => { typeof selectedItem.itemId === 'number' && setActiveItem(selectedItem.itemId); }; - const onDropdownToggle = () => { - setIsDropdownOpen((prevIsOpen) => !prevIsOpen); - }; + const mobileTextLogo = ( + + PatternFly + + + + + + + + + + + + + + + + + + + + + + + + + + + ); - const onDropdownSelect = () => { - setIsDropdownOpen(false); - }; + const dockTextLogo = ( + + PatternFly + + + + + + + + + + + + + + + + + + + + + + + + + + + ); const dashboardBreadcrumb = ( @@ -74,142 +161,254 @@ export const PageDockedNav: React.FunctionComponent = () => { ); - const userDropdownItems = [ - <> - My profile - User management - Logout - - ]; - const navItem1Ref = useRef(null); const navItem2Ref = useRef(null); const navItem3Ref = useRef(null); const navItem4Ref = useRef(null); + const appsRef = useRef(null); const settingsRef = useRef(null); const helpRef = useRef(null); - const userMenuRef = useRef(null); + const mobileToggleRef = useRef(null); + const dockedToggleRef = useRef(null); + + const onToggleDock = () => { + const willBeExpanded = !isDockExpanded; + setIsDockExpanded(willBeExpanded); + setIsDockTextExpanded(!isDockTextExpanded); - const masthead = ( - + // Shift focus between mobile and docked toggle buttons + setTimeout(() => { + if (willBeExpanded) { + // Opening: focus the docked toggle button + dockedToggleRef.current?.focus(); + } else { + // Closing: focus the mobile toggle button + mobileToggleRef.current?.focus(); + } + }, 200); + }; + + // Mobile masthead - shown on mobile viewports only, hidden on desktop + const mobileMasthead = ( + + + + }> + {mobileTextLogo} + {/* */} + {/* */} + + + + + + + + + ) : ( - - - + )} + + + {isDockTextExpanded ? ( + } + isDock + isTextExpanded={isDockTextExpanded} + aria-label="Help" + > + Help + + ) : ( - ); + expect(screen.getByRole('button')).toHaveClass(styles.modifiers.dock); + }); + + test(`Does not render with class ${styles.modifiers.dock} when isDock is not passed`, () => { + render(); + expect(screen.getByRole('button')).not.toHaveClass(styles.modifiers.dock); + }); + + test(`Renders with class ${styles.modifiers.textExpanded} when isTextExpanded = true`, () => { + render(); + expect(screen.getByRole('button')).toHaveClass(styles.modifiers.textExpanded); + }); + + test(`Does not render with class ${styles.modifiers.textExpanded} when isTextExpanded is not passed`, () => { + render(); + expect(screen.getByRole('button')).not.toHaveClass(styles.modifiers.textExpanded); + }); +}); + test(`Renders basic button`, () => { const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); diff --git a/packages/react-core/src/components/Masthead/__tests__/Masthead.test.tsx b/packages/react-core/src/components/Masthead/__tests__/Masthead.test.tsx index 982ae08d932..ed5f9ac3294 100644 --- a/packages/react-core/src/components/Masthead/__tests__/Masthead.test.tsx +++ b/packages/react-core/src/components/Masthead/__tests__/Masthead.test.tsx @@ -127,6 +127,29 @@ describe('MastheadLogo', () => { expect(asFragment()).toMatchSnapshot(); }); + + test(`Renders with ${styles.modifiers.compact} class when isCompact is true`, () => { + render( + + test + + ); + expect(screen.getByTestId('compact-logo')).toHaveClass(styles.modifiers.compact); + }); + + test(`Does not render with ${styles.modifiers.compact} class when isCompact is false`, () => { + render( + + test + + ); + expect(screen.getByTestId('logo')).not.toHaveClass(styles.modifiers.compact); + }); + + test(`Does not render with ${styles.modifiers.compact} class when isCompact is not passed`, () => { + render(test); + expect(screen.getByTestId('logo')).not.toHaveClass(styles.modifiers.compact); + }); }); describe('MastheadContent', () => { diff --git a/packages/react-core/src/components/MenuToggle/__tests__/MenuToggle.test.tsx b/packages/react-core/src/components/MenuToggle/__tests__/MenuToggle.test.tsx index 2adbeda6b5e..357aaa1f5b5 100644 --- a/packages/react-core/src/components/MenuToggle/__tests__/MenuToggle.test.tsx +++ b/packages/react-core/src/components/MenuToggle/__tests__/MenuToggle.test.tsx @@ -138,3 +138,23 @@ test('Does not render custom icon when icon prop and isSettings are passed', () ); expect(screen.queryByText('Custom icon')).not.toBeInTheDocument(); }); + +test(`Renders with class ${styles.modifiers.dock} when isDock is passed`, () => { + render(Dock Toggle); + expect(screen.getByRole('button')).toHaveClass(styles.modifiers.dock); +}); + +test(`Does not render with class ${styles.modifiers.dock} when isDock is not passed`, () => { + render(Toggle); + expect(screen.getByRole('button')).not.toHaveClass(styles.modifiers.dock); +}); + +test(`Renders with class ${styles.modifiers.textExpanded} when isTextExpanded is passed`, () => { + render(Text Expanded Toggle); + expect(screen.getByRole('button')).toHaveClass(styles.modifiers.textExpanded); +}); + +test(`Does not render with class ${styles.modifiers.textExpanded} when isTextExpanded is not passed`, () => { + render(Toggle); + expect(screen.getByRole('button')).not.toHaveClass(styles.modifiers.textExpanded); +}); diff --git a/packages/react-core/src/components/Nav/__tests__/Nav.test.tsx b/packages/react-core/src/components/Nav/__tests__/Nav.test.tsx index 81bd5b3f57f..b16c9fba8fc 100644 --- a/packages/react-core/src/components/Nav/__tests__/Nav.test.tsx +++ b/packages/react-core/src/components/Nav/__tests__/Nav.test.tsx @@ -274,4 +274,34 @@ describe('Nav', () => { ); expect(screen.getByTestId('docked-nav')).toHaveClass(styles.modifiers.docked); }); + + test(`Renders with ${styles.modifiers.textExpanded} class when isTextExpanded is true`, () => { + renderNav( + + ); + expect(screen.getByTestId('text-expanded-nav')).toHaveClass(styles.modifiers.textExpanded); + }); + + test(`Does not render with ${styles.modifiers.textExpanded} class when isTextExpanded is not passed`, () => { + renderNav( + + ); + expect(screen.getByTestId('nav')).not.toHaveClass(styles.modifiers.textExpanded); + }); }); diff --git a/packages/react-core/src/components/Page/Page.tsx b/packages/react-core/src/components/Page/Page.tsx index 52a8c46bb85..94a2f2aa128 100644 --- a/packages/react-core/src/components/Page/Page.tsx +++ b/packages/react-core/src/components/Page/Page.tsx @@ -21,14 +21,15 @@ export interface PageProps extends React.HTMLProps { /** Additional classes added to the page layout */ className?: string; /** @beta Indicates the layout variant */ - variant?: 'default' | 'docked'; - /** @beta Flag indicating the docked nav is expanded on mobile. Only applies when variant is docked. */ + variant?: 'default' | 'dock'; + /** @beta Flag indicating the dock nav is expanded on mobile. Only applies when variant is dock. */ isDockExpanded?: boolean; - /** @beta Flag indicating the docked nav should display text. Only applies when variant is docked. */ + /** @beta Flag indicating the dock nav should display text. Only applies when variant is dock. */ isDockTextExpanded?: boolean; - /** Masthead component (e.g. ) */ + /** The horizontal masthead content (e.g. ). When using the dock variant, this content will only render at mobile viewports. */ masthead?: React.ReactNode; - dockedMasthead?: React.ReactNode; + /** @beta Content to render in the vertical dock when variant of dock is used. At mobile viewports, this content will be replaced with the content passed to masthead. */ + dockContent?: React.ReactNode; /** Sidebar component for a side nav, recommended to be a PageSidebar. If set to null, the page grid layout * will render without a sidebar. */ @@ -240,7 +241,7 @@ class Page extends Component { isDockExpanded = false, isDockTextExpanded = false, masthead, - dockedMasthead, + dockContent, sidebar, notificationDrawer, isNotificationDrawerExpanded, @@ -347,7 +348,7 @@ class Page extends Component { {...rest} className={css( styles.page, - variant === 'docked' && styles.modifiers.dock, + variant === 'dock' && styles.modifiers.dock, width !== null && height !== null && 'pf-m-resize-observer', width !== null && `pf-m-breakpoint-${getBreakpoint(width)}`, height !== null && `pf-m-height-breakpoint-${getVerticalBreakpoint(height)}`, @@ -356,7 +357,7 @@ class Page extends Component { )} > {skipToContent} - {variant === 'docked' ? ( + {variant === 'dock' ? ( <> {masthead}
{ isDockTextExpanded && styles.modifiers.textExpanded )} > -
{dockedMasthead}
+
{dockContent}
) : ( diff --git a/packages/react-core/src/components/Page/__tests__/Page.test.tsx b/packages/react-core/src/components/Page/__tests__/Page.test.tsx index d1258ac18f2..f11fc0edaa1 100644 --- a/packages/react-core/src/components/Page/__tests__/Page.test.tsx +++ b/packages/react-core/src/components/Page/__tests__/Page.test.tsx @@ -389,28 +389,102 @@ describe('Page', () => { expect(screen.getByRole('main').parentElement).not.toHaveClass(styles.modifiers.noFill); }); +}); + +describe('Page dock variant', () => { + test(`Does not render with dock classes when variant is default`, () => { + render(); + + const page = screen.getByTestId('page'); + expect(page).not.toHaveClass(styles.modifiers.dock); + expect(page.querySelector(styles.pageDock)).not.toBeInTheDocument(); + expect(page.querySelector(styles.pageDockMain)).not.toBeInTheDocument(); + }); + + test(`Does not render with dock classes when variant is not passed`, () => { + render(); + + const page = screen.getByTestId('page'); + expect(page).not.toHaveClass(styles.modifiers.dock); + expect(page.querySelector(styles.pageDock)).not.toBeInTheDocument(); + expect(page.querySelector(styles.pageDockMain)).not.toBeInTheDocument(); + }); - test(`Renders with ${styles.modifiers.dock} class when variant is docked`, () => { - render(); + test(`Renders with ${styles.modifiers.dock} class when variant is dock`, () => { + render(); expect(screen.getByTestId('page')).toHaveClass(styles.modifiers.dock); }); - test(`Does not render with ${styles.modifiers.dock} class when variant is default`, () => { - render(); + test('Renders dock content when dockContent and variant="dock" is passed', () => { + render(Dock content} masthead={<>Masthead} data-testid="page">); - expect(screen.getByTestId('page')).not.toHaveClass(styles.modifiers.dock); + expect(screen.getByText('Dock content')).toBeInTheDocument(); }); - test(`Does not render with ${styles.modifiers.dock} class when variant is not passed`, () => { - render(); - expect(screen.getByTestId('page')).not.toHaveClass(styles.modifiers.dock); + test('Renders masthead content when masthead and variant="dock" is passed', () => { + render( + Dock content} masthead={<>Masthead content} data-testid="page"> + ); + + expect(screen.getByText('Masthead content')).toBeInTheDocument(); + }); + + test(`Renders with ${styles.pageDock} wrapper when variant is dock`, () => { + render(Dock content} masthead={<>Masthead} data-testid="page">); + + const pageDock = screen.getByText('Dock content').closest(`.${styles.pageDock}`); + expect(pageDock).toBeInTheDocument(); + }); + + test(`Does not render with ${styles.modifiers.expanded} by default when variant is dock`, () => { + render(Dock content} masthead={<>Masthead} data-testid="page">); + + const pageDock = screen.getByText('Dock content').closest(`.${styles.pageDock}`); + expect(pageDock).not.toHaveClass(styles.modifiers.expanded); + }); + + test(`Does not render with ${styles.modifiers.textExpanded} by default when variant is dock`, () => { + render(Dock content} masthead={<>Masthead} data-testid="page">); + + const pageDock = screen.getByText('Dock content').closest(`.${styles.pageDock}`); + expect(pageDock).not.toHaveClass(styles.modifiers.textExpanded); + }); + + test(`Renders with ${styles.modifiers.expanded} when isDockExpanded is true and variant is dock`, () => { + render( + Dock content} + masthead={<>Masthead} + data-testid="page" + > + ); + + const pageDock = screen.getByText('Dock content').closest(`.${styles.pageDock}`); + expect(pageDock).toHaveClass(styles.modifiers.expanded); + }); + + test(`Renders with ${styles.modifiers.textExpanded} when isDockTextExpanded is true and variant is dock`, () => { + render( + Dock content} + masthead={<>Masthead} + data-testid="page" + > + ); + + const pageDock = screen.getByText('Dock content').closest(`.${styles.pageDock}`); + expect(pageDock).toHaveClass(styles.modifiers.textExpanded); }); - test(`Renders with ${styles.pageDockMain} wrapper when variant is docked`, () => { - render(Masthead} data-testid="page">); + test(`Renders with ${styles.pageDockMain} wrapper when variant is dock`, () => { + render(Dock content} masthead={<>Masthead} data-testid="page">); - const pageDockMain = screen.getByText('Masthead').closest(`.${styles.pageDockMain}`); + const pageDockMain = screen.getByText('Dock content').closest(`.${styles.pageDockMain}`); expect(pageDockMain).toBeInTheDocument(); }); }); From bf172d03d99ef3f1c1bfc38cc69ec5516a6afa6e Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Fri, 10 Apr 2026 09:38:00 -0400 Subject: [PATCH 3/5] Reverted page dock variant back to docked (for now) --- .../__snapshots__/Button.test.tsx.snap | 2 +- .../react-core/src/components/Page/Page.tsx | 6 ++-- .../components/Page/__tests__/Page.test.tsx | 36 +++++++++---------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/react-core/src/components/Button/__tests__/__snapshots__/Button.test.tsx.snap b/packages/react-core/src/components/Button/__tests__/__snapshots__/Button.test.tsx.snap index d9ce0770e4a..4385859a719 100644 --- a/packages/react-core/src/components/Button/__tests__/__snapshots__/Button.test.tsx.snap +++ b/packages/react-core/src/components/Button/__tests__/__snapshots__/Button.test.tsx.snap @@ -5,7 +5,7 @@ exports[`Renders basic button 1`] = `