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(modal): customization #1903

Merged
merged 12 commits into from
Oct 5, 2021
6 changes: 6 additions & 0 deletions .changeset/clean-planets-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@twilio-paste/modal': patch
'@twilio-paste/core': patch
---

[Modal] Enable Component to respect element customizations set on the customization provider. Component now enables setting an element name on the underlying HTML element and checks the emotion theme object to determine whether it should merge in custom styles to the ones set by the component author.
165 changes: 165 additions & 0 deletions packages/paste-core/components/modal/__tests__/customization.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import * as React from 'react';

import {render, screen} from '@testing-library/react';
import {CustomizationProvider} from '@twilio-paste/customization';
import {matchers} from 'jest-emotion';

import {BaseModal, initStyles} from '../stories/customization.stories';

expect.extend(matchers);

jest.mock('@twilio-paste/modal-dialog-primitive', () => {
const actual = jest.requireActual('@twilio-paste/modal-dialog-primitive');
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess I'm a little unsure on why this needs to be mocked, so providing some sort of commentary on the why might be helpful in a code comment.

const {forwardRef: mockForwardRef} = jest.requireActual('react');
const MockModalDialogPrimitiveOverlay = mockForwardRef(
(
{
children,
'data-paste-element': dataPasteElement,
style,
className,
}: {children: any; 'data-paste-element': string; style: any; className: string},
ref: any
) => (
<div
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Mocking the react portal for this test.

data-testid="mock-reach-dialog-overlay"
data-paste-element={dataPasteElement}
style={style}
ref={ref}
className={className}
>
{children}
</div>
)
);
return {
...actual,
ModalDialogPrimitiveOverlay: MockModalDialogPrimitiveOverlay,
};
});

describe('Modal Customization', () => {
describe('"data-paste-element" HTML attributes', () => {
it('Should add the correct "data-paste-element" attribute when element prop is undefined', () => {
render(<BaseModal size="default" />);

expect(screen.getByTestId('mock-reach-dialog-overlay').getAttribute('data-paste-element')).toEqual(
'MODAL_OVERLAY'
);
expect(screen.getByTestId('modal-test-id').getAttribute('data-paste-element')).toEqual('MODAL');
expect(screen.getByTestId('modal-header-test-id').getAttribute('data-paste-element')).toEqual('MODAL_HEADER');
expect(screen.getByTestId('modal-heading-test-id').getAttribute('data-paste-element')).toEqual('MODAL_HEADING');
expect(screen.getByTestId('modal-body-test-id').getAttribute('data-paste-element')).toEqual('MODAL_BODY');

const modalFooter = screen.getByTestId('modal-footer-test-id');
expect(modalFooter.getAttribute('data-paste-element')).toEqual('MODAL_FOOTER');

const modalFooterActions = modalFooter.firstChild as HTMLElement;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this different from using .firstElementChild?

expect(modalFooterActions.getAttribute('data-paste-element')).toEqual('MODAL_FOOTER_ACTIONS');

const modalFooterActionItemOne = modalFooterActions.firstChild as HTMLElement;
const modalFooterActionItemTwo = modalFooterActions.lastChild as HTMLElement;
expect(modalFooterActionItemOne.getAttribute('data-paste-element')).toEqual('MODAL_FOOTER_ACTIONS_ITEM');
expect(modalFooterActionItemTwo.getAttribute('data-paste-element')).toEqual('MODAL_FOOTER_ACTIONS_ITEM');
});

it('Should add the correct "data-paste-element" attribute when element prop is defined', () => {
render(<BaseModal size="default" element="CUSTOM_TEST_MODAL" />);

expect(screen.getByTestId('mock-reach-dialog-overlay').getAttribute('data-paste-element')).toEqual(
'CUSTOM_TEST_MODAL_OVERLAY'
);
expect(screen.getByTestId('modal-test-id').getAttribute('data-paste-element')).toEqual('CUSTOM_TEST_MODAL');
expect(screen.getByTestId('modal-header-test-id').getAttribute('data-paste-element')).toEqual(
'CUSTOM_TEST_MODAL_HEADER'
);
expect(screen.getByTestId('modal-heading-test-id').getAttribute('data-paste-element')).toEqual(
'CUSTOM_TEST_MODAL_HEADING'
);
expect(screen.getByTestId('modal-body-test-id').getAttribute('data-paste-element')).toEqual(
'CUSTOM_TEST_MODAL_BODY'
);

const modalFooter = screen.getByTestId('modal-footer-test-id');
const modalFooterActions = modalFooter.firstChild as HTMLElement;
const modalFooterActionItemOne = modalFooterActions.firstChild as HTMLElement;
const modalFooterActionItemTwo = modalFooterActions.lastChild as HTMLElement;

expect(modalFooter.getAttribute('data-paste-element')).toEqual('CUSTOM_TEST_MODAL_FOOTER');
expect(modalFooterActions.getAttribute('data-paste-element')).toEqual('CUSTOM_TEST_MODAL_FOOTER_ACTIONS');
expect(modalFooterActionItemOne.getAttribute('data-paste-element')).toEqual(
'CUSTOM_TEST_MODAL_FOOTER_ACTIONS_ITEM'
);
expect(modalFooterActionItemTwo.getAttribute('data-paste-element')).toEqual(
'CUSTOM_TEST_MODAL_FOOTER_ACTIONS_ITEM'
);
});
});

describe('Custom styles', () => {
it('Should apply correct style rules to normal size variant', () => {
render(<BaseModal size="default" />, {
wrapper: ({children}) => (
<CustomizationProvider
// @ts-expect-error global test variable
theme={TestTheme}
elements={initStyles('MODAL')}
andioneto marked this conversation as resolved.
Show resolved Hide resolved
>
{children}
</CustomizationProvider>
),
});

expect(screen.getByTestId('mock-reach-dialog-overlay')).toHaveStyleRule('background-color', 'rgb(6,3,58)');

expect(screen.getByTestId('modal-test-id')).toHaveStyleRule('border-radius', '8px');
expect(screen.getByTestId('modal-test-id')).toHaveStyleRule('box-shadow', '0 16px 24px 0 rgba(18,28,45,0.2)');
expect(screen.getByTestId('modal-test-id')).toHaveStyleRule('border-color', 'rgb(96,107,133)');

expect(screen.getByTestId('modal-header-test-id')).toHaveStyleRule('border-width', '0');
expect(screen.getByTestId('modal-header-test-id')).toHaveStyleRule('border-style', 'none');
expect(screen.getByTestId('modal-header-test-id')).toHaveStyleRule('border-color', 'transparent');

expect(screen.getByTestId('modal-heading-test-id')).toHaveStyleRule('font-size', '3rem');

expect(screen.getByTestId('modal-body-test-id')).toHaveStyleRule('padding-right', '1.25rem');
expect(screen.getByTestId('modal-body-test-id')).toHaveStyleRule('padding-left', '1.25rem');

const modalFooter = screen.getByTestId('modal-footer-test-id');
const modalFooterActions = modalFooter.firstChild as HTMLElement;
const modalFooterActionItemOne = modalFooterActions.firstChild as HTMLElement;
const modalFooterActionItemTwo = modalFooterActions.lastChild as HTMLElement;

expect(modalFooter).toHaveStyleRule('border-width', '0');
expect(modalFooter).toHaveStyleRule('border-style', 'none');
expect(modalFooter).toHaveStyleRule('border-color', 'transparent');

expect(modalFooterActions).toHaveStyleRule('justify-content', 'flex-start');

expect(modalFooterActionItemOne).toHaveStyleRule('padding-left', '0', {target: ':first-of-type'});
expect(modalFooterActionItemOne).toHaveStyleRule('padding-right', '0.75rem');

expect(modalFooterActionItemTwo).toHaveStyleRule('padding-left', '0.75rem');
expect(modalFooterActionItemTwo).toHaveStyleRule('padding-right', '0.75rem');
});

it('Should apply correct style rules to wide size variant', () => {
render(<BaseModal size="wide" />, {
wrapper: ({children}) => (
<CustomizationProvider
// @ts-expect-error global test variable
theme={TestTheme}
elements={initStyles('MODAL')}
>
{children}
</CustomizationProvider>
),
});

expect(screen.getByTestId('mock-reach-dialog-overlay')).toHaveStyleRule('background-color', 'rgb(244,244,246)');

expect(screen.getByTestId('modal-test-id')).toHaveStyleRule('max-width', 'unset');
expect(screen.getByTestId('modal-test-id')).toHaveStyleRule('width', '70%');
});
});
});
22 changes: 12 additions & 10 deletions packages/paste-core/components/modal/__tests__/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,18 @@ describe('Modal', () => {
expect(handleCloseMock).toHaveBeenCalled();
});

it('Should have no accessibility violations', async () => {
const container = document.createElement('div');
document.body.append(container);
render(<MockModal />, container);
const results = await axe(document.body, {
rules: {
// ignore the tabindex of the focus trap helper
tabindex: {enabled: false},
},
describe('Accessibility', () => {
it('Should have no accessibility violations', async () => {
const container = document.createElement('div');
document.body.append(container);
render(<MockModal />, container);
const results = await axe(document.body, {
rules: {
// ignore the tabindex of the focus trap helper
tabindex: {enabled: false},
},
});
expect(results).toHaveNoViolations();
});
expect(results).toHaveNoViolations();
});
});
49 changes: 31 additions & 18 deletions packages/paste-core/components/modal/src/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as React from 'react';
import * as PropTypes from 'prop-types';
import {styled, css} from '@twilio-paste/styling-library';
import {css, styled} from '@twilio-paste/styling-library';
import {useTransition, animated} from '@twilio-paste/animation-library';
import {safelySpreadBoxProps} from '@twilio-paste/box';
import {safelySpreadBoxProps, Box} from '@twilio-paste/box';
import type {BoxElementProps} from '@twilio-paste/box';
import {pasteBaseStyles} from '@twilio-paste/theme';
import {ModalDialogPrimitiveOverlay, ModalDialogPrimitiveContent} from '@twilio-paste/modal-dialog-primitive';
import {ModalContext} from './ModalContext';
Expand Down Expand Up @@ -39,7 +40,10 @@ type Sizes = 'default' | 'wide';

interface ModalDialogContentProps {
size?: Sizes;
children: React.ReactNode;
element?: BoxElementProps['element'];
}

export const ModalDialogContent = animated(
/* eslint-disable emotion/syntax-preference */
styled(ModalDialogPrimitiveContent)<ModalDialogContentProps>(({size}) =>
Expand All @@ -63,8 +67,9 @@ export const ModalDialogContent = animated(

export interface ModalProps extends React.HTMLAttributes<HTMLDivElement> {
children: NonNullable<React.ReactNode>;
element?: BoxElementProps['element'];
isOpen: boolean;
onDismiss: () => void;
onDismiss: VoidFunction;
allowPinchZoom?: boolean;
size: Sizes;
initialFocusRef?: React.RefObject<any>;
Expand Down Expand Up @@ -94,6 +99,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
(
{
children,
element = 'MODAL',
isOpen,
onDismiss,
allowPinchZoom = true,
Expand Down Expand Up @@ -123,22 +129,30 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
{transitions(
(styles, item) =>
item && (
<ModalDialogOverlay
<Box
// @ts-expect-error Render overlay as box for customization
andioneto marked this conversation as resolved.
Show resolved Hide resolved
as={ModalDialogOverlay}
onDismiss={onDismiss}
allowPinchZoom={allowPinchZoom}
initialFocusRef={initialFocusRef}
style={{opacity: styles.opacity}}
element={`${element}_OVERLAY`}
variant={size}
>
<ModalDialogContent
<Box
// @ts-expect-error Render overlay as box for customization
as={ModalDialogContent}
aria-labelledby={ariaLabelledby}
{...safelySpreadBoxProps(props)}
element={element}
ref={ref}
style={styles}
size={size}
variant={size}
>
{children}
</ModalDialogContent>
</ModalDialogOverlay>
</Box>
</Box>
)
)}
</ModalContext.Provider>
Expand All @@ -147,16 +161,15 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
);
Modal.displayName = 'Modal';

if (process.env.NODE_ENV === 'development') {
Modal.propTypes = {
children: PropTypes.node.isRequired,
isOpen: PropTypes.bool.isRequired,
onDismiss: PropTypes.func.isRequired,
allowPinchZoom: PropTypes.bool,
size: PropTypes.oneOf(['default', 'wide'] as Sizes[]).isRequired,
initialFocusRef: PropTypes.object as any,
ariaLabelledby: PropTypes.string.isRequired,
};
}
Modal.propTypes = {
children: PropTypes.node.isRequired,
element: PropTypes.string,
isOpen: PropTypes.bool.isRequired,
onDismiss: PropTypes.func.isRequired,
allowPinchZoom: PropTypes.bool,
size: PropTypes.oneOf(['default', 'wide'] as Sizes[]).isRequired,
initialFocusRef: PropTypes.object as any,
ariaLabelledby: PropTypes.string.isRequired,
andioneto marked this conversation as resolved.
Show resolved Hide resolved
};

export {Modal};
28 changes: 16 additions & 12 deletions packages/paste-core/components/modal/src/ModalBody.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import * as React from 'react';
import * as PropTypes from 'prop-types';
import {Box, safelySpreadBoxProps} from '@twilio-paste/box';
import type {BoxElementProps} from '@twilio-paste/box';
import {modalBodyStyles} from './styles';

export interface ModalBodyProps extends React.HTMLAttributes<HTMLDivElement> {
children: NonNullable<React.ReactNode>;
element?: BoxElementProps['element'];
}
const ModalBody = React.forwardRef<HTMLDivElement, ModalBodyProps>(({children, ...props}, ref) => {
return (
<Box {...safelySpreadBoxProps(props)} {...modalBodyStyles} as="div" ref={ref}>
{children}
</Box>
);
});
const ModalBody = React.forwardRef<HTMLDivElement, ModalBodyProps>(
({children, element = 'MODAL_BODY', ...props}, ref) => {
return (
<Box {...safelySpreadBoxProps(props)} {...modalBodyStyles} as="div" element={element} ref={ref}>
{children}
</Box>
);
}
);
ModalBody.displayName = 'ModalBody';

if (process.env.NODE_ENV === 'development') {
ModalBody.propTypes = {
children: PropTypes.node.isRequired,
};
}
ModalBody.propTypes = {
children: PropTypes.node.isRequired,
element: PropTypes.string,
};

export {ModalBody};
28 changes: 16 additions & 12 deletions packages/paste-core/components/modal/src/ModalFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import * as React from 'react';
import * as PropTypes from 'prop-types';
import {Box, safelySpreadBoxProps} from '@twilio-paste/box';
import type {BoxElementProps} from '@twilio-paste/box';
import {modalFooterStyles} from './styles';

export interface ModalFooterProps extends React.HTMLAttributes<HTMLDivElement> {
children: NonNullable<React.ReactNode>;
element?: BoxElementProps['element'];
}
const ModalFooter = React.forwardRef<HTMLDivElement, ModalFooterProps>(({children, ...props}, ref) => {
return (
<Box {...safelySpreadBoxProps(props)} {...modalFooterStyles} as="footer" ref={ref}>
{children}
</Box>
);
});
const ModalFooter = React.forwardRef<HTMLDivElement, ModalFooterProps>(
({children, element = 'MODAL_FOOTER', ...props}, ref) => {
return (
<Box {...safelySpreadBoxProps(props)} {...modalFooterStyles} as="footer" element={element} ref={ref}>
{children}
</Box>
);
}
);
ModalFooter.displayName = 'ModalFooter';

if (process.env.NODE_ENV === 'development') {
ModalFooter.propTypes = {
children: PropTypes.node.isRequired,
};
}
ModalFooter.propTypes = {
children: PropTypes.node.isRequired,
element: PropTypes.string,
};

export {ModalFooter};
Loading