--- a/devtools/client/inspector/markup/utils.js
+++ b/devtools/client/inspector/markup/utils.js
@@ -108,28 +108,14 @@ function parseAttributeValues(attr, doc)
// Prevents InvalidCharacterError - "String contains an invalid
// character".
}
}
return attributes;
}
-/**
- * Truncate the string and add ellipsis to the middle of the string.
- */
-function truncateString(str, maxLength) {
- if (!str || str.length <= maxLength) {
- return str;
- }
-
- return str.substring(0, Math.ceil(maxLength / 2)) +
- "…" +
- str.substring(str.length - Math.floor(maxLength / 2));
-}
-
module.exports = {
flashElementOn,
flashElementOff,
getAutocompleteMaxWidth,
parseAttributeValues,
- truncateString,
};
--- a/devtools/client/inspector/markup/views/element-editor.js
+++ b/devtools/client/inspector/markup/views/element-editor.js
@@ -6,18 +6,18 @@
const Services = require("Services");
const TextEditor = require("devtools/client/inspector/markup/views/text-editor");
const {
getAutocompleteMaxWidth,
flashElementOn,
flashElementOff,
parseAttributeValues,
- truncateString,
} = require("devtools/client/inspector/markup/utils");
+const { truncateString } = require("devtools/shared/inspector/utils");
const {editableField, InplaceEditor} =
require("devtools/client/shared/inplace-editor");
const {parseAttribute} =
require("devtools/client/shared/node-attribute-parser");
const {getCssProperties} = require("devtools/shared/fronts/css-properties");
// Global tooltip inspector
const {LocalizationHelper} = require("devtools/shared/l10n");
--- a/devtools/server/actors/accessibility.js
+++ b/devtools/server/actors/accessibility.js
@@ -343,16 +343,22 @@ const AccessibleActor = ActorClassWithSp
x = x.value;
y = y.value;
w = w.value;
h = h.value;
} catch (e) {
return null;
}
+ // Check if accessible bounds are invalid.
+ const left = x, right = x + w, top = y, bottom = y + h;
+ if (left === right || top === bottom) {
+ return null;
+ }
+
return { x, y, w, h };
},
form() {
return {
actor: this.actorID,
role: this.role,
name: this.name,
@@ -381,19 +387,23 @@ const AccessibleWalkerActor = ActorClass
initialize(conn, targetActor) {
Actor.prototype.initialize.call(this, conn);
this.targetActor = targetActor;
this.refMap = new Map();
this.setA11yServiceGetter();
this.onPick = this.onPick.bind(this);
this.onHovered = this.onHovered.bind(this);
this.onKey = this.onKey.bind(this);
+ this.onHighlighterEvent = this.onHighlighterEvent.bind(this);
this.highlighter = CustomHighlighterActor(this, isXUL(this.rootWin) ?
"XULWindowAccessibleHighlighter" : "AccessibleHighlighter");
+
+ this.manage(this.highlighter);
+ this.highlighter.on("highlighter-event", this.onHighlighterEvent);
},
setA11yServiceGetter() {
DevToolsUtils.defineLazyGetter(this, "a11yService", () => {
Services.obs.addObserver(this, "accessible-event");
return Cc["@mozilla.org/accessibilityService;1"].getService(
Ci.nsIAccessibilityService);
});
@@ -436,17 +446,17 @@ const AccessibleWalkerActor = ActorClass
this.setA11yServiceGetter();
},
destroy() {
Actor.prototype.destroy.call(this);
this.reset();
- this.highlighter.destroy();
+ this.highlighter.off("highlighter-event", this.onHighlighterEvent);
this.highlighter = null;
this.targetActor = null;
this.refMap = null;
},
getRef(rawAccessible) {
return this.refMap.get(rawAccessible);
@@ -580,16 +590,20 @@ const AccessibleWalkerActor = ActorClass
} catch (error) {
throw new Error(`Failed to get ancestor for ${accessible}: ${error}`);
}
return ancestry.map(parent => (
{ accessible: parent, children: parent.children() }));
},
+ onHighlighterEvent: function(data) {
+ this.emit("highlighter-event", data);
+ },
+
/**
* Accessible event observer function.
*
* @param {nsIAccessibleEvent} subject
* accessible event object.
*/
observe(subject) {
const event = subject.QueryInterface(nsIAccessibleEvent);
@@ -695,23 +709,23 @@ const AccessibleWalkerActor = ActorClass
* @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.
*/
highlightAccessible(accessible, options = {}) {
- const bounds = accessible.bounds;
+ const { bounds, name, role } = accessible;
if (!bounds) {
return false;
}
return this.highlighter.show({ rawNode: accessible.rawAccessible.DOMNode },
- { ...options, ...bounds });
+ { ...options, ...bounds, name, role });
},
/**
* Public method used to hide an accessible object highlighter on the client
* side.
*/
unhighlight() {
this.highlighter.hide();
@@ -788,19 +802,23 @@ const AccessibleWalkerActor = ActorClass
}
const accessible = await this._findAndAttachAccessible(event);
if (!accessible) {
return;
}
if (this._currentAccessible !== accessible) {
- const { bounds } = accessible;
+ const { bounds, role, name } = accessible;
if (bounds) {
- this.highlighter.show({ rawNode: event.originalTarget || event.target }, bounds);
+ this.highlighter.show({ rawNode: event.originalTarget || event.target }, {
+ ...bounds,
+ role,
+ name
+ });
}
events.emit(this, "picker-accessible-hovered", accessible);
this._currentAccessible = accessible;
}
},
/**
--- a/devtools/server/actors/highlighters.css
+++ b/devtools/server/actors/highlighters.css
@@ -30,16 +30,17 @@
--highlighter-guide-color: #08c;
--highlighter-content-color: #87ceeb;
--highlighter-bubble-text-color: hsl(216, 33%, 97%);
--highlighter-bubble-background-color: hsl(214, 13%, 24%);
--highlighter-bubble-border-color: rgba(255, 255, 255, 0.2);
--highlighter-bubble-arrow-size: 8px;
--highlighter-font-family: message-box;
--highlighter-font-size: 11px;
+ --highlighter-infobar-color: hsl(210, 30%, 85%);
--highlighter-marker-color: #000;
}
/**
* Highlighters are asbolute positioned in the page by default.
* A single highlighter can have fixed position in its css class if needed (see below the
* eye dropper or rulers highlighter, for example); but if it has to handle the
* document's scrolling (as rulers does), it would lag a bit behind due the APZ (Async
@@ -205,17 +206,17 @@
:-moz-native-anonymous .box-model-infobar-classes,
:-moz-native-anonymous .box-model-infobar-pseudo-classes {
color: hsl(200, 74%, 57%);
overflow: hidden;
text-overflow: ellipsis;
}
:-moz-native-anonymous [class$=infobar-dimensions] {
- color: hsl(210, 30%, 85%);
+ color: var(--highlighter-infobar-color);
border-inline-start: 1px solid #5a6169;
margin-inline-start: 6px;
padding-inline-start: 6px;
}
/* CSS Grid Highlighter */
:-moz-native-anonymous .css-grid-canvas {
@@ -238,17 +239,17 @@
:-moz-native-anonymous .css-grid-area-infobar-name,
:-moz-native-anonymous .css-grid-cell-infobar-position,
:-moz-native-anonymous .css-grid-line-infobar-number {
color: hsl(285, 100%, 75%);
}
:-moz-native-anonymous .css-grid-line-infobar-names:not(:empty) {
- color: hsl(210, 30%, 85%);
+ color: var(--highlighter-infobar-color);
border-inline-start: 1px solid #5a6169;
margin-inline-start: 6px;
padding-inline-start: 6px;
}
/* CSS Transform Highlighter */
:-moz-native-anonymous .css-transform-transformed {
@@ -640,8 +641,24 @@
}
/* Accessible highlighter */
:-moz-native-anonymous .accessible-bounds {
opacity: 0.6;
fill: #6a5acd;
}
+
+:-moz-native-anonymous .accessible-infobar-name {
+ color:var(--highlighter-infobar-color);
+ max-width: 90%;
+}
+
+:-moz-native-anonymous .accessible-infobar-name:not(:empty) {
+ color: var(--highlighter-infobar-color);
+ border-inline-start: 1px solid #5a6169;
+ margin-inline-start: 6px;
+ padding-inline-start: 6px;
+}
+
+:-moz-native-anonymous .accessible-infobar-role {
+ color: #9CDCFE;
+}
--- a/devtools/server/actors/highlighters/accessible.js
+++ b/devtools/server/actors/highlighters/accessible.js
@@ -1,21 +1,20 @@
/* 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 { AutoRefreshHighlighter } = require("./auto-refresh");
-const { getBounds } = require("./utils/accessibility");
-
+const { getBounds, Infobar } = require("./utils/accessibility");
const {
CanvasFrameAnonymousContentHelper,
createNode,
- createSVGNode
+ createSVGNode,
} = require("./utils/markup");
const { setIgnoreLayoutChanges } = require("devtools/shared/layout/utils");
/**
* The AccessibleHighlighter draws the bounds of an accessible object.
*
* Usage example:
@@ -31,31 +30,43 @@ const { setIgnoreLayoutChanges } = requi
* - {Number} y
* y coordinate of the top left corner of the accessible object
* - {Number} w
* width of the the accessible object
* - {Number} h
* height of the the accessible object
* - {Number} duration
* Duration of time that the highlighter should be shown.
+ * - {String|null} name
+ * name of the the accessible object
+ * - {String} role
+ * role of the the accessible object
*
* Structure:
- * <div class="highlighter-container">
+ * <div class="highlighter-container" aria-hidden="true">
* <div class="accessible-root">
* <svg class="accessible-elements" hidden="true">
* <path class="accessible-bounds" points="..." />
* </svg>
+ * <div class="accessible-infobar-container">
+ * <div class="accessible-infobar">
+ * <div class="accessible-infobar-text">
+ * <span class="accessible-infobar-role">Accessible Role</span>
+ * <span class="accessible-infobar-name">Accessible Name</span>
+ * </div>
+ * </div>
+ * </div>
* </div>
* </div>
*/
class AccessibleHighlighter extends AutoRefreshHighlighter {
constructor(highlighterEnv) {
super(highlighterEnv);
-
this.ID_CLASS_PREFIX = "accessible-";
+ this.accessibleInfobar = new Infobar(this);
this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
this._buildMarkup.bind(this));
this.onPageHide = this.onPageHide.bind(this);
this.onWillNavigate = this.onWillNavigate.bind(this);
this.highlighterEnv.on("will-navigate", this.onWillNavigate);
@@ -68,70 +79,72 @@ class AccessibleHighlighter extends Auto
* Build highlighter markup.
*
* @return {Object} Container element for the highlighter markup.
*/
_buildMarkup() {
const container = createNode(this.win, {
attributes: {
"class": "highlighter-container",
- "role": "presentation"
+ "aria-hidden": "true"
}
});
const root = createNode(this.win, {
parent: container,
attributes: {
"id": "root",
"class": "root",
- "role": "presentation"
},
prefix: this.ID_CLASS_PREFIX
});
// Build the SVG element.
const svg = createSVGNode(this.win, {
nodeType: "svg",
parent: root,
attributes: {
"id": "elements",
"width": "100%",
"height": "100%",
"hidden": "true",
- "role": "presentation"
},
prefix: this.ID_CLASS_PREFIX
});
createSVGNode(this.win, {
nodeType: "path",
parent: svg,
attributes: {
"class": "bounds",
"id": "bounds",
- "role": "presentation"
},
prefix: this.ID_CLASS_PREFIX
});
+ // Build the accessible's infobar markup.
+ this.accessibleInfobar.buildMarkup(root);
+
return container;
}
/**
* Destroy the nodes. Remove listeners.
*/
destroy() {
if (this._highlightTimer) {
clearTimeout(this._highlightTimer);
this._highlightTimer = null;
}
this.highlighterEnv.off("will-navigate", this.onWillNavigate);
this.pageListenerTarget.removeEventListener("pagehide", this.onPageHide);
this.pageListenerTarget = null;
+ this.accessibleInfobar.destroy();
+ this.accessibleInfobar = null;
this.markup.destroy();
AutoRefreshHighlighter.prototype.destroy.call(this);
}
/**
* Find an element in highlighter markup.
*
@@ -151,35 +164,42 @@ class AccessibleHighlighter extends Auto
_show() {
if (this._highlightTimer) {
clearTimeout(this._highlightTimer);
this._highlightTimer = null;
}
const { duration } = this.options;
const shown = this._update();
- if (shown && duration) {
- this._highlightTimer = setTimeout(() => {
- this.hide();
- }, duration);
+ if (shown) {
+ this.emit("highlighter-event", { options: this.options, type: "shown"});
+ if (duration) {
+ this._highlightTimer = setTimeout(() => {
+ this.hide();
+ }, duration);
+ }
}
+
return shown;
}
/**
* Update and show accessible bounds for a current accessible.
*
* @return {Boolean} True if accessible is highlighted, false otherwise.
*/
_update() {
let shown = false;
setIgnoreLayoutChanges(true);
if (this._updateAccessibleBounds()) {
this._showAccessibleBounds();
+
+ this.accessibleInfobar.show();
+
shown = true;
} else {
// Nothing to highlight (0px rectangle like a <script> tag for instance)
this.hide();
}
setIgnoreLayoutChanges(false,
this.highlighterEnv.window.document.documentElement);
@@ -188,29 +208,30 @@ class AccessibleHighlighter extends Auto
}
/**
* Hide the highlighter.
*/
_hide() {
setIgnoreLayoutChanges(true);
this._hideAccessibleBounds();
+ this.accessibleInfobar.hide();
setIgnoreLayoutChanges(false,
this.highlighterEnv.window.document.documentElement);
}
/**
* Hide the accessible bounds container.
*/
_hideAccessibleBounds() {
this.getElement("elements").setAttribute("hidden", "true");
}
/**
- * Showthe accessible bounds container.
+ * Show the accessible bounds container.
*/
_showAccessibleBounds() {
this.getElement("elements").removeAttribute("hidden");
}
/**
* Get current accessible bounds.
*
@@ -225,17 +246,17 @@ class AccessibleHighlighter extends Auto
* Update accessible bounds for a current accessible. Re-draw highlighter
* markup.
*
* @return {Boolean} True if accessible is highlighted, false otherwise.
*/
_updateAccessibleBounds() {
const bounds = this._bounds;
if (!bounds) {
- this._hideAccessibleBounds();
+ this._hide();
return false;
}
const boundsEl = this.getElement("bounds");
const { left, right, top, bottom } = bounds;
const path =
`M${left},${top} L${right},${top} L${right},${bottom} L${left},${bottom}`;
boundsEl.setAttribute("d", path);
--- a/devtools/server/actors/highlighters/utils/accessibility.js
+++ b/devtools/server/actors/highlighters/utils/accessibility.js
@@ -1,15 +1,364 @@
/* 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 { getCurrentZoom } = require("devtools/shared/layout/utils");
+const { getCurrentZoom, getViewportDimensions } = require("devtools/shared/layout/utils");
+const { moveInfobar, createNode } = require("./markup");
+const { truncateString } = require("devtools/shared/inspector/utils");
+
+// Max string length for truncating accessible name values.
+const MAX_STRING_LENGTH = 50;
+
+/**
+ * The AccessibleInfobar is a class responsible for creating the markup for the
+ * accessible highlighter. It is also reponsible for updating content within the
+ * infobar such as role and name values.
+ */
+class Infobar {
+ constructor(highlighter) {
+ this.highlighter = highlighter;
+ }
+
+ get document() {
+ return this.highlighter.win.document;
+ }
+
+ get bounds() {
+ return this.highlighter._bounds;
+ }
+
+ get options() {
+ return this.highlighter.options;
+ }
+
+ get prefix() {
+ return this.highlighter.ID_CLASS_PREFIX;
+ }
+
+ get win() {
+ return this.highlighter.win;
+ }
+
+ /**
+ * Move the Infobar to the right place in the highlighter.
+ *
+ * @param {Element} container
+ * Container of infobar.
+ */
+ _moveInfobar(container) {
+ // Position the infobar using accessible's bounds
+ const { left: x, top: y, bottom, width } = this.bounds;
+ const infobarBounds = { x, y, bottom, width };
+
+ moveInfobar(container, infobarBounds, this.win);
+ }
+
+ /**
+ * Build markup for infobar.
+ *
+ * @param {Element} root
+ * Root element to build infobar with.
+ */
+ buildMarkup(root) {
+ const container = createNode(this.win, {
+ parent: root,
+ attributes: {
+ "class": "infobar-container",
+ "id": "infobar-container",
+ "aria-hidden": "true",
+ "hidden": "true"
+ },
+ prefix: this.prefix,
+ });
+
+ const infobar = createNode(this.win, {
+ parent: container,
+ attributes: {
+ "class": "infobar",
+ "id": "infobar",
+ },
+ prefix: this.prefix,
+ });
+
+ const infobarText = createNode(this.win, {
+ parent: infobar,
+ attributes: {
+ "class": "infobar-text",
+ "id": "infobar-text",
+ },
+ prefix: this.prefix,
+ });
+
+ createNode(this.win, {
+ nodeType: "span",
+ parent: infobarText,
+ attributes: {
+ "class": "infobar-role",
+ "id": "infobar-role",
+ },
+ prefix: this.prefix,
+ });
+
+ createNode(this.win, {
+ nodeType: "span",
+ parent: infobarText,
+ attributes: {
+ "class": "infobar-name",
+ "id": "infobar-name",
+ },
+ prefix: this.prefix,
+ });
+ }
+
+ /**
+ * Destroy the Infobar's highlighter.
+ */
+ destroy() {
+ this.highlighter = null;
+ }
+
+ /**
+ * Gets the element with the specified ID.
+ *
+ * @param {String} id
+ * Element ID.
+ * @return {Element} The element with specified ID.
+ */
+ getElement(id) {
+ return this.highlighter.getElement(id);
+ }
+
+ /**
+ * Gets the text content of element.
+ *
+ * @param {String} id
+ * Element ID to retrieve text content from.
+ * @return {String} The text content of the element.
+ */
+ getTextContent(id) {
+ const anonymousContent = this.highlighter.markup.content;
+ return anonymousContent.getTextContentForElement(`${this.prefix}${id}`);
+ }
+
+ /**
+ * Hide the accessible infobar.
+ */
+ hide() {
+ const container = this.getElement("infobar-container");
+ container.setAttribute("hidden", "true");
+ }
+
+ /**
+ * Show the accessible infobar highlighter.
+ */
+ show() {
+ const container = this.getElement("infobar-container");
+
+ // Remove accessible's infobar "hidden" attribute. We do this first to get the
+ // computed styles of the infobar container.
+ container.removeAttribute("hidden");
+
+ // Update the infobar's position and content.
+ this.update(container);
+ }
+
+ /**
+ * Update content of the infobar.
+ */
+ update(container) {
+ const { name, role } = this.options;
+
+ this.updateRole(role, this.getElement("infobar-role"));
+ this.updateName(name, this.getElement("infobar-name"));
+
+ // Position the infobar.
+ this._moveInfobar(container);
+ }
+
+ /**
+ * Sets the text content of the specified element.
+ *
+ * @param {Element} el
+ * Element to set text content on.
+ * @param {String} text
+ * Text for content.
+ */
+ setTextContent(el, text) {
+ el.setTextContent(text);
+ }
+
+ /**
+ * Show the accessible's name message.
+ *
+ * @param {String} name
+ * Accessible's name value.
+ * @param {Element} el
+ * Element to set text content on.
+ */
+ updateName(name, el) {
+ const nameText = name ? `"${truncateString(name, MAX_STRING_LENGTH)}"` : "";
+ this.setTextContent(el, nameText);
+ }
+
+ /**
+ * Show the accessible's role.
+ *
+ * @param {String} role
+ * Accessible's role value.
+ * @param {Element} el
+ * Element to set text content on.
+ */
+ updateRole(role, el) {
+ this.setTextContent(el, role);
+ }
+}
+
+/**
+ * The XULAccessibleInfobar handles building the XUL infobar markup where it isn't
+ * possible with the regular accessible highlighter.
+ */
+class XULWindowInfobar extends Infobar {
+ /**
+ * A helper function that calculates the positioning of a XUL accessible's infobar.
+ *
+ * @param {Object} container
+ * The infobar container.
+ */
+ _moveInfobar(container) {
+ const arrow = this.getElement("arrow");
+
+ // Show the container and arrow elements first.
+ container.removeAttribute("hidden");
+ arrow.removeAttribute("hidden");
+
+ // Set the left value of the infobar container in relation to
+ // highlighter's bounds position.
+ const {
+ left: boundsLeft,
+ right: boundsRight,
+ top: boundsTop,
+ bottom: boundsBottom
+ } = this.bounds;
+ const boundsMidPoint = (boundsLeft + boundsRight) / 2;
+ container.style.left = `${boundsMidPoint}px`;
+
+ const zoom = getCurrentZoom(this.win);
+ let {
+ width: viewportWidth,
+ height: viewportHeight,
+ } = getViewportDimensions(this.win);
+
+ const { width, height, left, } = container.getBoundingClientRect();
+
+ const containerHalfWidth = width / 2;
+ const containerHeight = height;
+ const margin = 100 * zoom;
+
+ viewportHeight *= zoom;
+ viewportWidth *= zoom;
+
+ // Determine viewport boundaries for infobar.
+ const topBoundary = margin;
+ const bottomBoundary = viewportHeight - containerHeight;
+ const leftBoundary = containerHalfWidth;
+ const rightBoundary = viewportWidth - containerHalfWidth;
+
+ // Determine if an infobar's position is offscreen.
+ const isOffScreenOnTop = boundsBottom < topBoundary;
+ const isOffScreenOnBottom = boundsBottom > bottomBoundary;
+ const isOffScreenOnLeft = left < leftBoundary;
+ const isOffScreenOnRight = left > rightBoundary;
+
+ // Check if infobar is offscreen on either left/right of viewport and position.
+ if (isOffScreenOnLeft) {
+ container.style.left = `${leftBoundary + boundsLeft}px`;
+ arrow.setAttribute("hidden", "true");
+ } else if (isOffScreenOnRight) {
+ const leftOffset = rightBoundary - boundsRight;
+ container.style.left = `${rightBoundary - leftOffset - containerHalfWidth}px`;
+ arrow.setAttribute("hidden", "true");
+ }
+
+ // Check if infobar is offscreen on either top/bottom of viewport and position.
+ const bubbleArrowSize = "var(--highlighter-bubble-arrow-size)";
+
+ if (isOffScreenOnTop) {
+ if (boundsTop < 0) {
+ container.style.top = bubbleArrowSize;
+ } else {
+ container.style.top = `calc(${boundsBottom}px + ${bubbleArrowSize})`;
+ }
+ arrow.setAttribute("class", "accessible-arrow top");
+ } else if (isOffScreenOnBottom) {
+ container.style.top = `calc(${bottomBoundary}px - ${bubbleArrowSize})`;
+ arrow.setAttribute("hidden", "true");
+ } else {
+ container.style.top = `calc(${boundsTop}px -
+ (${containerHeight}px + ${bubbleArrowSize}))`;
+ arrow.setAttribute("class", "accessible-arrow bottom");
+ }
+ }
+
+ /**
+ * Build markup for XUL window infobar.
+ *
+ * @param {Element} root
+ * Root element to build infobar with.
+ */
+ buildMarkup(root) {
+ super.buildMarkup(root, createNode);
+
+ createNode(this.win, {
+ parent: this.getElement("infobar"),
+ attributes: {
+ "class": "arrow",
+ "id": "arrow",
+ },
+ prefix: this.prefix,
+ });
+ }
+
+ /**
+ * Override of Infobar class's getTextContent method.
+ *
+ * @param {String} id
+ * Element ID to retrieve text content from.
+ * @return {String} Returns the text content of the element.
+ */
+ getTextContent(id) {
+ return this.getElement(id).textContent;
+ }
+
+ /**
+ * Override of Infobar class's getElement method.
+ *
+ * @param {String} id
+ * Element ID.
+ * @return {String} Returns the specified element.
+ */
+ getElement(id) {
+ return this.win.document.getElementById(`${this.prefix}${id}`);
+ }
+
+ /**
+ * Override of Infobar class's setTextContent method.
+ *
+ * @param {Element} el
+ * Element to set text content on.
+ * @param {String} text
+ * Text for content.
+ */
+ setTextContent(el, text) {
+ el.textContent = text;
+ }
+}
/**
* A helper function that calculate accessible object bounds and positioning to
* be used for highlighting.
*
* @param {Object} win
* window that contains accessible object.
* @param {Object} options
@@ -25,17 +374,20 @@ const { getCurrentZoom } = require("devt
* - {Number} zoom
* zoom level of the accessible object's parent window
* @return {Object|null} Returns, if available, positioning and bounds information for
* the accessible object.
*/
function getBounds(win, { x, y, w, h, zoom }) {
let { mozInnerScreenX, mozInnerScreenY, scrollX, scrollY } = win;
let zoomFactor = getCurrentZoom(win);
- let left = x, right = x + w, top = y, bottom = y + h;
+ let left = x;
+ let right = x + w;
+ let top = y;
+ let bottom = y + h;
// For a XUL accessible, normalize the top-level window with its current zoom level.
// We need to do this because top-level browser content does not allow zooming.
if (zoom) {
zoomFactor = zoom;
mozInnerScreenX /= zoomFactor;
mozInnerScreenY /= zoomFactor;
scrollX /= zoomFactor;
@@ -53,9 +405,12 @@ function getBounds(win, { x, y, w, h, zo
bottom *= zoomFactor;
const width = right - left;
const height = bottom - top;
return { left, right, top, bottom, width, height };
}
+exports.MAX_STRING_LENGTH = MAX_STRING_LENGTH;
exports.getBounds = getBounds;
+exports.Infobar = Infobar;
+exports.XULWindowInfobar = XULWindowInfobar;
--- a/devtools/server/actors/highlighters/xul-accessible.js
+++ b/devtools/server/actors/highlighters/xul-accessible.js
@@ -1,30 +1,96 @@
/* 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 { getBounds } = require("./utils/accessibility");
+const { getBounds, XULWindowInfobar } = require("./utils/accessibility");
const { createNode, isNodeValid } = require("./utils/markup");
const { getCurrentZoom, loadSheet } = require("devtools/shared/layout/utils");
/**
* Stylesheet used for highlighter styling of accessible objects in chrome. It
* is consistent with the styling of an in-content accessible highlighter.
*/
const ACCESSIBLE_BOUNDS_SHEET = "data:text/css;charset=utf-8," + encodeURIComponent(`
+ .highlighter-container {
+ --highlighter-bubble-background-color: hsl(214, 13%, 24%);
+ --highlighter-bubble-border-color: rgba(255, 255, 255, 0.2);
+ --highlighter-bubble-arrow-size: 8px;
+ }
+
.accessible-bounds {
position: fixed;
pointer-events: none;
z-index: 10;
display: block;
background-color: #6a5acd!important;
opacity: 0.6;
+ }
+
+ .accessible-infobar-container {
+ position: fixed;
+ max-width: 90%;
+ z-index: 11;
+ }
+
+ .accessible-infobar {
+ position: relative;
+ left: -50%;
+ background-color: var(--highlighter-bubble-background-color);
+ min-width: 75px;
+ border: 1px solid var(--highlighter-bubble-border-color);
+ border-radius: 3px;
+ padding: 5px;
+ }
+
+ .accessible-arrow {
+ position: absolute;
+ width: 0;
+ height: 0;
+ border-left: var(--highlighter-bubble-arrow-size) solid transparent;
+ border-right: var(--highlighter-bubble-arrow-size) solid transparent;
+ left: calc(50% - var(--highlighter-bubble-arrow-size));
+ }
+
+ .top {
+ border-bottom: var(--highlighter-bubble-arrow-size) solid
+ var(--highlighter-bubble-background-color);
+ top: calc(-1 * var(--highlighter-bubble-arrow-size));
+ }
+
+ .bottom {
+ border-top: var(--highlighter-bubble-arrow-size) solid
+ var(--highlighter-bubble-background-color);
+ bottom: calc(-1 * var(--highlighter-bubble-arrow-size));
+ }
+
+ .accessible-infobar-text {
+ overflow: hidden;
+ white-space: nowrap;
+ display: flex;
+ justify-content: center;
+ }
+
+ .accessible-infobar-name {
+ color: rgb(221, 0, 169);
+ max-width: 90%;
+ }
+
+ .accessible-infobar-name:not(:empty) {
+ color: hsl(210, 30%, 85%);
+ border-inline-start: 1px solid #5a6169;
+ margin-inline-start: 6px;
+ padding-inline-start: 6px;
+ }
+
+ .accessible-infobar-role {
+ color: #9CDCFE;
}`);
/**
* The XULWindowAccessibleHighlighter is a class that has the same API as the
* AccessibleHighlighter, and by extension other highlighters that implement
* auto-refresh highlighter, but instead of drawing in canvas frame anonymous
* content (that is not available for chrome accessible highlighting) it adds a
* transparrent inactionable element with the same position and bounds as the
@@ -32,18 +98,20 @@ const ACCESSIBLE_BOUNDS_SHEET = "data:te
* element (that corresponds to accessible object) itself because the accessible
* position and bounds are calculated differently.
*
* It is used when canvasframe-based AccessibleHighlighter can't be used. This
* is the case for XUL windows.
*/
class XULWindowAccessibleHighlighter {
constructor(highlighterEnv) {
+ this.ID_CLASS_PREFIX = "accessible-";
this.highlighterEnv = highlighterEnv;
this.win = highlighterEnv.window;
+ this.accessibleInfobar = new XULWindowInfobar(this);
}
/**
* Static getter that indicates that XULWindowAccessibleHighlighter supports
* highlighting in XUL windows.
*/
static get XULSupported() {
return true;
@@ -55,27 +123,28 @@ class XULWindowAccessibleHighlighter {
_buildMarkup() {
const doc = this.win.document;
loadSheet(doc.ownerGlobal, ACCESSIBLE_BOUNDS_SHEET);
this.container = createNode(this.win, {
parent: doc.body || doc.documentElement,
attributes: {
"class": "highlighter-container",
- "role": "presentation"
+ "aria-hidden": "true"
}
});
this.bounds = createNode(this.win, {
parent: this.container,
attributes: {
"class": "accessible-bounds",
- "role": "presentation"
}
});
+
+ this.accessibleInfobar.buildMarkup(this.container);
}
/**
* Get current accessible bounds.
*
* @return {Object|null} Returns, if available, positioning and bounds
* information for the accessible object.
*/
@@ -99,16 +168,21 @@ class XULWindowAccessibleHighlighter {
* - {Number} y
* y coordinate of the top left corner of the accessible object
* - {Number} w
* width of the the accessible object
* - {Number} h
* height of the the accessible object
* - duration {Number}
* Duration of time that the highlighter should be shown.
+ * - {String|null} name
+ * name of the the accessible object
+ * - {String} role
+ * role of the the accessible object
+ *
* @return {Boolean} True if accessible is highlighted, false otherwise.
*/
show(node, options = {}) {
const isSameNode = node === this.currentNode;
const hasBounds = options && typeof options.x == "number" &&
typeof options.y == "number" &&
typeof options.w == "number" &&
typeof options.h == "number";
@@ -153,40 +227,45 @@ class XULWindowAccessibleHighlighter {
_update() {
this._hideAccessibleBounds();
const bounds = this._bounds;
if (!bounds) {
return false;
}
let boundsEl = this.bounds;
+
if (!boundsEl) {
this._buildMarkup();
boundsEl = this.bounds;
}
const { left, top, width, height } = bounds;
boundsEl.style.top = `${top}px`;
boundsEl.style.left = `${left}px`;
boundsEl.style.width = `${width}px`;
boundsEl.style.height = `${height}px`;
+
this._showAccessibleBounds();
+ this.accessibleInfobar.show();
return true;
}
/**
* Hide the highlighter
*/
hide() {
if (!this.currentNode || !this.highlighterEnv.window) {
return;
}
this._hideAccessibleBounds();
+ this.accessibleInfobar.hide();
+
this.currentNode = null;
this.options = null;
}
/**
* Show accessible bounds highlighter.
*/
_showAccessibleBounds() {
@@ -213,13 +292,16 @@ class XULWindowAccessibleHighlighter {
this._highlightTimer = null;
}
this.hide();
if (this.container) {
this.container.remove();
}
+ this.accessibleInfobar.destroy();
+
+ this.accessibleInfobar = null;
this.win = null;
}
}
exports.XULWindowAccessibleHighlighter = XULWindowAccessibleHighlighter;
--- a/devtools/server/tests/browser/browser.ini
+++ b/devtools/server/tests/browser/browser.ini
@@ -1,14 +1,15 @@
[DEFAULT]
tags = devtools
subsuite = devtools
support-files =
head.js
animation.html
+ doc_accessibility_infobar.html
doc_accessibility.html
doc_allocations.html
doc_force_cc.html
doc_force_gc.html
doc_innerHTML.html
doc_perf.html
error-actor.js
grid.html
@@ -25,16 +26,18 @@ support-files =
test-spawn-actor-in-parent.js
timeline-iframe-child.html
timeline-iframe-parent.html
storage-helpers.js
!/devtools/client/shared/test/shared-head.js
!/devtools/client/shared/test/telemetry-test-helpers.js
!/devtools/server/tests/mochitest/hello-actor.js
+[browser_accessibility_highlighter_infobar.js]
+[browser_accessibility_infobar_show.js]
[browser_accessibility_node.js]
[browser_accessibility_node_events.js]
[browser_accessibility_simple.js]
[browser_accessibility_walker.js]
[browser_actor_error.js]
[browser_animation_emitMutations.js]
[browser_animation_getFrames.js]
[browser_animation_getProperties.js]
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_highlighter_infobar.js
@@ -0,0 +1,59 @@
+/* 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";
+
+// Test the accessible highlighter's infobar content.
+
+const { truncateString } = require("devtools/shared/inspector/utils");
+const { MAX_STRING_LENGTH } = require("devtools/server/actors/highlighters/utils/accessibility");
+
+add_task(async function() {
+ const { client, walker, accessibility } =
+ await initAccessibilityFrontForUrl(MAIN_DOMAIN + "doc_accessibility_infobar.html");
+
+ const a11yWalker = await accessibility.getWalker();
+ await accessibility.enable();
+
+ info("Button front checks");
+ await checkNameAndRole(walker, "#button", a11yWalker, "Accessible Button");
+
+ info("Front with long name checks");
+ await checkNameAndRole(walker, "#h1", a11yWalker,
+ "Lorem ipsum dolor sit ame" + "\u2026" + "e et dolore magna aliqua.");
+
+ await accessibility.disable();
+ await waitForA11yShutdown();
+ await client.close();
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * A helper function for testing the accessible's displayed name and roles.
+ *
+ * @param {Object} walker
+ * The DOM walker.
+ * @param {String} querySelector
+ * The selector for the node to retrieve accessible from.
+ * @param {Object} a11yWalker
+ * The accessibility walker.
+ * @param {String} expectedName
+ * Expected string content for displaying the accessible's name.
+ * We are testing this in particular because name can be truncated.
+ */
+async function checkNameAndRole(walker, querySelector, a11yWalker, expectedName) {
+ const node = await walker.querySelector(walker.rootNode, querySelector);
+ const accessibleFront = await a11yWalker.getAccessibleFor(node);
+
+ const { name, role } = accessibleFront;
+ const onHighlightEvent = a11yWalker.once("highlighter-event");
+
+ await a11yWalker.highlightAccessible(accessibleFront);
+ const { options } = await onHighlightEvent;
+ is(options.name, name, "Accessible highlight has correct name option");
+ is(options.role, role, "Accessible highlight has correct role option");
+
+ is(`"${truncateString(name, MAX_STRING_LENGTH)}"`, `"${expectedName}"`,
+ "Accessible has correct displayed name.");
+}
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_infobar_show.js
@@ -0,0 +1,162 @@
+/* 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";
+
+// Checks for the AccessibleHighlighter's and XULWindowHighlighter's infobar components.
+
+add_task(async function() {
+ await BrowserTestUtils.withNewTab({
+ gBrowser,
+ url: MAIN_DOMAIN + "doc_accessibility_infobar.html",
+ }, async function(browser) {
+ await ContentTask.spawn(browser, null, async function() {
+ const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm", {});
+ const { HighlighterEnvironment } = require("devtools/server/actors/highlighters");
+ const { AccessibleHighlighter } = require("devtools/server/actors/highlighters/accessible");
+ const { XULWindowAccessibleHighlighter } = require("devtools/server/actors/highlighters/xul-accessible");
+
+ /**
+ * Get whether or not infobar container is hidden.
+ *
+ * @param {Object} infobar
+ * Accessible highlighter's infobar component.
+ * @return {String|null} If the infobar container is hidden.
+ */
+ function isContainerHidden(infobar) {
+ return !!infobar.getElement("infobar-container").getAttribute("hidden");
+ }
+
+ /**
+ * Get name of accessible object.
+ *
+ * @param {Object} infobar
+ * Accessible highlighter's infobar component.
+ * @return {String} The text content of the infobar-name element.
+ */
+ function getName(infobar) {
+ return infobar.getTextContent("infobar-name");
+ }
+
+ /**
+ * Get role of accessible object.
+ *
+ * @param {Object} infobar
+ * Accessible highlighter's infobar component.
+ * @return {String} The text content of the infobar-role element.
+ */
+ function getRole(infobar) {
+ return infobar.getTextContent("infobar-role");
+ }
+
+ /**
+ * Checks for updated content for an infobar with valid bounds.
+ *
+ * @param {Object} infobar
+ * Accessible highlighter's infobar component.
+ * @param {Object} options
+ * Options to pass for the highlighter's show method.
+ * Available options:
+ * - {String} role
+ * Role value of the accessible.
+ * - {String} name
+ * Name value of the accessible.
+ * - {Boolean} shouldBeHidden
+ * If the infobar component should be hidden.
+ */
+ function checkInfobar(infobar, { shouldBeHidden, role, name }) {
+ is(isContainerHidden(infobar), shouldBeHidden,
+ "Infobar's hidden state is correct.");
+
+ if (shouldBeHidden) {
+ return;
+ }
+
+ is(getRole(infobar), role, "infobarRole text content is correct");
+ is(getName(infobar), `"${name}"`, "infoBarName text content is correct");
+ }
+
+ /**
+ * Checks for updated content of an infobar with valid bounds.
+ *
+ * @param {Element} node
+ * Node to check infobar content on.
+ * @param {Object} highlighter
+ * Accessible highlighter.
+ */
+ function testInfobar(node, highlighter) {
+ const infobar = highlighter.accessibleInfobar;
+ const bounds = {
+ x: 0,
+ y: 0,
+ w: 250,
+ h: 100,
+ };
+
+ info("Check that infobar is shown with valid bounds.");
+ highlighter.show(node, {
+ ...bounds,
+ role: "button",
+ name: "Accessible Button"
+ });
+
+ checkInfobar(infobar, {
+ role: "button",
+ name: "Accessible Button",
+ shouldBeHidden: false
+ });
+ highlighter.hide();
+
+ info("Check that infobar is hidden after .hide() is called.");
+ checkInfobar(infobar, { shouldBeHidden: true });
+
+ info("Check to make sure content is updated with new options.");
+ highlighter.show(node, {
+ ...bounds,
+ name: "Test link",
+ role: "link"
+ });
+ checkInfobar(infobar, {
+ name: "Test link",
+ role: "link",
+ shouldBeHidden: false
+ });
+ highlighter.hide();
+ }
+
+ // Start testing. First, create highlighter environment and initialize.
+ const env = new HighlighterEnvironment();
+ env.initFromWindow(content.window);
+
+ // Wait for loading highlighter environment content to complete before creating the
+ // highlighter.
+ await new Promise(resolve => {
+ const doc = env.document;
+
+ function onContentLoaded() {
+ if (doc.readyState === "interactive" || doc.readyState === "complete") {
+ resolve();
+ } else {
+ doc.addEventListener("DOMContentLoaded", onContentLoaded, { once: true });
+ }
+ }
+
+ onContentLoaded();
+ });
+
+ // Now, we can test the Infobar and XULWindowInfobar components with their
+ // respective highlighters.
+ const node = content.document.createElement("div");
+ content.document.body.append(node);
+
+ info("Checks for Infobar's show method");
+ const highlighter = new AccessibleHighlighter(env);
+ testInfobar(node, highlighter);
+
+ info("Checks for XULWindowInfobar's show method");
+ const xulWindowHighlighter = new XULWindowAccessibleHighlighter(env);
+ testInfobar(node, xulWindowHighlighter);
+ });
+ });
+});
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/browser/doc_accessibility_infobar.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+<body>
+ <h1 id="h1">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</h1>
+ <button id="button">Accessible Button</button>
+</body>
+</html>
--- a/devtools/shared/inspector/moz.build
+++ b/devtools/shared/inspector/moz.build
@@ -1,9 +1,10 @@
# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
# vim: set filetype=python:
# 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/.
DevToolsModules(
- 'css-logic.js'
+ 'css-logic.js',
+ 'utils.js'
)
new file mode 100644
--- /dev/null
+++ b/devtools/shared/inspector/utils.js
@@ -0,0 +1,20 @@
+/* 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";
+
+/**
+ * Truncate the string and add ellipsis to the middle of the string.
+ */
+function truncateString(str, maxLength) {
+ if (!str || str.length <= maxLength) {
+ return str;
+ }
+
+ return str.substring(0, Math.ceil(maxLength / 2)) +
+ "…" +
+ str.substring(str.length - Math.floor(maxLength / 2));
+}
+
+exports.truncateString = truncateString;
--- a/devtools/shared/specs/accessibility.js
+++ b/devtools/shared/specs/accessibility.js
@@ -99,16 +99,20 @@ const accessibleWalkerSpec = generateAct
accessible: Arg(0, "nullable:accessible")
},
"picker-accessible-hovered": {
type: "pickerAccessibleHovered",
accessible: Arg(0, "nullable:accessible")
},
"picker-accessible-canceled": {
type: "pickerAccessibleCanceled"
+ },
+ "highlighter-event": {
+ type: "highlighter-event",
+ data: Arg(0, "json")
}
},
methods: {
children: {
request: {},
response: {
children: RetVal("array:accessible")