Skip to content

Commit 470e464

Browse files
committed
feat(Drawer): Add full-size drawer
Use isViewport and place drawer below Page to achieve a full-height drawer that allows Page content to scroll behind it. Fixes #12635
1 parent 008744f commit 470e464

12 files changed

Lines changed: 224 additions & 43 deletions

File tree

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.6.0-prerelease.40",
57+
"@patternfly/patternfly": "6.6.0-prerelease.41",
5858
"case-anything": "^3.1.2",
5959
"css": "^3.0.0",
6060
"fs-extra": "^11.3.3"

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export interface DrawerProps extends React.HTMLProps<HTMLDivElement>, OUIAProps
2323
isInline?: boolean;
2424
/** @beta Indicates if the drawer will have pill styles */
2525
isPill?: boolean;
26+
/** @beta Positions the drawer as fixed to fill the viewport. Place the drawer after Page as a sibling. */
27+
isViewport?: boolean;
2628
/** Indicates if the drawer will always show both content and panel. */
2729
isStatic?: boolean;
2830
/** Position of the drawer panel. left and right are deprecated, use start and end instead. */
@@ -43,6 +45,7 @@ export interface DrawerContextProps {
4345
drawerRef?: React.RefObject<HTMLDivElement | null>;
4446
drawerContentRef?: React.RefObject<HTMLDivElement | null>;
4547
isInline: boolean;
48+
isViewport: boolean;
4649
}
4750

4851
export const DrawerContext = createContext<Partial<DrawerContextProps>>({
@@ -52,7 +55,8 @@ export const DrawerContext = createContext<Partial<DrawerContextProps>>({
5255
position: 'end',
5356
drawerRef: null,
5457
drawerContentRef: null,
55-
isInline: false
58+
isInline: false,
59+
isViewport: false
5660
});
5761

5862
export const Drawer: React.FunctionComponent<DrawerProps> = ({
@@ -61,6 +65,7 @@ export const Drawer: React.FunctionComponent<DrawerProps> = ({
6165
isExpanded = false,
6266
isInline = false,
6367
isPill = false,
68+
isViewport = false,
6469
isStatic = false,
6570
position = 'end',
6671
onExpand = () => {},
@@ -73,13 +78,16 @@ export const Drawer: React.FunctionComponent<DrawerProps> = ({
7378
const drawerContentRef = useRef<HTMLDivElement>(undefined);
7479

7580
return (
76-
<DrawerContext.Provider value={{ isExpanded, isStatic, onExpand, position, drawerRef, drawerContentRef, isInline }}>
81+
<DrawerContext.Provider
82+
value={{ isExpanded, isStatic, onExpand, position, drawerRef, drawerContentRef, isInline, isViewport }}
83+
>
7784
<div
7885
className={css(
7986
styles.drawer,
8087
isExpanded && styles.modifiers.expanded,
8188
isInline && styles.modifiers.inline,
8289
isPill && styles.modifiers.pill,
90+
isViewport && styles.modifiers.viewport,
8391
isStatic && styles.modifiers.static,
8492
(position === 'left' || position === 'start') && styles.modifiers.panelLeft,
8593
position === 'bottom' && styles.modifiers.panelBottom,

packages/react-core/src/components/Drawer/DrawerContent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export enum DrawerContentColorVariant {
1313
export interface DrawerContentProps extends React.HTMLProps<HTMLDivElement> {
1414
/** Additional classes added to the Drawer. */
1515
className?: string;
16-
/** Content to be rendered in the drawer. */
16+
/** Content to be rendered in the drawer. Can be omitted to leave the content container empty. */
1717
children?: React.ReactNode;
1818
/** Content rendered in the drawer panel. */
1919
panelContent: React.ReactNode;

packages/react-core/src/components/Drawer/DrawerPanelContent.tsx

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ export const DrawerPanelContent: React.FunctionComponent<DrawerPanelContentProps
100100
const panel = useRef<HTMLDivElement>(undefined);
101101
const splitterRef = useRef<HTMLDivElement>(undefined);
102102
const [separatorValue, setSeparatorValue] = useState(0);
103-
const { position, isExpanded, isStatic, onExpand, drawerRef, drawerContentRef, isInline } = useContext(DrawerContext);
103+
const { position, isExpanded, isStatic, onExpand, drawerRef, drawerContentRef, isInline, isViewport } =
104+
useContext(DrawerContext);
104105
const hidden = isStatic ? false : !isExpanded;
105106
const [isExpandedInternal, setIsExpandedInternal] = useState(!hidden);
106107
const [isFocusTrapActive, setIsFocusTrapActive] = useState(false);
@@ -126,9 +127,22 @@ export const DrawerPanelContent: React.FunctionComponent<DrawerPanelContentProps
126127
}
127128
}, [isStatic, isExpanded]);
128129

130+
const getSizeElement = () => {
131+
if (isViewport) {
132+
return drawerRef?.current ?? drawerContentRef?.current;
133+
}
134+
return drawerContentRef?.current ?? drawerRef?.current;
135+
};
136+
129137
const calcValueNow = () => {
130138
let splitterPos;
131139
let drawerSize;
140+
const sizeEl = getSizeElement();
141+
142+
if (!sizeEl || !panel.current || !splitterRef.current || !drawerRef?.current) {
143+
return 0;
144+
}
145+
132146
const isRTL = getLanguageDirection(panel.current) === 'rtl';
133147

134148
if (isInline && (position === 'end' || position === 'right')) {
@@ -149,37 +163,23 @@ export const DrawerPanelContent: React.FunctionComponent<DrawerPanelContentProps
149163
}
150164
} else if (position === 'end' || position === 'right') {
151165
if (isRTL) {
152-
splitterPos =
153-
drawerContentRef.current.getBoundingClientRect().left - splitterRef.current.getBoundingClientRect().right;
154-
drawerSize =
155-
drawerContentRef.current.getBoundingClientRect().left -
156-
drawerContentRef.current.getBoundingClientRect().right;
166+
splitterPos = sizeEl.getBoundingClientRect().left - splitterRef.current.getBoundingClientRect().right;
167+
drawerSize = sizeEl.getBoundingClientRect().left - sizeEl.getBoundingClientRect().right;
157168
} else {
158-
splitterPos =
159-
drawerContentRef.current.getBoundingClientRect().right - splitterRef.current.getBoundingClientRect().left;
160-
drawerSize =
161-
drawerContentRef.current.getBoundingClientRect().right -
162-
drawerContentRef.current.getBoundingClientRect().left;
169+
splitterPos = sizeEl.getBoundingClientRect().right - splitterRef.current.getBoundingClientRect().left;
170+
drawerSize = sizeEl.getBoundingClientRect().right - sizeEl.getBoundingClientRect().left;
163171
}
164172
} else if (position === 'start' || position === 'left') {
165173
if (isRTL) {
166-
splitterPos =
167-
splitterRef.current.getBoundingClientRect().left - drawerContentRef.current.getBoundingClientRect().right;
168-
drawerSize =
169-
drawerContentRef.current.getBoundingClientRect().left -
170-
drawerContentRef.current.getBoundingClientRect().right;
174+
splitterPos = splitterRef.current.getBoundingClientRect().left - sizeEl.getBoundingClientRect().right;
175+
drawerSize = sizeEl.getBoundingClientRect().left - sizeEl.getBoundingClientRect().right;
171176
} else {
172-
splitterPos =
173-
splitterRef.current.getBoundingClientRect().right - drawerContentRef.current.getBoundingClientRect().left;
174-
drawerSize =
175-
drawerContentRef.current.getBoundingClientRect().right -
176-
drawerContentRef.current.getBoundingClientRect().left;
177+
splitterPos = splitterRef.current.getBoundingClientRect().right - sizeEl.getBoundingClientRect().left;
178+
drawerSize = sizeEl.getBoundingClientRect().right - sizeEl.getBoundingClientRect().left;
177179
}
178180
} else if (position === 'bottom') {
179-
splitterPos =
180-
drawerContentRef.current.getBoundingClientRect().bottom - splitterRef.current.getBoundingClientRect().top;
181-
drawerSize =
182-
drawerContentRef.current.getBoundingClientRect().bottom - drawerContentRef.current.getBoundingClientRect().top;
181+
splitterPos = sizeEl.getBoundingClientRect().bottom - splitterRef.current.getBoundingClientRect().top;
182+
drawerSize = sizeEl.getBoundingClientRect().bottom - sizeEl.getBoundingClientRect().top;
183183
}
184184

185185
const newSplitterPos = (splitterPos / drawerSize) * 100;

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,56 @@ test('Renders with ouiaSafe=false when specified', () => {
231231
);
232232
expect(screen.getByTestId('drawer')).toHaveAttribute('data-ouia-safe', 'false');
233233
});
234+
235+
test(`Does not render with ${styles.modifiers.viewport} class by default`, () => {
236+
render(
237+
<Drawer data-testid="drawer">
238+
<DrawerContent panelContent={<DrawerPanelContent>panel</DrawerPanelContent>}>
239+
<DrawerContentBody>content</DrawerContentBody>
240+
</DrawerContent>
241+
</Drawer>
242+
);
243+
244+
expect(screen.getByTestId('drawer')).not.toHaveClass(styles.modifiers.viewport);
245+
});
246+
247+
test(`Renders with ${styles.modifiers.viewport} when isViewport is specified`, () => {
248+
render(
249+
<Drawer data-testid="drawer" isViewport>
250+
<DrawerContent panelContent={<DrawerPanelContent>panel</DrawerPanelContent>}>
251+
<DrawerContentBody>content</DrawerContentBody>
252+
</DrawerContent>
253+
</Drawer>
254+
);
255+
256+
expect(screen.getByTestId('drawer')).toHaveClass(styles.modifiers.viewport);
257+
});
258+
259+
test('Resizeable DrawerPanelContent without drawer content does not throw', async () => {
260+
const consoleError = jest.spyOn(console, 'error').mockImplementation();
261+
262+
const panelContent = (
263+
<DrawerPanelContent isResizable>
264+
<DrawerHead>
265+
<span>drawer-panel</span>
266+
<DrawerActions>
267+
<DrawerCloseButton />
268+
</DrawerActions>
269+
</DrawerHead>
270+
</DrawerPanelContent>
271+
);
272+
273+
const user = userEvent.setup();
274+
275+
render(
276+
<Drawer isExpanded isViewport>
277+
<DrawerContent panelContent={panelContent} />
278+
</Drawer>
279+
);
280+
281+
await user.tab();
282+
await user.keyboard(`{${KeyTypes.ArrowLeft}}`);
283+
284+
expect(consoleError).not.toHaveBeenCalled();
285+
consoleError.mockRestore();
286+
});

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ propComponents:
1313
DrawerCloseButton,
1414
DrawerPanelDescription,
1515
DrawerPanelBody,
16-
DrawerPanelFocusTrapObject,
16+
DrawerPanelFocusTrapObject
1717
]
1818
section: components
1919
---
@@ -156,3 +156,11 @@ To customize which element receives focus when the drawer panel expands, use the
156156
```ts file="./DrawerPillInline.tsx"
157157

158158
```
159+
160+
### Viewport
161+
162+
Use `isViewport` to position the drawer as `fixed` so it fills the viewport. Place the drawer after `<Page>` as a sibling. Omit children of `<DrawerContent>` so the content container stays empty; the empty container is still required so the panel can overlay the page.
163+
164+
```ts file="./DrawerViewport.tsx" isFullscreen isBeta
165+
166+
```
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Fragment, useRef, useState } from 'react';
2+
import {
3+
Backdrop,
4+
Button,
5+
Drawer,
6+
DrawerActions,
7+
DrawerCloseButton,
8+
DrawerContent,
9+
DrawerHead,
10+
DrawerPanelContent,
11+
Masthead,
12+
MastheadBrand,
13+
MastheadContent,
14+
MastheadLogo,
15+
MastheadMain,
16+
MastheadToggle,
17+
Page,
18+
PageSection,
19+
PageSidebar,
20+
PageSidebarBody,
21+
PageToggleButton,
22+
Toolbar,
23+
ToolbarContent,
24+
ToolbarItem
25+
} from '@patternfly/react-core';
26+
27+
export const DrawerViewport: React.FunctionComponent = () => {
28+
const [isExpanded, setIsExpanded] = useState(true);
29+
const drawerRef = useRef<HTMLSpanElement>(null);
30+
31+
const onExpand = () => {
32+
drawerRef.current && drawerRef.current.focus();
33+
};
34+
35+
const onClick = () => {
36+
setIsExpanded(!isExpanded);
37+
};
38+
39+
const onCloseClick = () => {
40+
setIsExpanded(false);
41+
};
42+
43+
const onResize = (_event: MouseEvent | TouchEvent | React.KeyboardEvent, newWidth: number, id: string) => {
44+
// eslint-disable-next-line no-console
45+
console.log(`${id} has new width of: ${newWidth}`);
46+
};
47+
48+
const headerToolbar = (
49+
<Toolbar id="viewport-drawer-toolbar">
50+
<ToolbarContent>
51+
<ToolbarItem>
52+
<Button aria-expanded={isExpanded} onClick={onClick}>
53+
Toggle drawer
54+
</Button>
55+
</ToolbarItem>
56+
</ToolbarContent>
57+
</Toolbar>
58+
);
59+
60+
const masthead = (
61+
<Masthead>
62+
<MastheadMain>
63+
<MastheadToggle>
64+
<PageToggleButton isHamburgerButton aria-label="Global navigation" id="viewport-drawer-nav-toggle" />
65+
</MastheadToggle>
66+
<MastheadBrand>
67+
<MastheadLogo href="https://patternfly.org" target="_blank">
68+
Logo
69+
</MastheadLogo>
70+
</MastheadBrand>
71+
</MastheadMain>
72+
<MastheadContent>{headerToolbar}</MastheadContent>
73+
</Masthead>
74+
);
75+
76+
const sidebar = (
77+
<PageSidebar id="viewport-drawer-sidebar">
78+
<PageSidebarBody>Navigation</PageSidebarBody>
79+
</PageSidebar>
80+
);
81+
82+
const panelContent = (
83+
<DrawerPanelContent isResizable onResize={onResize} id="viewport-resize-panel" minSize="150px">
84+
<DrawerHead>
85+
<span tabIndex={isExpanded ? 0 : -1} ref={drawerRef}>
86+
Drawer panel header
87+
</span>
88+
<DrawerActions>
89+
<DrawerCloseButton onClick={onCloseClick} />
90+
</DrawerActions>
91+
</DrawerHead>
92+
</DrawerPanelContent>
93+
);
94+
95+
return (
96+
<Fragment>
97+
<Page isManagedSidebar masthead={masthead} sidebar={sidebar}>
98+
<PageSection aria-labelledby="viewport-drawer-section">
99+
<h2 id="viewport-drawer-section">Viewport drawer example</h2>
100+
<p>
101+
The viewport drawer is a sibling of Page. DrawerContent is left empty so the panel overlays the page without
102+
affecting page layout or scrolling.
103+
</p>
104+
</PageSection>
105+
</Page>
106+
{isExpanded && <Backdrop onClick={onCloseClick}></Backdrop>}
107+
<Drawer isExpanded={isExpanded} isViewport onExpand={onExpand}>
108+
<DrawerContent panelContent={panelContent} />
109+
</Drawer>
110+
</Fragment>
111+
);
112+
};

packages/react-docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"test:a11y": "patternfly-a11y --config patternfly-a11y.config"
2424
},
2525
"dependencies": {
26-
"@patternfly/patternfly": "6.6.0-prerelease.40",
26+
"@patternfly/patternfly": "6.6.0-prerelease.41",
2727
"@patternfly/react-charts": "workspace:^",
2828
"@patternfly/react-code-editor": "workspace:^",
2929
"@patternfly/react-core": "workspace:^",

packages/react-icons/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"@fortawesome/free-brands-svg-icons": "^5.15.4",
3939
"@fortawesome/free-regular-svg-icons": "^5.15.4",
4040
"@fortawesome/free-solid-svg-icons": "^5.15.4",
41-
"@patternfly/patternfly": "6.6.0-prerelease.40",
41+
"@patternfly/patternfly": "6.6.0-prerelease.41",
4242
"@rhds/icons": "^2.3.1",
4343
"fs-extra": "^11.3.3"
4444
},

packages/react-styles/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"clean": "rimraf dist css"
2020
},
2121
"devDependencies": {
22-
"@patternfly/patternfly": "6.6.0-prerelease.40",
22+
"@patternfly/patternfly": "6.6.0-prerelease.41",
2323
"change-case": "^5.4.4",
2424
"fs-extra": "^11.3.3"
2525
},

0 commit comments

Comments
 (0)