Skip to content

Commit c489fdf

Browse files
committed
feat(Deck): add new Deck component
1 parent 459c09e commit c489fdf

File tree

6 files changed

+342
-0
lines changed

6 files changed

+342
-0
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
# Sidenav top-level section
3+
# should be the same for all markdown files
4+
section: Component groups
5+
subsection: Content containers
6+
# Sidenav secondary level section
7+
# should be the same for all markdown files
8+
id: Deck
9+
# Tab (react | react-demos | html | html-demos | design-guidelines | accessibility)
10+
source: react
11+
# If you use typescript, the name of the interface to display props for
12+
# These are found through the sourceProps function provided in patternfly-docs.source.js
13+
propComponents: ['Deck', 'DeckPage', 'DeckButton']
14+
sourceLink: https://github.com/patternfly/react-component-groups/blob/main/packages/module/patternfly-docs/content/extensions/component-groups/examples/Deck/Deck.md
15+
---
16+
17+
import Deck from '@patternfly/react-component-groups/dist/dynamic/Deck';
18+
import { FunctionComponent, useState } from 'react';
19+
20+
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.
21+
22+
## Examples
23+
24+
### Basic deck
25+
26+
This example demonstrates the basic deck with automatic navigation. Buttons can use the `navigation` prop to automatically handle page changes:
27+
- `navigation: 'next'` - Advances to the next page
28+
- `navigation: 'previous'` - Goes back to the previous page
29+
- `navigation: 'close'` - Triggers the onClose callback
30+
31+
You can also add custom `onClick` handlers for analytics, validation, or other logic. The custom `onClick` will be called **before** the automatic navigation occurs.
32+
33+
```ts file="./DeckExample.tsx"
34+
35+
```
36+
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/* eslint-disable no-console */
2+
import React, { FunctionComponent, useState } from 'react';
3+
import Deck, { DeckButton } from '@patternfly/react-component-groups/dist/dynamic/Deck';
4+
import { ButtonVariant } from '@patternfly/react-core';
5+
6+
export const BasicExample: FunctionComponent = () => {
7+
const [deckKey, setDeckKey] = useState(0);
8+
9+
// Simulated analytics function
10+
const trackEvent = (eventName, data) => {
11+
console.log('Analytics:', eventName, data);
12+
};
13+
14+
const restartDeck = () => {
15+
setDeckKey(prev => prev + 1);
16+
trackEvent('deck_restarted', {});
17+
};
18+
19+
const pages = [
20+
{
21+
content: (
22+
<div>
23+
<h2>Welcome to the Deck</h2>
24+
<p>This is the first page of your informational walkthrough.</p>
25+
</div>
26+
),
27+
buttons: [
28+
{
29+
children: 'Next',
30+
variant: ButtonVariant.primary,
31+
navigation: 'next',
32+
// Custom onClick for analytics - called before navigation
33+
onClick: () => trackEvent('deck_next_clicked', { from_page: 1 })
34+
}
35+
] as DeckButton[]
36+
},
37+
{
38+
content: (
39+
<div>
40+
<h2>Page 2</h2>
41+
<p>Continue through your walkthrough.</p>
42+
</div>
43+
),
44+
buttons: [
45+
{
46+
children: 'Next',
47+
variant: ButtonVariant.primary,
48+
navigation: 'next',
49+
onClick: () => trackEvent('deck_next_clicked', { from_page: 2 })
50+
}
51+
] as DeckButton[]
52+
},
53+
{
54+
content: (
55+
<div>
56+
<h2>Final Page</h2>
57+
<p>You've reached the end of the deck.</p>
58+
</div>
59+
),
60+
buttons: [
61+
{
62+
children: 'Restart',
63+
variant: ButtonVariant.primary,
64+
// Restart the deck for demo purposes
65+
onClick: () => {
66+
trackEvent('deck_completed', { total_pages: 3 });
67+
console.log('Deck completed! Restarting...');
68+
restartDeck();
69+
}
70+
}
71+
]
72+
}
73+
];
74+
75+
return (
76+
<Deck
77+
key={deckKey}
78+
pages={pages}
79+
onPageChange={(index) => {
80+
console.log('Current page:', index);
81+
trackEvent('deck_page_changed', { page: index + 1 });
82+
}}
83+
onClose={() => {
84+
trackEvent('deck_closed', {});
85+
console.log('Deck closed');
86+
}}
87+
/>
88+
);
89+
};
90+
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { render } from '@testing-library/react';
2+
import Deck from './Deck';
3+
4+
describe('Deck component', () => {
5+
test('should render', () => {
6+
const pages = [
7+
{
8+
content: <div>Page 1 content</div>,
9+
buttons: []
10+
},
11+
{
12+
content: <div>Page 2 content</div>,
13+
buttons: []
14+
}
15+
];
16+
17+
expect(render(
18+
<Deck pages={pages} />
19+
)).toMatchSnapshot();
20+
});
21+
22+
test('should render with hideProgressDots', () => {
23+
const pages = [
24+
{
25+
content: <div>Page 1 content</div>,
26+
buttons: []
27+
}
28+
];
29+
30+
const { container } = render(
31+
<Deck pages={pages} hideProgressDots />
32+
);
33+
34+
const progressDots = container.querySelector('[data-ouia-component-id="Deck-progress-dots"]');
35+
expect(progressDots).not.toBeInTheDocument();
36+
});
37+
});
38+

packages/module/src/Deck/Deck.tsx

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import type { FunctionComponent, ReactNode } from 'react';
2+
import { useState } from 'react';
3+
import { ActionList, ActionListItem, Bullseye, Button, ButtonProps, Flex, FlexItem, FlexProps } from '@patternfly/react-core';
4+
5+
/**
6+
* extends PatternFly's ButtonProps
7+
*/
8+
export interface DeckButton extends ButtonProps {
9+
/** Automatically navigate to next/previous page or close the deck when clicked */
10+
navigation?: 'next' | 'previous' | 'close';
11+
}
12+
13+
export interface DeckPage {
14+
/** Content to display on this page */
15+
content: ReactNode;
16+
/** Array of button configurations for this page */
17+
buttons?: DeckButton[];
18+
}
19+
20+
export interface DeckProps {
21+
/** Array of pages to display in the deck */
22+
pages: DeckPage[];
23+
/** Deck className */
24+
className?: string;
25+
/** Custom OUIA ID */
26+
ouiaId?: string;
27+
/** Hide the progress dots indicator */
28+
hideProgressDots?: boolean;
29+
/** Initial page index to display (0-based) */
30+
initialPage?: number;
31+
/** Callback when page changes */
32+
onPageChange?: (pageIndex: number) => void;
33+
/** Callback when deck is closed/cancelled */
34+
onClose?: () => void;
35+
/** Additional props for the Flex layout containing content, progress dots, and buttons */
36+
contentFlexProps?: FlexProps;
37+
/** Text alignment for content (uses PatternFly utility classes). Set to false to disable. */
38+
textAlign?: 'center' | 'left' | 'right' | false;
39+
/** Accessible label for the deck region */
40+
ariaLabel?: string;
41+
/** Accessible role description for the deck */
42+
ariaRoleDescription?: string;
43+
/** Function to generate accessible page info label. Receives (currentPage, totalPages) and returns a string. */
44+
getPageLabel?: (currentPage: number, totalPages: number) => string;
45+
}
46+
47+
export const Deck: FunctionComponent<DeckProps> = ({
48+
pages,
49+
className,
50+
ouiaId = 'Deck',
51+
hideProgressDots = false,
52+
initialPage = 0,
53+
onPageChange,
54+
onClose,
55+
contentFlexProps,
56+
textAlign = 'center',
57+
ariaLabel = 'Information deck',
58+
ariaRoleDescription = 'sequential information deck',
59+
getPageLabel = (current, total) => `Page ${current} of ${total}`,
60+
...props
61+
}: DeckProps) => {
62+
const [currentPageIndex, setCurrentPageIndex] = useState(initialPage);
63+
64+
const handlePageChange = (newIndex: number) => {
65+
if (newIndex >= 0 && newIndex < pages.length) {
66+
setCurrentPageIndex(newIndex);
67+
onPageChange?.(newIndex);
68+
}
69+
};
70+
71+
const currentPage = pages[currentPageIndex];
72+
73+
// Generate text alignment class if specified
74+
const textAlignClass = textAlign ? `pf-v6-u-text-align-${textAlign}` : '';
75+
76+
// Generate accessible label with page information
77+
const pageInfo = getPageLabel(currentPageIndex + 1, pages.length);
78+
79+
return (
80+
<Bullseye
81+
className={className}
82+
data-ouia-component-id={ouiaId}
83+
{...props}
84+
>
85+
<Flex
86+
direction={{ default: 'column' }}
87+
spaceItems={{ default: 'spaceItemsLg' }}
88+
alignItems={{ default: 'alignItemsCenter' }}
89+
role="region"
90+
aria-label={ariaLabel}
91+
aria-roledescription={ariaRoleDescription}
92+
{...contentFlexProps}
93+
>
94+
{/* Current page content */}
95+
<FlexItem
96+
className={textAlignClass}
97+
data-ouia-component-id={`${ouiaId}-content`}
98+
aria-live="polite"
99+
aria-atomic="true"
100+
aria-label={pageInfo}
101+
>
102+
{currentPage?.content}
103+
</FlexItem>
104+
105+
{/* Progress dots */}
106+
{!hideProgressDots && pages.length > 1 && (
107+
<FlexItem data-ouia-component-id={`${ouiaId}-progress-dots`}>
108+
<Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
109+
{pages.map((_, index) => (
110+
<FlexItem key={index}>
111+
<span
112+
style={{
113+
width: '6px',
114+
height: '6px',
115+
display: 'inline-block',
116+
borderRadius: 'var(--pf-t--global--border--radius--pill)',
117+
backgroundColor: index === currentPageIndex
118+
? 'var(--pf-t--global--background--color--inverse--default)'
119+
: 'transparent',
120+
border: index === currentPageIndex
121+
? 'none'
122+
: '1px solid var(--pf-t--global--background--color--inverse--default)',
123+
transition: 'all 0.2s ease'
124+
}}
125+
aria-hidden="true"
126+
/>
127+
</FlexItem>
128+
))}
129+
</Flex>
130+
</FlexItem>
131+
)}
132+
133+
{/* Page buttons */}
134+
{currentPage?.buttons && currentPage.buttons.length > 0 && (
135+
<FlexItem>
136+
<ActionList data-ouia-component-id={`${ouiaId}-buttons`}>
137+
{currentPage.buttons.map((buttonConfig, index) => {
138+
const { navigation, onClick, ...buttonProps } = buttonConfig;
139+
140+
// Auto-wire navigation if specified
141+
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
142+
// Call user's custom onClick first if provided
143+
onClick?.(event);
144+
145+
// Then handle navigation
146+
if (navigation === 'next') {
147+
handlePageChange(currentPageIndex + 1);
148+
} else if (navigation === 'previous') {
149+
handlePageChange(currentPageIndex - 1);
150+
} else if (navigation === 'close') {
151+
onClose?.();
152+
}
153+
};
154+
155+
return (
156+
<ActionListItem key={index}>
157+
<Button
158+
{...buttonProps}
159+
onClick={navigation || onClick ? handleClick : undefined}
160+
/>
161+
</ActionListItem>
162+
);
163+
})}
164+
</ActionList>
165+
</FlexItem>
166+
)}
167+
</Flex>
168+
</Bullseye>
169+
);
170+
};
171+
172+
export default Deck;

packages/module/src/Deck/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default } from './Deck';
2+
export * from './Deck';
3+

packages/module/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ export * from './ErrorStack';
7878
export { default as ErrorBoundary } from './ErrorBoundary';
7979
export * from './ErrorBoundary';
8080

81+
export { default as Deck } from './Deck';
82+
export * from './Deck';
83+
8184
export { default as ColumnManagementModal } from './ColumnManagementModal';
8285
export * from './ColumnManagementModal';
8386

0 commit comments

Comments
 (0)