-
Notifications
You must be signed in to change notification settings - Fork 13
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
Add <Dialog> element #1416
Add <Dialog> element #1416
Changes from all commits
c635f84
efc2789
c510b84
f7486a1
e84cc99
f98225b
ae8a156
8ff277a
8143bf8
a922e3d
6e21d48
308af8a
4ade05d
4abee15
e1dccce
7896644
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
<script src="https://polyfill.io/v3/polyfill.min.js?flags=gated&features=default"></script> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
// @flow | ||
|
||
// ----- Imports ----- // | ||
|
||
import React, { Component, type Node } from 'react'; | ||
|
||
import { type Option } from 'helpers/types/option'; | ||
import { classNameWithModifiers } from 'helpers/utilities'; | ||
|
||
import './dialog.scss'; | ||
|
||
|
||
// ----- Props ----- // | ||
|
||
export type PropTypes = {| | ||
onStatusChange: (boolean) => void, | ||
modal: boolean, | ||
open: boolean, | ||
'aria-label': Option<string>, | ||
dismissOnBackgroundClick: boolean, | ||
children: Node | ||
|}; | ||
|
||
|
||
// ----- Component ----- // | ||
|
||
class Dialog extends Component<PropTypes> { | ||
|
||
static defaultProps = { | ||
onStatusChange: () => {}, | ||
modal: true, | ||
open: false, | ||
dismissOnBackgroundClick: false, | ||
} | ||
|
||
componentDidMount() { | ||
if (this.props.open) { | ||
this.open(); | ||
} | ||
} | ||
|
||
componentDidUpdate(prevProps: PropTypes) { | ||
if (prevProps.open === true && this.props.open === false) { | ||
this.close(); | ||
} else if (prevProps.open === false && this.props.open === true) { | ||
this.open(); | ||
} | ||
} | ||
|
||
open() { | ||
if (this.ref && this.ref.showModal) { | ||
if (this.props.modal) { | ||
this.ref.showModal(); | ||
} else { | ||
this.ref.show(); | ||
} | ||
} | ||
requestAnimationFrame(() => { | ||
if (this.ref) { | ||
this.ref.focus(); | ||
} | ||
}); | ||
} | ||
|
||
close() { | ||
if (this.ref && this.ref.close) { | ||
this.ref.close(); | ||
} | ||
} | ||
|
||
ref: ?(HTMLDialogElement & {focus: Function}); | ||
|
||
render() { | ||
const { | ||
open, modal, children, onStatusChange, dismissOnBackgroundClick, ...otherProps | ||
} = this.props; | ||
|
||
return ( | ||
<dialog // eslint-disable-line jsx-a11y/no-redundant-roles | ||
className={classNameWithModifiers('component-dialog', [modal ? 'modal' : null, open ? 'open' : null])} | ||
aria-modal={modal} | ||
aria-hidden={!open} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
EDIT: I have just seen that the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. heya! it gets opened using the js callbacks in the lifecycle hooks a bit higher up the component. You can't actually use both. if you have (Also, As all There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting. And so confusing! I'm sure it's contrary to what is outlined in the spec. Browsers are a law unto themselves 🙄 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
tabIndex="-1" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The WHATWG spec states that
Not sure why though. Is this necessary? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So, since browser support for The native behaviour would be to focus on the first interactive element – however that requires more DOM fiddling, i'm not against exploring that solution but I'm not confident it's as portable as focusing the whole thing :( I found healthy debate here about this all, my takeaways are:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Awesome, thanks for sharing the discussion, really interesting! The guidance we received from the accessibility consultant:
I can link you the notes he provided if you need it. But there's more than one way to peel a potato, and as long as the approach is tested, it's all good! 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we do have a naively implemented oneline focus trap at the end but not one at the top 😢 I'm not sure how to implement both sides without a lot of code. it's something i want to look into though! |
||
role="dialog" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm guessing this is needed for browsers that don't support the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes :( |
||
onOpen={() => { onStatusChange(true); }} | ||
onCancel={() => { onStatusChange(false); }} | ||
ref={(d) => { this.ref = (d: any); }} | ||
{...otherProps} | ||
> | ||
<div className="component-dialog__contents"> | ||
{children} | ||
<div | ||
tabIndex="0" // eslint-disable-line jsx-a11y/no-noninteractive-tabindex | ||
onFocus={() => { | ||
/* this acts as a cheap focus trap */ | ||
if (this.ref) { this.ref.focus(); } | ||
}} | ||
/> | ||
</div> | ||
{modal && | ||
<div | ||
className="component-dialog__backdrop" | ||
aria-hidden | ||
onClick={() => dismissOnBackgroundClick && onStatusChange(false)} | ||
/> | ||
} | ||
</dialog> | ||
); | ||
} | ||
} | ||
|
||
|
||
// ----- Exports ----- // | ||
|
||
export default Dialog; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
.component-dialog { | ||
all: initial; | ||
position: absolute; | ||
visibility: hidden; | ||
height: 0; | ||
width: 0; | ||
overflow: hidden; | ||
} | ||
|
||
.component-dialog--open { | ||
visibility: visible; | ||
position: fixed; | ||
height: auto; | ||
width: auto; | ||
top: 0; | ||
left: 0; | ||
z-index: 100; | ||
contain: content; | ||
} | ||
|
||
.component-dialog--modal.component-dialog--open { | ||
display: flex; | ||
bottom: 0; | ||
right: 0; | ||
justify-content: center; | ||
align-items: center; | ||
contain: strict; | ||
} | ||
|
||
.component-dialog__contents { | ||
position: relative; | ||
z-index: 10; | ||
} | ||
|
||
.component-dialog--modal .component-dialog__backdrop { | ||
background: rgba(0, 0, 0, 0.6); | ||
position: absolute; | ||
top: 0; | ||
left: 0; | ||
bottom: 0; | ||
right: 0; | ||
z-index: 9; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
// @flow | ||
|
||
import React, { Component } from 'react'; | ||
|
||
import { storiesOf } from '@storybook/react'; | ||
|
||
import Dialog from 'components/dialog/dialog'; | ||
import Button from 'components/button/button'; | ||
import ProductPageTextBlock from 'components/productPage/productPageTextBlock/productPageTextBlock'; | ||
import { withCenterAlignment } from '../.storybook/decorators/withCenterAlignment'; | ||
|
||
// This is a barebones stateful wrapper - <Dialog/> needs to be controlled just like inputs | ||
class ControlledDialogButton extends Component<{modal: boolean}, {open: boolean}> { | ||
state = { | ||
open: false, | ||
} | ||
render() { | ||
return ( | ||
<div> | ||
<Button | ||
aria-haspopup="dialog" | ||
aria-label={null} | ||
appearance="greyHollow" | ||
onClick={() => { this.setState({ open: true }); }} | ||
>Open it up | ||
</Button> | ||
<Dialog | ||
aria-label="Modal dialog" | ||
modal={this.props.modal} | ||
onStatusChange={(status) => { this.setState({ open: status }); }} | ||
open={this.state.open} | ||
> | ||
<div style={{ padding: '1em', background: '#121212', color: '#fff' }}> | ||
<ProductPageTextBlock title={'I\'m a dialog!'}> | ||
I don't do much on my own :( | ||
</ProductPageTextBlock> | ||
<Button | ||
icon={null} | ||
aria-label={null} | ||
appearance="primary" | ||
onClick={() => { this.setState({ open: false }); }} | ||
>Close | ||
</Button> | ||
</div> | ||
</Dialog> | ||
</div> | ||
); | ||
} | ||
} | ||
|
||
const stories = storiesOf('Dialogs', module) | ||
.addDecorator(withCenterAlignment); | ||
|
||
stories.add('Modal dialog', () => ( | ||
<ControlledDialogButton modal /> | ||
)); | ||
|
||
stories.add('Non-modal dialog', () => ( | ||
<div> | ||
<ProductPageTextBlock title="This is a non-modal dialog example"> | ||
<p>It opens up but lets you interact | ||
with the page under it while it is open | ||
</p> | ||
<p>You probably do not actually want this, | ||
as this dialog does not have the click outside behaviour | ||
and makes for a confusing experience for screen readers | ||
</p> | ||
<ControlledDialogButton modal={false} /> | ||
</ProductPageTextBlock> | ||
</div> | ||
)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why make this an option available through props? Non-modals are fiddly and require more complex focus management. Can you think of a use case for it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good shout! I was thinking of stuff like the account menu but the more i think about it even that should be "modal" (you'd click anywhere on the background to close it but the page below doesnt receive that click – like context menus on mac) We actually discourage against its use in the stories, I think it's wise to remove it until a usage scenario presents itself |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens to the focus after the modal is dismissed? Ideally it would return back to the button that opened it, or perhaps returned to the beginning of the page
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
alas it depends on the implementer. the dialog does fire an event when it closes, so the component that fired it should be able to return the focus. In my testing it would seem that chrome makes a decent job at doing this on its own.
Something we could do could be to tweak the API so there's a nullable but required
returnFocusAfterClose()
prop to make it explicit that if you implement a dialog you have to take care of that? But I want to test default browser behaviour a bit more in case that just adds extra workThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, sounds like it would work!
As an aside, it's worth testing the following combos of browsers and screenreaders, as these are the most commonly used: