Dialog a11y#1652
Conversation
Signed-off-by: Stefan Parviainen <pafcu@iki.fi>
Signed-off-by: Stefan Parviainen <pafcu@iki.fi>
- Wrapped all the modals inside a react-focus-trap component disabling keyboard navigation outside the modal dialogs - Disabled our custom key handling at dialog level. Cancelling on esc key is now handled via FocusTrap component. - Removed onEnter prop from the BaseDialog component. Dialogs that submit data all now embed a form with onSubmit handler. And since keyboard focus is now managed better via FocusTrap it no longer makes sense for the other dialog types. Fixes element-hq/element-web#5736 - Set aria-hidden on the matrixChat outer node when showing dialogs to disable navigating outside the modals by using screen reader specific features.
leaving it in the Modal manager. We are using Modal manager to load other components not just BaseDialog and its subclasses and they might require different keyboard handling. Also depend on focus-trap-react rather than react-focus-trap for locking keyboard focus inside the dialog. The experience is much nicer and even the FocusTrap element it-self no longer gains the focus. On a side note using the FocusTrap element outside the dialog (on its parent) stops it from working properly.
Also added initial focus where it has not been set.
was also activating normal button that might just have received the system focus as a result of the key press and the other way round. The most obvious occurence of this issue is that dialogs were reappearing when dismissed by pressing the enter key.
create group dialog.
| }, | ||
|
|
||
| componentDidMount: function() { | ||
| this.applicationNode = document.getElementById('matrixchat'); |
There was a problem hiding this comment.
Perhaps add a comment here that this is to hide the rest of the application from screen readers while the dialog is open
| restProps.onClick = onClick; | ||
| restProps.onKeyUp = function(e) { | ||
| if (e.keyCode == 13 || e.keyCode == 32) return onClick(e); | ||
| restProps.onKeyDown = function(e) { |
There was a problem hiding this comment.
Perhaps document here why it needs to be onKeyDown, and what is special about keyCode 13 and 32
There was a problem hiding this comment.
Found a related issue so done along with its fix in 839f938
hide content outside of the BaseDialog to screen reader users.
fixing behaviour when pressing the enter key breaks behaviour when pressing space to activate the buttons. So we are now handling enter onKeyDown and space onKeyUp. Also briefly explained the situation with comments.
|
Still needs linting errors to be fixed |
lukebarnard1
left a comment
There was a problem hiding this comment.
This looks really good. Thanks very much you two for doing this!
|
|
||
| componentDidMount: function() { | ||
| // Retrieve the root node of the Riot application outside the dialog | ||
| this.applicationNode = document.getElementById('matrixchat'); |
There was a problem hiding this comment.
It would be best if this was done in the context of Modal, which is aware of how many dialogs are currently in the stack. At the point a dialog is closed, we can check the number of dialogs open and set aria-hidden to false if there are none remaining.
There was a problem hiding this comment.
Originally I wanted it to have it the way you are suggesting. I have started by using FocusTrap component on the dialog parent div element. I have found out FocusTrap is not working well when placed outside the dialog (i.e. managed via the Modal manager) so I moved it into the dialog. I think hiding the main application node complements trapping the keyboard focus it's why I have added it into the dialog not to its parent. Later on I have discovered we are sometimes loading other elements e.g. spinner into the modal. I was unable to assure if all the components loaded into the modal should trap the keyboard focus and disable screen reader access to the main application so this is only supporting my doupt. If you are sure we are only loading keyboard navigable stuff into the modal then I'm happy to move the aria-hide stuff into the Modal but I won't be moving FocusTrap thingy as it is not working very well outside the dialog. If there is a chance that we might occassionally load something into the modal that can't be navigated by the keyboard alone then implementing aria-hidden at this level would cause screen reader users to be locked in the modal without possible exit path out of it.
There was a problem hiding this comment.
If there is a chance that we might occassionally load something into the modal that can't be navigated by the keyboard
I'd say that would be a bug anyway.
If we don't check the number of dialogs open before deciding whether to apply aria-hidden to the root element, we might end up with races and sometimes the aria-hidden property will be set as expected.
|
|
||
| return ( | ||
| <div onKeyDown={this._onKeyDown} className={this.props.className}> | ||
| <FocusTrap onKeyDown={this._onKeyDown} className={this.props.className} role="dialog" aria-labelledby='mx_BaseDialog_title' aria-describedby={this.props.contentId}> |
There was a problem hiding this comment.
This line is quite long, could you break it up over several lines?
Also, the element IDs here assume that there is only one dialog being shown at one time. This is strictly true at the moment and I suppose that it's sufficiently unlikely for there to be more than one for us to ignore this for now. Also, I can't think of a simple, low-overhead way to make both IDs unique short of actively modifying the IDs of the children of BaseDialog.
There was a problem hiding this comment.
Long line split in ab0ff9b
As for the possible component ID clashes I guess Modal only allows single dialog to be rendered into the DOM so at the DOM level this should hopefully not be an issue. If I have missed something and in deed this might be dangerous or inappropriate in other way then I can append some randomly generated number at the end of these IDs. Should I do that?
There was a problem hiding this comment.
Sorry, to be clear I think that we can ignore this on exactly the basis you mentioned - Modal only allows a single dialog to be rendered.
| </div> | ||
| <div className="mx_Dialog_buttons"> | ||
| <button ref="button" className="mx_Dialog_primary" onClick={this.props.onFinished}> | ||
| <button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={this.props.focus}> |
There was a problem hiding this comment.
I'm not sure where focus is coming from. Should autoFocus not always be true?
There was a problem hiding this comment.
Error dialog has focus prop that is true by default. In fact I haven't introduced it my-self. It used button's ref to set the focus conditionally in the componentDidMount function and I have changed it to use autoFocus prop of the button component instead as I like it better. This is just cosmetic change and the functionality should not be affected.
There was a problem hiding this comment.
My mistake, ErrorDialog indeed has this property defined - I was obviously looking in the from file somehow.
Thanks for changing that - autoFocus is a clear improvement over using refs.
| <div> | ||
| <div>{ this.state.authError.message || this.state.authError.toString() }</div> | ||
| <div id='mx_Dialog_content'> | ||
| <div role={(this.state.authError.message || this.state.authError.toString()) ? "alert" : ""}>{ this.state.authError.message || this.state.authError.toString() }</div> |
There was a problem hiding this comment.
I think the existence of this div should be conditional on whether there is a message to display. That way, we only have a single conditional and the role can always be "alert".
There was a problem hiding this comment.
In the ErrorDialog the condition checking the error message turned out to be redundant as the same check is performed a few lines above. But thanks for the idea, I have checked the other occurences of this too.
|
|
||
| componentDidMount: function() { | ||
| if (this.refs.bugreportLink) { | ||
| this.refs.bugreportLink.focus(); |
There was a problem hiding this comment.
Should we not be auto-focussing the "Continue anyway" button instead?
There was a problem hiding this comment.
I like to focus the bug report link when it's available and focus the button when that is not there. As I think we should encourage people to report their issues when they find some. Focusing the button would mean screen reader users have to shift+tab in order to focus that link. I assume by default almost every keyboard user navigates by pressing the tab key not shift+tab. Still this is my feeling only and if you like me to change it then I will do it.
There was a problem hiding this comment.
Okay, fair reasoning. This dialog shouldn't come up with any frequency anyway.
| // We need to consume enter onKeyDown and space onKeyUp | ||
| // otherwise we are risking also activating other keyboard focusable elements | ||
| // that might receive focus as a result of the AccessibleButtonClick action | ||
| restProps.onKeyDown = function(e) { |
There was a problem hiding this comment.
I'm confused as to why we would treat SPACE and ENTER differently here. I'd expect them to both be handled onKeyDown because if for some bizarre reason pressing space results in focus arriving on a button, we really shouldn't be handling onKeyUp for space at that point.
There was a problem hiding this comment.
I can't explain this very well I was playing with this trying out various combinations of onKeyDown and onKeyUp and what I think is most important is that we stop propagating and prevent default of both onKeyDown and onKeyUp. I am not going to change this unless you can show me an example which is working well in both these cases:
- Go to the Riot settings, use the tab key to navigate to the Logout button, press either enter key or space key and see the dialog. The issue here is that depending on the default browser behaviour the default dialog button receives the focus as the dialog appears and it then gets activated.
- If the logout dialog has not been dismissed then press the tab key until the Cancel button receives the focus. Now test how if you can close the dialog by pressing both the enter key and the space bar key. The issue here is that the logout button in the settings might receive the focus as the dialog is closing and if the proper event is not consumed the dialog might reactivate.
You need to test both these cases with space key and the enter key.
I know this feels very broken however we are only handling the differences imposed by the fact we have various button types i.e. the native button and our own AccessibleButton and the resulting behaviour of their differences.
There was a problem hiding this comment.
OK, so what's actually going on here is that for a actual HTML button (eg. the cancel button in the Sign Out dialog), activating it with enter swallows the keydown event but propagates a keyup event. Activating it with space swallows both, which is why it happens to work in this configuration. This is still a little buggy though because the spacebar press still propagates out when activating the 'sign out' button with the spacebar (the settings page scrolls down as the dialog appears).
This may be an argument for changing everything to consistently use onKeyDown (it was originally changed on onKeyUp by me here: #742 (comment)). The problem with using keyup for space and keydown for enter will be that we may get this same problem again (ie. the accessiblebutton captures keydown but propagates keyup which triggers something else that it shouldn't do.
I'd probably argue for switching to keydown everywhere since this empirically what a native button does, but I'd also like to split this change out of this PR, because the rest of this PR looks great and it's already very large. In future it's probably better to split these changes up into smaller PRs.
There was a problem hiding this comment.
Please try the following two sets of steps
Take 1
- Apply this PR
- Edit AccessibleButton.js file in a way so both space and enter key presses will trigger onKeyDown and remove onKeyUp all together
- Build the resulting app
- Navigate to the Riot settings.
- When the Settings window comes up keep pressing the tab key until Logout button receives the focus
- Press the space bar key
Results
Damn! We were expecting a confirmation dialog however we have just logged out because of the underlying issue :(
Expected results
Before logging out we should be able to confirm our choice within the question dialog.
Take 2
- Apply this PR
- Edit AccessibleButton.js file in such a way that both enter and space bar keypresses will trigger onKeyDown i.e. onClick will be executed when either of them is pressed onKeyDown.
- Keep onKeyUp method present however change it so it will do nothing more except of e.preventDefault() and e.stopPropagation()
- Continue with the steps as if you were following take 1.
Results
Damn! We were expecting a confirmation dialog however we have just logged out because of the underlying issue :(
Expected results
Before logging out we should be able to confirm our choice within the question dialog.
So I can't explain it very well however by some tryal and error this is the only walid implementation as far as I can test.
There was a problem hiding this comment.
Yep, I suspect this is because we're using keydown/keyup inconsistently in some other file, hence suggesting we should use one of them consistently throughout the app.
There was a problem hiding this comment.
@dbkr isn't the point that browsers don't do onKeyUp vs. onKeyDown consistently the actual problem here? And by mimicking the weirdness as in the PR, we get something that works but that feels wrong.
There was a problem hiding this comment.
I don't know - if this is the case I think we should find out what this inconsistency is and document it in a comment in this block of code, otherwise it's going to continue to confuse people.
There was a problem hiding this comment.
Will it help if I change that inline comments to say something like this?
// We need to consume enter onKeyDown and space onKeyUp
// otherwise we are risking also activating other keyboard focusable elements
// that might receive focus as a result of the AccessibleButtonClick action
// It's because we are using html buttons at a few places e.g. inside dialogs
// And divs which we report as role button to assistive technologies.
// Browsers handle space and enter keypresses differently and we are only adjusting to the
// inconsistencies here
There was a problem hiding this comment.
OK, it at least gives a hint as to what's going on. Can we fix the fact that the spacebar keydown still leaks out when activating an accessible button with the spacebar though?
| </div> | ||
| </form> | ||
| <div className="error"> | ||
| <div className="error" role={ this.props.errorText ? "alert" : ""}> |
There was a problem hiding this comment.
Again, I think it'd be easier to read if the existence of the error div was conditional on the errorText and the role was always "alert".
There was a problem hiding this comment.
Changed all of InteractiveAuthEntryComponents to display the error div conditionally.
| onCaptchaResponse={this._onCaptchaResponse} | ||
| /> | ||
| <div className="error"> | ||
| <div className="error" role={ this.props.errorText ? "alert" : ""}> |
There was a problem hiding this comment.
The same applies here (see above comment).
| /> | ||
| </form> | ||
| <div className="error"> | ||
| <div className="error" role={this.state.errorText ? "alert" : ""}> |
| } | ||
| }, | ||
|
|
||
| focus: function() { |
There was a problem hiding this comment.
I'm not sure what would call this, could you clarify?
There was a problem hiding this comment.
Please see the comment at line 64. Apparently InteractiveAuthEntryComponents should implement that if they need it. I've got an inspiration from PasswordAuthEntry component otherwise I would just do this inside componentDidMount function.
There was a problem hiding this comment.
Hmm. Okay, I've managed to find where this is actually called. We're doing fun things in InteractiveAuth to call it.
its ref inside onComponentDidMount function. This is shorter better and has been requested.
|
@lukebarnard1 Excuse me for the delay. I think the suggested changes are now pushed. |
try to explain this key handling inconsistency with some additional comments as per the review discussion.
dbkr
left a comment
There was a problem hiding this comment.
Sorry, there's quite a lot of different changes in this PR and every time I look at it, I notice more things.
|
|
||
| _reRender() { | ||
| // Retrieve the root node of the Riot application outside the modal | ||
| let applicationNode = document.getElementById('matrixchat'); |
There was a problem hiding this comment.
Hmm, unfortunately we can't assume that the application will have called our root element matrixchat. Ideally these dialogs should all be in an element inside MatrixChat rather than just added to the document, but possibly the best compromise for now would be to dispatch a app-visible message with a true/false flag that MatrixChat can listen for and set its aria-hidden flag. This is also overriding React and so is likely to lead to subtle bugs like the aria-hidden flag disappearing if React re-renders the MatrixChat component.
| <TintableSvg src="img/icons-close-button.svg" width="35" height="35" /> | ||
| </AccessibleButton> | ||
| <div className={'mx_Dialog_title ' + this.props.titleClass}> | ||
| <div className={'mx_Dialog_title ' + this.props.titleClass} id='mx_BaseDialog_title'> |
There was a problem hiding this comment.
Why does this need an extra id for this rather just using mx_Dialog_title?
There was a problem hiding this comment.
We need an ID not a CSS class in order to pass as an aria-labelled-by on the root node. So other than to supply the same ID at line 78 and line 86 there are no other intentions here. Perhaps it can be improved somehow but I'd appreciate a hint on how to go about doing it. Perhaps it might just be because I am not very experienced.
There was a problem hiding this comment.
Oh, I completely missed that it was an ID and not a class.
| className={this.props.className} | ||
| role="dialog" | ||
| aria-labelledby='mx_BaseDialog_title' | ||
| aria-describedby={this.props.contentId} |
There was a problem hiding this comment.
This doesn't seem quite right to me - looks like this this should point to a description of what the thing is: the content will also include form controls and whatever else is in the dialog.
There was a problem hiding this comment.
Yes by reading aria docs or best practices we can learn this should be pointing to a DOM node describing the dialog i.e. for simple yes/no dialog it is its whole content excluding the buttons. For more complex dialogs it would be some other node nested inside the dialog. If we were using it preciselly the way as suggested we will have to delegate setup of this prop to the component where the description is added. Thus we have opted for a compromise by using the whole dialog content as its accessible description. All of Riot dialogs have the descriptive text at the top thus usefull text would be presented by the assistive tools first
. Note AT users don't have to listen to the whole description, they can interupt it naturally whenever they are confortable to continue working with the app.
There was a problem hiding this comment.
OK. When making compromises like this, we generally comment them so we know what compromises have been made and why, otherwise everyone reading the code will end up asking the same question I asked.
| { _t('Block users on other matrix homeservers from joining this room') } | ||
| <br /> | ||
| ({ _t('This setting cannot be changed later!') }) | ||
| </label> |
There was a problem hiding this comment.
I assume removing this wasn't intentional?
removed while trying to solve merge conflicts (thx @dbkr)
…ctions rather than assuming we can find and manipulate the node directly
This enhances dialogs accessibility according to the Aria Best practices.
A little breakdown of the changes
It's super awesome BaseDialog is getting attention elsewhere too as the DeactivateAccountDialog will also receive these accessibility changes once PR #1631 lands.