Skip to content

Commit

Permalink
feat(Drawer): added focustrap functionality (#9469)
Browse files Browse the repository at this point in the history
* feat(Drawer): added focustrap functionality

* Added tests

* Updated failing integration test

* Updated example description and integration tests
  • Loading branch information
thatblindgeye authored Sep 1, 2023
1 parent dd6e6a4 commit 41fedc5
Show file tree
Hide file tree
Showing 6 changed files with 409 additions and 69 deletions.
175 changes: 120 additions & 55 deletions packages/react-core/src/components/Drawer/DrawerPanelContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ import { css } from '@patternfly/react-styles';
import { DrawerColorVariant, DrawerContext } from './Drawer';
import { formatBreakpointMods } from '../../helpers/util';
import { GenerateId } from '../../helpers/GenerateId/GenerateId';
import { FocusTrap } from '../../helpers/FocusTrap/FocusTrap';

export interface DrawerPanelFocusTrapObject {
/** Enables a focus trap on the drawer panel content. This will also automatically
* handle focus management when the panel expands and when it collapses. Do not pass
* this prop if the isStatic prop on the drawer component is true.
*/
enabled?: boolean;
/** The element to focus when the drawer panel content expands. By default the
* first focusable element will receive focus. If there are no focusable elements, the
* panel itself will receive focus.
*/
elementToFocusOnExpand?: HTMLElement | SVGElement | string;
/** One or more id's to use for the drawer panel content's accessible label. */
'aria-labelledby'?: string;
}

export interface DrawerPanelContentProps extends React.HTMLProps<HTMLDivElement> {
/** Additional classes added to the drawer. */
Expand Down Expand Up @@ -37,6 +53,8 @@ export interface DrawerPanelContentProps extends React.HTMLProps<HTMLDivElement>
};
/** Color variant of the background of the drawer panel */
colorVariant?: DrawerColorVariant | 'light-200' | 'no-background' | 'default';
/** Adds and customizes a focus trap on the drawer panel content. */
focusTrap?: DrawerPanelFocusTrapObject;
}
let isResizing: boolean = null;
let newSize: number = 0;
Expand All @@ -55,6 +73,7 @@ export const DrawerPanelContent: React.FunctionComponent<DrawerPanelContentProps
resizeAriaLabel = 'Resize',
widths,
colorVariant = DrawerColorVariant.default,
focusTrap,
...props
}: DrawerPanelContentProps) => {
const panel = React.useRef<HTMLDivElement>();
Expand All @@ -64,13 +83,22 @@ export const DrawerPanelContent: React.FunctionComponent<DrawerPanelContentProps
React.useContext(DrawerContext);
const hidden = isStatic ? false : !isExpanded;
const [isExpandedInternal, setIsExpandedInternal] = React.useState(!hidden);
const [isFocusTrapActive, setIsFocusTrapActive] = React.useState(false);
const previouslyFocusedElement = React.useRef(null);
let currWidth: number = 0;
let panelRect: DOMRect;
let right: number;
let left: number;
let bottom: number;
let setInitialVals: boolean = true;

if (isStatic && focusTrap?.enabled) {
// eslint-disable-next-line no-console
console.warn(
`DrawerPanelContent: The focusTrap.enabled prop cannot be true if the Drawer's isStatic prop is true. This will cause a permanent focus trap.`
);
}

React.useEffect(() => {
if (!isStatic && isExpanded) {
setIsExpandedInternal(isExpanded);
Expand Down Expand Up @@ -246,64 +274,101 @@ export const DrawerPanelContent: React.FunctionComponent<DrawerPanelContentProps
if (maxSize) {
boundaryCssVars['--pf-v5-c-drawer__panel--md--FlexBasis--max'] = maxSize;
}

const isValidFocusTrap = focusTrap?.enabled && !isStatic;
const Component = isValidFocusTrap ? FocusTrap : 'div';

return (
<GenerateId prefix="pf-drawer-panel-">
{(panelId) => (
<div
id={id || panelId}
className={css(
styles.drawerPanel,
isResizable && styles.modifiers.resizable,
hasNoBorder && styles.modifiers.noBorder,
formatBreakpointMods(widths, styles),
colorVariant === DrawerColorVariant.light200 && styles.modifiers.light_200,
colorVariant === DrawerColorVariant.noBackground && styles.modifiers.noBackground,
className
)}
ref={panel}
onTransitionEnd={(ev) => {
if ((ev.target as HTMLElement) === panel.current) {
if (!hidden && ev.nativeEvent.propertyName === 'transform') {
onExpand(ev);
{(panelId) => {
const focusTrapProps = {
tabIndex: -1,
'aria-modal': true,
role: 'dialog',
active: isFocusTrapActive,
'aria-labelledby': focusTrap?.['aria-labelledby'] || id || panelId,
focusTrapOptions: {
fallbackFocus: () => panel.current,
onActivate: () => {
if (previouslyFocusedElement.current !== document.activeElement) {
previouslyFocusedElement.current = document.activeElement;
}
},
onDeactivate: () => {
previouslyFocusedElement.current &&
previouslyFocusedElement.current.focus &&
previouslyFocusedElement.current.focus();
},
clickOutsideDeactivates: true,
returnFocusOnDeactivate: false,
// FocusTrap's initialFocus can accept false as a value to prevent initial focus.
// We want to prevent this in case false is ever passed in.
initialFocus: focusTrap?.elementToFocusOnExpand || undefined,
escapeDeactivates: false
}
};

return (
<Component
{...(isValidFocusTrap && focusTrapProps)}
id={id || panelId}
className={css(
styles.drawerPanel,
isResizable && styles.modifiers.resizable,
hasNoBorder && styles.modifiers.noBorder,
formatBreakpointMods(widths, styles),
colorVariant === DrawerColorVariant.light200 && styles.modifiers.light_200,
colorVariant === DrawerColorVariant.noBackground && styles.modifiers.noBackground,
className
)}
onTransitionEnd={(ev) => {
if ((ev.target as HTMLElement) === panel.current) {
if (!hidden && ev.nativeEvent.propertyName === 'transform') {
onExpand(ev);
}
setIsExpandedInternal(!hidden);
if (isValidFocusTrap && ev.nativeEvent.propertyName === 'transform') {
setIsFocusTrapActive((prevIsFocusTrapActive) => !prevIsFocusTrapActive);
}
}
setIsExpandedInternal(!hidden);
}
}}
hidden={hidden}
{...((defaultSize || minSize || maxSize) && {
style: boundaryCssVars as React.CSSProperties
})}
{...props}
>
{isExpandedInternal && (
<React.Fragment>
{isResizable && (
<React.Fragment>
<div
className={css(styles.drawerSplitter, position !== 'bottom' && styles.modifiers.vertical)}
role="separator"
tabIndex={0}
aria-orientation={position === 'bottom' ? 'horizontal' : 'vertical'}
aria-label={resizeAriaLabel}
aria-valuenow={separatorValue}
aria-valuemin={0}
aria-valuemax={100}
aria-controls={id || panelId}
onMouseDown={handleMousedown}
onKeyDown={handleKeys}
onTouchStart={handleTouchStart}
ref={splitterRef}
>
<div className={css(styles.drawerSplitterHandle)} aria-hidden></div>
</div>
<div className={css(styles.drawerPanelMain)}>{children}</div>
</React.Fragment>
)}
{!isResizable && children}
</React.Fragment>
)}
</div>
)}
}}
hidden={hidden}
{...((defaultSize || minSize || maxSize) && {
style: boundaryCssVars as React.CSSProperties
})}
{...props}
ref={panel}
>
{isExpandedInternal && (
<React.Fragment>
{isResizable && (
<React.Fragment>
<div
className={css(styles.drawerSplitter, position !== 'bottom' && styles.modifiers.vertical)}
role="separator"
tabIndex={0}
aria-orientation={position === 'bottom' ? 'horizontal' : 'vertical'}
aria-label={resizeAriaLabel}
aria-valuenow={separatorValue}
aria-valuemin={0}
aria-valuemax={100}
aria-controls={id || panelId}
onMouseDown={handleMousedown}
onKeyDown={handleKeys}
onTouchStart={handleTouchStart}
ref={splitterRef}
>
<div className={css(styles.drawerSplitterHandle)} aria-hidden></div>
</div>
<div className={css(styles.drawerPanelMain)}>{children}</div>
</React.Fragment>
)}
{!isResizable && children}
</React.Fragment>
)}
</Component>
);
}}
</GenerateId>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { DrawerPanelContent } from '../DrawerPanelContent';
import { Drawer } from '../Drawer';

test('Does not render with aria-labelledby by default', () => {
render(
<Drawer isExpanded>
<DrawerPanelContent>Drawer panel content</DrawerPanelContent>
</Drawer>
);

expect(screen.getByText('Drawer panel content')).not.toHaveAccessibleName();
});

test('Renders with aria-labelledby when focusTrap.enabled is true', () => {
render(
<Drawer isExpanded>
<DrawerPanelContent focusTrap={{ enabled: true }}>Drawer panel content</DrawerPanelContent>
</Drawer>
);

expect(screen.getByText('Drawer panel content')).toHaveAccessibleName('Drawer panel content');
});

test('Renders with aria-labelledby when id is passed in', () => {
render(
<Drawer isExpanded>
<DrawerPanelContent id="drawer-panel-content" focusTrap={{ enabled: true }}>
Drawer panel content
</DrawerPanelContent>
</Drawer>
);

expect(screen.getByText('Drawer panel content')).toHaveAccessibleName('Drawer panel content');
});

test('Renders with custom aria-labelledby', () => {
render(
<Drawer isExpanded>
<DrawerPanelContent focusTrap={{ enabled: true, 'aria-labelledby': 'drawer-panel-title' }}>
<span id="drawer-panel-title">Title</span>
<span>Drawer panel content</span>
</DrawerPanelContent>
</Drawer>
);

expect(screen.getByText('Drawer panel content').parentElement).toHaveAccessibleName('Title');
});

test('Does not render with aria-modal="true" by default', () => {
render(
<Drawer isExpanded>
<DrawerPanelContent>Drawer panel content</DrawerPanelContent>
</Drawer>
);

expect(screen.getByText('Drawer panel content')).not.toHaveAttribute('aria-modal');
});

test('Renders with aria-modal="true" when focusTrap.enabled is true', () => {
render(
<Drawer isExpanded>
<DrawerPanelContent focusTrap={{ enabled: true }}>Drawer panel content</DrawerPanelContent>
</Drawer>
);

expect(screen.getByText('Drawer panel content')).toHaveAttribute('aria-modal', 'true');
});

test('Does not render with role="dialog" by default', () => {
render(
<Drawer isExpanded>
<DrawerPanelContent>Drawer panel content</DrawerPanelContent>
</Drawer>
);

expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

test('Renders with role="dialog" when focusTrap.enabled is true', () => {
render(
<Drawer isExpanded>
<DrawerPanelContent focusTrap={{ enabled: true }}>Drawer panel content</DrawerPanelContent>
</Drawer>
);

expect(screen.getByRole('dialog')).toBeInTheDocument();
});
Loading

0 comments on commit 41fedc5

Please sign in to comment.