1324 lines
38 KiB
JavaScript
1324 lines
38 KiB
JavaScript
/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
|
|
const {
|
|
accessibleWalkerSpec,
|
|
} = require("resource://devtools/shared/specs/accessibility.js");
|
|
|
|
const {
|
|
simulation: { COLOR_TRANSFORMATION_MATRICES },
|
|
} = require("resource://devtools/server/actors/accessibility/constants.js");
|
|
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"AccessibleActor",
|
|
"resource://devtools/server/actors/accessibility/accessible.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
["CustomHighlighterActor"],
|
|
"resource://devtools/server/actors/highlighters.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"DevToolsUtils",
|
|
"resource://devtools/shared/DevToolsUtils.js"
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"events",
|
|
"resource://devtools/shared/event-emitter.js"
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
["isWindowIncluded", "isFrameWithChildTarget"],
|
|
"resource://devtools/shared/layout/utils.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"isXUL",
|
|
"resource://devtools/server/actors/highlighters/utils/markup.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
[
|
|
"isDefunct",
|
|
"loadSheetForBackgroundCalculation",
|
|
"removeSheetForBackgroundCalculation",
|
|
],
|
|
"resource://devtools/server/actors/utils/accessibility.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"accessibility",
|
|
"resource://devtools/shared/constants.js",
|
|
true
|
|
);
|
|
|
|
const lazy = {};
|
|
ChromeUtils.defineESModuleGetters(
|
|
lazy,
|
|
{
|
|
TYPES: "resource://devtools/shared/highlighters.mjs",
|
|
},
|
|
{ global: "contextual" }
|
|
);
|
|
|
|
const kStateHover = 0x00000004; // ElementState::HOVER
|
|
|
|
const {
|
|
EVENT_TEXT_CHANGED,
|
|
EVENT_TEXT_INSERTED,
|
|
EVENT_TEXT_REMOVED,
|
|
EVENT_ACCELERATOR_CHANGE,
|
|
EVENT_ACTION_CHANGE,
|
|
EVENT_DEFACTION_CHANGE,
|
|
EVENT_DESCRIPTION_CHANGE,
|
|
EVENT_DOCUMENT_ATTRIBUTES_CHANGED,
|
|
EVENT_HIDE,
|
|
EVENT_NAME_CHANGE,
|
|
EVENT_OBJECT_ATTRIBUTE_CHANGED,
|
|
EVENT_REORDER,
|
|
EVENT_STATE_CHANGE,
|
|
EVENT_TEXT_ATTRIBUTE_CHANGED,
|
|
EVENT_VALUE_CHANGE,
|
|
} = Ci.nsIAccessibleEvent;
|
|
|
|
// TODO: We do not need this once bug 1422913 is fixed. We also would not need
|
|
// to fire a name change event for an accessible that has an updated subtree and
|
|
// that has its name calculated from the said subtree.
|
|
const NAME_FROM_SUBTREE_RULE_ROLES = new Set([
|
|
Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWN,
|
|
Ci.nsIAccessibleRole.ROLE_BUTTONMENU,
|
|
Ci.nsIAccessibleRole.ROLE_CELL,
|
|
Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
|
|
Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM,
|
|
Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION,
|
|
Ci.nsIAccessibleRole.ROLE_COLUMNHEADER,
|
|
Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION,
|
|
Ci.nsIAccessibleRole.ROLE_DEFINITION,
|
|
Ci.nsIAccessibleRole.ROLE_GRID_CELL,
|
|
Ci.nsIAccessibleRole.ROLE_HEADING,
|
|
Ci.nsIAccessibleRole.ROLE_KEY,
|
|
Ci.nsIAccessibleRole.ROLE_LABEL,
|
|
Ci.nsIAccessibleRole.ROLE_LINK,
|
|
Ci.nsIAccessibleRole.ROLE_LISTITEM,
|
|
Ci.nsIAccessibleRole.ROLE_MATHML_IDENTIFIER,
|
|
Ci.nsIAccessibleRole.ROLE_MATHML_NUMBER,
|
|
Ci.nsIAccessibleRole.ROLE_MATHML_OPERATOR,
|
|
Ci.nsIAccessibleRole.ROLE_MATHML_TEXT,
|
|
Ci.nsIAccessibleRole.ROLE_MATHML_STRING_LITERAL,
|
|
Ci.nsIAccessibleRole.ROLE_MATHML_GLYPH,
|
|
Ci.nsIAccessibleRole.ROLE_MENUITEM,
|
|
Ci.nsIAccessibleRole.ROLE_OPTION,
|
|
Ci.nsIAccessibleRole.ROLE_OUTLINEITEM,
|
|
Ci.nsIAccessibleRole.ROLE_PAGETAB,
|
|
Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM,
|
|
Ci.nsIAccessibleRole.ROLE_PUSHBUTTON,
|
|
Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
|
|
Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM,
|
|
Ci.nsIAccessibleRole.ROLE_RICH_OPTION,
|
|
Ci.nsIAccessibleRole.ROLE_ROW,
|
|
Ci.nsIAccessibleRole.ROLE_ROWHEADER,
|
|
Ci.nsIAccessibleRole.ROLE_SUMMARY,
|
|
Ci.nsIAccessibleRole.ROLE_SWITCH,
|
|
Ci.nsIAccessibleRole.ROLE_TERM,
|
|
Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON,
|
|
Ci.nsIAccessibleRole.ROLE_TOOLTIP,
|
|
]);
|
|
|
|
const IS_OSX = Services.appinfo.OS === "Darwin";
|
|
|
|
const {
|
|
SCORES: { BEST_PRACTICES, FAIL, WARNING },
|
|
} = accessibility;
|
|
|
|
/**
|
|
* Helper function that determines if nsIAccessible object is in stale state. When an
|
|
* object is stale it means its subtree is not up to date.
|
|
*
|
|
* @param {nsIAccessible} accessible
|
|
* object to be tested.
|
|
* @return {Boolean}
|
|
* True if accessible object is stale, false otherwise.
|
|
*/
|
|
function isStale(accessible) {
|
|
const extraState = {};
|
|
accessible.getState({}, extraState);
|
|
// extraState.value is a bitmask. We are applying bitwise AND to mask out
|
|
// irrelevant states.
|
|
return !!(extraState.value & Ci.nsIAccessibleStates.EXT_STATE_STALE);
|
|
}
|
|
|
|
/**
|
|
* Get accessibility audit starting with the passed accessible object as a root.
|
|
*
|
|
* @param {Object} acc
|
|
* AccessibileActor to be used as the root for the audit.
|
|
* @param {Object} options
|
|
* Options for running audit, may include:
|
|
* - types: Array of audit types to be performed during audit.
|
|
* @param {Map} report
|
|
* An accumulator map to be used to store audit information.
|
|
* @param {Object} progress
|
|
* An audit project object that is used to track the progress of the
|
|
* audit and send progress "audit-event" events to the client.
|
|
*/
|
|
function getAudit(acc, options, report, progress) {
|
|
if (acc.isDefunct) {
|
|
return;
|
|
}
|
|
|
|
// Audit returns a promise, save the actual value in the report.
|
|
report.set(
|
|
acc,
|
|
acc.audit(options).then(result => {
|
|
report.set(acc, result);
|
|
progress.increment();
|
|
})
|
|
);
|
|
|
|
for (const child of acc.children()) {
|
|
getAudit(child, options, report, progress);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A helper class that is used to track audit progress and send progress events
|
|
* to the client.
|
|
*/
|
|
class AuditProgress {
|
|
constructor(walker) {
|
|
this.completed = 0;
|
|
this.percentage = 0;
|
|
this.walker = walker;
|
|
}
|
|
|
|
setTotal(size) {
|
|
this.size = size;
|
|
}
|
|
|
|
notify() {
|
|
this.walker.emit("audit-event", {
|
|
type: "progress",
|
|
progress: {
|
|
total: this.size,
|
|
percentage: this.percentage,
|
|
completed: this.completed,
|
|
},
|
|
});
|
|
}
|
|
|
|
increment() {
|
|
this.completed++;
|
|
const { completed, size } = this;
|
|
if (!size) {
|
|
return;
|
|
}
|
|
|
|
const percentage = Math.round((completed / size) * 100);
|
|
if (percentage > this.percentage) {
|
|
this.percentage = percentage;
|
|
this.notify();
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
this.walker = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The AccessibleWalkerActor stores a cache of AccessibleActors that represent
|
|
* accessible objects in a given document.
|
|
*
|
|
* It is also responsible for implicitely initializing and shutting down
|
|
* accessibility engine by storing a reference to the XPCOM accessibility
|
|
* service.
|
|
*/
|
|
class AccessibleWalkerActor extends Actor {
|
|
constructor(conn, targetActor) {
|
|
super(conn, accessibleWalkerSpec);
|
|
this.targetActor = targetActor;
|
|
this.refMap = new Map();
|
|
this._loadedSheets = new WeakMap();
|
|
this.setA11yServiceGetter();
|
|
this.onPick = this.onPick.bind(this);
|
|
this.onHovered = this.onHovered.bind(this);
|
|
this._preventContentEvent = this._preventContentEvent.bind(this);
|
|
this.onKey = this.onKey.bind(this);
|
|
this.onFocusIn = this.onFocusIn.bind(this);
|
|
this.onFocusOut = this.onFocusOut.bind(this);
|
|
this.onHighlighterEvent = this.onHighlighterEvent.bind(this);
|
|
}
|
|
|
|
get highlighter() {
|
|
if (!this._highlighter) {
|
|
this._highlighter = new CustomHighlighterActor(
|
|
this,
|
|
lazy.TYPES.ACCESSIBLE
|
|
);
|
|
|
|
this.manage(this._highlighter);
|
|
this._highlighter.on("highlighter-event", this.onHighlighterEvent);
|
|
}
|
|
|
|
return this._highlighter;
|
|
}
|
|
|
|
get tabbingOrderHighlighter() {
|
|
if (!this._tabbingOrderHighlighter) {
|
|
this._tabbingOrderHighlighter = new CustomHighlighterActor(
|
|
this,
|
|
lazy.TYPES.TABBING_ORDER
|
|
);
|
|
|
|
this.manage(this._tabbingOrderHighlighter);
|
|
}
|
|
|
|
return this._tabbingOrderHighlighter;
|
|
}
|
|
|
|
setA11yServiceGetter() {
|
|
DevToolsUtils.defineLazyGetter(this, "a11yService", () => {
|
|
Services.obs.addObserver(this, "accessible-event");
|
|
return Cc["@mozilla.org/accessibilityService;1"].getService(
|
|
Ci.nsIAccessibilityService
|
|
);
|
|
});
|
|
}
|
|
|
|
get rootWin() {
|
|
return this.targetActor && this.targetActor.window;
|
|
}
|
|
|
|
get rootDoc() {
|
|
return this.targetActor && this.targetActor.window.document;
|
|
}
|
|
|
|
get isXUL() {
|
|
return isXUL(this.rootWin);
|
|
}
|
|
|
|
get colorMatrix() {
|
|
if (!this.targetActor.docShell) {
|
|
return null;
|
|
}
|
|
|
|
const colorMatrix = this.targetActor.docShell.getColorMatrix();
|
|
if (
|
|
colorMatrix.length === 0 ||
|
|
colorMatrix === COLOR_TRANSFORMATION_MATRICES.NONE
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return colorMatrix;
|
|
}
|
|
|
|
reset() {
|
|
try {
|
|
Services.obs.removeObserver(this, "accessible-event");
|
|
} catch (e) {
|
|
// Accessible event observer might not have been initialized if a11y
|
|
// service was never used.
|
|
}
|
|
|
|
this.cancelPick();
|
|
|
|
// Clean up accessible actors cache.
|
|
this.clearRefs();
|
|
|
|
this._childrenPromise = null;
|
|
delete this.a11yService;
|
|
this.setA11yServiceGetter();
|
|
}
|
|
|
|
/**
|
|
* Remove existing cache (of accessible actors) from tree.
|
|
*/
|
|
clearRefs() {
|
|
for (const actor of this.refMap.values()) {
|
|
actor.destroy();
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
super.destroy();
|
|
|
|
this.reset();
|
|
|
|
if (this._highlighter) {
|
|
this._highlighter.off("highlighter-event", this.onHighlighterEvent);
|
|
this._highlighter = null;
|
|
}
|
|
|
|
if (this._tabbingOrderHighlighter) {
|
|
this._tabbingOrderHighlighter = null;
|
|
}
|
|
|
|
this.targetActor = null;
|
|
this.refMap = null;
|
|
}
|
|
|
|
getRef(rawAccessible) {
|
|
return this.refMap.get(rawAccessible);
|
|
}
|
|
|
|
addRef(rawAccessible) {
|
|
let actor = this.refMap.get(rawAccessible);
|
|
if (actor) {
|
|
return actor;
|
|
}
|
|
|
|
actor = new AccessibleActor(this, rawAccessible);
|
|
// Add the accessible actor as a child of this accessible walker actor,
|
|
// assigning it an actorID.
|
|
this.manage(actor);
|
|
this.refMap.set(rawAccessible, actor);
|
|
|
|
return actor;
|
|
}
|
|
|
|
/**
|
|
* Clean up accessible actors cache for a given accessible's subtree.
|
|
*
|
|
* @param {null|nsIAccessible} rawAccessible
|
|
*/
|
|
purgeSubtree(rawAccessible) {
|
|
if (!rawAccessible) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
for (
|
|
let child = rawAccessible.firstChild;
|
|
child;
|
|
child = child.nextSibling
|
|
) {
|
|
this.purgeSubtree(child);
|
|
}
|
|
} catch (e) {
|
|
// rawAccessible or its descendants are defunct.
|
|
}
|
|
|
|
const actor = this.getRef(rawAccessible);
|
|
if (actor) {
|
|
actor.destroy();
|
|
}
|
|
}
|
|
|
|
unmanage(actor) {
|
|
if (actor instanceof AccessibleActor) {
|
|
this.refMap.delete(actor.rawAccessible);
|
|
}
|
|
Actor.prototype.unmanage.call(this, actor);
|
|
}
|
|
|
|
/**
|
|
* A helper method. Accessibility walker is assumed to have only 1 child which
|
|
* is the top level document.
|
|
*/
|
|
async children() {
|
|
if (this._childrenPromise) {
|
|
return this._childrenPromise;
|
|
}
|
|
|
|
this._childrenPromise = Promise.all([this.getDocument()]);
|
|
const children = await this._childrenPromise;
|
|
this._childrenPromise = null;
|
|
return children;
|
|
}
|
|
|
|
/**
|
|
* A promise for a root document accessible actor that only resolves when its
|
|
* corresponding document accessible object is fully loaded.
|
|
*
|
|
* @return {Promise}
|
|
*/
|
|
getDocument() {
|
|
if (!this.rootDoc || !this.rootDoc.documentElement) {
|
|
return this.once("document-ready").then(docAcc => this.addRef(docAcc));
|
|
}
|
|
|
|
if (this.isXUL) {
|
|
const doc = this.addRef(this.getRawAccessibleFor(this.rootDoc));
|
|
return Promise.resolve(doc);
|
|
}
|
|
|
|
const doc = this.getRawAccessibleFor(this.rootDoc);
|
|
|
|
// For non-visible same-process iframes we don't get a document and
|
|
// won't get a "document-ready" event.
|
|
if (!doc && !this.rootWin.windowGlobalChild.isProcessRoot) {
|
|
// We can ignore such document as there won't be anything to audit in them.
|
|
return null;
|
|
}
|
|
|
|
if (!doc || isStale(doc)) {
|
|
return this.once("document-ready").then(docAcc => this.addRef(docAcc));
|
|
}
|
|
|
|
return Promise.resolve(this.addRef(doc));
|
|
}
|
|
|
|
/**
|
|
* Get an accessible actor for a domnode actor.
|
|
* @param {Object} domNode
|
|
* domnode actor for which accessible actor is being created.
|
|
* @return {Promse}
|
|
* A promise that resolves when accessible actor is created for a
|
|
* domnode actor.
|
|
*/
|
|
getAccessibleFor(domNode) {
|
|
// We need to make sure that the document is loaded processed by a11y first.
|
|
return this.getDocument().then(() => {
|
|
const rawAccessible = this.getRawAccessibleFor(domNode.rawNode);
|
|
// Not all DOM nodes have corresponding accessible objects. It's usually
|
|
// the case where there is no semantics or relevance to the accessibility
|
|
// client.
|
|
if (!rawAccessible) {
|
|
return null;
|
|
}
|
|
|
|
return this.addRef(rawAccessible);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get a raw accessible object for a raw node.
|
|
* @param {DOMNode} rawNode
|
|
* Raw node for which accessible object is being retrieved.
|
|
* @return {nsIAccessible}
|
|
* Accessible object for a given DOMNode.
|
|
*/
|
|
getRawAccessibleFor(rawNode) {
|
|
// Accessible can only be retrieved iff accessibility service is enabled.
|
|
if (!Services.appinfo.accessibilityEnabled) {
|
|
return null;
|
|
}
|
|
|
|
return this.a11yService.getAccessibleFor(rawNode);
|
|
}
|
|
|
|
async getAncestry(accessible) {
|
|
if (!accessible || accessible.indexInParent === -1) {
|
|
return [];
|
|
}
|
|
const doc = await this.getDocument();
|
|
if (!doc) {
|
|
return [];
|
|
}
|
|
|
|
const ancestry = [];
|
|
if (accessible === doc) {
|
|
return ancestry;
|
|
}
|
|
|
|
try {
|
|
let parent = accessible;
|
|
while (parent && (parent = parent.parentAcc) && parent != doc) {
|
|
ancestry.push(parent);
|
|
}
|
|
ancestry.push(doc);
|
|
} catch (error) {
|
|
throw new Error(`Failed to get ancestor for ${accessible}: ${error}`);
|
|
}
|
|
|
|
return ancestry.map(parent => ({
|
|
accessible: parent,
|
|
children: parent.children(),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Run accessibility audit and return relevant ancestries for AccessibleActors
|
|
* that have non-empty audit checks.
|
|
*
|
|
* @param {Object} options
|
|
* Options for running audit, may include:
|
|
* - types: Array of audit types to be performed during audit.
|
|
*
|
|
* @return {Promise}
|
|
* A promise that resolves when the audit is complete and all relevant
|
|
* ancestries are calculated.
|
|
*/
|
|
async audit(options) {
|
|
const doc = await this.getDocument();
|
|
if (!doc) {
|
|
return [];
|
|
}
|
|
|
|
const report = new Map();
|
|
this._auditProgress = new AuditProgress(this);
|
|
getAudit(doc, options, report, this._auditProgress);
|
|
this._auditProgress.setTotal(report.size);
|
|
await Promise.all(report.values());
|
|
|
|
const ancestries = [];
|
|
for (const [acc, audit] of report.entries()) {
|
|
// Filter out audits that have no failing checks.
|
|
if (
|
|
audit &&
|
|
Object.values(audit).some(
|
|
check =>
|
|
check != null &&
|
|
!check.error &&
|
|
[BEST_PRACTICES, FAIL, WARNING].includes(check.score)
|
|
)
|
|
) {
|
|
ancestries.push(this.getAncestry(acc));
|
|
}
|
|
}
|
|
|
|
return Promise.all(ancestries);
|
|
}
|
|
|
|
/**
|
|
* Start accessibility audit. The result of this function will not be an audit
|
|
* report. Instead, an "audit-event" event will be fired when the audit is
|
|
* completed or fails.
|
|
*
|
|
* @param {Object} options
|
|
* Options for running audit, may include:
|
|
* - types: Array of audit types to be performed during audit.
|
|
*/
|
|
startAudit(options) {
|
|
// Audit is already running, wait for the "audit-event" event.
|
|
if (this._auditing) {
|
|
return;
|
|
}
|
|
|
|
this._auditing = this.audit(options)
|
|
// We do not want to block on audit request, instead fire "audit-event"
|
|
// event when internal audit is finished or failed.
|
|
.then(ancestries =>
|
|
this.emit("audit-event", {
|
|
type: "completed",
|
|
ancestries,
|
|
})
|
|
)
|
|
.catch(() => this.emit("audit-event", { type: "error" }))
|
|
.finally(() => {
|
|
this._auditing = null;
|
|
if (this._auditProgress) {
|
|
this._auditProgress.destroy();
|
|
this._auditProgress = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
onHighlighterEvent(data) {
|
|
this.emit("highlighter-event", data);
|
|
}
|
|
|
|
/**
|
|
* Accessible event observer function.
|
|
*
|
|
* @param {Ci.nsIAccessibleEvent} subject
|
|
* accessible event object.
|
|
*/
|
|
// eslint-disable-next-line complexity
|
|
observe(subject) {
|
|
const event = subject.QueryInterface(Ci.nsIAccessibleEvent);
|
|
const rawAccessible = event.accessible;
|
|
const accessible = this.getRef(rawAccessible);
|
|
|
|
if (rawAccessible instanceof Ci.nsIAccessibleDocument && !accessible) {
|
|
const rootDocAcc = this.getRawAccessibleFor(this.rootDoc);
|
|
if (rawAccessible === rootDocAcc && !isStale(rawAccessible)) {
|
|
this.clearRefs();
|
|
// If it's a top level document notify listeners about the document
|
|
// being ready.
|
|
events.emit(this, "document-ready", rawAccessible);
|
|
}
|
|
}
|
|
|
|
switch (event.eventType) {
|
|
case EVENT_STATE_CHANGE:
|
|
const { state, isEnabled } = event.QueryInterface(
|
|
Ci.nsIAccessibleStateChangeEvent
|
|
);
|
|
const isBusy = state & Ci.nsIAccessibleStates.STATE_BUSY;
|
|
if (accessible) {
|
|
// Only propagate state change events for active accessibles.
|
|
if (isBusy && isEnabled) {
|
|
if (rawAccessible instanceof Ci.nsIAccessibleDocument) {
|
|
// Remove existing cache from tree.
|
|
this.clearRefs();
|
|
}
|
|
return;
|
|
}
|
|
events.emit(accessible, "states-change", accessible.states);
|
|
}
|
|
|
|
break;
|
|
case EVENT_NAME_CHANGE:
|
|
if (accessible) {
|
|
events.emit(
|
|
accessible,
|
|
"name-change",
|
|
rawAccessible.name,
|
|
event.DOMNode == this.rootDoc
|
|
? undefined
|
|
: this.getRef(rawAccessible.parent)
|
|
);
|
|
}
|
|
break;
|
|
case EVENT_VALUE_CHANGE:
|
|
if (accessible) {
|
|
events.emit(accessible, "value-change", rawAccessible.value);
|
|
}
|
|
break;
|
|
case EVENT_DESCRIPTION_CHANGE:
|
|
if (accessible) {
|
|
events.emit(
|
|
accessible,
|
|
"description-change",
|
|
rawAccessible.description
|
|
);
|
|
}
|
|
break;
|
|
case EVENT_REORDER:
|
|
if (accessible) {
|
|
accessible
|
|
.children()
|
|
.forEach(child =>
|
|
events.emit(child, "index-in-parent-change", child.indexInParent)
|
|
);
|
|
events.emit(accessible, "reorder", rawAccessible.childCount);
|
|
}
|
|
break;
|
|
case EVENT_HIDE:
|
|
if (event.DOMNode == this.rootDoc) {
|
|
this.clearRefs();
|
|
} else {
|
|
this.purgeSubtree(rawAccessible);
|
|
}
|
|
break;
|
|
case EVENT_DEFACTION_CHANGE:
|
|
case EVENT_ACTION_CHANGE:
|
|
if (accessible) {
|
|
events.emit(accessible, "actions-change", accessible.actions);
|
|
}
|
|
break;
|
|
case EVENT_TEXT_CHANGED:
|
|
case EVENT_TEXT_INSERTED:
|
|
case EVENT_TEXT_REMOVED:
|
|
if (accessible) {
|
|
events.emit(accessible, "text-change");
|
|
if (NAME_FROM_SUBTREE_RULE_ROLES.has(rawAccessible.role)) {
|
|
events.emit(
|
|
accessible,
|
|
"name-change",
|
|
rawAccessible.name,
|
|
event.DOMNode == this.rootDoc
|
|
? undefined
|
|
: this.getRef(rawAccessible.parent)
|
|
);
|
|
}
|
|
}
|
|
break;
|
|
case EVENT_DOCUMENT_ATTRIBUTES_CHANGED:
|
|
case EVENT_OBJECT_ATTRIBUTE_CHANGED:
|
|
case EVENT_TEXT_ATTRIBUTE_CHANGED:
|
|
if (accessible) {
|
|
events.emit(accessible, "attributes-change", accessible.attributes);
|
|
}
|
|
break;
|
|
// EVENT_ACCELERATOR_CHANGE is currently not fired by gecko accessibility.
|
|
case EVENT_ACCELERATOR_CHANGE:
|
|
if (accessible) {
|
|
events.emit(
|
|
accessible,
|
|
"shortcut-change",
|
|
accessible.keyboardShortcut
|
|
);
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure that nothing interferes with the audit for an accessible object
|
|
* (CSS, overlays) by load accessibility highlighter style sheet used for
|
|
* preventing transitions and applying transparency when calculating colour
|
|
* contrast as well as temporarily hiding accessible highlighter overlay.
|
|
* @param {Object} win
|
|
* Window where highlighting happens.
|
|
*/
|
|
async clearStyles(win) {
|
|
const requests = this._loadedSheets.get(win);
|
|
if (requests != null) {
|
|
this._loadedSheets.set(win, requests + 1);
|
|
return;
|
|
}
|
|
|
|
// Disable potential mouse driven transitions (This is important because accessibility
|
|
// highlighter temporarily modifies text color related CSS properties. In case where
|
|
// there are transitions that affect them, there might be unexpected side effects when
|
|
// taking a snapshot for contrast measurement).
|
|
loadSheetForBackgroundCalculation(win);
|
|
this._loadedSheets.set(win, 1);
|
|
await this.hideHighlighter();
|
|
}
|
|
|
|
/**
|
|
* Restore CSS and overlays that could've interfered with the audit for an
|
|
* accessible object by unloading accessibility highlighter style sheet used
|
|
* for preventing transitions and applying transparency when calculating
|
|
* colour contrast and potentially restoring accessible highlighter overlay.
|
|
* @param {Object} win
|
|
* Window where highlighting was happenning.
|
|
*/
|
|
async restoreStyles(win) {
|
|
const requests = this._loadedSheets.get(win);
|
|
if (!requests) {
|
|
return;
|
|
}
|
|
|
|
if (requests > 1) {
|
|
this._loadedSheets.set(win, requests - 1);
|
|
return;
|
|
}
|
|
|
|
await this.showHighlighter();
|
|
removeSheetForBackgroundCalculation(win);
|
|
this._loadedSheets.delete(win);
|
|
}
|
|
|
|
async hideHighlighter() {
|
|
// TODO: Fix this workaround that temporarily removes higlighter bounds
|
|
// overlay that can interfere with the contrast ratio calculation.
|
|
if (this._highlighter) {
|
|
const highlighter = this._highlighter.instance;
|
|
await highlighter.isReady;
|
|
highlighter.hideAccessibleBounds();
|
|
}
|
|
}
|
|
|
|
async showHighlighter() {
|
|
// TODO: Fix this workaround that temporarily removes higlighter bounds
|
|
// overlay that can interfere with the contrast ratio calculation.
|
|
if (this._highlighter) {
|
|
const highlighter = this._highlighter.instance;
|
|
await highlighter.isReady;
|
|
highlighter.showAccessibleBounds();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Public method used to show an accessible object highlighter on the client
|
|
* side.
|
|
*
|
|
* @param {Object} accessible
|
|
* AccessibleActor to be highlighted.
|
|
* @param {Object} options
|
|
* Object used for passing options. Available options:
|
|
* - duration {Number}
|
|
* Duration of time that the highlighter should be shown.
|
|
* @return {Boolean}
|
|
* True if highlighter shows the accessible object.
|
|
*/
|
|
async highlightAccessible(accessible, options = {}) {
|
|
this.unhighlight();
|
|
// Do not highlight if accessible is dead.
|
|
if (!accessible || accessible.isDefunct || accessible.indexInParent < 0) {
|
|
return false;
|
|
}
|
|
|
|
this._highlightingAccessible = accessible;
|
|
const { bounds } = accessible;
|
|
if (!bounds) {
|
|
return false;
|
|
}
|
|
|
|
const { DOMNode: rawNode } = accessible.rawAccessible;
|
|
const audit = await accessible.audit();
|
|
if (this._highlightingAccessible !== accessible) {
|
|
return false;
|
|
}
|
|
|
|
const { name, role } = accessible;
|
|
const { highlighter } = this;
|
|
await highlighter.instance.isReady;
|
|
if (this._highlightingAccessible !== accessible) {
|
|
return false;
|
|
}
|
|
|
|
const shown = highlighter.show(
|
|
{ rawNode },
|
|
{ ...options, ...bounds, name, role, audit, isXUL: this.isXUL }
|
|
);
|
|
this._highlightingAccessible = null;
|
|
|
|
return shown;
|
|
}
|
|
|
|
/**
|
|
* Public method used to hide an accessible object highlighter on the client
|
|
* side.
|
|
*/
|
|
unhighlight() {
|
|
if (!this._highlighter) {
|
|
return;
|
|
}
|
|
|
|
this.highlighter.hide();
|
|
this._highlightingAccessible = null;
|
|
}
|
|
|
|
/**
|
|
* Picking state that indicates if picking is currently enabled and, if so,
|
|
* what the current and hovered accessible objects are.
|
|
*/
|
|
_isPicking = false;
|
|
_currentAccessible = null;
|
|
|
|
/**
|
|
* Check is event handling is allowed.
|
|
*/
|
|
_isEventAllowed({ view }) {
|
|
return this.rootWin.isChromeWindow || isWindowIncluded(this.rootWin, view);
|
|
}
|
|
|
|
/**
|
|
* Check if the DOM event received when picking shold be ignored.
|
|
* @param {Event} event
|
|
*/
|
|
_ignoreEventWhenPicking(event) {
|
|
return (
|
|
!this._isPicking ||
|
|
// If the DOM event is about a remote frame, only the WalkerActor for that
|
|
// remote frame target should emit RDP events (hovered/picked/...). And
|
|
// all other WalkerActor for intermediate iframe and top level document
|
|
// targets should stay silent.
|
|
isFrameWithChildTarget(
|
|
this.targetActor,
|
|
event.originalTarget || event.target
|
|
)
|
|
);
|
|
}
|
|
|
|
_preventContentEvent(event) {
|
|
if (this._ignoreEventWhenPicking(event)) {
|
|
return;
|
|
}
|
|
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
|
|
const target = event.originalTarget || event.target;
|
|
if (target !== this._currentTarget) {
|
|
this._resetStateAndReleaseTarget();
|
|
this._currentTarget = target;
|
|
// We use InspectorUtils to save the original hover content state of the target
|
|
// element (that includes its hover state). In order to not trigger any visual
|
|
// changes to the element that depend on its hover state we remove the state while
|
|
// the element is the most current target of the highlighter.
|
|
//
|
|
// TODO: This logic can be removed if/when we can use elementsAtPoint API for
|
|
// determining topmost DOMNode that corresponds to specific coordinates. We would
|
|
// then be able to use a highlighter overlay that would prevent all pointer events
|
|
// to content but still render highlighter for the node/element correctly.
|
|
this._currentTargetHoverState =
|
|
InspectorUtils.getContentState(target) & kStateHover;
|
|
InspectorUtils.removeContentState(target, kStateHover);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Click event handler for when picking is enabled.
|
|
*
|
|
* @param {Object} event
|
|
* Current click event.
|
|
*/
|
|
onPick(event) {
|
|
if (this._ignoreEventWhenPicking(event)) {
|
|
return;
|
|
}
|
|
|
|
this._preventContentEvent(event);
|
|
if (!this._isEventAllowed(event)) {
|
|
return;
|
|
}
|
|
|
|
// If shift is pressed, this is only a preview click, send the event to
|
|
// the client, but don't stop picking.
|
|
if (event.shiftKey) {
|
|
if (!this._currentAccessible) {
|
|
this._currentAccessible = this._findAndAttachAccessible(event);
|
|
}
|
|
events.emit(this, "picker-accessible-previewed", this._currentAccessible);
|
|
return;
|
|
}
|
|
|
|
this._unsetPickerEnvironment();
|
|
this._isPicking = false;
|
|
if (!this._currentAccessible) {
|
|
this._currentAccessible = this._findAndAttachAccessible(event);
|
|
}
|
|
events.emit(this, "picker-accessible-picked", this._currentAccessible);
|
|
}
|
|
|
|
/**
|
|
* Hover event handler for when picking is enabled.
|
|
*
|
|
* @param {Object} event
|
|
* Current hover event.
|
|
*/
|
|
async onHovered(event) {
|
|
if (this._ignoreEventWhenPicking(event)) {
|
|
return;
|
|
}
|
|
|
|
this._preventContentEvent(event);
|
|
if (!this._isEventAllowed(event)) {
|
|
return;
|
|
}
|
|
|
|
const accessible = this._findAndAttachAccessible(event);
|
|
if (!accessible || this._currentAccessible === accessible) {
|
|
return;
|
|
}
|
|
|
|
this._currentAccessible = accessible;
|
|
// Highlight current accessible and by the time we are done, if accessible that was
|
|
// highlighted is not current any more (user moved the mouse to a new node) highlight
|
|
// the most current accessible again.
|
|
const shown = await this.highlightAccessible(accessible);
|
|
if (this._isPicking && shown && accessible === this._currentAccessible) {
|
|
events.emit(this, "picker-accessible-hovered", accessible);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Keyboard event handler for when picking is enabled.
|
|
*
|
|
* @param {Object} event
|
|
* Current keyboard event.
|
|
*/
|
|
onKey(event) {
|
|
if (!this._currentAccessible || this._ignoreEventWhenPicking(event)) {
|
|
return;
|
|
}
|
|
|
|
this._preventContentEvent(event);
|
|
if (!this._isEventAllowed(event)) {
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* KEY: Action/scope
|
|
* ENTER/CARRIAGE_RETURN: Picks current accessible
|
|
* ESC/CTRL+SHIFT+C: Cancels picker
|
|
*/
|
|
switch (event.keyCode) {
|
|
// Select the element.
|
|
case event.DOM_VK_RETURN:
|
|
this.onPick(event);
|
|
break;
|
|
// Cancel pick mode.
|
|
case event.DOM_VK_ESCAPE:
|
|
this.cancelPick();
|
|
events.emit(this, "picker-accessible-canceled");
|
|
break;
|
|
case event.DOM_VK_C:
|
|
if (
|
|
(IS_OSX && event.metaKey && event.altKey) ||
|
|
(!IS_OSX && event.ctrlKey && event.shiftKey)
|
|
) {
|
|
this.cancelPick();
|
|
events.emit(this, "picker-accessible-canceled");
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Picker method that starts picker content listeners.
|
|
*/
|
|
pick() {
|
|
if (!this._isPicking) {
|
|
this._isPicking = true;
|
|
this._setPickerEnvironment();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This pick method also focuses the highlighter's target window.
|
|
*/
|
|
pickAndFocus() {
|
|
this.pick();
|
|
this.rootWin.focus();
|
|
}
|
|
|
|
attachAccessible(rawAccessible, accessibleDocument) {
|
|
// If raw accessible object is defunct or detached, no need to cache it and
|
|
// its ancestry.
|
|
if (
|
|
!rawAccessible ||
|
|
isDefunct(rawAccessible) ||
|
|
rawAccessible.indexInParent < 0
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const accessible = this.addRef(rawAccessible);
|
|
// There is a chance that ancestry lookup can fail if the accessible is in
|
|
// the detached subtree. At that point the root accessible object would be
|
|
// defunct and accessing it via parent property will throw.
|
|
try {
|
|
let parent = accessible;
|
|
while (parent && parent.rawAccessible != accessibleDocument) {
|
|
parent = parent.parentAcc;
|
|
}
|
|
} catch (error) {
|
|
throw new Error(`Failed to get ancestor for ${accessible}: ${error}`);
|
|
}
|
|
|
|
return accessible;
|
|
}
|
|
|
|
/**
|
|
* Find deepest accessible object that corresponds to the screen coordinates of the
|
|
* mouse pointer and attach it to the AccessibilityWalker tree.
|
|
*
|
|
* @param {Object} event
|
|
* Correspoinding content event.
|
|
* @return {null|Object}
|
|
* Accessible object, if available, that corresponds to a DOM node.
|
|
*/
|
|
_findAndAttachAccessible(event) {
|
|
const target = event.originalTarget || event.target;
|
|
const win = target.ownerGlobal;
|
|
// This event might be inside a sub-document, so don't use this.rootDoc.
|
|
const docAcc = this.getRawAccessibleFor(win.document);
|
|
// If the target is inside a pop-up widget, we need to query the pop-up
|
|
// Accessible, not the DocAccessible. The DocAccessible can't hit test
|
|
// inside pop-ups.
|
|
const popup = win.isChromeWindow ? target.closest("panel") : null;
|
|
const containerAcc = popup ? this.getRawAccessibleFor(popup) : docAcc;
|
|
const { devicePixelRatio } = this.rootWin;
|
|
const rawAccessible = containerAcc.getDeepestChildAtPointInProcess(
|
|
event.screenX * devicePixelRatio,
|
|
event.screenY * devicePixelRatio
|
|
);
|
|
return this.attachAccessible(rawAccessible, docAcc);
|
|
}
|
|
|
|
/**
|
|
* Start picker content listeners.
|
|
*/
|
|
_setPickerEnvironment() {
|
|
const target = this.targetActor.chromeEventHandler;
|
|
target.addEventListener("mousemove", this.onHovered, true);
|
|
target.addEventListener("click", this.onPick, true);
|
|
target.addEventListener("mousedown", this._preventContentEvent, true);
|
|
target.addEventListener("mouseup", this._preventContentEvent, true);
|
|
target.addEventListener("mouseover", this._preventContentEvent, true);
|
|
target.addEventListener("mouseout", this._preventContentEvent, true);
|
|
target.addEventListener("mouseleave", this._preventContentEvent, true);
|
|
target.addEventListener("mouseenter", this._preventContentEvent, true);
|
|
target.addEventListener("dblclick", this._preventContentEvent, true);
|
|
target.addEventListener("keydown", this.onKey, true);
|
|
target.addEventListener("keyup", this._preventContentEvent, true);
|
|
}
|
|
|
|
/**
|
|
* If content is still alive, stop picker content listeners, reset the hover state for
|
|
* last target element.
|
|
*/
|
|
_unsetPickerEnvironment() {
|
|
const target = this.targetActor.chromeEventHandler;
|
|
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
target.removeEventListener("mousemove", this.onHovered, true);
|
|
target.removeEventListener("click", this.onPick, true);
|
|
target.removeEventListener("mousedown", this._preventContentEvent, true);
|
|
target.removeEventListener("mouseup", this._preventContentEvent, true);
|
|
target.removeEventListener("mouseover", this._preventContentEvent, true);
|
|
target.removeEventListener("mouseout", this._preventContentEvent, true);
|
|
target.removeEventListener("mouseleave", this._preventContentEvent, true);
|
|
target.removeEventListener("mouseenter", this._preventContentEvent, true);
|
|
target.removeEventListener("dblclick", this._preventContentEvent, true);
|
|
target.removeEventListener("keydown", this.onKey, true);
|
|
target.removeEventListener("keyup", this._preventContentEvent, true);
|
|
|
|
this._resetStateAndReleaseTarget();
|
|
}
|
|
|
|
/**
|
|
* When using accessibility highlighter, we keep track of the most current event pointer
|
|
* event target. In order to update or release the target, we need to make sure we set
|
|
* the content state (using InspectorUtils) to its original value.
|
|
*
|
|
* TODO: This logic can be removed if/when we can use elementsAtPoint API for
|
|
* determining topmost DOMNode that corresponds to specific coordinates. We would then
|
|
* be able to use a highlighter overlay that would prevent all pointer events to content
|
|
* but still render highlighter for the node/element correctly.
|
|
*/
|
|
_resetStateAndReleaseTarget() {
|
|
if (!this._currentTarget) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (this._currentTargetHoverState) {
|
|
InspectorUtils.setContentState(this._currentTarget, kStateHover);
|
|
}
|
|
} catch (e) {
|
|
// DOMNode is already dead.
|
|
}
|
|
|
|
this._currentTarget = null;
|
|
this._currentTargetState = null;
|
|
}
|
|
|
|
/**
|
|
* Cacncel picker pick. Remvoe all content listeners and hide the highlighter.
|
|
*/
|
|
cancelPick() {
|
|
this.unhighlight();
|
|
|
|
if (this._isPicking) {
|
|
this._unsetPickerEnvironment();
|
|
this._isPicking = false;
|
|
this._currentAccessible = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Indicates that the tabbing order current active element (focused) is being
|
|
* tracked.
|
|
*/
|
|
_isTrackingTabbingOrderFocus = false;
|
|
|
|
/**
|
|
* Current focused element in the tabbing order.
|
|
*/
|
|
_currentFocusedTabbingOrder = null;
|
|
|
|
/**
|
|
* Focusin event handler for when interacting with tabbing order overlay.
|
|
*
|
|
* @param {Object} event
|
|
* Most recent focusin event.
|
|
*/
|
|
async onFocusIn(event) {
|
|
if (!this._isTrackingTabbingOrderFocus) {
|
|
return;
|
|
}
|
|
|
|
const target = event.originalTarget || event.target;
|
|
if (target === this._currentFocusedTabbingOrder) {
|
|
return;
|
|
}
|
|
|
|
this._currentFocusedTabbingOrder = target;
|
|
this.tabbingOrderHighlighter._highlighter.updateFocus({
|
|
node: target,
|
|
focused: true,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Focusout event handler for when interacting with tabbing order overlay.
|
|
*
|
|
* @param {Object} event
|
|
* Most recent focusout event.
|
|
*/
|
|
async onFocusOut(event) {
|
|
if (
|
|
!this._isTrackingTabbingOrderFocus ||
|
|
!this._currentFocusedTabbingOrder
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const target = event.originalTarget || event.target;
|
|
// Sanity check.
|
|
if (target !== this._currentFocusedTabbingOrder) {
|
|
console.warn(
|
|
`focusout target: ${target} does not match current focused element in tabbing order: ${this._currentFocusedTabbingOrder}`
|
|
);
|
|
}
|
|
|
|
this.tabbingOrderHighlighter._highlighter.updateFocus({
|
|
node: this._currentFocusedTabbingOrder,
|
|
focused: false,
|
|
});
|
|
this._currentFocusedTabbingOrder = null;
|
|
}
|
|
|
|
/**
|
|
* Show tabbing order overlay for a given target.
|
|
*
|
|
* @param {Object} elm
|
|
* domnode actor to be used as the starting point for generating the
|
|
* tabbing order.
|
|
* @param {Number} index
|
|
* Starting index for the tabbing order.
|
|
*
|
|
* @return {JSON}
|
|
* Tabbing order information for the last element in the tabbing
|
|
* order. It includes a ContentDOMReference for the node and a tabbing
|
|
* index. If we are at the end of the tabbing order for the top level
|
|
* content document, the ContentDOMReference will be null. If focus
|
|
* manager discovered a remote IFRAME, then the ContentDOMReference
|
|
* references the IFRAME itself.
|
|
*/
|
|
showTabbingOrder(elm, index) {
|
|
// Start track focus related events (only once). `showTabbingOrder` will be
|
|
// called multiple times for a given target if it contains other remote
|
|
// targets.
|
|
if (!this._isTrackingTabbingOrderFocus) {
|
|
this._isTrackingTabbingOrderFocus = true;
|
|
const target = this.targetActor.chromeEventHandler;
|
|
target.addEventListener("focusin", this.onFocusIn, true);
|
|
target.addEventListener("focusout", this.onFocusOut, true);
|
|
}
|
|
|
|
return this.tabbingOrderHighlighter.show(elm, { index });
|
|
}
|
|
|
|
/**
|
|
* Hide tabbing order overlay for a given target.
|
|
*/
|
|
hideTabbingOrder() {
|
|
if (!this._tabbingOrderHighlighter) {
|
|
return;
|
|
}
|
|
|
|
this.tabbingOrderHighlighter.hide();
|
|
if (!this._isTrackingTabbingOrderFocus) {
|
|
return;
|
|
}
|
|
|
|
this._isTrackingTabbingOrderFocus = false;
|
|
this._currentFocusedTabbingOrder = null;
|
|
const target = this.targetActor.chromeEventHandler;
|
|
if (target) {
|
|
target.removeEventListener("focusin", this.onFocusIn, true);
|
|
target.removeEventListener("focusout", this.onFocusOut, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
exports.AccessibleWalkerActor = AccessibleWalkerActor;
|