Modal Dialog Primitive
An unstyled and accessible basis upon which to build Modal Dialogs.
The modal dialog primitive is an unstyled and barebones version of a Modal dialog. It handles the implementation details around accessibility and provides a robust API to build upon. For example, our Modal component is built on top of this primitive. If you find that our designed Modal component can’t work for your particular use case, we suggest falling back to this component to roll your own solution. We encourage using this code as a basis for all modal dialogs in order to avoid code fragmentation and accessibility issues in our products.
Looking for Paste's styled Modal?
Only use this primitive if you have a bespoke modal design. For most Twilio interfaces, we recommend using the Paste Modal component.
This package is a wrapper around @reach/dialog
. If
you’re wondering why we wrapped that package into our own, we reasoned that it would be best
for our consumers’ developer experience. With reasons such as:
- We can control which APIs we expose and how to expose them. For example, in this package we rename and export only some of the source package's exports.
- If we want to migrate the underlying nuts and bolts in the future, Twilio products that
depend on this primitive would need to replace all occurrences of
import … from ‘@reach/dialog’
toimport … from ‘@some-new/package’
. By wrapping it in@twilio-paste/modal-dialog-primitive
, this refactor can be avoided. The only change would be a version bump in the package.json file. - We can more strictly enforce semver and backwards compatibility than some of our dependencies.
- We can control when to provide an update and which versions we allow, to help reduce potential bugs our consumers may face.
This package is available individually or as part of @twilio-paste/core
.
yarn add @twilio-paste/modal-dialog-primitive - or - yarn add @twilio-paste/core
import * as React from 'react';
import {styled} from '@twilio-paste/styling-library';
import {Text} from '@twilio-paste/text';
import {Button} from '@twilio-paste/button';
import {ModalDialogPrimitiveOverlay, ModalDialogPrimitiveContent} from '@twilio-paste/modal-dialog-primitive';
const StyledModalDialogOverlay = styled(ModalDialogPrimitiveOverlay)({
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
left: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: 'rgba(0, 0, 0, 0.7)',
});
const StyledModalDialogContent = styled(ModalDialogPrimitiveContent)({
width: '100%',
maxWidth: '560px',
maxHeight: 'calc(100% - 60px)',
background: '#f4f5f6',
borderRadius: '5px',
padding: '20px',
});
interface BasicModalDialogProps {
isOpen: boolean;
handleClose: () => void;
}
const BasicModalDialog: React.FC<BasicModalDialogProps> = ({isOpen, handleClose}) => {
const inputRef = React.useRef();
return (
<StyledModalDialogOverlay
isOpen={isOpen}
onDismiss={handleClose}
allowPinchZoom={true}
initialFocusRef={inputRef}
>
<StyledModalDialogContent>
<input type="text" value="first" />
<br />
<input ref={inputRef} type="text" value="second (initial focused)" />
<Text as="p" color="colorText">
Roll your own dialog!
</Text>
</StyledModalDialogContent>
</StyledModalDialogOverlay>
);
};
export const ModalActivator: React.FC = () => {
const [isOpen, setIsOpen] = React.useState(false);
const handleOpen = (): void => setIsOpen(true);
const handleClose = (): void => setIsOpen(false);
return (
<div>
<Button variant="primary" onClick={handleOpen}>
Open Sample Modal
</Button>
<BasicModalDialog isOpen={isOpen} handleClose={handleClose} />
</div>
);
};
Much of the following is copied directly from reach-ui's docs. Because we may update at a different cadence, we're duplicating the docs here to prevent inconsistent behaviors.
All the regular HTML attributes (role
, aria-*
, type
, and so on) including the following custom props:
Prop | Type | Default |
---|---|---|
isOpen | bool | false |
allowPinchZoom? | bool | false |
onDismiss? | (event) => void | noop |
initialFocusRef? | ref | null |
children | node | null |
isOpen
prop
Controls whether the dialog is open or not.
<ModalDialogPrimitiveOverlay isOpen={true}>
<p>I will be open</p>
</ModalDialogPrimitiveOverlay>
<ModalDialogPrimitiveOverlay isOpen={false}>
<p>I will be closed</p>
</ModalDialogPrimitiveOverlay>
allowPinchZoom
prop
Controls whether the dialog should allow zoom/pinch gestures on iOS devices.
onDismiss
prop
This function is called whenever the user hits "Escape" or clicks outside the dialog. It's important to close the dialog when onDismiss is fired, as seen in all the demos on this page.
The only time you shouldn't close the dialog onDismiss is when the dialog requires a choice and none of them are "cancel". For example, perhaps two records need to be merged and the user needs to pick the surviving record. Neither choice is less destructive than the other, in these cases you may want to alert the user they need to a make a choice onDismiss instead of closing the dialog.
function Example(props) {
const [showDialog, setShowDialog] = React.useState(false);
const open = () => setShowDialog(true);
const close = () => setShowDialog(false);
return (
<div>
<button onClick={open}>Show Dialog</button>
<ModalDialogPrimitiveOverlay isOpen={showDialog} onDismiss={close}>
<ModalDialogPrimitiveContent>
<Text as="p">
It is your job to close this with state when the user clicks outside
or presses escape.
</Text>
<button onClick={close}>Okay</button>
</ModalDialogPrimitiveContent>
</ModalDialogPrimitiveOverlay>
</div>
);
}
function Example(props) {
const [showDialog, setShowDialog] = React.useState(false);
const [showWarning, setShowWarning] = React.useState(false);
const open = () => {
setShowDialog(true);
setShowWarning(false);
};
const close = () => setShowDialog(false);
const dismiss = () => setShowWarning(true);
return (
<div>
<button onClick={open}>Show Dialog</button>
<ModalDialogPrimitiveOverlay isOpen={showDialog} onDismiss={close}>
<ModalDialogPrimitiveContent>
{showWarning && (
<p style={{ color: "red" }}>You must make a choice, sorry :(</p>
)}
<p>Which router should survive the merge?</p>
<button onClick={close}>React Router</button>{" "}
<button onClick={close}>@reach/router</button>
</ModalDialogPrimitiveContent>
</ModalDialogPrimitiveOverlay>
</div>
);
}
initialFocusRef
prop
By default the first focusable element will receive focus when the dialog opens but you can provide a ref to focus instead.
function Example(props) {
const [showDialog, setShowDialog] = React.useState(false);
const buttonRef = React.useRef();
const open = () => setShowDialog(true);
const close = () => setShowDialog(false);
return (
<div>
<button onClick={open}>Show Dialog</button>
{showDialog && (
<ModalDialogPrimitiveOverlay initialFocusRef={buttonRef} onDismiss={close}>
<ModalDialogPrimitiveContent>
<p>Pass the button ref to DialogOverlay and the button.</p>
<button onClick={close}>Not me</button>
<button
ref={buttonRef}
onClick={close}
>
Got me!
</button>
</ModalDialogPrimitiveContent>
</ModalDialogPrimitiveOverlay>
)}
</div>
);
}
All the regular HTML attributes (role
, aria-*
, type
, and so on) including the following custom props:
Prop | Type | Default |
---|---|---|
children | node | null |
Changelog
e238ce11a
#3618 Thanks @SiTaggart! - [Modal Dialog Primitive] Updated dev depenedencies to include typescript and tsx for running build scripts
733709127
#3395 Thanks @SiTaggart! - Modified the compile target of our JavaScript bundles fromnode
tobrowser
to minimize the risk of clashing with RequireJS. This is marked as a major out of an abundance of caution. You shouldn't need to do anything but we wanted you to be aware of the change on the off chance it has unintended consequences
a4c9e70b0
#2763 Thanks @shleewhite! - Update ESLint rules, which changed some formatting.
ae9dd50f
#2466 Thanks @TheSisb! - [All packages] Update our ESBuild version and remove minification of identifiers in our production builds.
12a5e83e
#2449 Thanks @shleewhite! - Made a slight improvement to the TypeScript typings of several packages for better interoperability.
cf5878d82
#2285 Thanks @SiTaggart! - [Modal primitive] update to Reach Modal version number to v0.16.2
73c596919
#2269 Thanks @SiTaggart! - Fixed a regression with the compilation script that caused incompatible ESM module importing of JSON files.
c867e3f48
#2237 Thanks @SiTaggart! - Updated a build dependency (esbuild) which changes the output of our builds slightly, without materially changing anything about the code.
b7675915
#1985 Thanks @TheSisb! - For debugging purposes we now ship afilename.debug.js
unminified version of each component or library in Paste.
ed5c0a49c
#1965 Thanks @shleewhite! - Upgrade Paste to use React 17 by default, but maintain React 16 support for consumers.
dd759feb
#1464 Thanks @richbachman! - Bumped the version of Reach/Dialog to 0.15.0
ac38757f
#1228 Thanks @SiTaggart! - Bump status of the component to production
All notable changes to this project will be documented in this file. See Conventional Commits for commit guidelines.
0.2.1 (2021-01-25)
Note: Version bump only for package @twilio-paste/modal-dialog-primitive
0.1.9 (2021-01-15)
Note: Version bump only for package @twilio-paste/modal-dialog-primitive
0.1.8 (2020-10-23)
Note: Version bump only for package @twilio-paste/modal-dialog-primitive
0.1.7 (2020-10-13)
Note: Version bump only for package @twilio-paste/modal-dialog-primitive
0.1.6 (2020-09-03)
Note: Version bump only for package @twilio-paste/modal-dialog-primitive
0.1.5 (2020-07-01)
Note: Version bump only for package @twilio-paste/modal-dialog-primitive
0.1.4 (2020-06-23)
Note: Version bump only for package @twilio-paste/modal-dialog-primitive
0.1.3 (2020-05-01)
Note: Version bump only for package @twilio-paste/modal-dialog-primitive
0.1.2 (2020-03-17)
Note: Version bump only for package @twilio-paste/modal-dialog-primitive
0.1.1 (2020-02-26)
- modal-dialog-primitive: rename exports (fcdd574)
- modal-dialog-primitive: add package (ba350fc)
0.0.2 (2019-10-29)
Note: Version bump only for package @twilio-paste/modal-dialog-primitive
Note: Version bump only for package @twilio-paste/modal-dialog-primitive