In this simple modal example, role is set to dialog and focus is received on dialog container when it opens, but NVDA won't announce the word 'dialog' in the beginning. I notice there is potential NVDA issue on this https://github.com/nvaccess/nvda/issues/8620, but I could find several other accessible modal examples where NVDA announces 'dialog' such as https://stackblitz.com/edit/stackblitz-starters-tkpczr?file=src%2Fcomponents%2FModal%2FModal.jsx,src%2Fcomponents%2FNewsletterModal%2FNewsletterModal.jsx, https://codesandbox.io/p/sandbox/react-accessible-modal-dialog-forked-ypw7q8?file=%2Fsrc%2FModal%2Findex.js%3A62%2C1-65%2C25 and w3c Modal https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/dialog/. So what's is wrong with this modal that is preventing NVDA from announcing 'dialog'?
var btn2 = document.getElementById('test_button2');
var dialog2 = document.getElementById('test_dialog2');
var btnClose2 = dialog2.querySelector('button');
btn2.addEventListener('click', function() {
dialog2.classList.add('show');
dialog2.focus();
this.setAttribute('aria-hidden', true);
this.setAttribute('tabindex', '-1');
});
btnClose2.addEventListener('click', function() {
dialog2.classList.remove('show');
btn2.removeAttribute('aria-hidden');
btn2.removeAttribute('tabindex');
btn2.focus();
});
#test_dialog2 {
visibility: hidden;
}
#test_dialog2.show {
visibility: visible;
padding: 1em;
box-shadow: 0 0 300px
}
button[aria-hidden] {
opacity: .2;
}
*:focus {
box-shadow: none;
outline: 2px solid blue;
}
<button id="test_button2">
Open Dialog
</button>
<div tabindex="-1" id="test_dialog2" role="dialog" aria-labelledby="test2">
<h2 id="test2">example</h2>
<p>a test paragraph</p>
<button>close</button>
</div>
This is exactly the situation described in the NVDA Bug which is still open.
The difference with the other linked examples is that they don’t focus the dialog itself, but an element inside. It’s best practice to focus the first interactive child. If there’s none, the title will do.
NVDA will announce the dialog, because it’s like a landmark grouping. When entering and leaving it (crossing it’s boundaries, so to say), its role and name will be announced.
const btn2 = document.getElementById('test_button2');
const dialog2 = document.getElementById('test_dialog2');
const btnClose2 = dialog2.querySelector('button');
const h2 = document.getElementById('test2');
btn2.addEventListener('click', function() {
dialog2.removeAttribute('hidden');
h2.focus();
this.setAttribute('aria-hidden', true);
this.setAttribute('tabindex', '-1');
});
btnClose2.addEventListener('click', function() {
dialog2.setAttribute('hidden', true);
btn2.removeAttribute('tabindex');
btn2.removeAttribute('aria-hidden');
btn2.focus();
});
#test_dialog2 {
padding: 1em;
box-shadow: 0 0 300px
}
button[aria-hidden] {
opacity: .2;
}
*:focus {
box-shadow: none;
outline: 2px solid blue;
}
<button id="test_button2">
Open Dialog
</button>
<div id="test_dialog2" role="dialog" aria-labelledby="test2" aria-modal="true" hidden>
<h2 id="test2" tabindex="-1">example</h2>
<p>a test paragraph</p>
<button>close</button>
</div>
Best would be today, to use the native <dialog>
element and its API.
Rendering anything outside of the dialog inaccessible will also be necessary.