summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/accessibility/audit/text-label.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/accessibility/audit/text-label.js')
-rw-r--r--devtools/server/actors/accessibility/audit/text-label.js438
1 files changed, 438 insertions, 0 deletions
diff --git a/devtools/server/actors/accessibility/audit/text-label.js b/devtools/server/actors/accessibility/audit/text-label.js
new file mode 100644
index 0000000000..8570c5cce8
--- /dev/null
+++ b/devtools/server/actors/accessibility/audit/text-label.js
@@ -0,0 +1,438 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ accessibility: {
+ AUDIT_TYPE: { TEXT_LABEL },
+ ISSUE_TYPE,
+ SCORES: { BEST_PRACTICES, FAIL, WARNING },
+ },
+} = require("resource://devtools/shared/constants.js");
+
+const {
+ AREA_NO_NAME_FROM_ALT,
+ DIALOG_NO_NAME,
+ DOCUMENT_NO_TITLE,
+ EMBED_NO_NAME,
+ FIGURE_NO_NAME,
+ FORM_FIELDSET_NO_NAME,
+ FORM_FIELDSET_NO_NAME_FROM_LEGEND,
+ FORM_NO_NAME,
+ FORM_NO_VISIBLE_NAME,
+ FORM_OPTGROUP_NO_NAME_FROM_LABEL,
+ FRAME_NO_NAME,
+ HEADING_NO_CONTENT,
+ HEADING_NO_NAME,
+ IFRAME_NO_NAME_FROM_TITLE,
+ IMAGE_NO_NAME,
+ INTERACTIVE_NO_NAME,
+ MATHML_GLYPH_NO_NAME,
+ TOOLBAR_NO_NAME,
+} = ISSUE_TYPE[TEXT_LABEL];
+
+/**
+ * Check if the accessible is visible to the assistive technology.
+ * @param {nsIAccessible} accessible
+ * Accessible object to be tested for visibility.
+ *
+ * @returns {Boolean}
+ * True if accessible object is visible to assistive technology.
+ */
+function isVisible(accessible) {
+ const state = {};
+ accessible.getState(state, {});
+ return !(state.value & Ci.nsIAccessibleStates.STATE_INVISIBLE);
+}
+
+/**
+ * Get related accessible objects that are targets of labelled by relation e.g.
+ * labels.
+ * @param {nsIAccessible} accessible
+ * Accessible objects to get labels for.
+ *
+ * @returns {Array}
+ * A list of accessible objects that are labels for a given accessible.
+ */
+function getLabels(accessible) {
+ const relation = accessible.getRelationByType(
+ Ci.nsIAccessibleRelation.RELATION_LABELLED_BY
+ );
+ return [...relation.getTargets().enumerate(Ci.nsIAccessible)];
+}
+
+/**
+ * Get a trimmed name of the accessible object.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible objects to get a name for.
+ *
+ * @returns {null|String}
+ * Trimmed name of the accessible object if available.
+ */
+function getAccessibleName(accessible) {
+ return accessible.name && accessible.name.trim();
+}
+
+/**
+ * A text label rule for accessible objects that must have a non empty
+ * accessible name.
+ *
+ * @returns {null|Object}
+ * Failure audit report if accessible object has no or empty name, null
+ * otherwise.
+ */
+const mustHaveNonEmptyNameRule = function (issue, accessible) {
+ const name = getAccessibleName(accessible);
+ return name ? null : { score: FAIL, issue };
+};
+
+/**
+ * A text label rule for accessible objects that should have a non empty
+ * accessible name as a best practice.
+ *
+ * @returns {null|Object}
+ * Best practices audit report if accessible object has no or empty
+ * name, null otherwise.
+ */
+const shouldHaveNonEmptyNameRule = function (issue, accessible) {
+ const name = getAccessibleName(accessible);
+ return name ? null : { score: BEST_PRACTICES, issue };
+};
+
+/**
+ * A text label rule for accessible objects that can be activated via user
+ * action and must have a non-empty name.
+ *
+ * @returns {null|Object}
+ * Failure audit report if interactive accessible object has no or
+ * empty name, null otherwise.
+ */
+const interactiveRule = mustHaveNonEmptyNameRule.bind(
+ null,
+ INTERACTIVE_NO_NAME
+);
+
+/**
+ * A text label rule for accessible objects that correspond to dialogs and thus
+ * should have a non-empty name.
+ *
+ * @returns {null|Object}
+ * Best practices audit report if dialog accessible object has no or
+ * empty name, null otherwise.
+ */
+const dialogRule = shouldHaveNonEmptyNameRule.bind(null, DIALOG_NO_NAME);
+
+/**
+ * A text label rule for accessible objects that provide visual information
+ * (images, canvas, etc.) and must have a defined name (that can be empty, e.g.
+ * "").
+ *
+ * @returns {null|Object}
+ * Failure audit report if interactive accessible object has no name,
+ * null otherwise.
+ */
+const imageRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ return name != null ? null : { score: FAIL, issue: IMAGE_NO_NAME };
+};
+
+/**
+ * A text label rule for accessible objects that correspond to form elements.
+ * These objects must have a non-empty name and must have a visible label.
+ *
+ * @returns {null|Object}
+ * Failure audit report if form element accessible object has no name,
+ * warning if the name does not come from a visible label, null
+ * otherwise.
+ */
+const formRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ if (!name) {
+ return { score: FAIL, issue: FORM_NO_NAME };
+ }
+
+ const labels = getLabels(accessible);
+ const hasNameFromVisibleLabel = labels.some(label => isVisible(label));
+
+ return hasNameFromVisibleLabel
+ ? null
+ : { score: WARNING, issue: FORM_NO_VISIBLE_NAME };
+};
+
+/**
+ * A text label rule for elements that map to ROLE_GROUPING:
+ * * <OPTGROUP> must have a non-empty name and must be provided via the
+ * "label" attribute.
+ * * <FIELDSET> must have a non-empty name and must be provided via the
+ * corresponding <LEGEND> element.
+ *
+ * @returns {null|Object}
+ * Failure audit report if form grouping accessible object has no name,
+ * or has a name that is not derived from a required location, null
+ * otherwise.
+ */
+const formGroupingRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ const { DOMNode } = accessible;
+
+ switch (DOMNode.nodeName) {
+ case "OPTGROUP":
+ return name && DOMNode.label && DOMNode.label.trim() === name
+ ? null
+ : {
+ score: FAIL,
+ issue: FORM_OPTGROUP_NO_NAME_FROM_LABEL,
+ };
+ case "FIELDSET":
+ if (!name) {
+ return { score: FAIL, issue: FORM_FIELDSET_NO_NAME };
+ }
+
+ const labels = getLabels(accessible);
+ const hasNameFromLegend = labels.some(
+ label =>
+ label.DOMNode.nodeName === "LEGEND" &&
+ label.name &&
+ label.name.trim() === name &&
+ isVisible(label)
+ );
+
+ return hasNameFromLegend
+ ? null
+ : {
+ score: WARNING,
+ issue: FORM_FIELDSET_NO_NAME_FROM_LEGEND,
+ };
+ default:
+ return null;
+ }
+};
+
+/**
+ * A text label rule for elements that map to ROLE_TEXT_CONTAINER:
+ * * <METER> mapps to ROLE_TEXT_CONTAINER and must have a name provided via
+ * the visible label. Note: Will only work when bug 559770 is resolved (right
+ * now, unlabelled meters are not mapped to an accessible object).
+ *
+ * @returns {null|Object}
+ * Failure audit report depending on requirements for dialogs or form
+ * meter element, null otherwise.
+ */
+const textContainerRule = function (accessible) {
+ const { DOMNode } = accessible;
+
+ switch (DOMNode.nodeName) {
+ case "DIALOG":
+ return dialogRule(accessible);
+ case "METER":
+ return formRule(accessible);
+ default:
+ return null;
+ }
+};
+
+/**
+ * A text label rule for elements that map to ROLE_INTERNAL_FRAME:
+ * * <OBJECT> maps to ROLE_INTERNAL_FRAME. Check the type attribute and whether
+ * it includes "image/" (e.g. image/jpeg, image/png, image/gif). If so, audit
+ * it the same way other image roles are audited.
+ * * <EMBED> maps to ROLE_INTERNAL_FRAME and must have a non-empty name.
+ * * <FRAME> and <IFRAME> map to ROLE_INTERNAL_FRAME and must have a non-empty
+ * title attribute.
+ *
+ * @returns {null|Object}
+ * Failure audit report if the internal frame accessible object name is
+ * not provided or if it is not derived from a required location, null
+ * otherwise.
+ */
+const internalFrameRule = function (accessible) {
+ const { DOMNode } = accessible;
+ switch (DOMNode.nodeName) {
+ case "FRAME":
+ return mustHaveNonEmptyNameRule(FRAME_NO_NAME, accessible);
+ case "IFRAME":
+ const name = getAccessibleName(accessible);
+ const title = DOMNode.title && DOMNode.title.trim();
+
+ return title && title === name
+ ? null
+ : { score: FAIL, issue: IFRAME_NO_NAME_FROM_TITLE };
+ case "OBJECT": {
+ const type = DOMNode.getAttribute("type");
+ if (!type || !type.startsWith("image/")) {
+ return null;
+ }
+
+ return imageRule(accessible);
+ }
+ case "EMBED": {
+ const type = DOMNode.getAttribute("type");
+ if (!type || !type.startsWith("image/")) {
+ return mustHaveNonEmptyNameRule(EMBED_NO_NAME, accessible);
+ }
+ return imageRule(accessible);
+ }
+ default:
+ return null;
+ }
+};
+
+/**
+ * A text label rule for accessible objects that represent documents and should
+ * have title element provided.
+ *
+ * @returns {null|Object}
+ * Failure audit report if document accessible object has no or empty
+ * title, null otherwise.
+ */
+const documentRule = function (accessible) {
+ const title = accessible.DOMNode.title && accessible.DOMNode.title.trim();
+ return title ? null : { score: FAIL, issue: DOCUMENT_NO_TITLE };
+};
+
+/**
+ * A text label rule for accessible objects that correspond to headings and thus
+ * must be non-empty.
+ *
+ * @returns {null|Object}
+ * Failure audit report if heading accessible object has no or
+ * empty name or if its text content is empty, null otherwise.
+ */
+const headingRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ if (!name) {
+ return { score: FAIL, issue: HEADING_NO_NAME };
+ }
+
+ const content =
+ accessible.DOMNode.textContent && accessible.DOMNode.textContent.trim();
+ return content ? null : { score: WARNING, issue: HEADING_NO_CONTENT };
+};
+
+/**
+ * A text label rule for accessible objects that represent toolbars and must
+ * have a non-empty name if there is more than one toolbar present.
+ *
+ * @returns {null|Object}
+ * Failure audit report if toolbar accessible object is not the only
+ * toolbar in the document and has no or empty title, null otherwise.
+ */
+const toolbarRule = function (accessible) {
+ const toolbars =
+ accessible.DOMNode.ownerDocument.querySelectorAll(`[role="toolbar"]`);
+
+ return toolbars.length > 1
+ ? mustHaveNonEmptyNameRule(TOOLBAR_NO_NAME, accessible)
+ : null;
+};
+
+/**
+ * A text label rule for accessible objects that represent link (anchors, areas)
+ * and must have a non-empty name.
+ *
+ * @returns {null|Object}
+ * Failure audit report if link accessible object has no or empty name,
+ * or in case when it's an <area> element with href attribute the name
+ * is not specified by an alt attribute, null otherwise.
+ */
+const linkRule = function (accessible) {
+ const { DOMNode } = accessible;
+ if (DOMNode.nodeName === "AREA" && DOMNode.hasAttribute("href")) {
+ const alt = DOMNode.getAttribute("alt");
+ const name = getAccessibleName(accessible);
+ return alt && alt.trim() === name
+ ? null
+ : { score: FAIL, issue: AREA_NO_NAME_FROM_ALT };
+ }
+
+ return interactiveRule(accessible);
+};
+
+/**
+ * A text label rule for accessible objects that are used to display
+ * non-standard symbols where existing Unicode characters are not available and
+ * must have a non-empty name.
+ *
+ * @returns {null|Object}
+ * Failure audit report if mglyph accessible object has no or empty
+ * name, and no or empty alt attribute, null otherwise.
+ */
+const mathmlGlyphRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ if (name) {
+ return null;
+ }
+
+ const { DOMNode } = accessible;
+ const alt = DOMNode.getAttribute("alt");
+ return alt && alt.trim()
+ ? null
+ : { score: FAIL, issue: MATHML_GLYPH_NO_NAME };
+};
+
+const RULES = {
+ [Ci.nsIAccessibleRole.ROLE_BUTTONMENU]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_CANVAS]: imageRule,
+ [Ci.nsIAccessibleRole.ROLE_CHECKBUTTON]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_COLUMNHEADER]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_COMBOBOX]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_DIAGRAM]: imageRule,
+ [Ci.nsIAccessibleRole.ROLE_DIALOG]: dialogRule,
+ [Ci.nsIAccessibleRole.ROLE_DOCUMENT]: documentRule,
+ [Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_ENTRY]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_FIGURE]: shouldHaveNonEmptyNameRule.bind(
+ null,
+ FIGURE_NO_NAME
+ ),
+ [Ci.nsIAccessibleRole.ROLE_GRAPHIC]: imageRule,
+ [Ci.nsIAccessibleRole.ROLE_GROUPING]: formGroupingRule,
+ [Ci.nsIAccessibleRole.ROLE_HEADING]: headingRule,
+ [Ci.nsIAccessibleRole.ROLE_IMAGE_MAP]: imageRule,
+ [Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME]: internalFrameRule,
+ [Ci.nsIAccessibleRole.ROLE_LINK]: linkRule,
+ [Ci.nsIAccessibleRole.ROLE_LISTBOX]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_MATHML_GLYPH]: mathmlGlyphRule,
+ [Ci.nsIAccessibleRole.ROLE_MENUITEM]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_OPTION]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_OUTLINEITEM]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_PAGETAB]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_PROGRESSBAR]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_PUSHBUTTON]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_RADIOBUTTON]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_ROWHEADER]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_SLIDER]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_SPINBUTTON]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_SWITCH]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_TEXT_CONTAINER]: textContainerRule,
+ [Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_TOOLBAR]: toolbarRule,
+};
+
+/**
+ * Perform audit for WCAG 1.1 criteria related to providing alternative text
+ * depending on the type of content.
+ * @param {nsIAccessible} accessible
+ * Accessible object to be tested to determine if it requires and has
+ * an appropriate text alternative.
+ *
+ * @return {null|Object}
+ * Null if accessible does not need or has the right text alternative,
+ * audit data otherwise. This data is used in the accessibility panel
+ * for its audit filters, audit badges, sidebar checks section and
+ * highlighter.
+ */
+function auditTextLabel(accessible) {
+ const rule = RULES[accessible.role];
+ return rule ? rule(accessible) : null;
+}
+
+module.exports.auditTextLabel = auditTextLabel;