Skip to content

Commit

Permalink
refactor(project): add modal provider and hook
Browse files Browse the repository at this point in the history
  • Loading branch information
ChristiaanScheermeijer committed Apr 9, 2024
1 parent 1011dd2 commit ef2317f
Show file tree
Hide file tree
Showing 18 changed files with 593 additions and 147 deletions.
3 changes: 2 additions & 1 deletion packages/ui-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
"typescript-plugin-css-modules": "^5.0.2",
"vi-fetch": "^0.8.0",
"vite-plugin-svgr": "^4.2.0",
"vitest": "^1.3.1"
"vitest": "^1.3.1",
"wicg-inert": "^3.1.2"
},
"peerDependencies": {
"@jwp/ott-common": "*",
Expand Down
5 changes: 3 additions & 2 deletions packages/ui-react/src/components/Alert/Alert.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React from 'react';
import { render } from '@testing-library/react';

import { renderWithRouter } from '../../../test/utils';

import Alert from './Alert';

describe('<Alert>', () => {
test('renders and matches snapshot', () => {
const { container } = render(<Alert message="Body" open={true} onClose={vi.fn()} />);
const { container } = renderWithRouter(<Alert message="Body" open={true} onClose={vi.fn()} />);
expect(container).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React from 'react';
import { render } from '@testing-library/react';

import { renderWithRouter } from '../../../test/utils';

import ConfirmationDialog from './ConfirmationDialog';

describe('<ConfirmationDialog>', () => {
test('renders and matches snapshot', () => {
const { container } = render(<ConfirmationDialog body="Body" title="Title" open={true} onConfirm={vi.fn()} onClose={vi.fn()} />);
const { container } = renderWithRouter(<ConfirmationDialog body="Body" title="Title" open={true} onConfirm={vi.fn()} onClose={vi.fn()} />);

expect(container).toMatchSnapshot();
});
Expand Down
7 changes: 4 additions & 3 deletions packages/ui-react/src/components/Dialog/Dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React from 'react';
import { render } from '@testing-library/react';

import { renderWithRouter } from '../../../test/utils';

import Dialog from './Dialog';

describe('<Dialog>', () => {
test('renders and matches snapshot', () => {
const { baseElement } = render(
const { baseElement } = renderWithRouter(
<>
<span>Some content</span>
<Dialog onClose={vi.fn()} open={true} role="dialog">
Expand All @@ -19,7 +20,7 @@ describe('<Dialog>', () => {
});

test('Should ensure Dialog is properly marked as a modal and has role "dialog"', () => {
const { getByTestId } = render(
const { getByTestId } = renderWithRouter(
<>
<span>Some content</span>
<Dialog onClose={vi.fn()} open={true} role="dialog" data-testid="dialog">
Expand Down
16 changes: 9 additions & 7 deletions packages/ui-react/src/components/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { fireEvent } from '@testing-library/react';

import { renderWithRouter } from '../../../test/utils';

import Modal from './Modal';

describe('<Modal>', () => {
test('renders and matches snapshot', () => {
const { container } = render(
const { container } = renderWithRouter(
<Modal open={true} onClose={vi.fn()}>
<p>Test modal</p>
</Modal>,
Expand All @@ -16,16 +18,16 @@ describe('<Modal>', () => {

test('calls the onClose function when clicking the backdrop', () => {
const onClose = vi.fn();
const { getByTestId } = render(<Modal open={true} onClose={onClose} />);
const { getByTestId } = renderWithRouter(<Modal open={true} onClose={onClose} />);

fireEvent.click(getByTestId('backdrop'));

expect(onClose).toBeCalledTimes(1);
});

test('Should add inert attribute on the root div when open', () => {
test('add the inert attribute on the root div when open', () => {
const onClose = vi.fn();
const { getByTestId, rerender } = render(
const { getByTestId, rerender } = renderWithRouter(
<div id="root" data-testid="root">
<Modal open={true} onClose={onClose} />
</div>,
Expand All @@ -42,9 +44,9 @@ describe('<Modal>', () => {
expect(getByTestId('root')).toHaveProperty('inert', false);
});

test('should add overflowY hidden on the body element when open', () => {
test('add overflowY hidden on the body element when open', () => {
const onClose = vi.fn();
const { container, rerender } = render(<Modal open={true} onClose={onClose} />);
const { container, rerender } = renderWithRouter(<Modal open={true} onClose={onClose} />);

expect(container.parentNode).toHaveStyle({ overflowY: 'hidden' });

Expand Down
72 changes: 7 additions & 65 deletions packages/ui-react/src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useRef } from 'react';
import ReactDOM from 'react-dom';
import { testId } from '@jwp/ott-common/src/utils/common';

import Fade from '../Animation/Fade/Fade';
import Grow from '../Animation/Grow/Grow';
import scrollbarSize from '../../utils/dom';
import { useModal } from '../../containers/ModalProvider/useModal';

import styles from './Modal.module.scss';

Expand All @@ -16,74 +16,16 @@ type Props = {
animationContainerClassName?: string;
} & React.AriaAttributes;

const Modal: React.FC<Props> = ({ open, onClose, children, AnimationComponent = Grow, animationContainerClassName, ...ariaAtributes }: Props) => {
const [visible, setVisible] = useState(open);
const lastFocus = useRef<HTMLElement>() as React.MutableRefObject<HTMLElement>;
const Modal: React.FC<Props> = ({ open, onClose, children, AnimationComponent = Grow, animationContainerClassName, ...ariaAttributes }: Props) => {
const modalRef = useRef<HTMLDivElement>() as React.MutableRefObject<HTMLDivElement>;

const keyDownEventHandler = (event: React.KeyboardEvent) => {
if (event.key === 'Escape' && onClose) {
onClose();
}
};

// delay the transition state so the CSS transition kicks in after toggling the `open` prop
useEffect(() => {
const activeElement = document.activeElement as HTMLElement;
const appView = document.querySelector('#root') as HTMLDivElement;

if (open) {
// store last focussed element
if (activeElement) {
lastFocus.current = activeElement;
}

// reset the visible state
setVisible(true);

// make sure main content is hidden for screen readers and inert
if (appView) {
appView.inert = true;
}

// prevent scrolling under the modal
document.body.style.marginRight = `${scrollbarSize()}px`;
document.body.style.overflowY = 'hidden';
} else {
if (appView) {
appView.inert = false;
}

document.body.style.removeProperty('margin-right');
document.body.style.removeProperty('overflow-y');
}
}, [open]);

useEffect(() => {
if (visible) {
// focus the first element in the modal
if (modalRef.current) {
const interactiveElement = modalRef.current.querySelectorAll(
'div[role="dialog"] input, div[role="dialog"] a, div[role="dialog"] button, div[role="dialog"] [tabindex]',
)[0] as HTMLElement | null;

if (interactiveElement) interactiveElement.focus();
}
} else {
// restore last focussed element
if (lastFocus.current) {
lastFocus.current.focus();
}
}
}, [visible]);

if (!open && !visible) return null;
useModal({ open, onClose, modalRef });

return ReactDOM.createPortal(
<Fade open={open} duration={300} onCloseAnimationEnd={() => setVisible(false)}>
<div className={styles.modal} onKeyDown={keyDownEventHandler} ref={modalRef}>
<Fade open={open} duration={300}>
<div className={styles.modal} ref={modalRef}>
<div className={styles.backdrop} onClick={onClose} data-testid={testId('backdrop')} />
<div className={styles.container} {...ariaAtributes}>
<div className={styles.container} {...ariaAttributes}>
<AnimationComponent open={open} duration={200} className={animationContainerClassName}>
{children}
</AnimationComponent>
Expand Down
91 changes: 91 additions & 0 deletions packages/ui-react/src/components/Sidebar/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import Sidebar from './Sidebar';
describe('<SideBar />', () => {
const playlistMenuItems = [<Button key="key" label="Home" to="/" />];

beforeAll(() => {
vi.useFakeTimers();
});

afterAll(() => {
vi.useRealTimers();
});

test('renders sideBar opened', () => {
const { container } = renderWithRouter(
<Sidebar isOpen={true} onClose={vi.fn()}>
Expand All @@ -27,4 +35,87 @@ describe('<SideBar />', () => {

expect(container).toMatchSnapshot();
});

test('sets inert on the given containerRef', () => {
const ref = {
current: document.createElement('div'),
};
const { rerender } = renderWithRouter(
<Sidebar isOpen={true} onClose={vi.fn()} containerRef={ref}>
{playlistMenuItems}
</Sidebar>,
);

expect(ref.current).toHaveProperty('inert', true);

rerender(
<Sidebar isOpen={false} onClose={vi.fn()} containerRef={ref}>
{playlistMenuItems}
</Sidebar>,
);

expect(ref.current).toHaveProperty('inert', false);
});

test('should add overflowY hidden on the body element when open', () => {
const { container, rerender } = renderWithRouter(<Sidebar isOpen={true} onClose={vi.fn()} />);

expect(container.parentNode).toHaveStyle({ overflowY: 'hidden' });

rerender(<Sidebar isOpen={false} onClose={vi.fn()} />);

expect(container.parentNode).not.toHaveStyle({ overflowY: 'hidden' });
});

test('should focus the first interactive element in the sidebar when opened', () => {
const { getByLabelText, rerender } = renderWithRouter(
<Sidebar isOpen={false} onClose={vi.fn()}>
<button>close</button>
</Sidebar>,
);

expect(document.activeElement).toBe(document.body);

rerender(
<Sidebar isOpen={true} onClose={vi.fn()}>
<button>close</button>
</Sidebar>,
);
vi.runAllTimers();

expect(document.activeElement).toBe(getByLabelText('close_menu'));
});

test('should focus the last focused element when the sidebar is closed', () => {
const { getByText, rerender } = renderWithRouter(
<>
<button>open</button>
<Sidebar isOpen={false} onClose={vi.fn()}>
<button>close</button>
</Sidebar>
</>,
);
getByText('open').focus();
rerender(
<>
<button>open</button>
<Sidebar isOpen={true} onClose={vi.fn()}>
<button>close</button>
</Sidebar>
</>,
);
vi.runAllTimers();

rerender(
<>
<button>open</button>
<Sidebar isOpen={false} onClose={vi.fn()}>
<button>close</button>
</Sidebar>
</>,
);
vi.runAllTimers();

expect(document.activeElement).toBe(getByText('open'));
});
});
63 changes: 9 additions & 54 deletions packages/ui-react/src/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,81 +1,36 @@
import React, { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
import React, { Fragment, type ReactNode, type RefObject, useEffect, useRef } from 'react';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import Close from '@jwp/ott-theme/assets/icons/close.svg?react';

import IconButton from '../IconButton/IconButton';
import Icon from '../Icon/Icon';
import scrollbarSize from '../../utils/dom';
import { useModal } from '../../containers/ModalProvider/useModal';

import styles from './Sidebar.module.scss';

type SidebarProps = {
isOpen: boolean;
onClose: () => void;
children?: ReactNode;
containerRef?: RefObject<HTMLElement>;
};

const Sidebar: React.FC<SidebarProps> = ({ isOpen, onClose, children }) => {
const Sidebar: React.FC<SidebarProps> = ({ isOpen, onClose, containerRef, children }) => {
const { t } = useTranslation('menu');
const lastFocusedElementRef = useRef<HTMLElement | null>(null);
const sidebarRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);

useModal({ open: isOpen, onClose, modalRef: sidebarRef, containerRef });

const htmlAttributes = { inert: !isOpen ? '' : undefined }; // inert is not yet officially supported in react. see: https://github.com/facebook/react/pull/24730

useEffect(() => {
if (isOpen) {
// Before inert on the body is applied in Layout, we need to set this ref
lastFocusedElementRef.current = document.activeElement as HTMLElement;

// When opened, adjust the margin-right to accommodate for the scrollbar width to prevent UI shifts in background
document.body.style.marginRight = `${scrollbarSize()}px`;
document.body.style.overflowY = 'hidden';

// Scroll the sidebar to the top if the user has previously scrolled down in the sidebar
if (sidebarRef.current) {
sidebarRef.current.scrollTop = 0;
}
} else {
document.body.style.removeProperty('margin-right');
document.body.style.removeProperty('overflow-y');
// Scroll the sidebar to the top if the user has previously scrolled down in the sidebar
if (isOpen && sidebarRef.current) {
sidebarRef.current.scrollTop = 0;
}
}, [isOpen]);

useEffect(() => {
const handleEscKey = (event: KeyboardEvent) => {
if (isOpen && event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscKey);

return () => {
document.removeEventListener('keydown', handleEscKey);
};
}, [isOpen, onClose]);

useEffect(() => {
const sidebarElement = sidebarRef.current;
const handleTransitionEnd = () => {
setVisible(isOpen);
};

sidebarElement?.addEventListener('transitionend', handleTransitionEnd);

return () => {
sidebarElement?.removeEventListener('transitionend', handleTransitionEnd);
};
}, [isOpen]);

useEffect(() => {
if (visible) {
sidebarRef.current?.querySelectorAll('a')[0]?.focus({ preventScroll: true });
} else {
lastFocusedElementRef.current?.focus({ preventScroll: true });
}
}, [visible]);

return (
<Fragment>
<div
Expand Down
Loading

0 comments on commit ef2317f

Please sign in to comment.