Expected behaviour to ensure a modal window is accessible for keyboard and screen reader users
Keyboard:
- Keyboard access is limited to only interacting with the modal dialog once it is visible
- When the modal window is opened, focused is moved to the first element in the window that can receive focus.
- As a user tabs through all focusable elements, they are cycled back to the beginning of the window
- The user is able to close the modal window through a clearly indicated close or OK button. In addition, ESC may also be set to close the window.
- After the modal is closed, focus is return to the element that user was on before the modal window was opened (or an appropriate element if the contents of the page have changed)
For screen reader as above plus:
- The window must have role=dialog (or role=alertdialog if appropriate). This informs the user that a dialog has opened.
- When the dialog is opened the screen reader announces the title or accessible name of the dialog
- If text appears before first element to receive focus or additional instructions are needed for screen reader intereaction with the dialog this is added to additional off-screen text and referenced this using aria-describedby (see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-describedby_attribute) .
- For dialogs with text, use headings to provide focus point and structure for the information.
- When the user tabs through each element its name and role is annouced. So each focusable elements must have:
- roles defined to be <button>, < a href="..."> or using role=... (see https://www.w3.org/TR/wai-aria/roles for definitions of roles)
- accessible names if there is no text in the element, set using aria-label or aria-labelledby
Adding a semantic-ui-react modal with a self state
All the following code will be added in the component which implements the modal.
- Requirements for using semantic-ui-react modal
Add the dependencies to package.json...if they are not:
"semantic-ui-react":"^0.67.1", "focus-trap-react":"^2.0.0",
Import all the semantic-ui-modal components needed, the focus trap and react moduls in the new component:
import React from 'react'; import { Button, Icon, Modal, Container, Segment, Menu,Label,Input,Divider} from 'semantic-ui-react'; import FocusTrap from 'focus-trap-react';
Create the modal class, extending from React.Component as usual:
class AccesibleModal extends React.Component{ ... }
Prepare the constructor with the initial state, which will control if the modal will be displaying or not, and also, if the focus will be trapped to the modal or not, from the begining. For example, in this example the modal will be opened after pressing a button which is located in the same component.
class AccesibleModal extends React.Component{ constructor(props) { super(props); this.state = { openModal: false, activeTrap: false }; } }
Create the methods to open and close the modal, in the modal component itself.. Also, the method needed to unmount the trap focus component.
The method which opens the modal should change the state of the modal component and also set the aria-hidden label of the application to true. In fact, the element #app is the div element which contains all the SlideWiki page. It is defined in the 'components/DefaultHTMLLayout.js' component, at line 26:
DeafultHTMLLayout.js... <body> <div id="app" aria-hidden = "false" dangerouslySetInnerHTML={{__html: this.props.markup}}></div> ....
Thus, the following method will be enough to open the modal:class AccesibleModal extends React.Component{ ... handleOpen(){ $('#app').attr('aria-hidden','true'); this.setState({ modalOpen:true, activeTrap:true }); } ... }
We can bind it at the constructor also:
class AccesibleModal extends React.Component{ constructor(props) { super(props); this.state = { openModal: false, activeTrap: false }; this.handleOpen = this.handleOpen.bind(this); } ... }
The method which close the modal should change the state of the modal component and also set the aria-hidden label of the application to false.
handleClose(){ $('#app').attr('aria-hidden','false'); this.setState({ modalOpen:false, activeTrap: false }); }
The method which unmounts the focus trap component should also change the state of the component. If we don't use it, if the user exits pressing outside the modal, the state of the component could be inconsistent.
unmountTrap(){ if(this.state.activeTrap){ this.setState({ activeTrap: false }); $('#app').attr('aria-hidden','false'); } }
Final constructor method:
constructor(props) { super(props); this.state = { openModal: false, activeItem: 'MyDecks', activeTrap: false }; this.handleOpen = this.handleOpen.bind(this); this.handleClose = this.handleClose.bind(this); this.unmountTrap = this.unmountTrap.bind(this); }
Add the modal to the render method of the component, adding all the aria-labels required
<Modal trigger={ <Button as="button" type="button" aria-label="Open" data-tooltip="Open" aria-hidden={this.state.modalOpen} basic onClick={this.handleOpen} > Open Button </Button> } open={this.state.modalOpen} onClose={this.handleClose} size="large" role="dialog" id="exampleModal" aria-labelledby="exampleModalHeader" aria-describedby="exampleModalDescription" tabIndex="0"> </Modal>
- Trigger: Defines the button which will open the modal.You can see different attributes needed for making it fully accessible:
- as="button", because if not use it, semantic-ui-react render it as a div, which is not focusable. This also exposes the role of the element.
- aria-label="Open", this is required if there is no text on the button.It should contain explanation in case of a icon button. of the action of the button but it does not need to say that it is a button.
- data-tooltip="Open", extra information displayed when you pass over the button
- aria-hidden={this.state.modalOpen}, hides the button for reader when modal is open
- onClick={this.handleOpen}, association to the previously handleOpen method (which is binded in the constructor)
- open={this.state.modalOpen}, it controls if the modal is displayed (true) or not (false). Notice that in this case there is a difference with respect semantic-ui. when the modal is not open, also is not rendered in the final DOM, so the components it has inside are not available.
- Trigger: Defines the button which will open the modal.You can see different attributes needed for making it fully accessible:
Add the focus-trap component. Modals from semantic-ui and semantic-ui-react do not trap the focus, so when users taps over the last element of the modal, the focus goes outside the modal. This make them not accessible. In order to avoid that, we should use the focus-trap-react component. If incorrectly placed this component destroys some of the final layout. After many tests, we found a good way to introduce it with less impact: adding it under <Modal>, and surrounding the rest of the elements of the modal:
<Modal trigger={ <Button as="button" type="button" aria-label="Open Button" data-tooltip="Open Button" aria-hidden={this.state.modalOpen} basic onClick={this.handleOpen} > Open Button </Button> } open={this.state.modalOpen} onClose={this.handleClose} size="large" role="dialog" id="exampleModal" aria-labelledby="exampleModalHeader" aria-describedby="exampleModalDescription" tabIndex="0"> <FocusTrap id='focus-trap-exampleModal' className = "header" active={this.state.activeTrap} focusTrapOptions={{ onDeactivate: this.unmountTrap, clickOutsideDeactivates: true, initialFocus: '#firstElement', }}> {/*elements of the modal here*/} </FocusTrap> </Modal>
- className="header": using header as extra class, the impact in the final layout is reduced
- active={this.state.activeTrap}, controls if the focus is trap or not
- aria-label="Open Button", it is required. Should contain the name of the button displayed, or its explanation in case of an icon button.
- focusTrapOptions.onDeactivate:this.unmountTrap, In this property we add the call to the method which ensures the trap component is unmount, even the user exits the modal pressing outside
- focusTrapOptions.clickOutsidesDeactivates:true, also, it is the default value..
- initialFocus:'#firsElement', the id of the element which will receive the focus. I notice that f you don't indicate it, the modal does not receive the focus well
Add Modal Elements. As mentioned above, we need to add a header for the modal and an explanation about it. Of course, all the content needed and, most probably , some action buttons. We can add them as semantic-ui-react documentation suggest us, but taking into account some tips.
<Modal trigger={ <Button as="button" type="button" aria-label="Open" data-tooltip="Open Button" aria-hidden={this.state.modalOpen} basic onClick={this.handleOpen} > Open Button </Button> } open={this.state.modalOpen} onClose={this.handleClose} size="large" role="dialog" id="exampleModal" aria-labelledby="exampleModalHeader" aria-describedby="exampleModalDescription" tabIndex="0"> <FocusTrap id='focus-trap-exampleModal' className = "header" active={this.state.activeTrap} focusTrapOptions={{ onDeactivate: this.unmountTrap, clickOutsideDeactivates: true, initialFocus: '#firstElement', }}> {/*elements of the modal here*/} <Modal.Header className="ui center aligned" as="h1" id="exampleModalHeader"> Accesible Modal </Modal.Header> <Modal.Content> <Container> <Segment color="blue" textAlign="center" padded> <Segment attached='bottom' textAlign="left"> <TextArea className="sr-only" id="exampleModalDescription" value="This modal only is a an example of how make a modal fully accessible" /> <Label htmlFor="firstElement" as="label" basic color="blue" pointing="right">Input some text</Label> <Input type="text" id="firstElement" placeholder="You should input something.." tabIndex="0" /> </Segment>; </Segment> <Modal.Actions> <Button color="green" tabIndex="0" type="button" aria-label="Accept" data-tooltip="Accept"> Accept </Button> <Button color='red' tabIndex="0" type="button" aria-label="Cancel" data-tooltip="Cancel" onClick={this.handleClose} > Cancel </Button> </Modal.Actions> </Segment> </Container> </Modal.Content> </FocusTrap> </Modal>
- <Modal.Header className="ui center aligned" as="h1" id="exampleModalHeader">:
- className="ui center aligned": it is the semantic ui class we used in our previous modals
- as="h1": in order it was finally rendered as h1, which is mandatory
- id="exampleModalHeader": the id used in aria-labelledby attribute in the modal (see line 12)
- <Modal.Content>, all the content of the modal should be defined here. It should have a Container inside, which also has at least two segments:
- <Segment color="blue" textAlign="center" padded> : in order to use the same style than in previous modals
- <TextArea className="sr-only" id="exampleModalDescription"... />: This text is only viewed by screen readers, and it is added to explain the content of the modal. For this reason it has the id=exampleModalDescription, which was previously used in the aria-describedby attributte of the modal (see line 36 ). If the modal contains an explanation to all users, we can use this explanation as a target of the aria-describedby label.
- <Modal.Actions>: they should be added nested to the firt segment element. The documentation indicates to add them at the same level than content, but when we use the FocusTrap, the final style of the buttons looks horrible. Thus, it is better using them inside the first segment element, because it has a better appareance.
- <Button color='red' tabIndex="0" type="button" aria-label="Cancel" data-tooltip="Cancel" onClick={this.handleClose} >: This is the button for closing the modal. It has all the aria attributes required: type, aria-label and data-tooltip. Also, its onClick event is associated to the This.handleClose method previously declared.
- <Modal.Header className="ui center aligned" as="h1" id="exampleModalHeader">:
What happens if the modal should be controlled from another component?
It that case, the component should be connected to an store, and also, we will need to actions to open and close the modal.
The store should contain, at least, the initial values for openModal and activeTrap states. Also, handlers to modified them to open and close the modal. A simple example:
import {BaseStore} from 'fluxible/addons'; class ExampleModalStore extends BaseStore{ constructor(dispatcher) { super(dispatcher); this.openModal = false; this.activeTrap = false; } getState(){ return { openModal : this.openModal, activeTrap : this.activeTrap }; } dehydrate() { return this.getState(); } rehydrate(state) { this.openModal = state.openModal; } openExampleModal(payload){ this.openModal = true; this.activeTrap = true; this.emitChange(); } closeExampleModal(payload){ this.openModal = false; this.activeTrap = false; this.emitChange(); } } ExampleModalStore.storeName = 'AttachSubdeckModalStore'; ExampleModalStore.handlers = { 'EXAMPLE_MODAL_OPEN' : 'openExampleModal', 'EXAMPLE_MODAL_CLOSE': 'closeExampleModal' }; export default ExampleModalStore;
The actions to open and close the modal, only should dispatch the proper instructions to the store. Examples:
export default function openExampleModal(context, payload, done) { console.log('action'); context.dispatch('EXAMPLE_MODAL_OPEN', payload); done(); }
export default function closeExampleModal(context, payload, done) { context.dispatch('EXAMPLE_MODAL_CLOSE', payload); done(); }
- At the modal component, we should connect it to the store and modify a little the way in which we manage the state:
Connecting to the store:
import { connectToStores } from 'fluxible-addons-react'; .... AccesibleModal = connectToStores(AccesibleModal ,[ExampleModalStore],(context,props) => { return { ExampleModalStore: context.getStore(ExampleModalStore).getState() }; });
Changes at the constructor:
constructor(props) { super(props); this.state = { openModal: this.props.ExampleModalStore.openModal, activeTrap: this.props.ExampleModalStore.activeTrap, }; this.handleClose = this.handleClose.bind(this); this.unmountTrap = this.unmountTrap.bind(this); }
Update the state
componentWillReceiveProps(nextProps){ if (nextProps.ExampleModalStore.activeTrap !== this.props.ExampleModalStore.activeTrap){ this.setState({ activeTrap: nextProps.ExampleModalStore.activeTrap }); } if (nextProps.ExampleModalStore.openModal!== this.props.ExampleModalStore.openModal){ this.setState({ openModal: nextProps.ExampleModalStore.openModal }); } }
The close method shoul call the proper action:
import closeAttachModal from '../../../../actions/exampleModal/closeExampleModal'; handleClose(){ this.context.executeAction(closeExampleModal); $('#app').attr('aria-hidden','false'); } .... AccesibleModal.contextTypes = { executeAction: React.PropTypes.func.isRequired };
The open method called from another component should call the corresponding action:
import React from 'react'; ... import AccesibleModal from '../AccesibleModal/AccesibleModal.js'; import openExampleModal from '../../../../actions/exampleModal/openExampleModal '; class ContentActionsHeader extends React.Component { .... .... handleOpenExampleModal(){ this.context.executeAction(openExampleModal ); $('#app').attr('aria-hidden','true'); } ... render(){ ... return( .... <Button basic onClick={this.handleOpenExampleModal.bind(this)} type="button" aria-label="Example Modal" data-tooltip="Example Modal" tabIndex="0" > Example Modal </Button> <AccesibleModal /> .... ); } }
Some interesting links about accessible modals
Technique: Accessible modal dialogs, from Harvard University
Advanced ARIA Tip #2: Accessible modal dialogs
Creating An Accessible Modal Dialog from bitsofcode
Deque University: Code Library of Accessibility Examples