Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 64 additions & 45 deletions js/helpers.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const OVERLAY_SELECTOR = '.notify, .drawer, dialog';
const HEADING_SELECTOR = 'h1,h2,h3,h4,h5,h6,[role=heading]';
export const HEADING_SELECTOR = 'h1,h2,h3,h4,h5,h6,[role=heading]';

function findLabel($element) {
const id = $element.attr('id');
Expand Down Expand Up @@ -33,7 +32,7 @@ function followId($element, property) {
return computeAccessibleName($toElement, true);
}

function computeAccessibleName($element, allowText = false) {
export function computeAccessibleName($element, allowText = false) {
if ($element.is('input:not([type=checkbox], [type=radio]), select, [role=range], textarea') && $element.val()) return $element.val();
const ariaHidden = $element.attr('aria-hidden');
if (ariaHidden === 'true') return '<span class="u-nobr">N/A (hidden from assistive technologies)</span>';
Expand All @@ -51,6 +50,9 @@ function computeAccessibleName($element, allowText = false) {
if (valueNow) return valueNow;
const alt = $element.attr('alt');
if (alt) return alt;
const childAriaLabel = !$element.is(HEADING_SELECTOR) &&
$element.find('.aria-label').first().text();
if (childAriaLabel) return childAriaLabel;
if (!allowText) return '';
return computeHeadingLevel($element) + getText($element[0]);
}
Expand All @@ -62,38 +64,60 @@ function computeHeadingLevel($element) {
return `h${headingLevel}: `;
}

function computeAccessibleDescription($element) {
export function computeAccessibleDescription($element) {
const describedByText = followId($element, 'aria-describedby');
if (describedByText) return describedByText;
return '';
}

function getAnnotationPosition($element, $annotation, isInOverlay = false) {
export function getContainer($element) {
const $fixedParent = $element.parents().add($element).filter((index, el) => $(el).css('position') === 'fixed');
return $fixedParent.length ? $fixedParent : $('body');
}

export function shouldAnnotate($element) {
const shouldAnnotate = isVisible($element) && isReadable($element);
return shouldAnnotate;
}

function isVisible($element) {
const isHeadingHeightZero = $element.is(HEADING_SELECTOR) && $element.height() === 0;
const isVisible = !isHeadingHeightZero && isInDom($element) && $element.onscreen().onscreen;
return isVisible;
}

function isReadable($element) {
const isImg = $element.is('img');
const isAncestorAriaHidden = Boolean($element.parents().filter('[aria-hidden=true]').length);
const isAriaHidden = Boolean($element.filter('[aria-hidden=true]').length);
const isNotAriaHidden = Boolean($element.filter('[aria-hidden=false]').length);
const hasAccessibleName = Boolean(computeAccessibleName($element) || computeAccessibleDescription($element));
const isReadable = !isAncestorAriaHidden && (isNotAriaHidden || !isAriaHidden || isImg || (hasAccessibleName && !isAriaHidden));
return isReadable;
}

function isInDom($element) {
const isInDom = $element.parents('html').length > 0;
return isInDom;
}

function isFixed($element) {
const isFixed = Boolean($element.parents().add($element).filter((index, el) => $(el).css('position') === 'fixed').length);
return isFixed;
}

export function getAnnotationPosition($element, $annotation) {
const $annotationContainer = getContainer($element);
const containerBoundingRect = $annotationContainer[0].getBoundingClientRect();
const targetBoundingRect = $element[0].getBoundingClientRect();
let availableWidth = $('html')[0].clientWidth;
let availableHeight = $('html')[0].clientHeight;
const availableWidth = $annotationContainer.width();
const availableHeight = $annotationContainer.height();
const tooltipsWidth = $annotation.width();
const tooltipsHeight = $annotation.height();
const elementWidth = $element.width();
const elementHeight = $element.height();
const isFixedPosition = Boolean($element.parents().add($element).filter((index, el) => $(el).css('position') === 'fixed').length);
const scrollOffsetTop = isFixedPosition ? 0 : $(window).scrollTop();
const scrollOffsetLeft = isFixedPosition ? 0 : $(window).scrollLeft();

// For overlay annotations, calculate position relative to overlay container
let overlayOffsetTop = 0;
let overlayOffsetLeft = 0;
if (isInOverlay) {
const $overlay = $element.closest(OVERLAY_SELECTOR);
if ($overlay.length) {
const overlayRect = $overlay[0].getBoundingClientRect();
overlayOffsetTop = overlayRect.top;
overlayOffsetLeft = overlayRect.left;
// Use overlay dimensions instead of viewport for boundary checks
availableWidth = overlayRect.right;
availableHeight = overlayRect.bottom;
}
}
const scrollOffsetTop = -containerBoundingRect.top + $annotationContainer.scrollTop();
const scrollOffsetLeft = -containerBoundingRect.left + $annotationContainer.scrollLeft();

const canAlignBottom = targetBoundingRect.bottom + tooltipsHeight < availableHeight;
const canAlignRight = targetBoundingRect.right + tooltipsWidth < availableWidth;
Expand All @@ -104,23 +128,24 @@ function getAnnotationPosition($element, $annotation, isInOverlay = false) {
return {
className: 'is-contained',
css: {
left: targetBoundingRect.left + scrollOffsetLeft - overlayOffsetLeft,
top: targetBoundingRect.top + scrollOffsetTop - overlayOffsetTop,
left: targetBoundingRect.left + scrollOffsetLeft,
top: targetBoundingRect.top + scrollOffsetTop,
'max-width': (elementHeight === 0) ? '' : elementWidth
}
};
}
if (!canAlignBottomRight) {
// Find the 'corner' with the most space from the viewport edge
const isTopPreferred = availableHeight - (targetBoundingRect.bottom + tooltipsHeight) < targetBoundingRect.top - tooltipsHeight;
const isHardTop = isFixed($annotationContainer) && (containerBoundingRect.top < tooltipsHeight && targetBoundingRect.top < tooltipsHeight);
const isTopPreferred = !isHardTop && (availableHeight - (targetBoundingRect.bottom + tooltipsHeight) < targetBoundingRect.top - tooltipsHeight);
const isLeftPreferred = availableWidth - (targetBoundingRect.right + tooltipsWidth) < targetBoundingRect.left - tooltipsWidth;
if (isTopPreferred && isLeftPreferred) {
// Top left
return {
className: 'is-left is-top',
css: {
left: targetBoundingRect.left - tooltipsWidth + scrollOffsetLeft - overlayOffsetLeft,
top: targetBoundingRect.top - tooltipsHeight + scrollOffsetTop - overlayOffsetTop,
left: targetBoundingRect.left - tooltipsWidth + scrollOffsetLeft,
top: targetBoundingRect.top - tooltipsHeight + scrollOffsetTop,
'max-width': ''
}
};
Expand All @@ -130,8 +155,8 @@ function getAnnotationPosition($element, $annotation, isInOverlay = false) {
return {
className: 'is-right is-top',
css: {
left: targetBoundingRect.right + scrollOffsetLeft - overlayOffsetLeft,
top: targetBoundingRect.top - tooltipsHeight + scrollOffsetTop - overlayOffsetTop,
left: targetBoundingRect.right + scrollOffsetLeft,
top: targetBoundingRect.top - tooltipsHeight + scrollOffsetTop,
'max-width': ''
}
};
Expand All @@ -141,8 +166,8 @@ function getAnnotationPosition($element, $annotation, isInOverlay = false) {
return {
className: 'is-left is-bottom',
css: {
left: targetBoundingRect.left - tooltipsWidth + scrollOffsetLeft - overlayOffsetLeft,
top: targetBoundingRect.bottom + scrollOffsetTop - overlayOffsetTop,
left: targetBoundingRect.left - tooltipsWidth + scrollOffsetLeft,
top: targetBoundingRect.bottom + scrollOffsetTop,
'max-width': ''
}
};
Expand All @@ -152,19 +177,14 @@ function getAnnotationPosition($element, $annotation, isInOverlay = false) {
return {
className: 'is-right is-bottom',
css: {
left: targetBoundingRect.right + scrollOffsetLeft - overlayOffsetLeft,
top: targetBoundingRect.bottom + scrollOffsetTop - overlayOffsetTop,
left: targetBoundingRect.right + scrollOffsetLeft,
top: targetBoundingRect.bottom + scrollOffsetTop,
'max-width': ''
}
};
}
const position = getPosition();
// Use fixed for fixed-position elements, absolute for overlays and normal elements
if (isInOverlay) {
position.css.position = 'absolute';
} else {
position.css.position = isFixedPosition ? 'fixed' : 'absolute';
}
position.css.position = 'absolute';
if (position.css.left < 0) position.css.left = 0;
position.css.left += 'px';
position.css.top += 'px';
Expand All @@ -174,9 +194,8 @@ function getAnnotationPosition($element, $annotation, isInOverlay = false) {

export default {
HEADING_SELECTOR,
OVERLAY_SELECTOR,
computeAccessibleName,
computeAccessibleDescription,
computeHeadingLevel,
getAnnotationPosition
getAnnotationPosition,
getContainer
};
84 changes: 38 additions & 46 deletions js/toggle-alt-text.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import Backbone from 'backbone';
import Adapt from 'core/js/adapt';
import helpers from './helpers';

const { HEADING_SELECTOR, OVERLAY_SELECTOR, computeAccessibleName, computeAccessibleDescription, getAnnotationPosition } = helpers;
import {
HEADING_SELECTOR,
computeAccessibleName,
computeAccessibleDescription,
getAnnotationPosition,
getContainer,
shouldAnnotate
} from './helpers';

class Annotation extends Backbone.View {

events() {
return {
click: 'onClick'
};
}

onClick(event) {
console.log('Annotation clicked for', this.$parent[0]);
}

className() {
return 'devtools__annotation';
}
Expand All @@ -16,22 +31,27 @@ class Annotation extends Backbone.View {

initialize(options) {
this.$parent = options.$parent;
this.$container = getContainer(this.$parent);
this.allowText = options.allowText;
this.isInOverlay = options.isInOverlay || false;
this.$el.data('annotating', this.$parent);
this.$el.data('view', this);
}

render() {
function hash(name, description, position) {
return name + description + position.className + position.css.top + position.css.left;
}
const template = Handlebars.templates.devtoolsAnnotation;
const name = computeAccessibleName(this.$parent, this.allowText);
const description = computeAccessibleDescription(this.$parent);
const position = getAnnotationPosition(this.$parent, this.$el);
if (this._last === hash(name, description, position)) return;
this.$el.html(template({ name, description }));
if (!name) this.$el.addClass('has-annotation-warning');
const position = getAnnotationPosition(this.$parent, this.$el, this.isInOverlay);
this.$el.toggleClass('has-annotation-warning', !name);
this.$el.css(position.css);
this.$el.removeClass('is-top is-left is-right is-bottom is-contained');
this.$el.addClass(position.className);
this._last = hash(name, description, position);
}

showOutline() {
Expand All @@ -43,7 +63,6 @@ class Annotation extends Backbone.View {
this.$parent.removeClass('devtools__annotation-outline');
this.$el.removeClass('has-mouse-over');
}

}

class AltText extends Backbone.Controller {
Expand All @@ -59,10 +78,7 @@ class AltText extends Backbone.Controller {
this.mutated = false;
this.listenTo(Adapt.devtools, 'change:_altTextEnabled', this.toggleAltText);
$('body').append($('<div class="devtools__annotations" aria-hidden="true"></div>'));
// if available we can use to avoid unnecessary checks
if (typeof MutationObserver === 'function') {
this.observer = new MutationObserver(this.onDomMutation);
}
this.observer = new MutationObserver(this.onDomMutation);
}

connectObserver() {
Expand All @@ -87,11 +103,11 @@ class AltText extends Backbone.Controller {
});
}
this.listenTo(Adapt, {
'notify:opened drawer:opened drawer:openedCustomView': this.onOverlayOpened,
'popup:closed notify:closed drawer:closed': this.onDomMutation,
remove: this.removeAllAnnotations
});
$(window).on('scroll', this.onDomMutation);
$(document).on('transitionend', this.onDomMutation);
$(document).on('animationend', this.onDomMutation);
$(document).on('mouseover', '*', this.onMouseOver);
}

Expand Down Expand Up @@ -123,10 +139,10 @@ class AltText extends Backbone.Controller {
if (this.observer) {
this.observer.disconnect();
}
this.stopListening(Adapt, 'notify:opened drawer:opened drawer:openedCustomView', this.onOverlayOpened);
this.stopListening(Adapt, 'popup:closed notify:closed drawer:closed', this.onDomMutation);
this.stopListening(Adapt, 'remove', this.removeAllAnnotations);
$(window).off('scroll', this.onDomMutation);
$(document).off('transitionend', this.onDomMutation);
$(document).off('animationend', this.onDomMutation);
$(document).off('mouseover', '*', this.onMouseOver);
}

Expand All @@ -141,18 +157,13 @@ class AltText extends Backbone.Controller {
this.connectObserver();
}

addAnnotation($element, allowText, isInOverlay) {
addAnnotation($element, allowText) {
const annotation = new Annotation({
$parent: $element,
allowText,
isInOverlay
allowText
});

if (isInOverlay) {
$element.closest(OVERLAY_SELECTOR).append(annotation.$el);
} else {
$('.devtools__annotations').append(annotation.$el);
}
annotation.$container.append(annotation.$el);

$element.data('annotation', annotation);
$element.attr('data-annotated', true);
Expand Down Expand Up @@ -181,9 +192,7 @@ class AltText extends Backbone.Controller {
const $element = $annotation.data('annotating');
const annotation = $annotation.data('view');
if (!$element) return;
const isOutOfDom = ($element.parents('html').length === 0);
const isHeadingHeightZero = $element.is(HEADING_SELECTOR) && $element.height() === 0;
if (!isOutOfDom && ($element.onscreen().onscreen || isHeadingHeightZero || annotation.isInOverlay)) return;
if (shouldAnnotate($element)) return;
this.removeAnnotation($element, annotation);
});
}
Expand All @@ -192,21 +201,12 @@ class AltText extends Backbone.Controller {
annotation.render();
}

onDomMutation(mutations) {
onDomMutation() {
if (this.mutated) return;
requestAnimationFrame(this.render);
this.mutated = true;
}

onOverlayOpened() {
// Wait for next frame to ensure overlay DOM layout is complete
this.mutated = false;
requestAnimationFrame(() => {
this.mutated = false;
this.onDomMutation();
});
}

render() {
if (this.mutated === false) return;
this.clearUpAnnotations();
Expand All @@ -229,17 +229,9 @@ class AltText extends Backbone.Controller {
.each((index, element) => {
const $element = $(element);
const annotation = $element.data('annotation');
const isVisible = $element.onscreen().onscreen;
const isParentAriaHidden = Boolean($element.parents().filter('[aria-hidden=true]').length);
const isAriaHidden = Boolean($element.filter('[aria-hidden=true]').length);
const isNotAriaHidden = Boolean($element.filter('[aria-hidden=false]').length);
const isImg = $element.is('img');
const allowText = $element.is(`.aria-label,${HEADING_SELECTOR}`);
const isOutOfDom = ($element.parents('html').length === 0);
const isHeadingHeightZero = $element.is(HEADING_SELECTOR) && $element.height() === 0;
const isInOverlay = $element.closest(OVERLAY_SELECTOR).length > 0;
if (!isOutOfDom && (isVisible || isHeadingHeightZero || isInOverlay) && (isNotAriaHidden || (!isAriaHidden && !isParentAriaHidden) || (isImg && !isParentAriaHidden))) {
if (!annotation) this.addAnnotation($element, allowText, isInOverlay);
if (shouldAnnotate($element)) {
if (!annotation) this.addAnnotation($element, allowText);
else this.updateAnnotation($element, annotation, allowText);
} else if (annotation) {
this.removeAnnotation($element, annotation);
Expand Down