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

Add <Dialog> element #1416

Merged
merged 16 commits into from
Jan 30, 2019
Merged

Add <Dialog> element #1416

merged 16 commits into from
Jan 30, 2019

Conversation

walaura
Copy link
Contributor

@walaura walaura commented Jan 30, 2019

Why are you doing this?

We need an accessible (as possible) way to display dialogs that exit the page flow, float over it, and can be dismissed. This component wraps a native <dialog> element (which should take care of a11y for us) and polyfills it as much as possible.

There's some barebones styling but the idea is that every dialog client would implement its own styling.

Tested it with voiceover in Chrome, Safari & Firefox, as well as with my own keyboard. A known issue is that the focus trap only works going down, this is for simplicity reasons in this PR, implementing a better focus trap can be done without altering the public interface.

Trello Card

Screenshots

screenshot 2019-01-30 at 12 30 36 pm

screenshot 2019-01-30 at 12 30 34 pm

@walaura walaura requested a review from rupertbates January 30, 2019 12:34
@walaura walaura requested a review from SiAdcock January 30, 2019 13:38
@rupertbates
Copy link
Member

I'm anticipating what Jamie would say if he were here: "Is it not possible to achieve the same thing using stateless components?"

@walaura
Copy link
Contributor Author

walaura commented Jan 30, 2019

@rupertbates I can't think of a way :( FWIW react's own section on a11y asks you to use props and state, notably being the 1 part of the react site that doesn't tell you to not use them

Copy link
Member

@rupertbates rupertbates left a comment

Choose a reason for hiding this comment

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

Great work!

@walaura walaura merged commit 7c2b3cf into master Jan 30, 2019
@prout-bot
Copy link

Seen on PROD (merged by @walaura 10 minutes and 40 seconds ago)

Sentry Release: support-client-side, support

@prout-bot
Copy link

✅ Testing in PROD passed! Details

@walaura walaura deleted the lg-what-are-dialogs-even branch January 31, 2019 07:39
<ControlledDialogButton modal={false} />
</ProductPageTextBlock>
</div>
));
Copy link
Contributor

@SiAdcock SiAdcock Jan 31, 2019

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

className={classNameWithModifiers('component-dialog', [modal ? 'modal' : null, open ? 'open' : null])}
aria-modal={modal}
aria-hidden={!open}
tabIndex="-1"
Copy link
Contributor

Choose a reason for hiding this comment

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

The WHATWG spec states that

The tabindex attribute must not be specified on dialog elements.

Not sure why though. Is this necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So, since browser support for dialog is fiddly at best I have decided to implement focus management cheaply by just focusing the whole dialog after it opens – this seems to net sensible results in all tested browsers.

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:

  • The guidance refers more about a numberical tabindex than the magical -1 and 0 (because dialogs live outside of the element to a level)
  • Some user agents make dialog focusable? (thinking about using tabindex=0
  • Focusing the dialog itself vs focusing the first interactive element is an open discussion?

Copy link
Contributor

Choose a reason for hiding this comment

The 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:

  • if there's an obvious candidate to receive focus in the dialog, use that, OTHERWISE
  • provide focusable empty divs at the top and bottom of the dialog and use JavaScript to return focus to the top div when tabbing out of the bottom div (and vice versa).

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! 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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!

<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}
Copy link
Contributor

@SiAdcock SiAdcock Jan 31, 2019

Choose a reason for hiding this comment

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

Should we also include the open attribute?

EDIT: I have just seen that the open attribute is provided by the wrapper. Is there a reason it is defined in the wrapper and not here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 open="true" and try to fire up dialog.show() that's a javascript warning, makes for a lovely imperative vs declarative debate!

(Also, open="true" doesn't support modals at all 😝. I think the usage scenario is for serving markup from a server. Even the MDN demos don't set it via js)

As all <dialog> elements support dialog.show() I chose to omit the open attr altogether and instead style the polyfill with traditional css

Copy link
Contributor

Choose a reason for hiding this comment

The 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 🙄

Copy link
Contributor Author

Choose a reason for hiding this comment

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

nono this is in the spec, see this:

screenshot 2019-01-31 at 10 29 50 am

aria-modal={modal}
aria-hidden={!open}
tabIndex="-1"
role="dialog"
Copy link
Contributor

Choose a reason for hiding this comment

The 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 <dialog> element?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes :(

close() {
if (this.ref && this.ref.close) {
this.ref.close();
}
Copy link
Contributor

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

Copy link
Contributor Author

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 work

Copy link
Contributor

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:

  • IE11 + JAWS
  • Firefox + NVDA
  • Firefox + JAWS
  • Safari + Voiceover

Copy link
Contributor

@SiAdcock SiAdcock left a comment

Choose a reason for hiding this comment

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

@walaura
Copy link
Contributor Author

walaura commented Jan 31, 2019

@SiAdcock not at all! you raise many super valid points and have given me food for thought, will tag you in a subsequent PR.

@walaura walaura mentioned this pull request Jan 31, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants