diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/Deck.md b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/Deck.md new file mode 100644 index 00000000..e10582e6 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/Deck.md @@ -0,0 +1,45 @@ +--- +# Sidenav top-level section +# should be the same for all markdown files +section: Component groups +subsection: Content containers +# Sidenav secondary level section +# should be the same for all markdown files +id: Deck +# Tab (react | react-demos | html | html-demos | design-guidelines | accessibility) +source: react +# If you use typescript, the name of the interface to display props for +# These are found through the sourceProps function provided in patternfly-docs.source.js +propComponents: ['Deck', 'DeckPage', 'DeckButton', 'ModalDeck'] +sourceLink: https://github.com/patternfly/react-component-groups/blob/main/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/Deck.md +--- + +import Deck from '@patternfly/react-component-groups/dist/dynamic/Deck'; +import { ModalDeck } from '@patternfly/react-component-groups/dist/dynamic/ModalDeck'; +import { FunctionComponent, useState } from 'react'; + +The **deck** component is a compact, sequential container for presenting a suite of static announcements or an informational walkthrough. It is not intended for task completion or form-filling workflows. + +## Examples + +### Basic deck + +This example demonstrates the basic deck with automatic navigation. Buttons can use the `navigation` prop to automatically handle page changes: +- `navigation: 'next'` - Advances to the next page +- `navigation: 'previous'` - Goes back to the previous page +- `navigation: 'close'` - Triggers the onClose callback + +You can also add custom `onClick` handlers for analytics, validation, or other logic. The custom `onClick` will be called **before** the automatic navigation occurs. + +```ts file="./DeckExample.tsx" + +``` + +### Modal deck + +Display the deck in a modal dialog. The `ModalDeck` component wraps the Deck in a PatternFly Modal without a close button or extra padding, ideal for guided walkthroughs that require user interaction. + +```ts file="./ModalDeckExample.tsx" + +``` + diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/DeckDemos.md b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/DeckDemos.md new file mode 100644 index 00000000..b736193f --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/DeckDemos.md @@ -0,0 +1,26 @@ +--- +# Sidenav top-level section +# should be the same for all markdown files +section: Component groups +subsection: Content containers +# Sidenav secondary level section +# should be the same for all markdown files +id: Deck +# Tab (react | react-demos | html | html-demos | design-guidelines | accessibility) +source: react-demos +sourceLink: https://github.com/patternfly/react-component-groups/blob/main/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/DeckDemos.md +--- +import { FunctionComponent, useState } from 'react'; + +import Deck from '@patternfly/react-component-groups/dist/dynamic/Deck'; +import { ModalDeck } from '@patternfly/react-component-groups/dist/dynamic/ModalDeck'; + +## Demos + +### Onboarding modal deck + +A complete onboarding experience using ModalDeck to guide users through key product features. This demo demonstrates a multi-step walkthrough with custom styling, labels, and content. + +```ts file="./OnboardingModalDeckDemo.tsx" + +``` \ No newline at end of file diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/DeckExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/DeckExample.tsx new file mode 100644 index 00000000..39644656 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/DeckExample.tsx @@ -0,0 +1,87 @@ +/* eslint-disable no-console */ +import { FunctionComponent, useState } from 'react'; +import Deck, { DeckButton } from '@patternfly/react-component-groups/dist/dynamic/Deck'; +import { ButtonVariant } from '@patternfly/react-core'; + +export const BasicExample: FunctionComponent = () => { + const [ deckKey, setDeckKey ] = useState(0); + + // Simulated analytics function + const trackEvent = (eventName, data) => { + console.log('Analytics:', eventName, data); + }; + + const restartDeck = () => { + setDeckKey(prev => prev + 1); + trackEvent('deck_restarted', {}); + }; + + const pages = [ + { + content: ( +
+

This is the first page of your informational walkthrough.

+
+ ), + buttons: [ + { + children: 'Next', + variant: ButtonVariant.primary, + navigation: 'next', + // Custom onClick for analytics - called before navigation + onClick: () => trackEvent('deck_next_clicked', { fromPage: 1 }) + } + ] as DeckButton[] + }, + { + content: ( +
+

Continue through your walkthrough.

+
+ ), + buttons: [ + { + children: 'Next', + variant: ButtonVariant.primary, + navigation: 'next', + onClick: () => trackEvent('deck_next_clicked', { fromPage: 2 }) + } + ] as DeckButton[] + }, + { + content: ( +
+

You've reached the end of the deck.

+
+ ), + buttons: [ + { + children: 'Restart', + variant: ButtonVariant.primary, + // Restart the deck for demo purposes + onClick: () => { + trackEvent('deck_completed', { totalPages: 3 }); + console.log('Deck completed! Restarting...'); + restartDeck(); + } + } + ] + } + ]; + + return ( + { + console.log('Current page:', index); + trackEvent('deck_page_changed', { page: index + 1 }); + }} + onClose={() => { + trackEvent('deck_closed', {}); + console.log('Deck closed'); + }} + /> + ); +}; + diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/ModalDeckExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/ModalDeckExample.tsx new file mode 100644 index 00000000..5b37e245 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/ModalDeckExample.tsx @@ -0,0 +1,78 @@ +/* eslint-disable no-console */ +import { FunctionComponent, useState } from 'react'; +import Deck, { DeckButton } from '@patternfly/react-component-groups/dist/dynamic/Deck'; +import { ModalDeck } from '@patternfly/react-component-groups/dist/dynamic/ModalDeck'; +import { Button, ButtonVariant } from '@patternfly/react-core'; + +export const ModalDeckExample: FunctionComponent = () => { + const [ isModalOpen, setIsModalOpen ] = useState(false); + + const handleClose = () => { + setIsModalOpen(false); + console.log('Modal deck closed'); + }; + + const pages = [ + { + content: ( +
+

Welcome to the Modal Deck

+

This deck is displayed in a modal dialog.

+
+ ), + buttons: [ + { + children: 'Next', + variant: ButtonVariant.primary, + navigation: 'next' + } + ] as DeckButton[] + }, + { + content: ( +
+

Page 2

+

Navigate through the walkthrough.

+
+ ), + buttons: [ + { + children: 'Next', + variant: ButtonVariant.primary, + navigation: 'next' + } + ] as DeckButton[] + }, + { + content: ( +
+

Final Page

+

Click Close to finish.

+
+ ), + buttons: [ + { + children: 'Close', + variant: ButtonVariant.primary, + navigation: 'close' + } + ] as DeckButton[] + } + ]; + + return ( + <> + + + console.log('Page changed to:', index + 1)} + /> + + + ); +}; + diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/OnboardingModalDeckDemo.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/OnboardingModalDeckDemo.tsx new file mode 100644 index 00000000..bf94a9cb --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/OnboardingModalDeckDemo.tsx @@ -0,0 +1,130 @@ +/* eslint-disable no-console */ +import { FunctionComponent, useState } from 'react'; +import Deck, { DeckButton } from '@patternfly/react-component-groups/dist/dynamic/Deck'; +import { ModalDeck } from '@patternfly/react-component-groups/dist/dynamic/ModalDeck'; +import { Button, ButtonVariant, Label, Title, Stack, StackItem, Content } from '@patternfly/react-core'; + +export const OnboardingModalDeckDemo: FunctionComponent = () => { + const [ isModalOpen, setIsModalOpen ] = useState(false); + + const handleClose = () => { + setIsModalOpen(false); + console.log('Onboarding completed or skipped'); + }; + + // Placeholder for illustration - in a real app, replace with actual images + const placeholderImage = ( +
+ ); + + const pages = [ + { + content: ( + + {placeholderImage} + + Welcome to [Product Name] + + + + Harness the full potential of the hybrid cloud, simply by asking. + + + + ), + buttons: [ + { + children: 'Continue', + variant: ButtonVariant.primary, + navigation: 'next' + } + ] as DeckButton[] + }, + { + content: ( + + {placeholderImage} + + Intelligence at your command + Ask anything. Get answers. Troubleshoot, analyze, and understand your entire fleet just by asking. It's the power of your data, in plain language. + + ), + buttons: [ + { + children: 'Continue', + variant: ButtonVariant.primary, + navigation: 'next' + } + ] as DeckButton[] + }, + { + content: ( + + {placeholderImage} + + Go from conversation to clarity. + Transform answers into custom dashboards. In Canvas Mode, you can effortlessly arrange, customize, and build the precise view you need to monitor what matters most. + + ), + buttons: [ + { + children: 'Continue', + variant: ButtonVariant.primary, + navigation: 'next' + } + ] as DeckButton[] + }, + { + content: ( + + {placeholderImage} + + Share your vision. Instantly. + An insight is only powerful when it’s shared. Save any view to your library and share it with your team in a single click. Drive decisions, together. + + ), + buttons: [ + { + children: 'Get started', + variant: ButtonVariant.primary, + navigation: 'close' + } + ] as DeckButton[] + } + ]; + + return ( + <> + + + console.log('Onboarding page:', index + 1)} + ariaLabel="Product onboarding" + ariaRoleDescription="onboarding walkthrough" + contentFlexProps={{ + spaceItems: { default: 'spaceItemsXl' } + }} + /> + + + ); +}; + diff --git a/packages/module/src/Deck/Deck.test.tsx b/packages/module/src/Deck/Deck.test.tsx new file mode 100644 index 00000000..bd298498 --- /dev/null +++ b/packages/module/src/Deck/Deck.test.tsx @@ -0,0 +1,263 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Deck, { DeckButton } from './Deck'; +import { ButtonVariant } from '@patternfly/react-core'; + +describe('Deck component', () => { + const mockPages = [ + { + content:
Page 1 content
, + buttons: [ + { + children: 'Next', + variant: ButtonVariant.primary, + navigation: 'next' as const + } + ] as DeckButton[] + }, + { + content:
Page 2 content
, + buttons: [ + { + children: 'Previous', + variant: ButtonVariant.secondary, + navigation: 'previous' as const + }, + { + children: 'Next', + variant: ButtonVariant.primary, + navigation: 'next' as const + } + ] as DeckButton[] + }, + { + content:
Page 3 content
, + buttons: [ + { + children: 'Close', + variant: ButtonVariant.primary, + navigation: 'close' as const + } + ] as DeckButton[] + } + ]; + + test('should render with basic pages', () => { + const pages = [ + { + content:
Page 1 content
, + buttons: [] + } + ]; + + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + test('should render first page content by default', () => { + render(); + expect(screen.getByText('Page 1 content')).toBeInTheDocument(); + }); + + test('should render with hideProgressDots', () => { + const pages = [ + { + content:
Page 1 content
, + buttons: [] + } + ]; + + const { container } = render( + + ); + + const progressDots = container.querySelector('[data-ouia-component-id="Deck-progress-dots"]'); + expect(progressDots).not.toBeInTheDocument(); + }); + + test('should not render progress dots when only one page', () => { + const pages = [ + { + content:
Single page
, + buttons: [] + } + ]; + + const { container } = render(); + const progressDots = container.querySelector('[data-ouia-component-id="Deck-progress-dots"]'); + expect(progressDots).not.toBeInTheDocument(); + }); + + test('should navigate to next page when next button is clicked', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByText('Page 1 content')).toBeInTheDocument(); + + const nextButton = screen.getByRole('button', { name: 'Next' }); + await user.click(nextButton); + + expect(screen.getByText('Page 2 content')).toBeInTheDocument(); + }); + + test('should navigate to previous page when previous button is clicked', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByText('Page 2 content')).toBeInTheDocument(); + + const previousButton = screen.getByRole('button', { name: 'Previous' }); + await user.click(previousButton); + + expect(screen.getByText('Page 1 content')).toBeInTheDocument(); + }); + + test('should call onClose when close button is clicked', async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + + render(); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + await user.click(closeButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test('should call onPageChange callback when page changes', async () => { + const user = userEvent.setup(); + const onPageChange = jest.fn(); + + render(); + + const nextButton = screen.getByRole('button', { name: 'Next' }); + await user.click(nextButton); + + expect(onPageChange).toHaveBeenCalledWith(1); + }); + + test('should call custom onClick before navigation', async () => { + const user = userEvent.setup(); + const customOnClick = jest.fn(); + + const pagesWithCustomClick = [ + { + content:
Page 1
, + buttons: [ + { + children: 'Next', + variant: ButtonVariant.primary, + navigation: 'next' as const, + onClick: customOnClick + } + ] as DeckButton[] + }, + { + content:
Page 2
, + buttons: [] + } + ]; + + render(); + + const nextButton = screen.getByRole('button', { name: 'Next' }); + await user.click(nextButton); + + expect(customOnClick).toHaveBeenCalledTimes(1); + expect(screen.getByText('Page 2')).toBeInTheDocument(); + }); + + test('should start at initialPage', () => { + render(); + expect(screen.getByText('Page 2 content')).toBeInTheDocument(); + }); + + test('should render with custom OUIA ID', () => { + const { container } = render(); + const element = container.querySelector('[data-ouia-component-id="CustomDeck"]'); + expect(element).toBeInTheDocument(); + }); + + test('should apply custom text alignment class', () => { + const { container } = render(); + const contentElement = container.querySelector('[data-ouia-component-id="Deck-content"]'); + expect(contentElement).toHaveClass('pf-v6-u-text-align-left'); + }); + + test('should not apply text alignment class when textAlign is false', () => { + const { container } = render(); + const contentElement = container.querySelector('[data-ouia-component-id="Deck-content"]'); + expect(contentElement).not.toHaveClass('pf-v6-u-text-align-center'); + }); + + test('should render with custom aria labels', () => { + const { container } = render( + + ); + + const region = container.querySelector('[role="region"]'); + expect(region).toHaveAttribute('aria-label', 'Custom deck label'); + expect(region).toHaveAttribute('aria-roledescription', 'Custom role description'); + }); + + test('should render accessible page info for screen readers', () => { + const { container } = render(); + const pageInfo = container.querySelector('.pf-v6-screen-reader'); + expect(pageInfo).toBeInTheDocument(); + expect(pageInfo).toHaveTextContent('Page 1 of 3'); + }); + + test('should update page info when page changes', async () => { + const user = userEvent.setup(); + const { container } = render(); + + const nextButton = screen.getByRole('button', { name: 'Next' }); + await user.click(nextButton); + + const pageInfo = container.querySelector('.pf-v6-screen-reader'); + expect(pageInfo).toHaveTextContent('Page 2 of 3'); + }); + + test('should use custom getPageLabel function', () => { + const getPageLabel = (current: number, total: number) => `Step ${current}/${total}`; + const { container } = render( + + ); + + const pageInfo = container.querySelector('.pf-v6-screen-reader'); + expect(pageInfo).toHaveTextContent('Step 1/3'); + }); + + test('should render correct number of progress dots', () => { + const { container } = render(); + const dots = container.querySelectorAll('[data-ouia-component-id="Deck-progress-dots"] span[aria-hidden="true"]'); + expect(dots).toHaveLength(3); + }); + + test('should not navigate beyond last page', async () => { + const onPageChange = jest.fn(); + + render(); + + expect(screen.getByText('Page 3 content')).toBeInTheDocument(); + + // There's no next button on the last page in our mock, so the page shouldn't change + // This tests the boundary condition + expect(onPageChange).not.toHaveBeenCalled(); + }); + + test('should not navigate before first page', async () => { + const onPageChange = jest.fn(); + + render(); + + expect(screen.getByText('Page 1 content')).toBeInTheDocument(); + + // There's no previous button on the first page in our mock + expect(onPageChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/module/src/Deck/Deck.tsx b/packages/module/src/Deck/Deck.tsx new file mode 100644 index 00000000..89ed6f0a --- /dev/null +++ b/packages/module/src/Deck/Deck.tsx @@ -0,0 +1,183 @@ +import type { FunctionComponent, ReactNode } from 'react'; +import { useState } from 'react'; +import { ActionList, ActionListItem, ActionListGroup, Bullseye, Button, ButtonProps, Flex, FlexItem, FlexProps } from '@patternfly/react-core'; + +/** + * extends PatternFly's ButtonProps + */ +export interface DeckButton extends ButtonProps { + /** Automatically navigate to next/previous page or close the deck when clicked */ + navigation?: 'next' | 'previous' | 'close'; +} + +export interface DeckPage { + /** Content to display on this page */ + content: ReactNode; + /** Array of button configurations for this page */ + buttons?: DeckButton[]; +} + +export interface DeckProps { + /** Array of pages to display in the deck */ + pages: DeckPage[]; + /** Deck className */ + className?: string; + /** Custom OUIA ID */ + ouiaId?: string; + /** Hide the progress dots indicator */ + hideProgressDots?: boolean; + /** Initial page index to display (0-based) */ + initialPage?: number; + /** Callback when page changes */ + onPageChange?: (pageIndex: number) => void; + /** Callback when deck is closed/cancelled */ + onClose?: () => void; + /** Additional props for the Flex layout containing content, progress dots, and buttons */ + contentFlexProps?: FlexProps; + /** Text alignment for content (uses PatternFly utility classes). Set to false to disable. */ + textAlign?: 'center' | 'left' | 'right' | false; + /** Accessible label for the deck region */ + ariaLabel?: string; + /** Accessible role description for the deck */ + ariaRoleDescription?: string; + /** Function to generate accessible page info label. Receives (currentPage, totalPages) and returns a string. */ + getPageLabel?: (currentPage: number, totalPages: number) => string; +} + +export const Deck: FunctionComponent = ({ + pages, + className, + ouiaId = 'Deck', + hideProgressDots = false, + initialPage = 0, + onPageChange, + onClose, + contentFlexProps, + textAlign = 'center', + ariaLabel = 'Information deck', + ariaRoleDescription = 'sequential information deck', + getPageLabel = (current, total) => `Page ${current} of ${total}`, + ...props +}: DeckProps) => { + const [ currentPageIndex, setCurrentPageIndex ] = useState(initialPage); + + const handlePageChange = (newIndex: number) => { + if (newIndex >= 0 && newIndex < pages.length) { + setCurrentPageIndex(newIndex); + onPageChange?.(newIndex); + } + }; + + const currentPage = pages[currentPageIndex]; + + // Generate text alignment class if specified + const textAlignClass = textAlign ? `pf-v6-u-text-align-${textAlign}` : ''; + + // Generate accessible label with page information + const pageInfo = getPageLabel(currentPageIndex + 1, pages.length); + const pageInfoId = `${ouiaId}-page-info`; + + return ( + + + {/* Visually hidden page info for screen readers */} + + {pageInfo} + + + {/* Current page content */} + + {currentPage?.content} + + + {/* Progress dots */} + {!hideProgressDots && pages.length > 1 && ( + + + {pages.map((_, index) => ( + + + ))} + + + )} + + {/* Page buttons */} + {currentPage?.buttons && currentPage.buttons.length > 0 && ( + + + + {currentPage.buttons.map((buttonConfig, index) => { + const { navigation, onClick, ...buttonProps } = buttonConfig; + + // Auto-wire navigation if specified + const handleClick = (event: React.MouseEvent) => { + // Call user's custom onClick first if provided + onClick?.(event); + + // Then handle navigation + if (navigation === 'next') { + handlePageChange(currentPageIndex + 1); + } else if (navigation === 'previous') { + handlePageChange(currentPageIndex - 1); + } else if (navigation === 'close') { + onClose?.(); + } + }; + + return ( + + +
+ + ); + + expect(screen.getByText('Modal content')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + test('should not render when closed', () => { + render( + +
+ Modal content + +
+
+ ); + + expect(screen.queryByText('Modal content')).not.toBeInTheDocument(); + }); + + test('should render children', () => { + render( + +
+ Test content + +
+
+ ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + expect(screen.getByText('Test content')).toBeInTheDocument(); + }); + + test('should apply small variant by default', () => { + render( + +
+ Content + +
+
+ ); + + const modal = screen.getByRole('dialog'); + expect(modal).toBeInTheDocument(); + // Small variant is the default, just verify modal renders + }); + + test('should pass through modalProps', () => { + render( + +
+ Content + +
+
+ ); + + const modal = screen.getByRole('dialog'); + expect(modal).toHaveAttribute('aria-label', 'Custom modal label'); + expect(modal).toHaveAttribute('aria-describedby', 'custom-description'); + }); + + test('should override variant through modalProps', () => { + render( + +
+ Content + +
+
+ ); + + const modal = screen.getByRole('dialog'); + expect(modal).toBeInTheDocument(); + // Variant override is passed through modalProps, just verify modal renders + }); +}); + diff --git a/packages/module/src/ModalDeck/ModalDeck.tsx b/packages/module/src/ModalDeck/ModalDeck.tsx new file mode 100644 index 00000000..11e4a801 --- /dev/null +++ b/packages/module/src/ModalDeck/ModalDeck.tsx @@ -0,0 +1,30 @@ +import type { FunctionComponent, ReactNode } from 'react'; +import { Modal, ModalBody, ModalProps } from '@patternfly/react-core'; + +export interface ModalDeckProps { + /** Whether the modal is open */ + isOpen: boolean; + /** Deck component to display in the modal */ + children: ReactNode; + /** Additional Modal props */ + modalProps?: Omit; +} + +export const ModalDeck: FunctionComponent = ({ + isOpen, + children, + modalProps +}: ModalDeckProps) => ( + + + {children} + + +); + +export default ModalDeck; + diff --git a/packages/module/src/ModalDeck/__snapshots__/ModalDeck.test.tsx.snap b/packages/module/src/ModalDeck/__snapshots__/ModalDeck.test.tsx.snap new file mode 100644 index 00000000..49048f97 --- /dev/null +++ b/packages/module/src/ModalDeck/__snapshots__/ModalDeck.test.tsx.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ModalDeck component should render when open 1`] = ` +