Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Drawer): added focustrap functionality #9469

Merged
merged 4 commits into from
Sep 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this only needed when we have focus trap enabled?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the focus trap enabled we're adding a role="dialog" to the panel content. MDN has some context on labelling a dialog. For the non-focus trap Drawer, we just have a plain div which typically doesn't do well with aria labelling.

}

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