辅助功能:聚焦时移除 <p> 上的轮廓
Accessibility: Remove outline on <p> when focused
需要说明的是,我正在寻找最方便且设计最佳的选项。所以不,我不会删除按钮和链接上的大纲以及任何有用的地方。但我正在努力使 Dialog 工作。为此,我使用这个 example。在此示例中,第一个 <p>
在打开对话框时获得焦点。如示例中所述:
In this dialog, the first paragraph has tabindex=-1. The first paragraph is also contained inside the element that provides the dialog description, i.e., the element that is referenced by aria-describedby. With some screen readers, this may have one negative but relatively insignificant side effect when the dialog opens -- the first paragraph may be announced twice. Nonetheless, making the first paragraph focusable and setting the initial focus on it is the most broadly accessible option.
但是当然这一段有一个大纲,因为它是重点。我想知道该元素是否具有 tabindex=-1
并且不是您可以与之交互的元素。可以去掉这部分的轮廓吗?
提前致谢
简答
如果您愿意,可以安全地删除带有 tabindex="-1"
的段落上的焦点指示器。不过,有一种更好的方法来处理嵌套模态。
长答案
WCAG 在描述事物的方式上有点“模糊”(不够具体),但 guidance for focus indicators 用于“控件”或“交互元素”。
另外主要描述的条件是
Success Criterion 2.4.7 Focus Visible (Level AA): Any keyboard operable user interface has a mode of operation where the keyboard focus indicator is visible.
由于您无法与该段落进行交互(它不可通过键盘操作),因此很容易争辩说提供焦点指示器实际上更加混乱,因为没有标准操作会起作用。但是,您也可能会争辩说,在没有焦点指示器的情况下登陆新模式也会令人困惑。
因此这是一个判断调用,如果我需要以编程方式聚焦它们,我总是会删除非交互元素上的聚焦指示器,尤其是在按 Enter 时可以提交表格等
有没有更好的方法来避免聚焦 non-interactive 元素的陷阱?
有一种方法可以解决所有这些问题,并且仍然有一个可见的焦点指示器。
我们将关闭按钮添加到模式的顶部(通常位于右上角)并使用 aria-describedby
指向模式标题。
<button id="dialog2_close_btn" aria-describedby="dialog2_label" onclick="closeDialog(this)">Close</button>
这将显示为“关闭验证结果”。然后我们只关注那个按钮而不是模态标题
<button onclick="openDialog('dialog2', this, 'dialog2_close_btn')">
Verify Address
</button>
我已经在下面的示例中进行了调整,如果您在打开第一个模式后单击“验证地址”,您会看到顶部有一个关闭按钮。
/**
* @namespace aria
*/
var aria = aria || {};
/**
* @desc
* Key code constants
*/
aria.KeyCode = {
BACKSPACE: 8,
TAB: 9,
RETURN: 13,
ESC: 27,
SPACE: 32,
PAGE_UP: 33,
PAGE_DOWN: 34,
END: 35,
HOME: 36,
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40,
DELETE: 46
};
aria.Utils = aria.Utils || {};
// Polyfill src https://developer.mozilla.org/en-US/docs/Web/API/Element/matches
aria.Utils.matches = function (element, selector) {
if (!Element.prototype.matches) {
Element.prototype.matches =
Element.prototype.matchesSelector ||
Element.prototype.mozMatchesSelector ||
Element.prototype.msMatchesSelector ||
Element.prototype.oMatchesSelector ||
Element.prototype.webkitMatchesSelector ||
function (s) {
var matches = element.parentNode.querySelectorAll(s);
var i = matches.length;
while (--i >= 0 && matches.item(i) !== this) {}
return i > -1;
};
}
return element.matches(selector);
};
aria.Utils.remove = function (item) {
if (item.remove && typeof item.remove === 'function') {
return item.remove();
}
if (item.parentNode &&
item.parentNode.removeChild &&
typeof item.parentNode.removeChild === 'function') {
return item.parentNode.removeChild(item);
}
return false;
};
aria.Utils.isFocusable = function (element) {
if (element.tabIndex > 0 || (element.tabIndex === 0 && element.getAttribute('tabIndex') !== null)) {
return true;
}
if (element.disabled) {
return false;
}
switch (element.nodeName) {
case 'A':
return !!element.href && element.rel != 'ignore';
case 'INPUT':
return element.type != 'hidden' && element.type != 'file';
case 'BUTTON':
case 'SELECT':
case 'TEXTAREA':
return true;
default:
return false;
}
};
aria.Utils.getAncestorBySelector = function (element, selector) {
if (!aria.Utils.matches(element, selector + ' ' + element.tagName)) {
// Element is not inside an element that matches selector
return null;
}
// Move up the DOM tree until a parent matching the selector is found
var currentNode = element;
var ancestor = null;
while (ancestor === null) {
if (aria.Utils.matches(currentNode.parentNode, selector)) {
ancestor = currentNode.parentNode;
}
else {
currentNode = currentNode.parentNode;
}
}
return ancestor;
};
aria.Utils.hasClass = function (element, className) {
return (new RegExp('(\s|^)' + className + '(\s|$)')).test(element.className);
};
aria.Utils.addClass = function (element, className) {
if (!aria.Utils.hasClass(element, className)) {
element.className += ' ' + className;
}
};
aria.Utils.removeClass = function (element, className) {
var classRegex = new RegExp('(\s|^)' + className + '(\s|$)');
element.className = element.className.replace(classRegex, ' ').trim();
};
aria.Utils.bindMethods = function (object /* , ...methodNames */) {
var methodNames = Array.prototype.slice.call(arguments, 1);
methodNames.forEach(function (method) {
object[method] = object[method].bind(object);
});
};
/*
* This content is licensed according to the W3C Software License at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
*/
var aria = aria || {};
aria.Utils = aria.Utils || {};
(function () {
/*
* When util functions move focus around, set this true so the focus listener
* can ignore the events.
*/
aria.Utils.IgnoreUtilFocusChanges = false;
aria.Utils.dialogOpenClass = 'has-dialog';
/**
* @desc Set focus on descendant nodes until the first focusable element is
* found.
* @param element
* DOM node for which to find the first focusable descendant.
* @returns
* true if a focusable element is found and focus is set.
*/
aria.Utils.focusFirstDescendant = function (element) {
for (var i = 0; i < element.childNodes.length; i++) {
var child = element.childNodes[i];
if (aria.Utils.attemptFocus(child) ||
aria.Utils.focusFirstDescendant(child)) {
return true;
}
}
return false;
}; // end focusFirstDescendant
/**
* @desc Find the last descendant node that is focusable.
* @param element
* DOM node for which to find the last focusable descendant.
* @returns
* true if a focusable element is found and focus is set.
*/
aria.Utils.focusLastDescendant = function (element) {
for (var i = element.childNodes.length - 1; i >= 0; i--) {
var child = element.childNodes[i];
if (aria.Utils.attemptFocus(child) ||
aria.Utils.focusLastDescendant(child)) {
return true;
}
}
return false;
}; // end focusLastDescendant
/**
* @desc Set Attempt to set focus on the current node.
* @param element
* The node to attempt to focus on.
* @returns
* true if element is focused.
*/
aria.Utils.attemptFocus = function (element) {
if (!aria.Utils.isFocusable(element)) {
return false;
}
aria.Utils.IgnoreUtilFocusChanges = true;
try {
element.focus();
}
catch (e) {
}
aria.Utils.IgnoreUtilFocusChanges = false;
return (document.activeElement === element);
}; // end attemptFocus
/* Modals can open modals. Keep track of them with this array. */
aria.OpenDialogList = aria.OpenDialogList || new Array(0);
/**
* @returns the last opened dialog (the current dialog)
*/
aria.getCurrentDialog = function () {
if (aria.OpenDialogList && aria.OpenDialogList.length) {
return aria.OpenDialogList[aria.OpenDialogList.length - 1];
}
};
aria.closeCurrentDialog = function () {
var currentDialog = aria.getCurrentDialog();
if (currentDialog) {
currentDialog.close();
return true;
}
return false;
};
aria.handleEscape = function (event) {
var key = event.which || event.keyCode;
if (key === aria.KeyCode.ESC && aria.closeCurrentDialog()) {
event.stopPropagation();
}
};
document.addEventListener('keyup', aria.handleEscape);
/**
* @constructor
* @desc Dialog object providing modal focus management.
*
* Assumptions: The element serving as the dialog container is present in the
* DOM and hidden. The dialog container has role='dialog'.
*
* @param dialogId
* The ID of the element serving as the dialog container.
* @param focusAfterClosed
* Either the DOM node or the ID of the DOM node to focus when the
* dialog closes.
* @param focusFirst
* Optional parameter containing either the DOM node or the ID of the
* DOM node to focus when the dialog opens. If not specified, the
* first focusable element in the dialog will receive focus.
*/
aria.Dialog = function (dialogId, focusAfterClosed, focusFirst) {
this.dialogNode = document.getElementById(dialogId);
if (this.dialogNode === null) {
throw new Error('No element found with id="' + dialogId + '".');
}
var validRoles = ['dialog', 'alertdialog'];
var isDialog = (this.dialogNode.getAttribute('role') || '')
.trim()
.split(/\s+/g)
.some(function (token) {
return validRoles.some(function (role) {
return token === role;
});
});
if (!isDialog) {
throw new Error(
'Dialog() requires a DOM element with ARIA role of dialog or alertdialog.');
}
// Wrap in an individual backdrop element if one doesn't exist
// Native <dialog> elements use the ::backdrop pseudo-element, which
// works similarly.
var backdropClass = 'dialog-backdrop';
if (this.dialogNode.parentNode.classList.contains(backdropClass)) {
this.backdropNode = this.dialogNode.parentNode;
}
else {
this.backdropNode = document.createElement('div');
this.backdropNode.className = backdropClass;
this.dialogNode.parentNode.insertBefore(this.backdropNode, this.dialogNode);
this.backdropNode.appendChild(this.dialogNode);
}
this.backdropNode.classList.add('active');
// Disable scroll on the body element
document.body.classList.add(aria.Utils.dialogOpenClass);
if (typeof focusAfterClosed === 'string') {
this.focusAfterClosed = document.getElementById(focusAfterClosed);
}
else if (typeof focusAfterClosed === 'object') {
this.focusAfterClosed = focusAfterClosed;
}
else {
throw new Error(
'the focusAfterClosed parameter is required for the aria.Dialog constructor.');
}
if (typeof focusFirst === 'string') {
this.focusFirst = document.getElementById(focusFirst);
}
else if (typeof focusFirst === 'object') {
this.focusFirst = focusFirst;
}
else {
this.focusFirst = null;
}
// Bracket the dialog node with two invisible, focusable nodes.
// While this dialog is open, we use these to make sure that focus never
// leaves the document even if dialogNode is the first or last node.
var preDiv = document.createElement('div');
this.preNode = this.dialogNode.parentNode.insertBefore(preDiv,
this.dialogNode);
this.preNode.tabIndex = 0;
var postDiv = document.createElement('div');
this.postNode = this.dialogNode.parentNode.insertBefore(postDiv,
this.dialogNode.nextSibling);
this.postNode.tabIndex = 0;
// If this modal is opening on top of one that is already open,
// get rid of the document focus listener of the open dialog.
if (aria.OpenDialogList.length > 0) {
aria.getCurrentDialog().removeListeners();
}
this.addListeners();
aria.OpenDialogList.push(this);
this.clearDialog();
this.dialogNode.className = 'default_dialog'; // make visible
if (this.focusFirst) {
this.focusFirst.focus();
}
else {
aria.Utils.focusFirstDescendant(this.dialogNode);
}
this.lastFocus = document.activeElement;
}; // end Dialog constructor
aria.Dialog.prototype.clearDialog = function () {
Array.prototype.map.call(
this.dialogNode.querySelectorAll('input'),
function (input) {
input.value = '';
}
);
};
/**
* @desc
* Hides the current top dialog,
* removes listeners of the top dialog,
* restore listeners of a parent dialog if one was open under the one that just closed,
* and sets focus on the element specified for focusAfterClosed.
*/
aria.Dialog.prototype.close = function () {
aria.OpenDialogList.pop();
this.removeListeners();
aria.Utils.remove(this.preNode);
aria.Utils.remove(this.postNode);
this.dialogNode.className = 'hidden';
this.backdropNode.classList.remove('active');
this.focusAfterClosed.focus();
// If a dialog was open underneath this one, restore its listeners.
if (aria.OpenDialogList.length > 0) {
aria.getCurrentDialog().addListeners();
}
else {
document.body.classList.remove(aria.Utils.dialogOpenClass);
}
}; // end close
/**
* @desc
* Hides the current dialog and replaces it with another.
*
* @param newDialogId
* ID of the dialog that will replace the currently open top dialog.
* @param newFocusAfterClosed
* Optional ID or DOM node specifying where to place focus when the new dialog closes.
* If not specified, focus will be placed on the element specified by the dialog being replaced.
* @param newFocusFirst
* Optional ID or DOM node specifying where to place focus in the new dialog when it opens.
* If not specified, the first focusable element will receive focus.
*/
aria.Dialog.prototype.replace = function (newDialogId, newFocusAfterClosed,
newFocusFirst) {
var closedDialog = aria.getCurrentDialog();
aria.OpenDialogList.pop();
this.removeListeners();
aria.Utils.remove(this.preNode);
aria.Utils.remove(this.postNode);
this.dialogNode.className = 'hidden';
this.backdropNode.classList.remove('active');
var focusAfterClosed = newFocusAfterClosed || this.focusAfterClosed;
var dialog = new aria.Dialog(newDialogId, focusAfterClosed, newFocusFirst);
}; // end replace
aria.Dialog.prototype.addListeners = function () {
document.addEventListener('focus', this.trapFocus, true);
}; // end addListeners
aria.Dialog.prototype.removeListeners = function () {
document.removeEventListener('focus', this.trapFocus, true);
}; // end removeListeners
aria.Dialog.prototype.trapFocus = function (event) {
if (aria.Utils.IgnoreUtilFocusChanges) {
return;
}
var currentDialog = aria.getCurrentDialog();
if (currentDialog.dialogNode.contains(event.target)) {
currentDialog.lastFocus = event.target;
}
else {
aria.Utils.focusFirstDescendant(currentDialog.dialogNode);
if (currentDialog.lastFocus == document.activeElement) {
aria.Utils.focusLastDescendant(currentDialog.dialogNode);
}
currentDialog.lastFocus = document.activeElement;
}
}; // end trapFocus
window.openDialog = function (dialogId, focusAfterClosed, focusFirst) {
var dialog = new aria.Dialog(dialogId, focusAfterClosed, focusFirst);
};
window.closeDialog = function (closeButton) {
var topDialog = aria.getCurrentDialog();
if (topDialog.dialogNode.contains(closeButton)) {
topDialog.close();
}
}; // end closeDialog
window.replaceDialog = function (newDialogId, newFocusAfterClosed,
newFocusFirst) {
var topDialog = aria.getCurrentDialog();
if (topDialog.dialogNode.contains(document.activeElement)) {
topDialog.replace(newDialogId, newFocusAfterClosed, newFocusFirst);
}
}; // end replaceDialog
}());
.hidden {
display: none;
}
[role="alertdialog"],
[role="dialog"] {
box-sizing: border-box;
padding: 15px;
border: 1px solid #000;
background-color: #fff;
min-height: 100vh;
}
@media screen and (min-width: 640px) {
[role="alertdialog"],
[role="dialog"] {
position: absolute;
top: 2rem;
left: 50vw; /* move to the middle of the screen (assumes relative parent is the body/viewport) */
transform: translateX(-50%); /* move backwards 50% of this element's width */
min-width: calc(640px - (15px * 2)); /* == breakpoint - left+right margin */
min-height: auto;
box-shadow: 0 19px 38px rgba(0, 0, 0, 0.12), 0 15px 12px rgba(0, 0, 0, 0.22);
}
}
.dialog_label {
text-align: center;
}
.dialog_form {
margin: 15px;
}
.dialog_form .label_text {
box-sizing: border-box;
padding-right: 0.5em;
display: inline-block;
font-size: 16px;
font-weight: bold;
width: 30%;
text-align: right;
}
.dialog_form .label_info {
box-sizing: border-box;
padding-right: 0.5em;
font-size: 12px;
width: 30%;
text-align: right;
display: inline-block;
}
.dialog_form_item {
margin: 10px 0;
font-size: 0;
}
.dialog_form_item .wide_input {
box-sizing: border-box;
max-width: 70%;
width: 27em;
}
.dialog_form_item .city_input {
box-sizing: border-box;
max-width: 70%;
width: 17em;
}
.dialog_form_item .state_input {
box-sizing: border-box;
max-width: 70%;
width: 15em;
}
.dialog_form_item .zip_input {
box-sizing: border-box;
max-width: 70%;
width: 9em;
}
.dialog_form_actions {
text-align: right;
padding: 0 20px 20px;
}
.dialog_close_button {
float: right;
position: absolute;
top: 10px;
left: 92%;
height: 25px;
}
.dialog_close_button img {
border: 0;
}
.dialog_desc {
padding: 10px 20px;
}
/* native <dialog> element uses the ::backdrop pseudo-element */
/* dialog::backdrop, */
.dialog-backdrop {
display: none;
position: fixed;
overflow-y: auto;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
@media screen and (min-width: 640px) {
.dialog-backdrop {
background: rgba(0, 0, 0, 0.3);
}
}
.dialog-backdrop.active {
display: block;
}
.no-scroll {
overflow-y: auto !important;
}
/* this is added to the body when a dialog is open */
.has-dialog {
overflow: hidden;
}
/* styling for alert-dialog example */
.notes {
display: block;
font-size: 1rem;
line-height: 1.3;
min-width: 400px;
max-width: 100%;
width: 33%;
}
.toast {
background-color: rgba(0, 0, 0, 0.9);
color: #fff;
padding: 1rem;
border: none;
border-radius: 0.25rem;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
position: fixed;
top: 1rem;
right: 1rem;
transform: translateY(-150%);
transition: transform 225ms cubic-bezier(0.4, 0, 0.2, 1);
}
.toast.active {
transform: translateY(0);
}
<button onclick="openDialog('dialog1', this)">
Add Delivery Address
</button>
<div role="dialog"
id="dialog1"
aria-labelledby="dialog1_label"
aria-modal="true"
class="hidden">
<h2 id="dialog1_label" class="dialog_label">
Add Delivery Address
</h2>
<div class="dialog_form">
<div class="dialog_form_item">
<label>
<span class="label_text">
Street:
</span>
<input type="text" class="wide_input">
</label>
</div>
<div class="dialog_form_item">
<label>
<span class="label_text">
City:
</span>
<input type="text" class="city_input">
</label>
</div>
<div class="dialog_form_item">
<label>
<span class="label_text">
State:
</span>
<input type="text" class="state_input">
</label>
</div>
<div class="dialog_form_item">
<label>
<span class="label_text">
Zip:
</span>
<input type="text" class="zip_input">
</label>
</div>
<div class="dialog_form_item">
<label for="special_instructions">
<span class="label_text">
Special instructions:
</span>
</label>
<input id="special_instructions"
type="text"
aria-describedby="special_instructions_desc"
class="wide_input">
<div class="label_info" id="special_instructions_desc">
For example, gate code or other information to help the driver find you
</div>
</div>
</div>
<div class="dialog_form_actions">
<button onclick="openDialog('dialog2', this, 'dialog2_close_btn')">
Verify Address
</button>
<button onclick="replaceDialog('dialog3', undefined, 'dialog3_close_btn')">
Add
</button>
<button onclick="closeDialog(this)">
Cancel
</button>
</div>
</div>
<!-- Second modal to open on top of the first modal -->
<div id="dialog2"
role="dialog"
aria-labelledby="dialog2_label"
aria-describedby="dialog2_desc"
aria-modal="true"
class="hidden">
<button id="dialog2_close_btn" aria-describedby="dialog2_label" onclick="closeDialog(this)">Close</button>
<h2 id="dialog2_label" class="dialog_label">
Verification Result
</h2>
<div id="dialog2_desc" class="dialog_desc">
<p tabindex="-1" id="dialog2_para1">
This is just a demonstration. If it were a real application, it would
provide a message telling whether the entered address is valid.
</p>
<p>
For demonstration purposes, this dialog has a lot of text. It demonstrates a
scenario where:
</p>
<ul>
<li>
The first interactive element, the help link, is at the bottom of the dialog.
</li>
<li>
If focus is placed on the first interactive element when the dialog opens, the
validation message may not be visible.
</li>
<li>
If the validation message is visible and the focus is on the help link, then
the focus may not be visible.
</li>
<li>
When the dialog opens, it is important that both:
<ul>
<li>
The beginning of the text is visible so users do not have to scroll back to
start reading.
</li>
<li>
The keyboard focus always remains visible.
</li>
</ul>
</li>
</ul>
<p>
There are several ways to resolve this issue:
</p>
<ul>
<li>
Place an interactive element at the top of the dialog, e.g., a button or link.
</li>
<li>
Make a static element focusable, e.g., the dialog title or the first block of
text.
</li>
</ul>
<p>
Please
<em>
DO NOT
</em>
make the element with role dialog focusable!
</p>
<ul>
<li>
The larger a focusable element is, the more difficult it is to visually
identify the location of focus, especially for users with a narrow field of view.
</li>
<li>
The dialog has a visual border, so creating a clear visual indicator of focus
when the entire dialog has focus is not very feasible.
</li>
<li>
Screen readers read the label and content of focusable elements. The dialog
contains its label and a lot of content! If a dialog like this one has focus, the
actual focus is difficult to comprehend.
</li>
</ul>
<p>
In this dialog, the first paragraph has
<code>
tabindex=
<q>
-1
</q>
</code>
. The first
paragraph is also contained inside the element that provides the dialog description, i.e., the element that is referenced
by
<code>
aria-describedby
</code>
. With some screen readers, this may have one negative
but relatively insignificant side effect when the dialog opens -- the first paragraph
may be announced twice. Nonetheless, making the first paragraph focusable and setting
the initial focus on it is the most broadly accessible option.
</p>
</div>
<div class="dialog_form_actions">
<a href="#" onclick="openDialog('dialog4', this)">
link to help
</a>
<button onclick="openDialog('dialog4', this)">
accepting an alternative form
</button>
<button onclick="closeDialog(this)">
Close
</button>
</div>
</div>
<!-- Dialog that replaces dialog 1. -->
<div id="dialog3"
role="dialog"
aria-labelledby="dialog3_label"
aria-describedby="dialog3_desc"
aria-modal="true"
class="hidden">
<h2 id="dialog3_label" class="dialog_label">
Address Added
</h2>
<p id="dialog3_desc" class="dialog_desc">
The address you provided has been added to your list of delivery addresses. It is ready
for immediate use. If you wish to remove it, you can do so from
<a href="#" onclick="openDialog('dialog4', this)">
your profile.
</a>
</p>
<div class="dialog_form_actions">
<button id="dialog3_close_btn" onclick="closeDialog(this)">
OK
</button>
</div>
</div>
<div id="dialog4"
role="dialog"
aria-labelledby="dialog4_label"
aria-describedby="dialog4_desc"
class="hidden"
aria-modal="true">
<h2 id="dialog4_label" class="dialog_label">
End of the Road!
</h2>
<p id="dialog4_desc" class="dialog_desc">
You activated a fake link or button that goes nowhere!
The link or button is present for demonstration purposes only.
</p>
<div class="dialog_form_actions">
<button id="dialog4_close_btn" onclick="closeDialog(this)">
Close
</button>
</div>
</div>
需要说明的是,我正在寻找最方便且设计最佳的选项。所以不,我不会删除按钮和链接上的大纲以及任何有用的地方。但我正在努力使 Dialog 工作。为此,我使用这个 example。在此示例中,第一个 <p>
在打开对话框时获得焦点。如示例中所述:
In this dialog, the first paragraph has tabindex=-1. The first paragraph is also contained inside the element that provides the dialog description, i.e., the element that is referenced by aria-describedby. With some screen readers, this may have one negative but relatively insignificant side effect when the dialog opens -- the first paragraph may be announced twice. Nonetheless, making the first paragraph focusable and setting the initial focus on it is the most broadly accessible option.
但是当然这一段有一个大纲,因为它是重点。我想知道该元素是否具有 tabindex=-1
并且不是您可以与之交互的元素。可以去掉这部分的轮廓吗?
提前致谢
简答
如果您愿意,可以安全地删除带有 tabindex="-1"
的段落上的焦点指示器。不过,有一种更好的方法来处理嵌套模态。
长答案
WCAG 在描述事物的方式上有点“模糊”(不够具体),但 guidance for focus indicators 用于“控件”或“交互元素”。
另外主要描述的条件是
Success Criterion 2.4.7 Focus Visible (Level AA): Any keyboard operable user interface has a mode of operation where the keyboard focus indicator is visible.
由于您无法与该段落进行交互(它不可通过键盘操作),因此很容易争辩说提供焦点指示器实际上更加混乱,因为没有标准操作会起作用。但是,您也可能会争辩说,在没有焦点指示器的情况下登陆新模式也会令人困惑。
因此这是一个判断调用,如果我需要以编程方式聚焦它们,我总是会删除非交互元素上的聚焦指示器,尤其是在按 Enter 时可以提交表格等
有没有更好的方法来避免聚焦 non-interactive 元素的陷阱?
有一种方法可以解决所有这些问题,并且仍然有一个可见的焦点指示器。
我们将关闭按钮添加到模式的顶部(通常位于右上角)并使用 aria-describedby
指向模式标题。
<button id="dialog2_close_btn" aria-describedby="dialog2_label" onclick="closeDialog(this)">Close</button>
这将显示为“关闭验证结果”。然后我们只关注那个按钮而不是模态标题
<button onclick="openDialog('dialog2', this, 'dialog2_close_btn')">
Verify Address
</button>
我已经在下面的示例中进行了调整,如果您在打开第一个模式后单击“验证地址”,您会看到顶部有一个关闭按钮。
/**
* @namespace aria
*/
var aria = aria || {};
/**
* @desc
* Key code constants
*/
aria.KeyCode = {
BACKSPACE: 8,
TAB: 9,
RETURN: 13,
ESC: 27,
SPACE: 32,
PAGE_UP: 33,
PAGE_DOWN: 34,
END: 35,
HOME: 36,
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40,
DELETE: 46
};
aria.Utils = aria.Utils || {};
// Polyfill src https://developer.mozilla.org/en-US/docs/Web/API/Element/matches
aria.Utils.matches = function (element, selector) {
if (!Element.prototype.matches) {
Element.prototype.matches =
Element.prototype.matchesSelector ||
Element.prototype.mozMatchesSelector ||
Element.prototype.msMatchesSelector ||
Element.prototype.oMatchesSelector ||
Element.prototype.webkitMatchesSelector ||
function (s) {
var matches = element.parentNode.querySelectorAll(s);
var i = matches.length;
while (--i >= 0 && matches.item(i) !== this) {}
return i > -1;
};
}
return element.matches(selector);
};
aria.Utils.remove = function (item) {
if (item.remove && typeof item.remove === 'function') {
return item.remove();
}
if (item.parentNode &&
item.parentNode.removeChild &&
typeof item.parentNode.removeChild === 'function') {
return item.parentNode.removeChild(item);
}
return false;
};
aria.Utils.isFocusable = function (element) {
if (element.tabIndex > 0 || (element.tabIndex === 0 && element.getAttribute('tabIndex') !== null)) {
return true;
}
if (element.disabled) {
return false;
}
switch (element.nodeName) {
case 'A':
return !!element.href && element.rel != 'ignore';
case 'INPUT':
return element.type != 'hidden' && element.type != 'file';
case 'BUTTON':
case 'SELECT':
case 'TEXTAREA':
return true;
default:
return false;
}
};
aria.Utils.getAncestorBySelector = function (element, selector) {
if (!aria.Utils.matches(element, selector + ' ' + element.tagName)) {
// Element is not inside an element that matches selector
return null;
}
// Move up the DOM tree until a parent matching the selector is found
var currentNode = element;
var ancestor = null;
while (ancestor === null) {
if (aria.Utils.matches(currentNode.parentNode, selector)) {
ancestor = currentNode.parentNode;
}
else {
currentNode = currentNode.parentNode;
}
}
return ancestor;
};
aria.Utils.hasClass = function (element, className) {
return (new RegExp('(\s|^)' + className + '(\s|$)')).test(element.className);
};
aria.Utils.addClass = function (element, className) {
if (!aria.Utils.hasClass(element, className)) {
element.className += ' ' + className;
}
};
aria.Utils.removeClass = function (element, className) {
var classRegex = new RegExp('(\s|^)' + className + '(\s|$)');
element.className = element.className.replace(classRegex, ' ').trim();
};
aria.Utils.bindMethods = function (object /* , ...methodNames */) {
var methodNames = Array.prototype.slice.call(arguments, 1);
methodNames.forEach(function (method) {
object[method] = object[method].bind(object);
});
};
/*
* This content is licensed according to the W3C Software License at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
*/
var aria = aria || {};
aria.Utils = aria.Utils || {};
(function () {
/*
* When util functions move focus around, set this true so the focus listener
* can ignore the events.
*/
aria.Utils.IgnoreUtilFocusChanges = false;
aria.Utils.dialogOpenClass = 'has-dialog';
/**
* @desc Set focus on descendant nodes until the first focusable element is
* found.
* @param element
* DOM node for which to find the first focusable descendant.
* @returns
* true if a focusable element is found and focus is set.
*/
aria.Utils.focusFirstDescendant = function (element) {
for (var i = 0; i < element.childNodes.length; i++) {
var child = element.childNodes[i];
if (aria.Utils.attemptFocus(child) ||
aria.Utils.focusFirstDescendant(child)) {
return true;
}
}
return false;
}; // end focusFirstDescendant
/**
* @desc Find the last descendant node that is focusable.
* @param element
* DOM node for which to find the last focusable descendant.
* @returns
* true if a focusable element is found and focus is set.
*/
aria.Utils.focusLastDescendant = function (element) {
for (var i = element.childNodes.length - 1; i >= 0; i--) {
var child = element.childNodes[i];
if (aria.Utils.attemptFocus(child) ||
aria.Utils.focusLastDescendant(child)) {
return true;
}
}
return false;
}; // end focusLastDescendant
/**
* @desc Set Attempt to set focus on the current node.
* @param element
* The node to attempt to focus on.
* @returns
* true if element is focused.
*/
aria.Utils.attemptFocus = function (element) {
if (!aria.Utils.isFocusable(element)) {
return false;
}
aria.Utils.IgnoreUtilFocusChanges = true;
try {
element.focus();
}
catch (e) {
}
aria.Utils.IgnoreUtilFocusChanges = false;
return (document.activeElement === element);
}; // end attemptFocus
/* Modals can open modals. Keep track of them with this array. */
aria.OpenDialogList = aria.OpenDialogList || new Array(0);
/**
* @returns the last opened dialog (the current dialog)
*/
aria.getCurrentDialog = function () {
if (aria.OpenDialogList && aria.OpenDialogList.length) {
return aria.OpenDialogList[aria.OpenDialogList.length - 1];
}
};
aria.closeCurrentDialog = function () {
var currentDialog = aria.getCurrentDialog();
if (currentDialog) {
currentDialog.close();
return true;
}
return false;
};
aria.handleEscape = function (event) {
var key = event.which || event.keyCode;
if (key === aria.KeyCode.ESC && aria.closeCurrentDialog()) {
event.stopPropagation();
}
};
document.addEventListener('keyup', aria.handleEscape);
/**
* @constructor
* @desc Dialog object providing modal focus management.
*
* Assumptions: The element serving as the dialog container is present in the
* DOM and hidden. The dialog container has role='dialog'.
*
* @param dialogId
* The ID of the element serving as the dialog container.
* @param focusAfterClosed
* Either the DOM node or the ID of the DOM node to focus when the
* dialog closes.
* @param focusFirst
* Optional parameter containing either the DOM node or the ID of the
* DOM node to focus when the dialog opens. If not specified, the
* first focusable element in the dialog will receive focus.
*/
aria.Dialog = function (dialogId, focusAfterClosed, focusFirst) {
this.dialogNode = document.getElementById(dialogId);
if (this.dialogNode === null) {
throw new Error('No element found with id="' + dialogId + '".');
}
var validRoles = ['dialog', 'alertdialog'];
var isDialog = (this.dialogNode.getAttribute('role') || '')
.trim()
.split(/\s+/g)
.some(function (token) {
return validRoles.some(function (role) {
return token === role;
});
});
if (!isDialog) {
throw new Error(
'Dialog() requires a DOM element with ARIA role of dialog or alertdialog.');
}
// Wrap in an individual backdrop element if one doesn't exist
// Native <dialog> elements use the ::backdrop pseudo-element, which
// works similarly.
var backdropClass = 'dialog-backdrop';
if (this.dialogNode.parentNode.classList.contains(backdropClass)) {
this.backdropNode = this.dialogNode.parentNode;
}
else {
this.backdropNode = document.createElement('div');
this.backdropNode.className = backdropClass;
this.dialogNode.parentNode.insertBefore(this.backdropNode, this.dialogNode);
this.backdropNode.appendChild(this.dialogNode);
}
this.backdropNode.classList.add('active');
// Disable scroll on the body element
document.body.classList.add(aria.Utils.dialogOpenClass);
if (typeof focusAfterClosed === 'string') {
this.focusAfterClosed = document.getElementById(focusAfterClosed);
}
else if (typeof focusAfterClosed === 'object') {
this.focusAfterClosed = focusAfterClosed;
}
else {
throw new Error(
'the focusAfterClosed parameter is required for the aria.Dialog constructor.');
}
if (typeof focusFirst === 'string') {
this.focusFirst = document.getElementById(focusFirst);
}
else if (typeof focusFirst === 'object') {
this.focusFirst = focusFirst;
}
else {
this.focusFirst = null;
}
// Bracket the dialog node with two invisible, focusable nodes.
// While this dialog is open, we use these to make sure that focus never
// leaves the document even if dialogNode is the first or last node.
var preDiv = document.createElement('div');
this.preNode = this.dialogNode.parentNode.insertBefore(preDiv,
this.dialogNode);
this.preNode.tabIndex = 0;
var postDiv = document.createElement('div');
this.postNode = this.dialogNode.parentNode.insertBefore(postDiv,
this.dialogNode.nextSibling);
this.postNode.tabIndex = 0;
// If this modal is opening on top of one that is already open,
// get rid of the document focus listener of the open dialog.
if (aria.OpenDialogList.length > 0) {
aria.getCurrentDialog().removeListeners();
}
this.addListeners();
aria.OpenDialogList.push(this);
this.clearDialog();
this.dialogNode.className = 'default_dialog'; // make visible
if (this.focusFirst) {
this.focusFirst.focus();
}
else {
aria.Utils.focusFirstDescendant(this.dialogNode);
}
this.lastFocus = document.activeElement;
}; // end Dialog constructor
aria.Dialog.prototype.clearDialog = function () {
Array.prototype.map.call(
this.dialogNode.querySelectorAll('input'),
function (input) {
input.value = '';
}
);
};
/**
* @desc
* Hides the current top dialog,
* removes listeners of the top dialog,
* restore listeners of a parent dialog if one was open under the one that just closed,
* and sets focus on the element specified for focusAfterClosed.
*/
aria.Dialog.prototype.close = function () {
aria.OpenDialogList.pop();
this.removeListeners();
aria.Utils.remove(this.preNode);
aria.Utils.remove(this.postNode);
this.dialogNode.className = 'hidden';
this.backdropNode.classList.remove('active');
this.focusAfterClosed.focus();
// If a dialog was open underneath this one, restore its listeners.
if (aria.OpenDialogList.length > 0) {
aria.getCurrentDialog().addListeners();
}
else {
document.body.classList.remove(aria.Utils.dialogOpenClass);
}
}; // end close
/**
* @desc
* Hides the current dialog and replaces it with another.
*
* @param newDialogId
* ID of the dialog that will replace the currently open top dialog.
* @param newFocusAfterClosed
* Optional ID or DOM node specifying where to place focus when the new dialog closes.
* If not specified, focus will be placed on the element specified by the dialog being replaced.
* @param newFocusFirst
* Optional ID or DOM node specifying where to place focus in the new dialog when it opens.
* If not specified, the first focusable element will receive focus.
*/
aria.Dialog.prototype.replace = function (newDialogId, newFocusAfterClosed,
newFocusFirst) {
var closedDialog = aria.getCurrentDialog();
aria.OpenDialogList.pop();
this.removeListeners();
aria.Utils.remove(this.preNode);
aria.Utils.remove(this.postNode);
this.dialogNode.className = 'hidden';
this.backdropNode.classList.remove('active');
var focusAfterClosed = newFocusAfterClosed || this.focusAfterClosed;
var dialog = new aria.Dialog(newDialogId, focusAfterClosed, newFocusFirst);
}; // end replace
aria.Dialog.prototype.addListeners = function () {
document.addEventListener('focus', this.trapFocus, true);
}; // end addListeners
aria.Dialog.prototype.removeListeners = function () {
document.removeEventListener('focus', this.trapFocus, true);
}; // end removeListeners
aria.Dialog.prototype.trapFocus = function (event) {
if (aria.Utils.IgnoreUtilFocusChanges) {
return;
}
var currentDialog = aria.getCurrentDialog();
if (currentDialog.dialogNode.contains(event.target)) {
currentDialog.lastFocus = event.target;
}
else {
aria.Utils.focusFirstDescendant(currentDialog.dialogNode);
if (currentDialog.lastFocus == document.activeElement) {
aria.Utils.focusLastDescendant(currentDialog.dialogNode);
}
currentDialog.lastFocus = document.activeElement;
}
}; // end trapFocus
window.openDialog = function (dialogId, focusAfterClosed, focusFirst) {
var dialog = new aria.Dialog(dialogId, focusAfterClosed, focusFirst);
};
window.closeDialog = function (closeButton) {
var topDialog = aria.getCurrentDialog();
if (topDialog.dialogNode.contains(closeButton)) {
topDialog.close();
}
}; // end closeDialog
window.replaceDialog = function (newDialogId, newFocusAfterClosed,
newFocusFirst) {
var topDialog = aria.getCurrentDialog();
if (topDialog.dialogNode.contains(document.activeElement)) {
topDialog.replace(newDialogId, newFocusAfterClosed, newFocusFirst);
}
}; // end replaceDialog
}());
.hidden {
display: none;
}
[role="alertdialog"],
[role="dialog"] {
box-sizing: border-box;
padding: 15px;
border: 1px solid #000;
background-color: #fff;
min-height: 100vh;
}
@media screen and (min-width: 640px) {
[role="alertdialog"],
[role="dialog"] {
position: absolute;
top: 2rem;
left: 50vw; /* move to the middle of the screen (assumes relative parent is the body/viewport) */
transform: translateX(-50%); /* move backwards 50% of this element's width */
min-width: calc(640px - (15px * 2)); /* == breakpoint - left+right margin */
min-height: auto;
box-shadow: 0 19px 38px rgba(0, 0, 0, 0.12), 0 15px 12px rgba(0, 0, 0, 0.22);
}
}
.dialog_label {
text-align: center;
}
.dialog_form {
margin: 15px;
}
.dialog_form .label_text {
box-sizing: border-box;
padding-right: 0.5em;
display: inline-block;
font-size: 16px;
font-weight: bold;
width: 30%;
text-align: right;
}
.dialog_form .label_info {
box-sizing: border-box;
padding-right: 0.5em;
font-size: 12px;
width: 30%;
text-align: right;
display: inline-block;
}
.dialog_form_item {
margin: 10px 0;
font-size: 0;
}
.dialog_form_item .wide_input {
box-sizing: border-box;
max-width: 70%;
width: 27em;
}
.dialog_form_item .city_input {
box-sizing: border-box;
max-width: 70%;
width: 17em;
}
.dialog_form_item .state_input {
box-sizing: border-box;
max-width: 70%;
width: 15em;
}
.dialog_form_item .zip_input {
box-sizing: border-box;
max-width: 70%;
width: 9em;
}
.dialog_form_actions {
text-align: right;
padding: 0 20px 20px;
}
.dialog_close_button {
float: right;
position: absolute;
top: 10px;
left: 92%;
height: 25px;
}
.dialog_close_button img {
border: 0;
}
.dialog_desc {
padding: 10px 20px;
}
/* native <dialog> element uses the ::backdrop pseudo-element */
/* dialog::backdrop, */
.dialog-backdrop {
display: none;
position: fixed;
overflow-y: auto;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
@media screen and (min-width: 640px) {
.dialog-backdrop {
background: rgba(0, 0, 0, 0.3);
}
}
.dialog-backdrop.active {
display: block;
}
.no-scroll {
overflow-y: auto !important;
}
/* this is added to the body when a dialog is open */
.has-dialog {
overflow: hidden;
}
/* styling for alert-dialog example */
.notes {
display: block;
font-size: 1rem;
line-height: 1.3;
min-width: 400px;
max-width: 100%;
width: 33%;
}
.toast {
background-color: rgba(0, 0, 0, 0.9);
color: #fff;
padding: 1rem;
border: none;
border-radius: 0.25rem;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
position: fixed;
top: 1rem;
right: 1rem;
transform: translateY(-150%);
transition: transform 225ms cubic-bezier(0.4, 0, 0.2, 1);
}
.toast.active {
transform: translateY(0);
}
<button onclick="openDialog('dialog1', this)">
Add Delivery Address
</button>
<div role="dialog"
id="dialog1"
aria-labelledby="dialog1_label"
aria-modal="true"
class="hidden">
<h2 id="dialog1_label" class="dialog_label">
Add Delivery Address
</h2>
<div class="dialog_form">
<div class="dialog_form_item">
<label>
<span class="label_text">
Street:
</span>
<input type="text" class="wide_input">
</label>
</div>
<div class="dialog_form_item">
<label>
<span class="label_text">
City:
</span>
<input type="text" class="city_input">
</label>
</div>
<div class="dialog_form_item">
<label>
<span class="label_text">
State:
</span>
<input type="text" class="state_input">
</label>
</div>
<div class="dialog_form_item">
<label>
<span class="label_text">
Zip:
</span>
<input type="text" class="zip_input">
</label>
</div>
<div class="dialog_form_item">
<label for="special_instructions">
<span class="label_text">
Special instructions:
</span>
</label>
<input id="special_instructions"
type="text"
aria-describedby="special_instructions_desc"
class="wide_input">
<div class="label_info" id="special_instructions_desc">
For example, gate code or other information to help the driver find you
</div>
</div>
</div>
<div class="dialog_form_actions">
<button onclick="openDialog('dialog2', this, 'dialog2_close_btn')">
Verify Address
</button>
<button onclick="replaceDialog('dialog3', undefined, 'dialog3_close_btn')">
Add
</button>
<button onclick="closeDialog(this)">
Cancel
</button>
</div>
</div>
<!-- Second modal to open on top of the first modal -->
<div id="dialog2"
role="dialog"
aria-labelledby="dialog2_label"
aria-describedby="dialog2_desc"
aria-modal="true"
class="hidden">
<button id="dialog2_close_btn" aria-describedby="dialog2_label" onclick="closeDialog(this)">Close</button>
<h2 id="dialog2_label" class="dialog_label">
Verification Result
</h2>
<div id="dialog2_desc" class="dialog_desc">
<p tabindex="-1" id="dialog2_para1">
This is just a demonstration. If it were a real application, it would
provide a message telling whether the entered address is valid.
</p>
<p>
For demonstration purposes, this dialog has a lot of text. It demonstrates a
scenario where:
</p>
<ul>
<li>
The first interactive element, the help link, is at the bottom of the dialog.
</li>
<li>
If focus is placed on the first interactive element when the dialog opens, the
validation message may not be visible.
</li>
<li>
If the validation message is visible and the focus is on the help link, then
the focus may not be visible.
</li>
<li>
When the dialog opens, it is important that both:
<ul>
<li>
The beginning of the text is visible so users do not have to scroll back to
start reading.
</li>
<li>
The keyboard focus always remains visible.
</li>
</ul>
</li>
</ul>
<p>
There are several ways to resolve this issue:
</p>
<ul>
<li>
Place an interactive element at the top of the dialog, e.g., a button or link.
</li>
<li>
Make a static element focusable, e.g., the dialog title or the first block of
text.
</li>
</ul>
<p>
Please
<em>
DO NOT
</em>
make the element with role dialog focusable!
</p>
<ul>
<li>
The larger a focusable element is, the more difficult it is to visually
identify the location of focus, especially for users with a narrow field of view.
</li>
<li>
The dialog has a visual border, so creating a clear visual indicator of focus
when the entire dialog has focus is not very feasible.
</li>
<li>
Screen readers read the label and content of focusable elements. The dialog
contains its label and a lot of content! If a dialog like this one has focus, the
actual focus is difficult to comprehend.
</li>
</ul>
<p>
In this dialog, the first paragraph has
<code>
tabindex=
<q>
-1
</q>
</code>
. The first
paragraph is also contained inside the element that provides the dialog description, i.e., the element that is referenced
by
<code>
aria-describedby
</code>
. With some screen readers, this may have one negative
but relatively insignificant side effect when the dialog opens -- the first paragraph
may be announced twice. Nonetheless, making the first paragraph focusable and setting
the initial focus on it is the most broadly accessible option.
</p>
</div>
<div class="dialog_form_actions">
<a href="#" onclick="openDialog('dialog4', this)">
link to help
</a>
<button onclick="openDialog('dialog4', this)">
accepting an alternative form
</button>
<button onclick="closeDialog(this)">
Close
</button>
</div>
</div>
<!-- Dialog that replaces dialog 1. -->
<div id="dialog3"
role="dialog"
aria-labelledby="dialog3_label"
aria-describedby="dialog3_desc"
aria-modal="true"
class="hidden">
<h2 id="dialog3_label" class="dialog_label">
Address Added
</h2>
<p id="dialog3_desc" class="dialog_desc">
The address you provided has been added to your list of delivery addresses. It is ready
for immediate use. If you wish to remove it, you can do so from
<a href="#" onclick="openDialog('dialog4', this)">
your profile.
</a>
</p>
<div class="dialog_form_actions">
<button id="dialog3_close_btn" onclick="closeDialog(this)">
OK
</button>
</div>
</div>
<div id="dialog4"
role="dialog"
aria-labelledby="dialog4_label"
aria-describedby="dialog4_desc"
class="hidden"
aria-modal="true">
<h2 id="dialog4_label" class="dialog_label">
End of the Road!
</h2>
<p id="dialog4_desc" class="dialog_desc">
You activated a fake link or button that goes nowhere!
The link or button is present for demonstration purposes only.
</p>
<div class="dialog_form_actions">
<button id="dialog4_close_btn" onclick="closeDialog(this)">
Close
</button>
</div>
</div>