辅助功能:聚焦时删除轮廓<p>



为了明确,我正在寻找最容易访问和最佳设计的选项。所以我不会删除按钮和链接上的轮廓以及任何有用的地方。但我正在努力让对话正常工作。我用这个例子来说明。在这个例子中,第一个<p>在打开对话框时被聚焦。如示例中所述:

在这个对话框中,第一段的tabindex=-1。第一段也包含在提供对话框描述的元素中,即由aria- descripbedby引用的元素。对于某些屏幕阅读器,当对话框打开时,这可能会产生一个负面但相对无关紧要的副作用——第一段可能会被宣布两次。尽管如此,让第一段具有可聚焦性,并将焦点设置在第一段上,这是最容易获得的选择。

但是这一段当然有一个提纲,因为它是焦点。我想知道元素是否具有tabindex=-1,并且不是可以与之交互的元素。这个部分的轮廓可以去掉吗?

简短回答

如果你愿意,你可以安全地移除tabindex="-1"段落上的焦点指示器。不过,有一种更好的方法来处理嵌套模态。

长回答

WCAG有点"羊毛";(不够具体)在这里他们描述事物的方式,但焦点指标的指导是"控制";或者"交互式元素">

标准的主要描述也是

成功标准2.4.7 Focus Visible (Level AA): Any键盘可操作用户界面具有键盘焦点指示器可见的操作模式。

因为你不能与段落交互(它不是键盘可操作的),所以很容易争辩说,提供焦点指示器实际上更令人困惑,因为没有标准的操作将起作用。然而,你也可以争辩说,在没有焦点指示符的新模式中着陆也会令人困惑。

因此,这是一个判断呼叫,我总是去删除焦点指示器上的元素是非交互式的,如果我需要以编程的方式聚焦他们,特别是如果按输入可以提交表单等。

是否有更好的方法来处理这个问题,避免专注于非交互式元素的陷阱?

有一种方法可以解决所有这些问题,并且仍然有一个可见的焦点指示器。

我们将关闭按钮添加到模态的顶部(通常位于右上方),并使用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>

最新更新