Modals fixes (#16310)

* .

* dark

* simplify and add stories

* update stories and docs

* fix
This commit is contained in:
ludwiczakpawel 2022-01-27 16:24:23 +01:00 committed by GitHub
parent 188877a865
commit c9ef2ac15c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 278 additions and 180 deletions

View file

@ -1,26 +1,22 @@
@import '../config/import';
.crayons-modal {
--modal-max-width: 768px;
--modal-body-padding: var(--su-3);
--modal-header-padding: var(--su-2) var(--su-2) var(--su-2) var(--su-3);
--modal-header-font-size: var(--fs-l);
--modal-body-font-size: var(--fs-base);
--modal-max-width: 640px;
--modal-padding: var(--su-3);
--modal-backdrop-pointer-events: none;
--modal-border: none;
--modal-header-border: 1px solid var(--divider);
--modal-header-padding: var(--su-2) var(--su-2) var(--su-2)
var(--modal-padding);
--modal-alignment: unset;
--modal-header-alignment: space-between;
@media (min-width: $breakpoint-s) {
--modal-header-padding: var(--su-2) var(--su-2) var(--su-2) var(--su-4);
--modal-body-padding: var(--su-4);
--modal-padding: var(--su-4);
}
@media (min-width: $breakpoint-m) {
--modal-header-padding: var(--su-3) var(--su-3) var(--su-3) var(--su-6);
--modal-body-padding: var(--su-6);
--modal-header-font-size: var(--fs-xl);
--modal-body-font-size: var(--fs-l);
}
@media (min-width: $breakpoint-l) {
--modal-body-padding: var(--su-8);
--modal-padding: var(--su-7);
}
position: fixed;
@ -34,82 +30,103 @@
z-index: var(--z-modal);
pointer-events: none;
&__overlay {
background: rgba(0, 0, 0, 0.6);
opacity: 0.5;
&--bg-dismissible {
--modal-backdrop-pointer-events: auto;
}
&--prompt {
--modal-header-border: none;
--modal-header-padding: var(--modal-padding) var(--su-8) 0
var(--modal-padding);
--modal-body-margin: 0;
@media (min-width: $breakpoint-m) {
--modal-body-margin: calc((var(--modal-padding) * -1) + var(--su-4));
}
.crayons-modal__dismiss {
position: absolute;
right: var(--su-2);
top: var(--su-2);
}
}
&--centered {
--modal-header-padding: var(--su-7) var(--su-8) 0 var(--su-8);
--modal-alignment: center;
--modal-header-alignment: center;
}
&--danger {
--modal-border: 4px solid var(--modal-danger-border-color);
}
&__backdrop {
background: var(--modal-backdrop);
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
pointer-events: auto;
&.background-clickable {
pointer-events: none;
}
}
&__box {
display: grid;
grid-template-rows: auto 1fr;
max-width: var(--modal-max-width);
background: var(--card-bg);
color: var(--card-color);
background: var(--modal-bg);
z-index: var(--z-elevate);
max-height: 100%;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
pointer-events: auto;
border-radius: var(--radius-large-auto);
border: var(--modal-border);
text-align: var(--modal-alignment);
box-shadow: var(--shadow-2);
@media (min-width: $breakpoint-s) {
background: var(--card-bg);
color: var(--card-color);
max-height: calc(100% - var(--su-6));
height: auto;
width: 90%;
}
&__header {
border-bottom: 1px solid var(--base-a5);
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: var(--modal-header-border);
padding: var(--modal-header-padding);
flex-shrink: 0;
&__title {
font-size: var(--modal-header-font-size);
}
display: flex;
justify-content: var(--modal-header-alignment);
align-items: center;
}
&__body {
font-size: var(--modal-body-font-size);
padding: var(--modal-body-padding);
padding: var(--modal-padding);
margin-top: var(--modal-body-margin);
max-height: 100%;
overflow-y: auto;
grid-auto-rows: min-content;
}
}
&--s {
&--small {
@media (min-width: $breakpoint-s) {
--modal-max-width: 480px;
--modal-body-padding: var(--su-6);
--modal-header-font-size: var(--fs-l);
--modal-body-font-size: var(--fs-base);
}
}
&--m {
&--large {
@media (min-width: $breakpoint-m) {
--modal-max-width: 640px;
--modal-body-padding: var(--su-7);
--modal-header-padding: var(--su-3) var(--su-5);
--modal-header-font-size: var(--fs-xl);
--modal-body-font-size: var(--fs-l);
--modal-max-width: 768px;
}
}
&--prompt {
@media (min-width: $breakpoint-s) {
--modal-max-width: 480px;
}
}
}

View file

@ -276,6 +276,14 @@
--indicator-info-bg: var(--accent-brand);
--indicator-info-color: rgb(var(--white));
/***********************************************
** Modals **************************************
***********************************************/
--modal-bg: rgb(var(--white));
--modal-backdrop: rgba(var(--black), 0.6);
--modal-danger-border-color: rgb(var(--red-500));
/***********************************************
** Colors **************************************
***********************************************/
@ -301,6 +309,9 @@
/* Focus */
--focus: var(--accent-brand);
/* Divider */
--divider: rgb(var(--grey-200));
/* Shadows */
--shadow-1: 0 10px 15px -3px rgba(var(--black), 0.1),
0 4px 6px -2px rgba(var(--black), 0.05), 0 0 0 1px rgba(var(--black), 0.1);

View file

@ -284,6 +284,14 @@
--indicator-info-bg: rgb(var(--indigo-700));
--indicator-info-color: rgb(var(--indigo-100));
/***********************************************
** Modals **************************************
***********************************************/
--modal-bg: rgb(var(--grey-900));
--modal-backdrop: rgba(var(--black), 0.6);
--modal-danger-border-color: rgb(var(--red-400));
/***********************************************
** Colors **************************************
***********************************************/
@ -308,6 +316,9 @@
/* Focus */
--focus: var(--accent-brand-lighter);
/* Divider */
--divider: rgb(var(--grey-700));
/* Shadows */
--shadow-1: 0 10px 15px -3px rgba(var(--black), 0.5),
0 4px 6px -2px rgba(var(--black), 0.25), inset 0 0 0 1px rgba(var(--white), 0.1);

View file

@ -31,7 +31,7 @@ export const adminModal = function ({
rightCustomDataAttr = null,
}) {
return `
<div class="crayons-modal crayons-modal--s">
<div class="crayons-modal crayons-modal--small">
<div class="crayons-modal__box">
<header class="crayons-modal__box__header">
<p class="fw-bold fs-l">${title}</p>

View file

@ -1,102 +1,59 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import classNames from 'classnames/bind';
import { FocusTrap } from '../../shared/components/focusTrap';
import { defaultChildrenPropTypes } from '../../common-prop-types';
import { Button } from '@crayons';
import { ButtonNew as Button } from '@crayons';
import CloseIcon from '@images/x.svg';
function getAdditionalClassNames({ size, className }) {
let additionalClassNames = '';
if (size && size.length > 0 && size !== 'default') {
additionalClassNames += ` crayons-modal--${size}`;
}
if (className && className.length > 0) {
additionalClassNames += ` ${className}`;
}
return additionalClassNames;
}
/**
* A modal component which can be presented with or without an overlay.
* The modal is presented within a focus trap for accessibility purposes - please note that the selector used for the focusTrap must be unique on the given page, otherwise focus may be trapped on the wrong element.
*
* @param {Object} props
* @param {Array} props.children The content to be displayed inside the Modal. Can be provided by composition (see example).
* @param {string} props.size The desired modal size ('s', 'm' or 'default')
* @param {string} props.className Optional additional classnames to apply to the modal container
* @param {string} props.title The title to be displayed in the modal heading. If provided, a title bar with a close button will be displayed.
* @param {boolean} props.overlay Whether or not to show a semi-opaque overlay behind the modal
* @param {Function} props.onClose Callback for any function to be executed on close button click or Escape
* @param {boolean} props.closeOnClickOutside Whether the modal should close if the user clicks outside of it
* @param {string} props.focusTrapSelector The CSS selector for where to trap the user's focus. This should be unique to the page in which the modal is presented.
*
* @example
* <Modal
overlay={true}
title="Example modal title"
onClose={cancelAction}
size="s"
focusTrapSelector="#window-modal"
closeOnClickOutside={false}
className=".additional-class-name"
>
<div>
<p>Some modal content</p>
</div>
</Modal>
*/
export const Modal = ({
children,
size,
className,
title,
overlay = true,
prompt,
centered,
noBackdrop,
backdropDismissible = false,
onClose = () => {},
closeOnClickOutside = false,
focusTrapSelector = '.crayons-modal',
focusTrapSelector = '.crayons-modal__box',
}) => {
const classes = classNames('crayons-modal', {
[`crayons-modal--${size}`]: size && size !== 'medium',
'crayons-modal--prompt': prompt,
'crayons-modal--centered': centered && prompt,
'crayons-modal--bg-dismissible': !noBackdrop && backdropDismissible,
[className]: className,
});
return (
<FocusTrap
onDeactivate={onClose}
clickOutsideDeactivates={closeOnClickOutside}
clickOutsideDeactivates={backdropDismissible}
selector={focusTrapSelector}
>
<div
data-testid="modal-container"
className={`crayons-modal${getAdditionalClassNames({
size,
className,
})}`}
>
<div data-testid="modal-container" className={classes}>
<div
role="dialog"
aria-modal="true"
aria-label="modal"
className="crayons-modal__box"
>
{title && (
<div className="crayons-modal__box__header">
<h2>{title}</h2>
<Button
icon={CloseIcon}
variant="ghost"
contentType="icon"
aria-label="Close"
onClick={onClose}
/>
</div>
)}
<header className="crayons-modal__box__header">
<h2 class="crayons-subtitle-2">{title}</h2>
<Button
icon={CloseIcon}
aria-label="Close"
className="crayons-modal__dismiss"
onClick={onClose}
/>
</header>
<div className="crayons-modal__box__body">{children}</div>
</div>
{overlay && (
{!noBackdrop && (
<div
data-testid="modal-overlay"
className={`crayons-modal__overlay ${
closeOnClickOutside ? 'background-clickable' : ''
}`}
className="crayons-modal__backdrop"
/>
)}
</div>
@ -106,16 +63,15 @@ export const Modal = ({
Modal.displayName = 'Modal';
Modal.defaultProps = {
size: 'default',
};
Modal.propTypes = {
children: defaultChildrenPropTypes.isRequired,
className: PropTypes.string,
title: PropTypes.string.isRequired,
overlay: PropTypes.bool,
backdrop: PropTypes.bool,
backdropDismissible: PropTypes.bool,
prompt: PropTypes.bool,
centered: PropTypes.bool,
onClose: PropTypes.func,
size: PropTypes.oneOf(['default', 's', 'm']).isRequired,
size: PropTypes.oneOf(['small', 'medium', 'large']),
focusTrapSelector: PropTypes.string,
};

View file

@ -15,24 +15,49 @@ export default {
control: {
type: 'select',
options: {
default: 'default',
small: 's',
medium: 'm',
Small: 'small',
'Medium (default)': undefined,
Large: 'large',
},
},
description:
'There are several sizes available - use appropriate size depending on how much content you need to display inside modal.',
table: {
defaultValue: { summary: 'default' },
},
},
overlay: {
noBackdrop: {
description: 'Removes the default backdrop overlay.',
table: {
defaultValue: { summary: true },
},
},
backdropDismissible: {
description:
'If `backdrop` is visible you can also make it clickable so clicking it would dismiss the Modal',
table: {
defaultValue: { summary: false },
},
},
prompt: {
description:
'Special style for Modals that should be used for short prompts (short messages or confirmations). Prompts can only be used with size `small` (`s`).',
table: {
defaultValue: { summary: false },
},
},
centered: {
description:
'In some cases it might be "nicer" to center Modals content. This will only work with `prompt` though.',
table: {
defaultValue: { summary: false },
},
},
title: {
control: {
type: 'text',
},
description: '🤷‍♂️',
table: {
defaultValue: { summary: 'Modal title' },
},
@ -40,7 +65,7 @@ export default {
},
};
export const Default = (args) => {
const Template = (args) => {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
@ -48,19 +73,77 @@ export const Default = (args) => {
<Button onClick={() => setIsModalOpen(true)}>Open modal</Button>
{isModalOpen && (
<Modal onClose={() => setIsModalOpen(false)} {...args}>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse
odio est, ultricies vel euismod ut, fringilla quis tellus. Sed at
dui mi. Fusce cursus nibh lectus, vitae lobortis orci volutpat quis.{' '}
</p>
<div class="flex flex-col gap-4">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Suspendisse odio est, ultricies vel euismod ut, fringilla quis
tellus. Sed at dui mi. Fusce cursus nibh lectus, vitae lobortis
orci volutpat quis.
</p>
{!args.prompt && (
<p>
Sed at dui mi. Fusce cursus nibh lectus, vitae lobortis orci
volutpat quis. Lorem ipsum dolor sit amet, consectetur
adipiscing elit. Suspendisse odio est, ultricies vel euismod ut,
fringilla quis tellus. Sed at dui mi. Fusce cursus nibh lectus,
vitae lobortis orci volutpat quis.
</p>
)}
</div>
</Modal>
)}
</div>
);
};
export const Default = Template.bind({});
Default.args = {
size: 'default',
title: 'My modal',
overlay: true,
size: undefined,
title: 'Modal title',
noBackdrop: false,
backdropDismissible: false,
prompt: false,
centered: false,
};
export const Prompt = Template.bind({});
Prompt.args = {
size: undefined,
title:
'Are you sure you want to remove Paweł Ludwiczak from Design Department?',
noBackdrop: false,
backdropDismissible: false,
prompt: true,
centered: false,
};
export const PromptCentered = Template.bind({});
PromptCentered.args = {
size: undefined,
title:
'Are you sure you want to remove Paweł Ludwiczak from Design Department?',
noBackdrop: false,
backdropDismissible: false,
prompt: true,
centered: true,
};
export const BackdropDismissible = Template.bind({});
BackdropDismissible.args = {
size: undefined,
title: 'Modal title',
noBackdrop: false,
backdropDismissible: true,
prompt: false,
centered: false,
};
export const NoBackdrop = Template.bind({});
NoBackdrop.args = {
size: undefined,
title: 'Modal title',
noBackdrop: true,
backdropDismissible: false,
prompt: false,
centered: false,
};

View file

@ -1,11 +1,48 @@
## Modals
# Modals
Modals should be positioned centered in relation to entire viewport. So relation
to its trigger doesnt really matter.
to its trigger doesn't really matter.
Use your best judgements when choosing the right size.
### Modal accessibility
## Sizes
We have 3 possible sizes to use:
- Small - 480px - for displaying small amount of data/text.
- Medium - 640px (this is default one) - you will probably want to use this one in most cases.
- Large - 768px - use for long and/or complex contnet.
## Prompts
Except classic Modals you can also use `prompt` style (prop) to apply _special styling_.
It will work best with really short messages or some sort of confirmation prompts, for example:
> Are you sure you want to remove Ben's account?
>
> [YES] [NO]
The "special styling" means it will:
- always use Small (480px) size,
- remove the divider between title and content so it all looks more like a one message.
### Centered prompt
Some prompts may visually benefit from centering the content.
Leave the decision to designers but keep it mind this is possible with `centered` prop
(and it will only work in combination with `prompt`).
## Backdrop
By default Modals come with dark backdrop (a layer overlaying underlaying content).
It is possible to not show it but it should be used in some very rare scenarios, when underlaying
content might still be important as additional context.
Because of having that backdrop is a "good default", you should use `noBackdrop` prop cerfaully,
ideally after consulting with your desinger.
## Modal accessibility
The Preact Modal component utilises the `focus-trap` library to ensure that:
@ -16,3 +53,7 @@ The Preact Modal component utilises the `focus-trap` library to ensure that:
- While the modal is open, focus is trapped inside so that when a user presses
the Tab key, interactive items behind the modal are not focused
- When the modal is open, pressing the Escape key will close it
## Design
<iframe style="border: 1px solid rgba(0, 0, 0, 0.1);" width="100%" height="450" src="https://www.figma.com/embed?embed_host=share&url=https%3A%2F%2Fwww.figma.com%2Ffile%2FgHpwugeu9jFGW4d9u3ReuR%2FCrayons%3Fnode-id%3D456%253A215" allowfullscreen></iframe>

View file

@ -101,7 +101,7 @@ it('should close on click outside, if enabled', () => {
<Modal
title="This is a modal title"
onClose={onClose}
closeOnClickOutside
backdropDismissible
>
This is the modal body content
</Modal>
@ -130,9 +130,9 @@ it('should render with additional class names', async () => {
).toEqual(true);
});
it('should render with an overlay', async () => {
it('should render with a backdrop', async () => {
const { getByTestId } = render(
<Modal title="This is a modal title" overlay onClose={jest.fn()}>
<Modal title="This is a modal title" backdrop onClose={jest.fn()}>
This is the modal body content
</Modal>,
);
@ -144,12 +144,7 @@ it('should render with an overlay', async () => {
it('should render with a different size modal', async () => {
const { getByTestId } = render(
<Modal
title="This is a modal title"
size="large"
className="some-additional-class-name"
onClose={jest.fn()}
>
<Modal title="This is a modal title" size="large" onClose={jest.fn()}>
This is the modal body content
</Modal>,
);

View file

@ -54,7 +54,7 @@ export class EmailPreferencesForm extends Component {
return (
<div
data-testid="onboarding-email-preferences-form"
className="onboarding-main crayons-modal"
className="onboarding-main crayons-modal crayons-modal--large"
>
<div
className="crayons-modal__box"

View file

@ -104,7 +104,7 @@ export class FollowTags extends Component {
return (
<div
data-testid="onboarding-follow-tags"
className="onboarding-main crayons-modal"
className="onboarding-main crayons-modal crayons-modal--large"
>
<div
className="crayons-modal__box overflow-auto"

View file

@ -145,7 +145,7 @@ export class FollowUsers extends Component {
return (
<div
data-testid="onboarding-follow-users"
className="onboarding-main crayons-modal"
className="onboarding-main crayons-modal crayons-modal--large"
>
<div
className="crayons-modal__box overflow-auto"

View file

@ -57,30 +57,21 @@ export class IntroSlide extends Component {
}
isButtonDisabled() {
const {
checked_code_of_conduct,
checked_terms_and_conditions,
} = this.state;
const { checked_code_of_conduct, checked_terms_and_conditions } =
this.state;
return !checked_code_of_conduct || !checked_terms_and_conditions;
}
render() {
const {
slidesCount,
currentSlideIndex,
prev,
communityConfig,
} = this.props;
const {
checked_code_of_conduct,
checked_terms_and_conditions,
text,
} = this.state;
const { slidesCount, currentSlideIndex, prev, communityConfig } =
this.props;
const { checked_code_of_conduct, checked_terms_and_conditions, text } =
this.state;
if (text) {
return (
<div className="onboarding-main crayons-modal crayons-modal--m">
<div className="onboarding-main crayons-modal crayons-modal--large">
<div className="crayons-modal__box overflow-auto">
<div className="onboarding-content terms-and-conditions-wrapper">
<button
@ -104,7 +95,7 @@ export class IntroSlide extends Component {
return (
<div
data-testid="onboarding-intro-slide"
className="onboarding-main introduction crayons-modal crayons-modal--m"
className="onboarding-main introduction crayons-modal"
>
<div
className="crayons-modal__box overflow-auto"

View file

@ -176,7 +176,7 @@ export class ProfileForm extends Component {
return (
<div
data-testid="onboarding-profile-form"
className="onboarding-main crayons-modal"
className="onboarding-main crayons-modal crayons-modal--large"
>
<div
className="crayons-modal__box"

View file

@ -60,13 +60,7 @@ window.Forem = {
}
return Promise.all(this.modalImports);
},
showModal: async ({
title,
contentSelector,
overlay = false,
size = 's',
onOpen,
}) => {
showModal: async ({ title, contentSelector, size = 'small', onOpen }) => {
const [{ Modal }, { render, h }] = await window.Forem.getModalImports();
// Guard against two modals being opened at once
@ -81,7 +75,6 @@ window.Forem = {
render(
<Modal
overlay={overlay}
title={title}
onClose={() => {
render(null, currentModalContainer);

View file

@ -101,7 +101,7 @@ export function FlagUserModal({ modCenterArticleUrl, authorId }) {
return (
<div
data-testid="flag-user-modal"
class="crayons-modal crayons-modal--s absolute flag-user-modal"
class="crayons-modal crayons-modal--small absolute flag-user-modal"
>
<div class="crayons-modal__box">
<header class="crayons-modal__box__header flag-user-modal-header">

View file

@ -49,7 +49,7 @@
</div>
</div>
<div id="user-subscription-confirmation-modal" class="hidden">
<div class="crayons-modal crayons-modal--s">
<div class="crayons-modal crayons-modal--small">
<div class="crayons-modal__box">
<header class="crayons-modal__box__header">
<h2 class="fs-l fw-bold mt-0 mb-0">