Skip to content

Commit

Permalink
feat(modal): customization (twilio-labs#1903)
Browse files Browse the repository at this point in the history
  • Loading branch information
andioneto authored and Alexandru Bereghici committed Oct 8, 2021
1 parent ded72e7 commit ba30b95
Show file tree
Hide file tree
Showing 10 changed files with 510 additions and 109 deletions.
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.
166 changes: 166 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,166 @@
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', () => {
// Mocking the portal as a div so it renders within the body of the rendered test fragment, rather than using Portal behavior.
const actual = jest.requireActual('@twilio-paste/modal-dialog-primitive');
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
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;
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')}
>
{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
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,
};

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

0 comments on commit ba30b95

Please sign in to comment.