Skip to content

Commit 42b7e54

Browse files
committed
feat(docked nav): add support for docked nav layout
1 parent d5053dd commit 42b7e54

File tree

23 files changed

+741
-49
lines changed

23 files changed

+741
-49
lines changed

packages/react-core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"tslib": "^2.8.1"
5555
},
5656
"devDependencies": {
57-
"@patternfly/patternfly": "6.5.0-prerelease.27",
57+
"@patternfly/patternfly": "6.5.0-prerelease.32",
5858
"case-anything": "^3.1.2",
5959
"css": "^3.0.0",
6060
"fs-extra": "^11.3.0"

packages/react-core/src/components/Compass/Compass.tsx

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import compassBackgroundImageDark from '@patternfly/react-tokens/dist/esm/c_comp
88
export interface CompassProps extends React.HTMLProps<HTMLDivElement> {
99
/** Additional classes added to the Compass. */
1010
className?: string;
11+
/** Content of the docked navigation area of the layout */
12+
dock?: React.ReactNode;
1113
/** Content placed at the top of the layout */
1214
header?: React.ReactNode;
1315
/** Flag indicating if the header is expanded */
@@ -38,6 +40,7 @@ export interface CompassProps extends React.HTMLProps<HTMLDivElement> {
3840

3941
export const Compass: React.FunctionComponent<CompassProps> = ({
4042
className,
43+
dock,
4144
header,
4245
isHeaderExpanded = true,
4346
sidebarStart,
@@ -64,32 +67,45 @@ export const Compass: React.FunctionComponent<CompassProps> = ({
6467
}
6568

6669
const compassContent = (
67-
<div className={css(styles.compass, className)} {...props} style={{ ...props.style, ...backgroundImageStyles }}>
68-
<div
69-
className={css(styles.compassHeader, isHeaderExpanded && 'pf-m-expanded')}
70-
{...(!isHeaderExpanded && { inert: 'true' })}
71-
>
72-
{header}
73-
</div>
74-
<div
75-
className={css(styles.compassSidebar, styles.modifiers.start, isSidebarStartExpanded && 'pf-m-expanded')}
76-
{...(!isSidebarStartExpanded && { inert: 'true' })}
77-
>
78-
{sidebarStart}
79-
</div>
80-
<div className={css(styles.compassMain)}>{main}</div>
81-
<div
82-
className={css(styles.compassSidebar, styles.modifiers.end, isSidebarEndExpanded && 'pf-m-expanded')}
83-
{...(!isSidebarEndExpanded && { inert: 'true' })}
84-
>
85-
{sidebarEnd}
86-
</div>
87-
<div
88-
className={css(styles.compassFooter, isFooterExpanded && 'pf-m-expanded')}
89-
{...(!isFooterExpanded && { inert: 'true' })}
90-
>
91-
{footer}
92-
</div>
70+
<div
71+
className={css(styles.compass, dock !== undefined && styles.modifiers.dock, className)}
72+
{...props}
73+
style={{ ...props.style, ...backgroundImageStyles }}
74+
>
75+
{dock && <div className={css(`${styles.compass}__dock`)}>{dock}</div>}
76+
{header && (
77+
<div
78+
className={css(styles.compassHeader, isHeaderExpanded && 'pf-m-expanded')}
79+
{...(!isHeaderExpanded && { inert: 'true' })}
80+
>
81+
{header}
82+
</div>
83+
)}
84+
{sidebarStart && (
85+
<div
86+
className={css(styles.compassSidebar, styles.modifiers.start, isSidebarStartExpanded && 'pf-m-expanded')}
87+
{...(!isSidebarStartExpanded && { inert: 'true' })}
88+
>
89+
{sidebarStart}
90+
</div>
91+
)}
92+
{main && <div className={css(styles.compassMain)}>{main}</div>}
93+
{sidebarEnd && (
94+
<div
95+
className={css(styles.compassSidebar, styles.modifiers.end, isSidebarEndExpanded && 'pf-m-expanded')}
96+
{...(!isSidebarEndExpanded && { inert: 'true' })}
97+
>
98+
{sidebarEnd}
99+
</div>
100+
)}
101+
{footer && (
102+
<div
103+
className={css(styles.compassFooter, isFooterExpanded && 'pf-m-expanded')}
104+
{...(!isFooterExpanded && { inert: 'true' })}
105+
>
106+
{footer}
107+
</div>
108+
)}
93109
</div>
94110
);
95111

packages/react-core/src/components/Compass/__tests__/Compass.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,13 @@ test('Matches the snapshot with drawer', () => {
170170
);
171171
expect(asFragment()).toMatchSnapshot();
172172
});
173+
174+
test(`Renders with ${styles.modifiers.dock} class when dock is passed`, () => {
175+
render(<Compass dock={<div>Dock content</div>} data-testid="compass" />);
176+
expect(screen.getByTestId('compass')).toHaveClass(styles.modifiers.dock);
177+
});
178+
179+
test(`Does not render with ${styles.modifiers.dock} class when dock is not passed`, () => {
180+
render(<Compass data-testid="compass" />);
181+
expect(screen.getByTestId('compass')).not.toHaveClass(styles.modifiers.dock);
182+
});

packages/react-core/src/components/Compass/examples/Compass.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,18 @@ propComponents:
1717
]
1818
---
1919

20-
import { useRef, useState } from 'react';
20+
import { useRef, useState, useEffect } from 'react';
2121
import PlayIcon from '@patternfly/react-icons/dist/esm/icons/play-icon';
2222
import OutlinedPlusSquare from '@patternfly/react-icons/dist/esm/icons/outlined-plus-square-icon';
2323
import OutlinedCopy from '@patternfly/react-icons/dist/esm/icons/outlined-copy-icon';
2424
import OutlinedQuestionCircleIcon from '@patternfly/react-icons/dist/esm/icons/outlined-question-circle-icon';
25+
import CubeIcon from '@patternfly/react-icons/dist/esm/icons/cube-icon';
26+
import FolderIcon from '@patternfly/react-icons/dist/esm/icons/folder-icon';
27+
import QuestionCircleIcon from '@patternfly/react-icons/dist/esm/icons/question-circle-icon';
28+
import CloudIcon from '@patternfly/react-icons/dist/esm/icons/cloud-icon';
29+
import CodeIcon from '@patternfly/react-icons/dist/esm/icons/code-icon';
30+
import imgAvatar from '../../assets/avatarImg.svg';
31+
import pfLogo from '../../assets/PF-IconLogo-color.svg';
2532

2633
import './compass.css';
2734

@@ -51,6 +58,20 @@ When `footer` is used, its content will fill the width of the screen. By default
5158

5259
```
5360

61+
### With docked nav
62+
63+
As an alternative navigation, a `CompassDock` component may be passed to `Compass` via the `dock` prop. This component will allocate a thin sidebar intended for icons to the start of the screen. The `CompassDock` component has three sub-areas, from top to bottom: logo, main, and tools. Typically a `Brand` or other logo should be passed to the `logo` prop. The `main` and `tools` are flexible and can be passed `Nav`, `ActionList`, or `Toolbar` vertical variants depending on the use case. Account or profile avatars and links would typically be passed as part of the `tools` section at the bottom of the page.
64+
65+
```ts file="CompassDockLayout.tsx"
66+
67+
```
68+
69+
### Docked nav demo
70+
71+
```ts isFullscreen file="CompassDockDemo.tsx"
72+
73+
```
74+
5475
## Composable structure
5576

5677
When building a more custom implementation with Compass components, there are some intended or expected structures that must remain present.
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { useRef, useState, useEffect } from 'react';
2+
import {
3+
Compass,
4+
CompassContent,
5+
CompassMainHeader,
6+
CompassPanel,
7+
Title,
8+
NavItem,
9+
NavList,
10+
Nav,
11+
Brand,
12+
MastheadLogo,
13+
MastheadBrand,
14+
MastheadContent,
15+
MastheadMain,
16+
Masthead,
17+
Toolbar,
18+
ToolbarContent,
19+
ToolbarItem,
20+
ToolbarGroup,
21+
Dropdown,
22+
DropdownList,
23+
MenuToggle,
24+
MenuToggleElement,
25+
DropdownItem,
26+
Button,
27+
ButtonVariant,
28+
Avatar,
29+
Tooltip,
30+
Divider
31+
} from '@patternfly/react-core';
32+
import CubeIcon from '@patternfly/react-icons/dist/esm/icons/cube-icon';
33+
import FolderIcon from '@patternfly/react-icons/dist/esm/icons/folder-icon';
34+
import QuestionCircleIcon from '@patternfly/react-icons/dist/esm/icons/question-circle-icon';
35+
import CloudIcon from '@patternfly/react-icons/dist/esm/icons/cloud-icon';
36+
import CodeIcon from '@patternfly/react-icons/dist/esm/icons/code-icon';
37+
import pfLogo from '../../assets/PF-IconLogo-color.svg';
38+
import imgAvatar from '../../assets/avatarImg.svg';
39+
40+
interface NavOnSelectProps {
41+
groupId: number | string;
42+
itemId: number | string;
43+
to: string;
44+
}
45+
46+
export const CompassDockDemo: React.FunctionComponent = () => {
47+
const [activeItem, setActiveItem] = useState<number>(0);
48+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
49+
const [isOpen, setIsOpen] = useState<boolean>(false);
50+
const menuRef = useRef<HTMLDivElement>(null);
51+
const toggleRef = useRef<HTMLButtonElement>(null);
52+
53+
const onNavSelect = (_event: React.FormEvent<HTMLInputElement>, selectedItem: NavOnSelectProps) => {
54+
typeof selectedItem.itemId === 'number' && setActiveItem(selectedItem.itemId);
55+
};
56+
57+
const onDropdownToggle = () => {
58+
setIsDropdownOpen(!isDropdownOpen);
59+
};
60+
61+
const onDropdownSelect = () => {
62+
setIsDropdownOpen(!isDropdownOpen);
63+
};
64+
65+
const handleMenuKeys = (event: KeyboardEvent) => {
66+
if (!isOpen) {
67+
return;
68+
}
69+
if (menuRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)) {
70+
if (event.key === 'Escape') {
71+
setIsOpen(!isOpen);
72+
toggleRef.current?.focus();
73+
}
74+
}
75+
};
76+
77+
const handleClickOutside = (event: MouseEvent) => {
78+
if (isOpen && !menuRef.current?.contains(event.target as Node)) {
79+
setIsOpen(false);
80+
}
81+
};
82+
83+
useEffect(() => {
84+
window.addEventListener('keydown', handleMenuKeys);
85+
window.addEventListener('click', handleClickOutside);
86+
87+
return () => {
88+
window.removeEventListener('keydown', handleMenuKeys);
89+
window.removeEventListener('click', handleClickOutside);
90+
};
91+
}, [isOpen, menuRef]);
92+
93+
const userDropdownItems = [
94+
<>
95+
<DropdownItem key="group 2 profile">My profile</DropdownItem>
96+
<DropdownItem key="group 2 user">User management</DropdownItem>
97+
<DropdownItem key="group 2 logout">Logout</DropdownItem>
98+
</>
99+
];
100+
101+
const navItem1Ref = useRef<HTMLAnchorElement>(null);
102+
const navItem2Ref = useRef<HTMLAnchorElement>(null);
103+
const navItem3Ref = useRef<HTMLAnchorElement>(null);
104+
const navItem4Ref = useRef<HTMLAnchorElement>(null);
105+
const dockContent = (
106+
<Masthead id="icon-router-link" variant="docked">
107+
<MastheadMain>
108+
<MastheadBrand>
109+
<MastheadLogo component={(props) => <a {...props} href="#" />}>
110+
<Brand src={pfLogo} alt="PatternFly" heights={{ default: '36px' }} />
111+
</MastheadLogo>
112+
</MastheadBrand>
113+
</MastheadMain>
114+
<Divider />
115+
<MastheadContent>
116+
<Toolbar id="toolbar" isVertical>
117+
<ToolbarContent>
118+
<ToolbarItem>
119+
<Nav onSelect={onNavSelect} variant="docked" aria-label="Icon global" ouiaId="IconNav">
120+
<NavList>
121+
<NavItem
122+
key="nav-icon-link1"
123+
preventDefault
124+
id="nav-icon-link1"
125+
to="#nav-icon-link1"
126+
itemId={0}
127+
isActive={activeItem === 0}
128+
icon={<CubeIcon />}
129+
ref={navItem1Ref}
130+
/>
131+
<NavItem
132+
key="nav-icon-link2"
133+
preventDefault
134+
id="nav-icon-link2"
135+
to="#nav-icon-link2"
136+
itemId={1}
137+
isActive={activeItem === 1}
138+
icon={<FolderIcon />}
139+
ref={navItem2Ref}
140+
/>
141+
<NavItem
142+
key="nav-icon-link3"
143+
preventDefault
144+
id="nav-icon-link3"
145+
to="#nav-icon-link3"
146+
itemId={0}
147+
isActive={activeItem === 2}
148+
icon={<CloudIcon />}
149+
ref={navItem3Ref}
150+
/>
151+
<NavItem
152+
key="nav-icon-link4"
153+
preventDefault
154+
id="nav-icon-link4"
155+
to="#nav-icon-link4"
156+
itemId={0}
157+
isActive={activeItem === 3}
158+
icon={<CodeIcon />}
159+
ref={navItem4Ref}
160+
/>
161+
</NavList>
162+
</Nav>
163+
<Tooltip triggerRef={navItem1Ref} content="Link 1"></Tooltip>
164+
<Tooltip triggerRef={navItem2Ref} content="Link 2"></Tooltip>
165+
<Tooltip triggerRef={navItem3Ref} content="Link 3"></Tooltip>
166+
<Tooltip triggerRef={navItem4Ref} content="Link 4"></Tooltip>
167+
</ToolbarItem>
168+
<ToolbarGroup
169+
variant="action-group-plain"
170+
align={{ default: 'alignEnd' }}
171+
gap={{ default: 'gapNone', md: 'gapMd' }}
172+
>
173+
<ToolbarGroup variant="action-group-plain" visibility={{ default: 'hidden', lg: 'visible' }}>
174+
<ToolbarItem>
175+
<Button aria-label="Settings" isSettings variant="plain" />
176+
</ToolbarItem>
177+
<ToolbarItem>
178+
<Button aria-label="Help" variant={ButtonVariant.plain} icon={<QuestionCircleIcon />} />
179+
</ToolbarItem>
180+
</ToolbarGroup>
181+
</ToolbarGroup>
182+
<ToolbarItem>
183+
<Dropdown
184+
isOpen={isDropdownOpen}
185+
onSelect={onDropdownSelect}
186+
onOpenChange={(isOpen: boolean) => setIsDropdownOpen(isOpen)}
187+
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
188+
<MenuToggle
189+
ref={toggleRef}
190+
onClick={onDropdownToggle}
191+
isExpanded={isDropdownOpen}
192+
icon={<Avatar src={imgAvatar} alt="" size="sm" />}
193+
variant="plain"
194+
></MenuToggle>
195+
)}
196+
>
197+
<DropdownList>{userDropdownItems}</DropdownList>
198+
</Dropdown>
199+
</ToolbarItem>
200+
</ToolbarContent>
201+
</Toolbar>
202+
</MastheadContent>
203+
</Masthead>
204+
);
205+
206+
const mainContent = (
207+
<>
208+
<CompassMainHeader title={<Title headingLevel="h1">Content title</Title>} />
209+
<CompassContent>
210+
<CompassPanel>Content</CompassPanel>
211+
</CompassContent>
212+
</>
213+
);
214+
215+
return (
216+
<Compass
217+
dock={dockContent}
218+
main={mainContent}
219+
backgroundSrcDark="/assets/images/pf-background.svg"
220+
backgroundSrcLight="/assets/images/pf-background.svg"
221+
/>
222+
);
223+
};

0 commit comments

Comments
 (0)