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: focustrap: export focus trap and add new hook props #743

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
112 changes: 62 additions & 50 deletions src/components/Dialog/BaseDialog/BaseDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,26 @@ export const BaseDialog: FC<BaseDialogProps> = React.forwardRef(
closeIcon = IconName.mdiClose,
dialogClassNames,
dialogWrapperClassNames,
firstFocusableSelector,
focusTrap = true,
header,
headerButtonProps,
headerClassNames,
headerIcon = IconName.mdiArrowLeftThick,
height,
lastFocusableSelector,
maskClosable = true,
onClose,
onVisibleChange,
overlay = true,
parent = document.body,
positionStrategy = 'fixed',
renderContentAlways = true,
skipFocusableSelectorsFromIndex,
style,
visible,
width,
zIndex,
focusTrap = true,
...rest
},
ref: Ref<HTMLDivElement>
Expand Down Expand Up @@ -117,6 +121,9 @@ export const BaseDialog: FC<BaseDialogProps> = React.forwardRef(
const getDialog = (): JSX.Element => (
<NoFormStyle status override>
<FocusTrap
firstFocusableSelector={firstFocusableSelector}
lastFocusableSelector={lastFocusableSelector}
skipFocusableSelectorsFromIndex={skipFocusableSelectorsFromIndex}
trap={visible && focusTrap}
{...rest}
ref={ref}
Expand All @@ -134,55 +141,60 @@ export const BaseDialog: FC<BaseDialogProps> = React.forwardRef(
style={dialogStyle}
onClick={stopPropagation}
>
<div className={headerClasses}>
<span id={labelId}>
{headerButtonProps && (
<NeutralButton
classNames={styles.headerButton}
shape={ButtonShape.Round}
iconProps={{ path: headerIcon }}
style={{
transform: htmlDir === 'rtl' ? 'rotate(180deg)' : 'none',
}}
{...headerButtonProps}
/>
)}
{header}
</span>
<span className={styles.headerButtons}>
{actionButtonThreeProps && (
<NeutralButton
shape={ButtonShape.Round}
{...actionButtonThreeProps}
/>
)}
{actionButtonTwoProps && (
<NeutralButton
shape={ButtonShape.Round}
{...actionButtonTwoProps}
/>
)}
{actionButtonOneProps && (
<NeutralButton
shape={ButtonShape.Round}
{...actionButtonOneProps}
/>
)}
{closable && (
<NeutralButton
ariaLabel={closeButtonAriaLabelText}
iconProps={{ path: closeIcon }}
shape={ButtonShape.Round}
onClick={onClose}
{...closeButtonProps}
/>
)}
</span>
</div>
<div ref={scrollRef} className={bodyClasses}>
{body}
</div>
{actions && <div className={actionsClasses}>{actions}</div>}
{renderContentAlways && (
<>
<div className={headerClasses}>
<span id={labelId}>
{headerButtonProps && (
<NeutralButton
classNames={styles.headerButton}
shape={ButtonShape.Round}
iconProps={{ path: headerIcon }}
style={{
transform:
htmlDir === 'rtl' ? 'rotate(180deg)' : 'none',
}}
{...headerButtonProps}
/>
)}
{header}
</span>
<span className={styles.headerButtons}>
{actionButtonThreeProps && (
<NeutralButton
shape={ButtonShape.Round}
{...actionButtonThreeProps}
/>
)}
{actionButtonTwoProps && (
<NeutralButton
shape={ButtonShape.Round}
{...actionButtonTwoProps}
/>
)}
{actionButtonOneProps && (
<NeutralButton
shape={ButtonShape.Round}
{...actionButtonOneProps}
/>
)}
{closable && (
<NeutralButton
ariaLabel={closeButtonAriaLabelText}
iconProps={{ path: closeIcon }}
shape={ButtonShape.Round}
onClick={onClose}
{...closeButtonProps}
/>
)}
</span>
</div>
<div ref={scrollRef} className={bodyClasses}>
{body}
</div>
{actions && <div className={actionsClasses}>{actions}</div>}
</>
)}
</div>
</FocusTrap>
</NoFormStyle>
Expand Down
23 changes: 23 additions & 0 deletions src/components/Dialog/BaseDialog/BaseDialog.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ export interface BaseDialogProps
* Custom classes for the dialog wrapper
*/
dialogWrapperClassNames?: string;
/**
* Prepend a specific selector to the beginning
* of the focus loop generated list of selectors.
* Use optionally when `focusTrap` is `true`.
*/
firstFocusableSelector?: string;
/**
* Unset this to disable focus trap
* @default true
Expand All @@ -125,6 +131,12 @@ export interface BaseDialogProps
* Custom height of the dialog
*/
height?: number;
/**
* Append a specific selector to the end
* of the focus loop generated list of selectors.
* Use optionally when `focusTrap` is `true`.
*/
lastFocusableSelector?: string;
/**
* The Dialog locale.
* @default 'enUS'
Expand Down Expand Up @@ -170,6 +182,17 @@ export interface BaseDialogProps
* Ref for the dialog element
*/
ref?: Ref<HTMLDivElement>;
/**
* Whether to render Dialog content when Dialog `visible` is `false`.
* @default true
*/
renderContentAlways?: boolean;
/**
* Optionally skip some selectors when tabbing by index.length - skipFocusableSelectorsFromIndex
* Use when `focusTrap` is `true`
* @default `index.length - 1`
*/
skipFocusableSelectorsFromIndex?: number;
/**
* Custom width of the dialog
*/
Expand Down
4 changes: 4 additions & 0 deletions src/components/Dialog/Dialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ const dialogArgs: Object = {
body: 'Body 2 which is at 16px font size is used here in the body section of the dialog. The dialog body text can wrap to multiple lines.',
bodyClassNames: 'my-dialog-body-class',
bodyPadding: true,
firstFocusableSelector: null,
lastFocusableSelector: null,
skipFocusableSelectorsFromIndex: null,
renderContentAlways: true,
cancelButtonProps: {
ariaLabel: 'Cancel',
classNames: 'my-cancel-btn-class',
Expand Down
21 changes: 21 additions & 0 deletions src/components/Dialog/Dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,25 @@ describe('Dialog', () => {
});
expect(wrapper.find('.modeless').length).toBeTruthy();
});

test('Should render content when renderContentAlways is true', () => {
wrapper.setProps({
header,
body,
});
expect(wrapper.find('.body').contains('This is the dialog body')).toBe(
true
);
});

test('Should not render content when renderContentAlways is false', () => {
wrapper.setProps({
header,
body,
renderContentAlways: false,
});
expect(wrapper.find('.body').contains('This is the dialog body')).toBe(
false
);
});
});
4 changes: 4 additions & 0 deletions src/components/Modal/Modal.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ const modalArgs: Object = {
body: 'Body 2 which is at 16px font size is used here in the body section of the modal. The modal body text can wrap to multiple lines.',
bodyClassNames: 'my-modal-body-class',
bodyPadding: true,
firstFocusableSelector: null,
lastFocusableSelector: null,
skipFocusableSelectorsFromIndex: null,
renderContentAlways: true,
'data-test-id': 'my-modal-test-id',
header: 'Header 4 used in this modal',
headerClassNames: 'my-modal-header-class',
Expand Down
4 changes: 4 additions & 0 deletions src/components/Panel/Panel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,10 @@ const panelArgs: Object = {
bodyPadding: true,
headerPadding: true,
scrollLock: true,
firstFocusableSelector: null,
lastFocusableSelector: null,
skipFocusableSelectorsFromIndex: null,
renderContentAlways: true,
children: (
<>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum
Expand Down
19 changes: 19 additions & 0 deletions src/components/Panel/Panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import MatchMediaMock from 'jest-matchmedia-mock';
import { Panel } from './';
import { Button, ButtonVariant } from '../Button';
import { IconName } from '../Icon';
import { render } from '@testing-library/react';

Enzyme.configure({ adapter: new Adapter() });

Expand Down Expand Up @@ -144,4 +145,22 @@ describe('Panel', () => {
expect(wrapper.find('.footer')).toBeTruthy();
expect(wrapper.find('.button-primary').text()).toBe('Close');
});

test('Should render content when renderContentAlways is true', () => {
const { getByText } = render(
<Panel renderContentAlways>
<div>Content is always rendered</div>
</Panel>
);
expect(getByText('Content is always rendered')).toBeTruthy();
});

test('Should not render content when renderContentAlways is false', () => {
const { queryByText } = render(
<Panel renderContentAlways={false}>
<div>Content is not always rendered</div>
</Panel>
);
expect(queryByText('Content is not always rendered')).toBeNull();
});
});
19 changes: 16 additions & 3 deletions src/components/Panel/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@ export const Panel = React.forwardRef<PanelRef, PanelProps>(
closeButtonAriaLabelText: defaultCloseButtonAriaLabelText,
closeButtonProps,
closeIcon = IconName.mdiClose,
firstFocusableSelector,
footer,
footerClassNames,
headerButtonProps,
headerClassNames,
headerIcon = IconName.mdiArrowLeftThick,
height,
lastFocusableSelector,
locale = enUS,
maskClosable = true,
onClose = () => {},
Expand All @@ -62,7 +64,9 @@ export const Panel = React.forwardRef<PanelRef, PanelProps>(
parent = document.body,
placement = 'right',
push = true,
renderContentAlways = true,
size = PanelSize.medium,
skipFocusableSelectorsFromIndex,
title,
visible = false,
width,
Expand Down Expand Up @@ -280,6 +284,11 @@ export const Panel = React.forwardRef<PanelRef, PanelProps>(
<PanelContext.Provider value={operations}>
<NoFormStyle status override>
<FocusTrap
firstFocusableSelector={firstFocusableSelector}
lastFocusableSelector={lastFocusableSelector}
skipFocusableSelectorsFromIndex={
skipFocusableSelectorsFromIndex
}
trap={visible && focusTrap}
{...rest}
ref={containerRef}
Expand All @@ -295,9 +304,13 @@ export const Panel = React.forwardRef<PanelRef, PanelProps>(
onClick={stopPropagation}
style={getPanelStyle()}
>
{getHeader()}
{getBody()}
{!!footer && getFooter()}
{renderContentAlways && (
<>
{getHeader()}
{getBody()}
{!!footer && getFooter()}
</>
)}
</div>
</FocusTrap>
</NoFormStyle>
Expand Down
23 changes: 23 additions & 0 deletions src/components/Panel/Panel.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ export interface PanelProps extends Omit<OcBaseProps<HTMLElement>, 'title'> {
* Close icon name
*/
closeIcon?: IconName;
/**
* Prepend a specific selector to the beginning
* of the focus loop generated list of selectors.
* Use optionally when `focusTrap` is `true`.
*/
firstFocusableSelector?: string;
/**
* Unset this to disable focus trap
* @default true
Expand Down Expand Up @@ -120,6 +126,12 @@ export interface PanelProps extends Omit<OcBaseProps<HTMLElement>, 'title'> {
* Custom height of the panel
*/
height?: number;
/**
* Append a specific selector to the end
* of the focus loop generated list of selectors.
* Use optionally when `focusTrap` is `true`.
*/
lastFocusableSelector?: string;
/**
* The Panel locale.
* @default 'enUS'
Expand Down Expand Up @@ -177,6 +189,11 @@ export interface PanelProps extends Omit<OcBaseProps<HTMLElement>, 'title'> {
* @default true
*/
push?: boolean;
/**
* Whether to render Panel content when Panel `visible` is `false`.
* @default true
*/
renderContentAlways?: boolean;
/**
* Set this to enable/disable parent scroll
* @default true
Expand All @@ -187,6 +204,12 @@ export interface PanelProps extends Omit<OcBaseProps<HTMLElement>, 'title'> {
* @default medium
*/
size?: PanelSize;
/**
* Optionally skip some selectors when tabbing by index.length - skipFocusableSelectorsFromIndex
* Use when `focusTrap` is `true`
* @default `index.length - 1`
*/
skipFocusableSelectorsFromIndex?: number;
/**
* The title node of the panel
*/
Expand Down
Loading
Loading