diff --git a/packages/react-core/package.json b/packages/react-core/package.json index 4b83bf814f9..1c38728488f 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.9", + "@patternfly/patternfly": "6.5.0-prerelease.11", "case-anything": "^3.1.2", "css": "^3.0.0", "fs-extra": "^11.3.0" diff --git a/packages/react-core/src/components/Compass/Compass.tsx b/packages/react-core/src/components/Compass/Compass.tsx new file mode 100644 index 00000000000..864db7d3b45 --- /dev/null +++ b/packages/react-core/src/components/Compass/Compass.tsx @@ -0,0 +1,107 @@ +import { Drawer, DrawerContent, DrawerProps } from '../Drawer'; +import styles from '@patternfly/react-styles/css/components/Compass/compass'; +import { css } from '@patternfly/react-styles'; + +import compassBackgroundImageLight from '@patternfly/react-tokens/dist/esm/c_compass_BackgroundImage_light'; +import compassBackgroundImageDark from '@patternfly/react-tokens/dist/esm/c_compass_BackgroundImage_dark'; + +export interface CompassProps extends React.HTMLProps { + /** Additional classes added to the compass. */ + className?: string; + /** Content placed at the top of the layout */ + header?: React.ReactNode; + /** Flag indicating if the header is expanded */ + isHeaderExpanded?: boolean; + /** Content placed at the horizontal start of the layout, before the main content */ + sidebarStart?: React.ReactNode; + /** Flag indicating if the start sidebar is expanded */ + isSidebarStartExpanded?: boolean; + /** Content placed at the center of the layout */ + main?: React.ReactNode; + /** Content placed at the horizontal end of the layout, after the main content */ + sidebarEnd?: React.ReactNode; + /** Flag indicating if the end sidebar is expanded */ + isSidebarEndExpanded?: boolean; + /** Content placed at the bottom of the layout */ + footer?: React.ReactNode; + /** Flag indicating if the footer is expanded */ + isFooterExpanded?: boolean; + /** Content rendered in an optional drawer wrapping the layout */ + drawerContent?: React.ReactNode; + /** Additional props passed to the drawer */ + drawerProps?: DrawerProps; + /** Light theme background image path of the compass */ + backgroundSrcLight?: string; + /** Dark theme background image path of the compass */ + backgroundSrcDark?: string; +} + +export const Compass: React.FunctionComponent = ({ + className, + header, + isHeaderExpanded = true, + sidebarStart, + isSidebarStartExpanded = true, + main, + sidebarEnd, + isSidebarEndExpanded = true, + footer, + isFooterExpanded = true, + drawerContent, + drawerProps, + backgroundSrcLight, + backgroundSrcDark, + ...props +}) => { + const hasDrawer = drawerContent !== undefined; + + const backgroundImageStyles: { [key: string]: string } = {}; + if (backgroundSrcLight) { + backgroundImageStyles[compassBackgroundImageLight.name] = `url(${backgroundSrcLight})`; + } + if (backgroundSrcDark) { + backgroundImageStyles[compassBackgroundImageDark.name] = `url(${backgroundSrcDark})`; + } + + const compassContent = ( +
+
+ {header} +
+
+ {sidebarStart} +
+
{main}
+
+ {sidebarEnd} +
+
+ {footer} +
+
+ ); + + if (hasDrawer) { + return ( + + {compassContent} + + ); + } + + return compassContent; +}; + +Compass.displayName = 'Compass'; diff --git a/packages/react-core/src/components/Compass/CompassContent.tsx b/packages/react-core/src/components/Compass/CompassContent.tsx new file mode 100644 index 00000000000..09b3dc1cb19 --- /dev/null +++ b/packages/react-core/src/components/Compass/CompassContent.tsx @@ -0,0 +1,42 @@ +import { Drawer, DrawerContent, DrawerProps } from '../Drawer'; +import styles from '@patternfly/react-styles/css/components/Compass/compass'; +import { css } from '@patternfly/react-styles'; + +interface CompassContentProps extends React.HTMLProps { + /** Content of the main compass area. Typically one or more CompassPanel components. */ + children: React.ReactNode; + /** Additional classes added to the CompassContent */ + className?: string; + /** Content rendered in an optional drawer wrapping the CompassContent */ + drawerContent?: React.ReactNode; + /** Additional props passed to the drawer */ + drawerProps?: DrawerProps; +} + +export const CompassContent: React.FunctionComponent = ({ + children, + className, + drawerProps, + drawerContent, + ...props +}) => { + const hasDrawer = drawerContent !== undefined; + + const compassContent = ( +
+ {children} +
+ ); + + if (hasDrawer) { + return ( + + {compassContent} + + ); + } + + return compassContent; +}; + +CompassContent.displayName = 'CompassContent'; diff --git a/packages/react-core/src/components/Compass/CompassHeader.tsx b/packages/react-core/src/components/Compass/CompassHeader.tsx new file mode 100644 index 00000000000..c249c1d8858 --- /dev/null +++ b/packages/react-core/src/components/Compass/CompassHeader.tsx @@ -0,0 +1,21 @@ +import styles from '@patternfly/react-styles/css/components/Compass/compass'; +import { css } from '@patternfly/react-styles'; + +interface CompassHeaderProps { + /** Content of the logo area */ + logo?: React.ReactNode; + /** Content of the navigation area */ + nav?: React.ReactNode; + /** Content of the profile area */ + profile?: React.ReactNode; +} + +export const CompassHeader: React.FunctionComponent = ({ logo, nav, profile }) => ( + <> +
{logo}
+
{nav}
+
{profile}
+ +); + +CompassHeader.displayName = 'CompassHeader'; diff --git a/packages/react-core/src/components/Compass/CompassHero.tsx b/packages/react-core/src/components/Compass/CompassHero.tsx new file mode 100644 index 00000000000..0987c25277e --- /dev/null +++ b/packages/react-core/src/components/Compass/CompassHero.tsx @@ -0,0 +1,87 @@ +import styles from '@patternfly/react-styles/css/components/Compass/compass'; +import { css } from '@patternfly/react-styles'; + +import compassHeroBackgroundImageLight from '@patternfly/react-tokens/dist/esm/c_compass__hero_BackgroundImage_light'; +import compassHeroBackgroundImageDark from '@patternfly/react-tokens/dist/esm/c_compass__hero_BackgroundImage_dark'; +import compassHeroGradientStop1Light from '@patternfly/react-tokens/dist/esm/c_compass__hero_gradient_stop_1_light'; +import compassHeroGradientStop2Light from '@patternfly/react-tokens/dist/esm/c_compass__hero_gradient_stop_2_light'; +import compassHeroGradientStop3Light from '@patternfly/react-tokens/dist/esm/c_compass__hero_gradient_stop_3_light'; +import compassHeroGradientStop1Dark from '@patternfly/react-tokens/dist/esm/c_compass__hero_gradient_stop_1_dark'; +import compassHeroGradientStop2Dark from '@patternfly/react-tokens/dist/esm/c_compass__hero_gradient_stop_2_dark'; +import compassHeroGradientStop3Dark from '@patternfly/react-tokens/dist/esm/c_compass__hero_gradient_stop_3_dark'; + +interface CompassHeroProps extends Omit, 'content'> { + /** Content of the hero */ + children?: React.ReactNode; + /** Additional classes added to the hero */ + className?: string; + /** Light theme background image path of the hero */ + backgroundSrcLight?: string; + /** Dark theme background image path of the hero */ + backgroundSrcDark?: string; + /** Light theme gradient of the hero */ + gradientLight?: { + stop1?: string; + stop2?: string; + stop3?: string; + }; + /** Dark theme gradient of the hero */ + gradientDark?: { + stop1?: string; + stop2?: string; + stop3?: string; + }; +} + +export const CompassHero: React.FunctionComponent = ({ + className, + children, + backgroundSrcLight, + backgroundSrcDark, + gradientLight, + gradientDark, + ...props +}) => { + const backgroundImageStyles: { [key: string]: string } = {}; + if (backgroundSrcLight) { + backgroundImageStyles[compassHeroBackgroundImageLight.name] = `url(${backgroundSrcLight})`; + } + if (backgroundSrcDark) { + backgroundImageStyles[compassHeroBackgroundImageDark.name] = `url(${backgroundSrcDark})`; + } + + if (gradientLight) { + if (gradientLight.stop1) { + backgroundImageStyles[compassHeroGradientStop1Light.name] = gradientLight.stop1; + } + if (gradientLight.stop2) { + backgroundImageStyles[compassHeroGradientStop2Light.name] = gradientLight.stop2; + } + if (gradientLight.stop3) { + backgroundImageStyles[compassHeroGradientStop3Light.name] = gradientLight.stop3; + } + } + if (gradientDark) { + if (gradientDark.stop1) { + backgroundImageStyles[compassHeroGradientStop1Dark.name] = gradientDark.stop1; + } + if (gradientDark.stop2) { + backgroundImageStyles[compassHeroGradientStop2Dark.name] = gradientDark.stop2; + } + if (gradientDark.stop3) { + backgroundImageStyles[compassHeroGradientStop3Dark.name] = gradientDark.stop3; + } + } + + return ( +
+
{children}
+
+ ); +}; + +CompassHero.displayName = 'CompassHero'; diff --git a/packages/react-core/src/components/Compass/CompassMainHeader.tsx b/packages/react-core/src/components/Compass/CompassMainHeader.tsx new file mode 100644 index 00000000000..409f7b2e454 --- /dev/null +++ b/packages/react-core/src/components/Compass/CompassMainHeader.tsx @@ -0,0 +1,43 @@ +import { Flex, FlexItem } from '../../layouts/Flex'; +import { CompassPanel } from './CompassPanel'; +import styles from '@patternfly/react-styles/css/components/Compass/compass'; +import { css } from '@patternfly/react-styles'; + +interface CompassMainHeaderProps extends Omit, 'title'> { + /** Additional classes added to the main header */ + className?: string; + /** Styled title. If title or toolbar is provided, the children will be ignored. */ + title?: React.ReactNode; + /** Styled toolbar. If title or toolbar is provided, the children will be ignored. */ + toolbar?: React.ReactNode; + /** Custom main header content. To opt into a default styling, use the title and toolbar props instead. */ + children?: React.ReactNode; +} + +export const CompassMainHeader: React.FunctionComponent = ({ + className, + title, + toolbar, + children, + ...props +}) => { + const _content = + title !== undefined || toolbar !== undefined ? ( + + + {title} + {toolbar && {toolbar}} + + + ) : ( + children + ); + + return ( +
+ {_content} +
+ ); +}; + +CompassMainHeader.displayName = 'CompassMainHeader'; diff --git a/packages/react-core/src/components/Compass/CompassMessageBar.tsx b/packages/react-core/src/components/Compass/CompassMessageBar.tsx new file mode 100644 index 00000000000..4e9a737aa37 --- /dev/null +++ b/packages/react-core/src/components/Compass/CompassMessageBar.tsx @@ -0,0 +1,21 @@ +import styles from '@patternfly/react-styles/css/components/Compass/compass'; +import { css } from '@patternfly/react-styles'; + +interface CompassMessageBarProps extends React.HTMLProps { + /** Content of the message bar. Typically a @patternfly/chatbot MessageBar component. */ + children?: React.ReactNode; + /** Additional classes added to the message bar */ + className?: string; +} + +export const CompassMessageBar: React.FunctionComponent = ({ + children, + className, + ...props +}) => ( +
+ {children} +
+); + +CompassMessageBar.displayName = 'CompassMessageBar'; diff --git a/packages/react-core/src/components/Compass/CompassPanel.tsx b/packages/react-core/src/components/Compass/CompassPanel.tsx new file mode 100644 index 00000000000..6b8096a51e6 --- /dev/null +++ b/packages/react-core/src/components/Compass/CompassPanel.tsx @@ -0,0 +1,51 @@ +import styles from '@patternfly/react-styles/css/components/Compass/compass'; +import { css } from '@patternfly/react-styles'; + +interface CompassPanelProps extends React.HTMLProps { + /** Content of the panel. */ + children: React.ReactNode; + /** Additional classes added to the panel. */ + className?: string; + /** Indicates the panel should have a pill border radius */ + isPill?: boolean; + /** Indicates the panel should expand to fill the available height */ + isFullHeight?: boolean; + /** Indicates the panel should scroll its overflow */ + isScrollable?: boolean; + /** Indicates the panel should have no border */ + hasNoBorder?: boolean; + /** Indicates the panel should have no padding */ + hasNoPadding?: boolean; + /** Indicates the panel should have a "thinking" animation */ + isThinking?: boolean; +} + +export const CompassPanel: React.FunctionComponent = ({ + children, + className, + isPill, + hasNoBorder, + hasNoPadding, + isThinking, + isFullHeight, + isScrollable, + ...props +}) => ( +
+ {children} +
+); + +CompassPanel.displayName = 'CompassPanel'; diff --git a/packages/react-core/src/components/Compass/__tests__/Compass.test.tsx b/packages/react-core/src/components/Compass/__tests__/Compass.test.tsx new file mode 100644 index 00000000000..932dcb61233 --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/Compass.test.tsx @@ -0,0 +1,172 @@ +import { render, screen } from '@testing-library/react'; +import { Compass } from '../Compass'; +import styles from '@patternfly/react-styles/css/components/Compass/compass'; + +test('Renders without children', () => { + render( +
+ +
+ ); + expect(screen.getByTestId('test-compass').firstChild).toBeVisible(); +}); + +test('Renders with custom class name when className prop is provided', () => { + render(); + expect(screen.getByTestId('compass')).toHaveClass('custom-class'); +}); + +test('Renders with default compass class', () => { + render(); + expect(screen.getByTestId('compass')).toHaveClass(styles.compass); +}); + +test('Renders header content when provided', () => { + render(Header content} />); + expect(screen.getByText('Header content')).toBeVisible(); +}); + +test('Renders sidebar start content when provided', () => { + render(Sidebar start} />); + expect(screen.getByText('Sidebar start')).toBeVisible(); +}); + +test('Renders main content when provided', () => { + render(Main content} />); + expect(screen.getByText('Main content')).toBeVisible(); +}); + +test('Renders sidebar end content when provided', () => { + render(Sidebar end} />); + expect(screen.getByText('Sidebar end')).toBeVisible(); +}); + +test('Renders footer content when provided', () => { + render(Footer content} />); + expect(screen.getByText('Footer content')).toBeVisible(); +}); + +test('Renders header with expanded class when isHeaderExpanded is true', () => { + render(Header} isHeaderExpanded />); + expect(screen.getByText('Header').parentElement).toHaveClass('pf-m-expanded'); +}); + +test('Renders header without expanded class and with inert when isHeaderExpanded is false', () => { + render(Header} isHeaderExpanded={false} />); + const headerElement = screen.getByText('Header').parentElement; + expect(headerElement).not.toHaveClass('pf-m-expanded'); + expect(headerElement).toHaveAttribute('inert'); +}); + +test('Renders sidebar start with expanded class when isSidebarStartExpanded is true', () => { + render(Sidebar start} isSidebarStartExpanded />); + expect(screen.getByText('Sidebar start').parentElement).toHaveClass('pf-m-expanded'); +}); + +test('Renders sidebar start without expanded class and with inert when isSidebarStartExpanded is false', () => { + render(Sidebar start} isSidebarStartExpanded={false} />); + const sidebarElement = screen.getByText('Sidebar start').parentElement; + expect(sidebarElement).not.toHaveClass('pf-m-expanded'); + expect(sidebarElement).toHaveAttribute('inert'); +}); + +test('Renders sidebar end with expanded class when isSidebarEndExpanded is true', () => { + render(Sidebar end} isSidebarEndExpanded />); + expect(screen.getByText('Sidebar end').parentElement).toHaveClass('pf-m-expanded'); +}); + +test('Renders sidebar end without expanded class and with inert when isSidebarEndExpanded is false', () => { + render(Sidebar end} isSidebarEndExpanded={false} />); + const sidebarElement = screen.getByText('Sidebar end').parentElement; + expect(sidebarElement).not.toHaveClass('pf-m-expanded'); + expect(sidebarElement).toHaveAttribute('inert'); +}); + +test('Renders footer with expanded class when isFooterExpanded is true', () => { + render(Footer} isFooterExpanded />); + expect(screen.getByText('Footer').parentElement).toHaveClass('pf-m-expanded'); +}); + +test('Renders footer without expanded class and with inert when isFooterExpanded is false', () => { + render(Footer} isFooterExpanded={false} />); + const footerElement = screen.getByText('Footer').parentElement; + expect(footerElement).not.toHaveClass('pf-m-expanded'); + expect(footerElement).toHaveAttribute('inert'); +}); + +test('Renders with drawer when drawerContent is provided', () => { + render(Drawer content} />); + expect(screen.getByText('Drawer content')).toBeVisible(); +}); + +test('Renders with light background image when backgroundSrcLight is provided', () => { + const backgroundSrc = 'light-bg.jpg'; + render(); + expect(screen.getByTestId('compass')).toHaveStyle(`--pf-v6-c-compass--BackgroundImage--light: url(${backgroundSrc})`); +}); + +test('Renders with dark background image when backgroundSrcDark is provided', () => { + const backgroundSrc = 'dark-bg.jpg'; + render(); + expect(screen.getByTestId('compass')).toHaveStyle(`--pf-v6-c-compass--BackgroundImage--dark: url(${backgroundSrc})`); +}); + +test('Renders with both light and dark background images when both are provided', () => { + const lightSrc = 'light-bg.jpg'; + const darkSrc = 'dark-bg.jpg'; + render(); + const compassElement = screen.getByTestId('compass'); + expect(compassElement).toHaveStyle(`--pf-v6-c-compass--BackgroundImage--light: url(${lightSrc})`); + expect(compassElement).toHaveStyle(`--pf-v6-c-compass--BackgroundImage--dark: url(${darkSrc})`); +}); + +test('Renders with additional props spread to the component', () => { + render(); + expect(screen.getByTestId('compass')).toHaveAccessibleName('Test label'); +}); + +test('Renders with default expansion states', () => { + render( + Header} + sidebarStart={
Sidebar start
} + sidebarEnd={
Sidebar end
} + footer={
Footer
} + > + Test +
+ ); + + expect(screen.getByText('Header').parentElement).toHaveClass('pf-m-expanded'); + expect(screen.getByText('Sidebar start').parentElement).toHaveClass('pf-m-expanded'); + expect(screen.getByText('Sidebar end').parentElement).toHaveClass('pf-m-expanded'); + expect(screen.getByText('Footer').parentElement).toHaveClass('pf-m-expanded'); +}); + +test('Matches the snapshot with basic layout', () => { + const { asFragment } = render( + Header} + sidebarStart={
Sidebar start
} + main={
Main content
} + sidebarEnd={
Sidebar end
} + footer={
Footer
} + /> + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('Matches the snapshot with drawer', () => { + const { asFragment } = render( + Drawer content} + drawerProps={{ isExpanded: true }} + header={
Header
} + sidebarStart={
Sidebar start
} + main={
Main content
} + sidebarEnd={
Sidebar end
} + footer={
Footer
} + /> + ); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-core/src/components/Compass/__tests__/CompassContent.test.tsx b/packages/react-core/src/components/Compass/__tests__/CompassContent.test.tsx new file mode 100644 index 00000000000..1b3cf2eeb0f --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/CompassContent.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react'; +import { CompassContent } from '../CompassContent'; +import styles from '@patternfly/react-styles/css/components/Compass/compass'; + +test('Renders with children', () => { + render(Test content); + expect(screen.getByText('Test content')).toBeVisible(); +}); + +test('Renders with custom class name when className prop is provided', () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass('custom-class'); +}); + +test(`Renders with default ${styles.compassContent} class`, () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass(styles.compassContent); +}); + +test('Renders with drawer when drawerContent is provided', () => { + render(Drawer content}>Test); + expect(screen.getByText('Drawer content')).toBeVisible(); +}); + +test('Renders with additional props spread to the component', () => { + render(Test); + expect(screen.getByText('Test')).toHaveAccessibleName('Test label'); +}); + +test('Matches the snapshot without drawer', () => { + const { asFragment } = render( + +
Test content
+
+ ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('Matches the snapshot with drawer', () => { + const { asFragment } = render( + Drawer content} drawerProps={{ isExpanded: true }}> +
Test content
+
+ ); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-core/src/components/Compass/__tests__/CompassHeader.test.tsx b/packages/react-core/src/components/Compass/__tests__/CompassHeader.test.tsx new file mode 100644 index 00000000000..750f275671d --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/CompassHeader.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react'; +import { CompassHeader } from '../CompassHeader'; +import styles from '@patternfly/react-styles/css/components/Compass/compass'; + +test('Renders without children', () => { + render( +
+ +
+ ); + expect(screen.getByTestId('test-header').firstChild).toBeVisible(); +}); + +test('Renders logo content when provided', () => { + render(Logo content} />); + expect(screen.getByText('Logo content')).toBeVisible(); +}); + +test('Renders nav content when provided', () => { + render(Nav content} />); + expect(screen.getByText('Nav content')).toBeVisible(); +}); + +test('Renders profile content when provided', () => { + render(Profile content} />); + expect(screen.getByText('Profile content')).toBeVisible(); +}); + +test('Renders all content when all props are provided', () => { + render(Logo} nav={
Nav
} profile={
Profile
} />); + expect(screen.getByText('Logo')).toBeVisible(); + expect(screen.getByText('Nav')).toBeVisible(); + expect(screen.getByText('Profile')).toBeVisible(); +}); + +test(`Renders logo with ${styles.compass}__logo class`, () => { + render(Logo} />); + expect(screen.getByText('Logo').parentElement).toHaveClass(`${styles.compass}__logo`); +}); + +test(`Renders nav with ${styles.compassNav} class`, () => { + render(Nav} />); + expect(screen.getByText('Nav').parentElement).toHaveClass(styles.compassNav); +}); + +test(`Renders profile with ${styles.compassProfile} class`, () => { + render(Profile} />); + expect(screen.getByText('Profile').parentElement).toHaveClass(styles.compassProfile); +}); + +test('Matches the snapshot', () => { + const { asFragment } = render( + Logo} nav={
Nav
} profile={
Profile
} /> + ); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-core/src/components/Compass/__tests__/CompassHero.test.tsx b/packages/react-core/src/components/Compass/__tests__/CompassHero.test.tsx new file mode 100644 index 00000000000..f68ed214b79 --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/CompassHero.test.tsx @@ -0,0 +1,150 @@ +import { render, screen } from '@testing-library/react'; +import { CompassHero } from '../CompassHero'; +import styles from '@patternfly/react-styles/css/components/Compass/compass'; + +test('Renders without children', () => { + render( +
+ +
+ ); + expect(screen.getByTestId('test-hero').firstChild).toBeVisible(); +}); + +test('Renders with children', () => { + render(Test content); + expect(screen.getByText('Test content')).toBeVisible(); +}); + +test('Renders with custom class name when className prop is provided', () => { + render(Test); + expect(screen.getByText('Test').parentElement).toHaveClass('custom-class'); +}); + +test(`Renders with default ${styles.compassPanel} and ${styles.compassHero} classes on the hero and ${styles.compassHeroBody} class on the hero body`, () => { + render(Test); + const heroBodyElement = screen.getByText('Test'); + expect(heroBodyElement).toHaveClass(styles.compassHeroBody); + + const heroElement = heroBodyElement.parentElement; + expect(heroElement).toHaveClass(styles.compassPanel); + expect(heroElement).toHaveClass(styles.compassHero); +}); + +test('Renders with light background image style when backgroundSrcLight is provided', () => { + const backgroundSrc = 'light-bg.jpg'; + render(Test); + expect(screen.getByText('Test').parentElement).toHaveStyle( + `--pf-v6-c-compass__hero--BackgroundImage--light: url(${backgroundSrc})` + ); +}); + +test('Renders with dark background image style when backgroundSrcDark is provided', () => { + const backgroundSrc = 'dark-bg.jpg'; + render(Test); + expect(screen.getByText('Test').parentElement).toHaveStyle( + `--pf-v6-c-compass__hero--BackgroundImage--dark: url(${backgroundSrc})` + ); +}); + +test('Renders with both light and dark background image styles when both are provided', () => { + const lightSrc = 'light-bg.jpg'; + const darkSrc = 'dark-bg.jpg'; + render( + + Test + + ); + const heroElement = screen.getByText('Test').parentElement; + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--BackgroundImage--light: url(${lightSrc})`); + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--BackgroundImage--dark: url(${darkSrc})`); +}); + +test('Renders with light gradient styles when gradientLight is provided', () => { + const gradient = { + stop1: '#ff0000', + stop2: '#00ff00', + stop3: '#0000ff' + }; + render(Test); + const heroElement = screen.getByText('Test').parentElement; + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--gradient--stop-1--light: ${gradient.stop1}`); + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--gradient--stop-2--light: ${gradient.stop2}`); + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--gradient--stop-3--light: ${gradient.stop3}`); +}); + +test('Renders with dark gradient styles when gradientDark is provided', () => { + const gradient = { + stop1: '#ff0000', + stop2: '#00ff00', + stop3: '#0000ff' + }; + render(Test); + const heroElement = screen.getByText('Test').parentElement; + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--gradient--stop-1--dark: ${gradient.stop1}`); + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--gradient--stop-2--dark: ${gradient.stop2}`); + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--gradient--stop-3--dark: ${gradient.stop3}`); +}); + +test('Renders with both light and dark gradient styles when both are provided', () => { + const lightGradient = { + stop1: '#ff0000', + stop2: '#00ff00', + stop3: '#0000ff' + }; + const darkGradient = { + stop1: '#000000', + stop2: '#ffffff', + stop3: '#808080' + }; + render( + + Test + + ); + const heroElement = screen.getByText('Test').parentElement; + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--gradient--stop-1--light: ${lightGradient.stop1}`); + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--gradient--stop-1--dark: ${darkGradient.stop1}`); +}); + +test('Renders with both background images and gradient styles when both are provided', () => { + const lightSrc = 'light-bg.jpg'; + const darkSrc = 'dark-bg.jpg'; + const lightGradient = { stop1: '#ff0000' }; + const darkGradient = { stop1: '#000000' }; + + render( + + Test + + ); + const heroElement = screen.getByText('Test').parentElement; + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--BackgroundImage--light: url(${lightSrc})`); + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--BackgroundImage--dark: url(${darkSrc})`); + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--gradient--stop-1--light: ${lightGradient.stop1}`); + expect(heroElement).toHaveStyle(`--pf-v6-c-compass__hero--gradient--stop-1--dark: ${darkGradient.stop1}`); +}); + +test('Renders with additional props spread to the component', () => { + render(Test); + expect(screen.getByText('Test').parentElement).toHaveAccessibleName('Test label'); +}); + +test('Matches the snapshot', () => { + const { asFragment } = render( + +
Hero content
+
+ ); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-core/src/components/Compass/__tests__/CompassMainHeader.test.tsx b/packages/react-core/src/components/Compass/__tests__/CompassMainHeader.test.tsx new file mode 100644 index 00000000000..ef13deb96ef --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/CompassMainHeader.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from '@testing-library/react'; +import { CompassMainHeader } from '../CompassMainHeader'; +import styles from '@patternfly/react-styles/css/components/Compass/compass'; + +test('Renders without children', () => { + render( +
+ +
+ ); + expect(screen.getByTestId('test-main-header').firstChild).toBeVisible(); +}); + +test('Renders with children', () => { + render(Custom content); + expect(screen.getByText('Custom content')).toBeVisible(); +}); + +test('Renders with custom class name when className prop is provided', () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass('custom-class'); +}); + +test(`Renders with default ${styles.compass}__main-header class`, () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass(`${styles.compass}__main-header`); +}); + +test('Renders title when provided', () => { + render(Title content}>Test); + expect(screen.getByText('Title content')).toBeVisible(); +}); + +test('Renders toolbar when provided', () => { + render(Toolbar content}>Test); + expect(screen.getByText('Toolbar content')).toBeVisible(); +}); + +test('Renders both title and toolbar when provided', () => { + render( + Title} toolbar={
Toolbar
}> + Test +
+ ); + expect(screen.getByText('Title')).toBeVisible(); + expect(screen.getByText('Toolbar')).toBeVisible(); +}); + +test('Ignores children when title is provided', () => { + render(Title}>Ignored children); + expect(screen.getByText('Title')).toBeVisible(); + expect(screen.queryByText('Ignored children')).not.toBeInTheDocument(); +}); + +test('Ignores children when toolbar is provided', () => { + render(Toolbar}>Ignored children); + expect(screen.getByText('Toolbar')).toBeVisible(); + expect(screen.queryByText('Ignored children')).not.toBeInTheDocument(); +}); + +test('Ignores children when both title and toolbar are provided', () => { + render( + Title} toolbar={
Toolbar
}> + Ignored children +
+ ); + expect(screen.getByText('Title')).toBeVisible(); + expect(screen.getByText('Toolbar')).toBeVisible(); + expect(screen.queryByText('Ignored children')).not.toBeInTheDocument(); +}); + +test('Renders children when neither title nor toolbar are provided', () => { + render(Custom children content); + expect(screen.getByText('Custom children content')).toBeVisible(); +}); + +test('Renders with additional props spread to the component', () => { + render(Test); + expect(screen.getByText('Test')).toHaveAccessibleName('Test label'); +}); + +test('Matches the snapshot with both title and toolbar', () => { + const { asFragment } = render(Title} toolbar={
Toolbar
} />); + expect(asFragment()).toMatchSnapshot(); +}); + +test('Matches the snapshot with children', () => { + const { asFragment } = render(Custom children content); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-core/src/components/Compass/__tests__/CompassMessageBar.test.tsx b/packages/react-core/src/components/Compass/__tests__/CompassMessageBar.test.tsx new file mode 100644 index 00000000000..f6f7546e72c --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/CompassMessageBar.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react'; +import { CompassMessageBar } from '../CompassMessageBar'; +import styles from '@patternfly/react-styles/css/components/Compass/compass'; + +test('Renders without children', () => { + render( +
+ +
+ ); + expect(screen.getByTestId('test-message-bar').firstChild).toBeVisible(); +}); + +test('Renders with children', () => { + render(Test content); + expect(screen.getByText('Test content')).toBeVisible(); +}); + +test('Renders with custom class name when className prop is provided', () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass('custom-class'); +}); + +test(`Renders with default ${styles.compassMessageBar} class`, () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass(styles.compassMessageBar); +}); + +test('Renders with additional props spread to the component', () => { + render(Test); + expect(screen.getByText('Test')).toHaveAccessibleName('Test label'); +}); + +test('Matches the snapshot', () => { + const { asFragment } = render( + +
Message bar content
+
+ ); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-core/src/components/Compass/__tests__/CompassPanel.test.tsx b/packages/react-core/src/components/Compass/__tests__/CompassPanel.test.tsx new file mode 100644 index 00000000000..64a66961d61 --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/CompassPanel.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/react'; +import { CompassPanel } from '../CompassPanel'; +import styles from '@patternfly/react-styles/css/components/Compass/compass'; + +test('Renders with children', () => { + render(Test content); + expect(screen.getByText('Test content')).toBeVisible(); +}); + +test('Renders with custom class name when className prop is provided', () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass('custom-class'); +}); + +test(`Renders with default ${styles.compassPanel} class`, () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass(styles.compassPanel); +}); + +test(`Renders with ${styles.modifiers.pill} when isPill is true`, () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass(styles.modifiers.pill); +}); + +test(`Renders with ${styles.modifiers.noBorder} when hasNoBorder is true`, () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass(styles.modifiers.noBorder); +}); + +test(`Renders with ${styles.modifiers.noPadding} when hasNoPadding is true`, () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass(styles.modifiers.noPadding); +}); + +test('Renders with pf-v6-m-thinking when isThinking is true', () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass('pf-v6-m-thinking'); +}); + +test(`Renders with ${styles.modifiers.fullHeight} when isFullHeight is true`, () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass(styles.modifiers.fullHeight); +}); + +test(`Renders with ${styles.modifiers.scrollable} when isScrollable is true`, () => { + render(Test); + expect(screen.getByText('Test')).toHaveClass(styles.modifiers.scrollable); +}); + +test('Renders with multiple modifier classes when multiple props are true', () => { + render( + + Test + + ); + const panelElement = screen.getByText('Test'); + expect(panelElement).toHaveClass(styles.modifiers.pill); + expect(panelElement).toHaveClass(styles.modifiers.noBorder); + expect(panelElement).toHaveClass(styles.modifiers.noPadding); + expect(panelElement).toHaveClass('pf-v6-m-thinking'); + expect(panelElement).toHaveClass(styles.modifiers.fullHeight); + expect(panelElement).toHaveClass(styles.modifiers.scrollable); +}); + +test('Renders with additional props spread to the component', () => { + render(Test); + expect(screen.getByText('Test')).toHaveAccessibleName('Test label'); +}); + +test('Matches the snapshot with all modifiers', () => { + const { asFragment } = render( + +
Panel with all modifiers
+
+ ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('Matches the snapshot with no modifiers', () => { + const { asFragment } = render( + +
Basic panel
+
+ ); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-core/src/components/Compass/__tests__/__snapshots__/Compass.test.tsx.snap b/packages/react-core/src/components/Compass/__tests__/__snapshots__/Compass.test.tsx.snap new file mode 100644 index 00000000000..5efef8172f5 --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/__snapshots__/Compass.test.tsx.snap @@ -0,0 +1,104 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Matches the snapshot with basic layout 1`] = ` + +
+
+
+ Header +
+
+
+
+ Sidebar start +
+
+
+
+ Main content +
+
+
+
+ Sidebar end +
+
+ +
+
+`; + +exports[`Matches the snapshot with drawer 1`] = ` + +
+
+
+
+
+
+ Header +
+
+
+
+ Sidebar start +
+
+
+
+ Main content +
+
+
+
+ Sidebar end +
+
+ +
+
+
+ Drawer content +
+
+
+
+`; diff --git a/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassContent.test.tsx.snap b/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassContent.test.tsx.snap new file mode 100644 index 00000000000..ba18b59bb71 --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassContent.test.tsx.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Matches the snapshot with drawer 1`] = ` + +
+
+
+
+
+ Test content +
+
+
+
+ Drawer content +
+
+
+
+`; + +exports[`Matches the snapshot without drawer 1`] = ` + +
+
+ Test content +
+
+
+`; diff --git a/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassHeader.test.tsx.snap b/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassHeader.test.tsx.snap new file mode 100644 index 00000000000..f3e52b6d77d --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassHeader.test.tsx.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Matches the snapshot 1`] = ` + + +
+
+ Nav +
+
+
+
+ Profile +
+
+
+`; diff --git a/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassHero.test.tsx.snap b/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassHero.test.tsx.snap new file mode 100644 index 00000000000..f130da9f4dc --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassHero.test.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Matches the snapshot 1`] = ` + +
+
+
+ Hero content +
+
+
+
+`; diff --git a/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassMainHeader.test.tsx.snap b/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassMainHeader.test.tsx.snap new file mode 100644 index 00000000000..a64dcc96592 --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassMainHeader.test.tsx.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Matches the snapshot with both title and toolbar 1`] = ` + +
+
+
+
+
+ Title +
+
+
+
+ Toolbar +
+
+
+
+
+
+`; + +exports[`Matches the snapshot with children 1`] = ` + +
+ Custom children content +
+
+`; diff --git a/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassMessageBar.test.tsx.snap b/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassMessageBar.test.tsx.snap new file mode 100644 index 00000000000..edde4c73946 --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassMessageBar.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Matches the snapshot 1`] = ` + +
+
+ Message bar content +
+
+
+`; diff --git a/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassPanel.test.tsx.snap b/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassPanel.test.tsx.snap new file mode 100644 index 00000000000..d9557da1c91 --- /dev/null +++ b/packages/react-core/src/components/Compass/__tests__/__snapshots__/CompassPanel.test.tsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Matches the snapshot with all modifiers 1`] = ` + +
+
+ Panel with all modifiers +
+
+
+`; + +exports[`Matches the snapshot with no modifiers 1`] = ` + +
+
+ Basic panel +
+
+
+`; diff --git a/packages/react-core/src/components/Compass/examples/Compass.md b/packages/react-core/src/components/Compass/examples/Compass.md new file mode 100644 index 00000000000..60433d031ad --- /dev/null +++ b/packages/react-core/src/components/Compass/examples/Compass.md @@ -0,0 +1,38 @@ +--- +id: Compass +cssPrefix: pf-v6-c-compass +section: components +beta: true +propComponents: ['Compass', 'CompassHeader', 'CompassContent', 'CompassHero', 'CompassMainHeader', 'CompassPanel'] +--- + +import './compass.css'; +import { useRef, useState } from 'react'; +import PlayIcon from '@patternfly/react-icons/dist/esm/icons/play-icon'; +import OutlinedPlusSquare from '@patternfly/react-icons/dist/esm/icons/outlined-plus-square-icon'; +import OutlinedCopy from '@patternfly/react-icons/dist/esm/icons/outlined-copy-icon'; +import OutlinedQuestionCircleIcon from '@patternfly/react-icons/dist/esm/icons/outlined-question-circle-icon'; + +## Examples + +### Basic + +In a basic compass layout, content can be passed to the following props to populate areas of the page: + +- `header`: content rendered in the top of the page. This will typically be a `CompassHeader` component to break the header into 3 areas consisting of a logo or brand, middle navigation, and profile. +- `sidebarStart`: content rendered in the left side or start side of the page +- `main`: content rendered in the center of the page. This will typically consist of a `CompassMainHeader` or `CompassHero`, along with a `CompassContent` filled with one or more `CompassPanel` components. +- `sidebarEnd`: content rendered in the right side or end side of the page +- `footer`: content rendered in the bottom of the page + +The background image of the `Compass` and `CompassHero` may be customized by using their respective `backgroundSrcLight` and `backgroundSrcDark` props. The `CompassHero` also allows customization of a color gradient across its container by using the `gradientLight` and `gradientDark` props. + +```ts file="CompassBasic.tsx" + +``` + +### Demo + +```ts isFullscreen file="CompassDemo.tsx" + +``` diff --git a/packages/react-core/src/components/Compass/examples/CompassBasic.tsx b/packages/react-core/src/components/Compass/examples/CompassBasic.tsx new file mode 100644 index 00000000000..f43c55b79bb --- /dev/null +++ b/packages/react-core/src/components/Compass/examples/CompassBasic.tsx @@ -0,0 +1,33 @@ +import { Compass, CompassHeader, CompassHero, CompassContent, CompassMainHeader } from '@patternfly/react-core'; +import './compass.css'; + +export const CompassBasic: React.FunctionComponent = () => { + const headerContent = Logo} nav={
Nav
} profile={
Profile
} />; + const sidebarStartContent =
Sidebar start
; + // TODO: simplify mainContent to only a div string + const mainContent = ( + <> + +
Hero
+
+ + +
Content title
+
+
Content
+
+ + ); + const sidebarEndContent =
Sidebar end
; + const footerContent =
Footer
; + + return ( + + ); +}; diff --git a/packages/react-core/src/components/Compass/examples/CompassDemo.tsx b/packages/react-core/src/components/Compass/examples/CompassDemo.tsx new file mode 100644 index 00000000000..f3b631aa2b4 --- /dev/null +++ b/packages/react-core/src/components/Compass/examples/CompassDemo.tsx @@ -0,0 +1,151 @@ +import { useRef, useState } from 'react'; +import { + Compass, + CompassHeader, + CompassHero, + CompassContent, + CompassMainHeader, + CompassPanel, + CompassMessageBar, + Tabs, + TabsComponent, + Tab, + TabContent, + TabTitleText, + ActionList, + ActionListGroup, + ActionListItem, + Button, + Title, + Tooltip +} from '@patternfly/react-core'; +import PlayIcon from '@patternfly/react-icons/dist/esm/icons/play-icon'; +import OutlinedPlusSquare from '@patternfly/react-icons/dist/esm/icons/outlined-plus-square-icon'; +import OutlinedCopy from '@patternfly/react-icons/dist/esm/icons/outlined-copy-icon'; +import OutlinedQuestionCircleIcon from '@patternfly/react-icons/dist/esm/icons/outlined-question-circle-icon'; + +export const CompassBasic: React.FunctionComponent = () => { + const [activeTab, setActiveTab] = useState(0); + const [activeSubtab, setActiveSubtab] = useState(0); + const subTabsRef = useRef(null); + + const navContent = ( + <> + + setActiveTab(tabIndex as number)} + component={TabsComponent.nav} + aria-label="Compass navigation tabs" + inset={{ default: 'insetXl' }} + > + Tab 1} + aria-label="Compass tab with subtabs" + /> + Tab 2} /> + Tab 3} /> + Disabled Tab 4} isDisabled /> + + + + + setActiveSubtab(tabIndex as number)} + aria-label="Compass navigation subtabs" + inset={{ default: 'insetXl' }} + > + +
Subtab 1
+ + } + /> + Subtab 2} /> + Disabled Subtab 3} isDisabled /> +
+
+
+ + ); + + const sidebarContent = ( + + + + + +