From a28f9345bd10b2d410c42df84d83e77de5a1f232 Mon Sep 17 00:00:00 2001 From: Dylan Kilgore Date: Tue, 31 Oct 2023 12:02:40 -0700 Subject: [PATCH 1/2] feat: focustrap: export focus trap and add new hook props --- .../Dialog/BaseDialog/BaseDialog.tsx | 112 ++++++++++-------- .../Dialog/BaseDialog/BaseDialog.types.ts | 23 ++++ src/components/Dialog/Dialog.stories.tsx | 4 + src/components/Dialog/Dialog.test.tsx | 21 ++++ src/components/Modal/Modal.stories.tsx | 4 + src/components/Panel/Panel.stories.tsx | 4 + src/components/Panel/Panel.test.tsx | 19 +++ src/components/Panel/Panel.tsx | 19 ++- src/components/Panel/Panel.types.ts | 23 ++++ src/octuple.ts | 4 + src/shared/FocusTrap/FocusTrap.tsx | 29 ++++- .../FocusTrap/hooks/useFocusTrap.test.tsx | 19 +++ src/shared/FocusTrap/hooks/useFocusTrap.ts | 21 +++- src/shared/FocusTrap/index.ts | 1 + 14 files changed, 246 insertions(+), 57 deletions(-) diff --git a/src/components/Dialog/BaseDialog/BaseDialog.tsx b/src/components/Dialog/BaseDialog/BaseDialog.tsx index 85c5edd54..5a2736e1a 100644 --- a/src/components/Dialog/BaseDialog/BaseDialog.tsx +++ b/src/components/Dialog/BaseDialog/BaseDialog.tsx @@ -33,22 +33,26 @@ export const BaseDialog: FC = 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 @@ -117,6 +121,9 @@ export const BaseDialog: FC = React.forwardRef( const getDialog = (): JSX.Element => ( = React.forwardRef( style={dialogStyle} onClick={stopPropagation} > -
- - {headerButtonProps && ( - - )} - {header} - - - {actionButtonThreeProps && ( - - )} - {actionButtonTwoProps && ( - - )} - {actionButtonOneProps && ( - - )} - {closable && ( - - )} - -
-
- {body} -
- {actions &&
{actions}
} + {renderContentAlways && ( + <> +
+ + {headerButtonProps && ( + + )} + {header} + + + {actionButtonThreeProps && ( + + )} + {actionButtonTwoProps && ( + + )} + {actionButtonOneProps && ( + + )} + {closable && ( + + )} + +
+
+ {body} +
+ {actions &&
{actions}
} + + )}
diff --git a/src/components/Dialog/BaseDialog/BaseDialog.types.ts b/src/components/Dialog/BaseDialog/BaseDialog.types.ts index 12533c8a0..c63d5e35b 100644 --- a/src/components/Dialog/BaseDialog/BaseDialog.types.ts +++ b/src/components/Dialog/BaseDialog/BaseDialog.types.ts @@ -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 @@ -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' @@ -170,6 +182,17 @@ export interface BaseDialogProps * Ref for the dialog element */ ref?: Ref; + /** + * Whether to render Dialog content when Dialog `visible` is `true`. + * @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 */ diff --git a/src/components/Dialog/Dialog.stories.tsx b/src/components/Dialog/Dialog.stories.tsx index 960a6aa40..3beb672a9 100644 --- a/src/components/Dialog/Dialog.stories.tsx +++ b/src/components/Dialog/Dialog.stories.tsx @@ -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', diff --git a/src/components/Dialog/Dialog.test.tsx b/src/components/Dialog/Dialog.test.tsx index 9acc512fd..1dd2cfb6b 100644 --- a/src/components/Dialog/Dialog.test.tsx +++ b/src/components/Dialog/Dialog.test.tsx @@ -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 + ); + }); }); diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx index b9bc873dd..4db24ee66 100644 --- a/src/components/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal.stories.tsx @@ -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', diff --git a/src/components/Panel/Panel.stories.tsx b/src/components/Panel/Panel.stories.tsx index 859ee0d96..10ea9d8e6 100644 --- a/src/components/Panel/Panel.stories.tsx +++ b/src/components/Panel/Panel.stories.tsx @@ -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 diff --git a/src/components/Panel/Panel.test.tsx b/src/components/Panel/Panel.test.tsx index 8fabb649e..3adb3aaef 100644 --- a/src/components/Panel/Panel.test.tsx +++ b/src/components/Panel/Panel.test.tsx @@ -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() }); @@ -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( + +
Content is always rendered
+
+ ); + expect(getByText('Content is always rendered')).toBeTruthy(); + }); + + test('Should not render content when renderContentAlways is false', () => { + const { queryByText } = render( + +
Content is not always rendered
+
+ ); + expect(queryByText('Content is not always rendered')).toBeNull(); + }); }); diff --git a/src/components/Panel/Panel.tsx b/src/components/Panel/Panel.tsx index 3e9c52784..435affdd8 100644 --- a/src/components/Panel/Panel.tsx +++ b/src/components/Panel/Panel.tsx @@ -44,12 +44,14 @@ export const Panel = React.forwardRef( closeButtonAriaLabelText: defaultCloseButtonAriaLabelText, closeButtonProps, closeIcon = IconName.mdiClose, + firstFocusableSelector, footer, footerClassNames, headerButtonProps, headerClassNames, headerIcon = IconName.mdiArrowLeftThick, height, + lastFocusableSelector, locale = enUS, maskClosable = true, onClose = () => {}, @@ -62,7 +64,9 @@ export const Panel = React.forwardRef( parent = document.body, placement = 'right', push = true, + renderContentAlways = true, size = PanelSize.medium, + skipFocusableSelectorsFromIndex, title, visible = false, width, @@ -280,6 +284,11 @@ export const Panel = React.forwardRef( ( onClick={stopPropagation} style={getPanelStyle()} > - {getHeader()} - {getBody()} - {!!footer && getFooter()} + {renderContentAlways && ( + <> + {getHeader()} + {getBody()} + {!!footer && getFooter()} + + )} diff --git a/src/components/Panel/Panel.types.ts b/src/components/Panel/Panel.types.ts index caa77c5be..561711ca1 100644 --- a/src/components/Panel/Panel.types.ts +++ b/src/components/Panel/Panel.types.ts @@ -86,6 +86,12 @@ export interface PanelProps extends Omit, '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 @@ -120,6 +126,12 @@ export interface PanelProps extends Omit, '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' @@ -177,6 +189,11 @@ export interface PanelProps extends Omit, 'title'> { * @default true */ push?: boolean; + /** + * Whether to render Panel content when Panel `visible` is `true`. + * @default true + */ + renderContentAlways?: boolean; /** * Set this to enable/disable parent scroll * @default true @@ -187,6 +204,12 @@ export interface PanelProps extends Omit, '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 */ diff --git a/src/octuple.ts b/src/octuple.ts index cd9367d00..72ed73ed9 100644 --- a/src/octuple.ts +++ b/src/octuple.ts @@ -74,6 +74,8 @@ import { Empty, EmptyMode } from './components/Empty'; import { FadeIn } from './components/FadeIn'; +import { FocusTrap, useFocusTrap } from './shared/FocusTrap'; + import Form, { FormInstance } from './components/Form'; import Grid, { Col, Row } from './components/Grid'; @@ -302,6 +304,7 @@ export { FadeIn, FilterConfirmProps, FilterValue, + FocusTrap, Form, FormInstance, Grid, @@ -440,6 +443,7 @@ export { UploadSize, useBoolean, useCanvasDirection, + useFocusTrap, useMatchMedia, useMaxVisibleSections, useOnClickOutside, diff --git a/src/shared/FocusTrap/FocusTrap.tsx b/src/shared/FocusTrap/FocusTrap.tsx index d6e1a1657..39c4e1d43 100644 --- a/src/shared/FocusTrap/FocusTrap.tsx +++ b/src/shared/FocusTrap/FocusTrap.tsx @@ -3,17 +3,44 @@ import { useFocusTrap } from './hooks/useFocusTrap'; import { OcBaseProps } from '../../components/OcBase'; interface FocusTrapProps extends OcBaseProps { + /** + * Whether or not to trap focus. + */ trap: boolean; + /** + * Prepend a specific selector to the beginning + * of the focus loop generated list of selectors. + */ + firstFocusableSelector?: string; + /** + * Append a specific selector to the end + * of the focus loop generated list of selectors. + */ + lastFocusableSelector?: string; + /** + * Optionally skip some selectors when tabbing by index.length - skipFocusableSelectorsFromIndex + * Use when `trap` is `true` + * @default `index.length - 1` + */ + skipFocusableSelectorsFromIndex?: number; } export const FocusTrap: FC = ({ trap = true, children, classNames, + firstFocusableSelector, + lastFocusableSelector, + skipFocusableSelectorsFromIndex, 'data-test-id': dataTestId, ...rest }) => { - const focusRef = useFocusTrap(trap); + const focusRef: React.MutableRefObject = useFocusTrap( + trap, + firstFocusableSelector, + lastFocusableSelector, + skipFocusableSelectorsFromIndex + ); return (
{ await waitFor(() => expect(firstButton.matches(':focus')).toBe(true)); expect(firstButton.matches(':focus')).toBe(true); }); + + test('setUpFocus sets up the initial focus with the firstFocusableSelector', async () => { + const { container } = render( + + <> + + + + + ); + container.focus(); + const secondButton = container.getElementsByClassName('button-2')[0]; + await waitFor(() => expect(secondButton.matches(':focus')).toBe(true)); + expect(secondButton.matches(':focus')).toBe(true); + }); }); diff --git a/src/shared/FocusTrap/hooks/useFocusTrap.ts b/src/shared/FocusTrap/hooks/useFocusTrap.ts index bffac4743..22f683f2a 100644 --- a/src/shared/FocusTrap/hooks/useFocusTrap.ts +++ b/src/shared/FocusTrap/hooks/useFocusTrap.ts @@ -8,7 +8,10 @@ const SELECTORS = const FOCUS_DELAY_INTERVAL: number = 100; export function useFocusTrap( - visible = true + visible: boolean = true, + firstFocusableSelector?: string, + lastFocusableSelector?: string, + skipFocusableSelectorsFromIndex?: number ): React.MutableRefObject { const elRef: React.MutableRefObject = useRef(null); const intervalRef: React.MutableRefObject = @@ -32,8 +35,17 @@ export function useFocusTrap( return; } + if (lastFocusableSelector) { + focusableEls.push(elRef.current?.querySelector(lastFocusableSelector)); + } + const firstFocusableEl: HTMLElement = focusableEls[0]; - const lastFocusableEl: HTMLElement = focusableEls[focusableEls.length - 1]; + const lastFocusableEl: HTMLElement = + focusableEls[ + skipFocusableSelectorsFromIndex + ? focusableEls.length - skipFocusableSelectorsFromIndex + : focusableEls.length - 1 + ]; if (isShiftPressed) { if (document.activeElement === firstFocusableEl) { @@ -55,7 +67,10 @@ export function useFocusTrap( return; } restoreFocusRef.current = document.activeElement; - const elementToFocus: HTMLElement = getFocusableElements()?.[0]; + let elementToFocus: HTMLElement = getFocusableElements()?.[0]; + if (firstFocusableSelector) { + elementToFocus = elRef.current?.querySelector(firstFocusableSelector); + } clearInterval(intervalRef?.current); intervalRef.current = setInterval((): void => { elementToFocus?.focus(); diff --git a/src/shared/FocusTrap/index.ts b/src/shared/FocusTrap/index.ts index 9cf91c3a4..418fded53 100644 --- a/src/shared/FocusTrap/index.ts +++ b/src/shared/FocusTrap/index.ts @@ -1 +1,2 @@ export * from './FocusTrap'; +export * from './hooks/useFocusTrap'; From 6ee693eeba4dd259b1e436515d45a9366347718b Mon Sep 17 00:00:00 2001 From: Dylan Kilgore Date: Tue, 31 Oct 2023 14:13:35 -0700 Subject: [PATCH 2/2] chore: focustrap: address pr feedback and fix comment typo --- src/components/Dialog/BaseDialog/BaseDialog.types.ts | 2 +- src/components/Panel/Panel.types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Dialog/BaseDialog/BaseDialog.types.ts b/src/components/Dialog/BaseDialog/BaseDialog.types.ts index c63d5e35b..c72817a40 100644 --- a/src/components/Dialog/BaseDialog/BaseDialog.types.ts +++ b/src/components/Dialog/BaseDialog/BaseDialog.types.ts @@ -183,7 +183,7 @@ export interface BaseDialogProps */ ref?: Ref; /** - * Whether to render Dialog content when Dialog `visible` is `true`. + * Whether to render Dialog content when Dialog `visible` is `false`. * @default true */ renderContentAlways?: boolean; diff --git a/src/components/Panel/Panel.types.ts b/src/components/Panel/Panel.types.ts index 561711ca1..afd3c8b83 100644 --- a/src/components/Panel/Panel.types.ts +++ b/src/components/Panel/Panel.types.ts @@ -190,7 +190,7 @@ export interface PanelProps extends Omit, 'title'> { */ push?: boolean; /** - * Whether to render Panel content when Panel `visible` is `true`. + * Whether to render Panel content when Panel `visible` is `false`. * @default true */ renderContentAlways?: boolean;