Skip to content

Commit

Permalink
update(ModalInner): refactor with hooks (#407)
Browse files Browse the repository at this point in the history
refactor with hooks
  • Loading branch information
moyus authored Oct 28, 2020
1 parent 27e2fde commit 34e5a3f
Show file tree
Hide file tree
Showing 2 changed files with 123 additions and 134 deletions.
203 changes: 99 additions & 104 deletions packages/core/src/components/Modal/private/Inner.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React from 'react';
import withStyles, { WithStylesProps } from '../../../composers/withStyles';
import React, { useRef, useCallback, useEffect } from 'react';
import useStyles from '../../../hooks/useStyles';
import FocusTrap from '../../FocusTrap';
import focusFirstFocusableChild from '../../../utils/focus/focusFirstFocusableChild';
import ModalImageLayout, { ModalImageConfig } from './ImageLayout';
import ModalInnerContent, { ModalInnerContentProps } from './InnerContent';
import {
styleSheetInner as styleSheet,
styleSheetInner,
MODAL_MAX_WIDTH_SMALL,
MODAL_MAX_WIDTH_MEDIUM,
MODAL_MAX_WIDTH_LARGE,
Expand All @@ -23,112 +23,107 @@ export type ModalInnerProps = ModalInnerContentProps & {
};

/** A Dialog component with a backdrop and a standardized layout. */
export class ModalInner extends React.Component<ModalInnerProps & WithStylesProps> {
dialogRef = React.createRef<HTMLDivElement>();

lastActiveElement: HTMLElement | null = null;

openTimeout?: number;

componentDidMount() {
this.handleOpen();

document.addEventListener('click', this.handleClickOutside, true);
}

componentWillUnmount() {
document.removeEventListener('click', this.handleClickOutside, true);

if (this.openTimeout) {
window.clearTimeout(this.openTimeout);
}

if (this.lastActiveElement) {
this.lastActiveElement.focus();
}
}

private handleClickOutside = (event: React.MouseEvent | MouseEvent) => {
const { current } = this.dialogRef;

if (current?.contains(event.target as Element) || this.props.persistOnOutsideClick) {
return;
}

this.handleClose(event as React.MouseEvent);
};

private handleOpen = () => {
this.lastActiveElement = document.activeElement as HTMLElement;
export default function ModalInner({
styleSheet,
onClose,
children,
footer,
image,
large,
small,
fluid,
scrollable,
subtitle,
title,
topBar,
topBarCentered,
persistOnOutsideClick,
}: ModalInnerProps) {
const [styles, cx] = useStyles(styleSheet ?? styleSheetInner);
const dialogRef = useRef<HTMLDivElement>(null);
const lastActiveElementRef = useRef<HTMLElement | null>(null);
const openTimeoutRef = useRef<number>();

const handleClose = useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
onClose(event);
},
[onClose],
);

useEffect(() => {
lastActiveElementRef.current = document.activeElement as HTMLElement;

// Putting this in a setTimeout helps screen readers notice that focus has changed.
this.openTimeout = window.setTimeout(() => {
const { current: dialogRefElement } = this.dialogRef;
openTimeoutRef.current = window.setTimeout(() => {
const { current: dialogRefElement } = dialogRef;

if (dialogRefElement) {
focusFirstFocusableChild(dialogRefElement);
}
}, 0);
};

private handleClose = (event: React.MouseEvent | React.KeyboardEvent) => {
const { onClose } = this.props;
onClose(event);
};

render() {
const {
cx,
children,
footer,
image,
large,
small,
fluid,
scrollable,
styles,
subtitle,
title,
topBar,
topBarCentered,
} = this.props;

const showLargeContent = large || !!image;

const innerContent = (
<ModalInnerContent
footer={footer}
large={showLargeContent}
small={small}
scrollable={scrollable}
subtitle={subtitle}
title={title}
topBar={topBar}
topBarCentered={topBarCentered}
onClose={this.handleClose}
>
{children}
</ModalInnerContent>
);

return (
<div
ref={this.dialogRef}
aria-modal
role="dialog"
className={cx(
styles.content,
small && styles.content_small,
showLargeContent && styles.content_large,
fluid && styles.content_fluid,
)}
>
<FocusTrap>
{image ? <ModalImageLayout {...image}>{innerContent}</ModalImageLayout> : innerContent}
</FocusTrap>
</div>
);
}
}

export default withStyles(styleSheet)(ModalInner);
return () => {
if (openTimeoutRef.current) {
window.clearTimeout(openTimeoutRef.current);
}
if (lastActiveElementRef.current) {
lastActiveElementRef.current.focus();
}
};
}, []);

useEffect(() => {
const handleClickOutside = (event: React.MouseEvent | MouseEvent) => {
const { current } = dialogRef;

if (current?.contains(event.target as Element) || persistOnOutsideClick) {
return;
}

handleClose(event as React.MouseEvent);
};

document.addEventListener('click', handleClickOutside, true);

return () => {
document.removeEventListener('click', handleClickOutside, true);
};
}, [handleClose, persistOnOutsideClick]);

const showLargeContent = large || !!image;

const innerContent = (
<ModalInnerContent
footer={footer}
large={showLargeContent}
small={small}
scrollable={scrollable}
subtitle={subtitle}
title={title}
topBar={topBar}
topBarCentered={topBarCentered}
onClose={handleClose}
>
{children}
</ModalInnerContent>
);

return (
<div
ref={dialogRef}
aria-modal
role="dialog"
className={cx(
styles.content,
small && styles.content_small,
showLargeContent && styles.content_large,
fluid && styles.content_fluid,
)}
>
<FocusTrap>
{image ? <ModalImageLayout {...image}>{innerContent}</ModalImageLayout> : innerContent}
</FocusTrap>
</div>
);
}
54 changes: 24 additions & 30 deletions packages/core/test/components/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ describe('<Modal />', () => {
eventMap[event] = cb;
});

shallowWithStyles(<ModalInner onClose={closeSpy}>Foo</ModalInner>);
mountWithStyles(<ModalInner onClose={closeSpy}>Foo</ModalInner>);

eventMap.click!();
// @ts-ignore
eventMap.click!({ target: null });

expect(closeSpy).toHaveBeenCalled();
});
Expand All @@ -92,19 +93,19 @@ describe('<Modal />', () => {
eventMap[event] = cb;
});

shallowWithStyles(
mountWithStyles(
<ModalInner persistOnOutsideClick onClose={closeSpy}>
Foo
</ModalInner>,
);

eventMap.click!();
// @ts-ignore
eventMap.click!({ target: null });

expect(closeSpy).not.toHaveBeenCalled();
});

it('does not close when clicked on self', () => {
const target = document.createElement('div');
const closeSpy = jest.fn();

const eventMap: EventMap = {
Expand All @@ -118,38 +119,31 @@ describe('<Modal />', () => {
eventMap[event] = cb;
});

const wrapper = shallowWithStyles(<ModalInner onClose={closeSpy}>Foo</ModalInner>);
const instance = wrapper.instance();

// @ts-ignore
instance.dialogRef = { current: target };
const wrapper = mountWithStyles(<ModalInner onClose={closeSpy}>Foo</ModalInner>);

// @ts-ignore
eventMap.click!({ preventDefault: jest.fn(), target });
eventMap.click!({ preventDefault: jest.fn(), target: wrapper.getDOMNode() });

expect(closeSpy).toHaveBeenCalledTimes(0);
});

describe('componentDidMount', () => {
describe('event listener', () => {
it('adds event listener', () => {
const eventSpy = jest.spyOn(document, 'addEventListener');

shallowWithStyles(<ModalInner onClose={() => {}}>Foo</ModalInner>);
mountWithStyles(<ModalInner onClose={() => {}}>Foo</ModalInner>);

expect(eventSpy).toHaveBeenCalledWith('click', expect.any(Function), true);

eventSpy.mockRestore();
});
});

describe('componentWillUnmount', () => {
it('removes event listener', () => {
const eventSpy = jest.spyOn(document, 'removeEventListener');

const wrapper = shallowWithStyles(<ModalInner onClose={() => {}}>Foo</ModalInner>);
const wrapper = mountWithStyles(<ModalInner onClose={() => {}}>Foo</ModalInner>);

// @ts-ignore
wrapper.instance().componentWillUnmount();
wrapper.unmount();

expect(eventSpy).toHaveBeenCalledWith('click', expect.any(Function), true);

Expand All @@ -158,29 +152,29 @@ describe('<Modal />', () => {
});

it('different class for small size', () => {
const wrapper = shallowWithStyles(<ModalInner onClose={jest.fn()}>Foo</ModalInner>);
const small = shallowWithStyles(
const wrapper = mountWithStyles(<ModalInner onClose={jest.fn()}>Foo</ModalInner>);
const small = mountWithStyles(
<ModalInner small onClose={jest.fn()}>
Foo
</ModalInner>,
);

expect(wrapper.prop('className')).not.toBe(small.prop('className'));
expect(wrapper.getDOMNode().className).not.toBe(small.getDOMNode().className);
});

it('different class for large size', () => {
const wrapper = shallowWithStyles(<ModalInner onClose={jest.fn()}>Foo</ModalInner>);
const large = shallowWithStyles(
const wrapper = mountWithStyles(<ModalInner onClose={jest.fn()}>Foo</ModalInner>);
const large = mountWithStyles(
<ModalInner large onClose={jest.fn()}>
Foo
</ModalInner>,
);

expect(wrapper.prop('className')).not.toBe(large.prop('className'));
expect(wrapper.getDOMNode().className).not.toBe(large.getDOMNode().className);
});

it('same class for image as large size', () => {
const wrapper = shallowWithStyles(
const wrapper = mountWithStyles(
<ModalInner
image={{
type: 'center',
Expand All @@ -191,24 +185,24 @@ describe('<Modal />', () => {
Foo
</ModalInner>,
);
const large = shallowWithStyles(
const large = mountWithStyles(
<ModalInner large onClose={jest.fn()}>
Foo
</ModalInner>,
);

expect(wrapper.prop('className')).toBe(large.prop('className'));
expect(wrapper.getDOMNode().className).toBe(large.getDOMNode().className);
});

it('different class for fluid size', () => {
const wrapper = shallowWithStyles(<ModalInner onClose={jest.fn()}>Foo</ModalInner>);
const fluid = shallowWithStyles(
const wrapper = mountWithStyles(<ModalInner onClose={jest.fn()}>Foo</ModalInner>);
const fluid = mountWithStyles(
<ModalInner fluid onClose={jest.fn()}>
Foo
</ModalInner>,
);

expect(wrapper.prop('className')).not.toBe(fluid.prop('className'));
expect(wrapper.getDOMNode().className).not.toBe(fluid.getDOMNode().className);
});

it('focuses the first element on open', () => {
Expand Down

0 comments on commit 34e5a3f

Please sign in to comment.