439 lines
12 KiB
JavaScript
439 lines
12 KiB
JavaScript
/* *
|
|
*
|
|
* (c) 2009-2019 Øystein Moseng
|
|
*
|
|
* Accessibility component class definition
|
|
*
|
|
* License: www.highcharts.com/license
|
|
*
|
|
* */
|
|
|
|
'use strict';
|
|
import Highcharts from '../../parts/Globals.js';
|
|
|
|
var win = Highcharts.win,
|
|
doc = win.document,
|
|
merge = Highcharts.merge,
|
|
addEvent = Highcharts.addEvent;
|
|
|
|
|
|
/**
|
|
* The AccessibilityComponent base class, representing a part of the chart that
|
|
* has accessibility logic connected to it. This class can be inherited from to
|
|
* create a custom accessibility component for a chart.
|
|
*
|
|
* A component:
|
|
* - Must call initBase after inheriting.
|
|
* - Can override any of the following functions: init(), destroy(),
|
|
* getKeyboardNavigation(), onChartUpdate().
|
|
* - Should take care to destroy added elements and unregister event handlers
|
|
* on destroy.
|
|
*
|
|
* @sample highcharts/accessibility/custom-component
|
|
* Custom accessibility component
|
|
*
|
|
* @requires module:modules/accessibility
|
|
* @class
|
|
* @name Highcharts.AccessibilityComponent
|
|
*/
|
|
function AccessibilityComponent() {}
|
|
/**
|
|
* @lends Highcharts.AccessibilityComponent
|
|
*/
|
|
AccessibilityComponent.prototype = {
|
|
|
|
/**
|
|
* Initialize the class
|
|
* @private
|
|
* @param {Highcharts.Chart} chart
|
|
* Chart object
|
|
*/
|
|
initBase: function (chart) {
|
|
this.chart = chart;
|
|
this.eventRemovers = [];
|
|
this.domElements = [];
|
|
// Key code enum for common keys
|
|
this.keyCodes = {
|
|
left: 37,
|
|
right: 39,
|
|
up: 38,
|
|
down: 40,
|
|
enter: 13,
|
|
space: 32,
|
|
esc: 27,
|
|
tab: 9
|
|
};
|
|
// CSS Styles for hiding elements visually but keeping them visible to
|
|
// AT.
|
|
this.hiddenStyle = {
|
|
position: 'absolute',
|
|
width: '1px',
|
|
height: '1px',
|
|
overflow: 'hidden'
|
|
};
|
|
},
|
|
|
|
|
|
/**
|
|
* Add an event to an element and keep track of it for destroy().
|
|
* Same args as Highcharts.addEvent
|
|
* @private
|
|
*/
|
|
addEvent: function () {
|
|
var remover = Highcharts.addEvent.apply(Highcharts, arguments);
|
|
this.eventRemovers.push(remover);
|
|
return remover;
|
|
},
|
|
|
|
|
|
/**
|
|
* Create an element and keep track of it for destroy().
|
|
* Same args as document.createElement
|
|
* @private
|
|
*/
|
|
createElement: function () {
|
|
var el = Highcharts.win.document.createElement.apply(
|
|
Highcharts.win.document, arguments
|
|
);
|
|
this.domElements.push(el);
|
|
return el;
|
|
},
|
|
|
|
|
|
/**
|
|
* Utility function to clone a mouse event for re-dispatching.
|
|
* @private
|
|
* @param {global.Event} event The event to clone.
|
|
* @return {global.Event} The cloned event
|
|
*/
|
|
cloneMouseEvent: function (event) {
|
|
if (typeof win.MouseEvent === 'function') {
|
|
return new win.MouseEvent(event.type, event);
|
|
}
|
|
// No MouseEvent support, try using initMouseEvent
|
|
if (doc.createEvent) {
|
|
var evt = doc.createEvent('MouseEvent');
|
|
if (evt.initMouseEvent) {
|
|
evt.initMouseEvent(
|
|
event.type,
|
|
event.canBubble,
|
|
event.cancelable,
|
|
event.view,
|
|
event.detail,
|
|
event.screenX,
|
|
event.screenY,
|
|
event.clientX,
|
|
event.clientY,
|
|
event.ctrlKey,
|
|
event.altKey,
|
|
event.shiftKey,
|
|
event.metaKey,
|
|
event.button,
|
|
event.relatedTarget
|
|
);
|
|
return evt;
|
|
}
|
|
|
|
// Fallback to basic Event
|
|
evt = doc.createEvent('Event');
|
|
if (evt.initEvent) {
|
|
evt.initEvent(event.type, true, true);
|
|
return evt;
|
|
}
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* Utility function to attempt to fake a click event on an element.
|
|
* @private
|
|
* @param {Highcharts.HTMLDOMElement|Highcharts.SVGDOMElement} element
|
|
*/
|
|
fakeClickEvent: function (element) {
|
|
if (element && element.onclick && doc.createEvent) {
|
|
var fakeEvent = doc.createEvent('Event');
|
|
fakeEvent.initEvent('click', true, false);
|
|
element.onclick(fakeEvent);
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* Create an invisible proxy HTML button in the same position as an SVG
|
|
* element
|
|
* @private
|
|
* @param {Highcharts.SVGElement} svgElement The wrapped svg el to proxy.
|
|
* @param {Highcharts.HTMLElement} parentGroup The proxy group element in
|
|
* the proxy container to add this button to.
|
|
* @param {object} [attributes] Additional attributes to set.
|
|
* @param {Highcharts.SVGElement} [posElement] Element to use for
|
|
* positioning instead of svgElement.
|
|
* @param {Function} [preClickEvent] Function to call before click event
|
|
* fires.
|
|
*
|
|
* @return {Highcharts.HTMLElement} The proxy button.
|
|
*/
|
|
createProxyButton: function (
|
|
svgElement, parentGroup, attributes, posElement, preClickEvent
|
|
) {
|
|
var svgEl = svgElement.element,
|
|
component = this,
|
|
proxy = this.createElement('button'),
|
|
attrs = merge({
|
|
'aria-label': svgEl.getAttribute('aria-label')
|
|
}, attributes),
|
|
positioningElement = posElement || svgElement,
|
|
bBox = this.getElementPosition(positioningElement);
|
|
|
|
// If we don't support getBoundingClientRect, no button is made
|
|
if (!bBox) {
|
|
return;
|
|
}
|
|
|
|
Object.keys(attrs).forEach(function (prop) {
|
|
if (attrs[prop] !== null) {
|
|
proxy.setAttribute(prop, attrs[prop]);
|
|
}
|
|
});
|
|
|
|
merge(true, proxy.style, {
|
|
'border-width': 0,
|
|
'background-color': 'transparent',
|
|
position: 'absolute',
|
|
width: (bBox.width || 1) + 'px',
|
|
height: (bBox.height || 1) + 'px',
|
|
display: 'block',
|
|
cursor: 'pointer',
|
|
overflow: 'hidden',
|
|
outline: 'none',
|
|
opacity: 0.001,
|
|
filter: 'alpha(opacity=1)',
|
|
'-ms-filter': 'progid:DXImageTransform.Microsoft.Alpha(Opacity=1)',
|
|
zIndex: 999,
|
|
padding: 0,
|
|
margin: 0,
|
|
left: bBox.x + 'px',
|
|
top: bBox.y - this.chart.containerHeight + 'px'
|
|
});
|
|
|
|
// Handle pre-click
|
|
if (preClickEvent) {
|
|
addEvent(proxy, 'click', preClickEvent);
|
|
}
|
|
|
|
// Proxy mouse events
|
|
[
|
|
'click', 'mouseover', 'mouseenter', 'mouseleave', 'mouseout'
|
|
].forEach(function (evtType) {
|
|
addEvent(proxy, evtType, function (e) {
|
|
var clonedEvent = component.cloneMouseEvent(e);
|
|
if (svgEl) {
|
|
if (clonedEvent) {
|
|
if (svgEl.fireEvent) {
|
|
svgEl.fireEvent(clonedEvent);
|
|
} else if (svgEl.dispatchEvent) {
|
|
svgEl.dispatchEvent(clonedEvent);
|
|
}
|
|
} else if (svgEl['on' + evtType]) {
|
|
svgEl['on' + evtType](e);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Add to chart div and unhide from screen readers
|
|
parentGroup.appendChild(proxy);
|
|
if (!attrs['aria-hidden']) {
|
|
this.unhideElementFromScreenReaders(proxy);
|
|
}
|
|
return proxy;
|
|
},
|
|
|
|
|
|
/**
|
|
* Get the position relative to chart container for a wrapped SVG element.
|
|
* @private
|
|
* @param {Highcharts.SVGElement} element The element to calculate position
|
|
* for.
|
|
*
|
|
* @return {object} Object with x and y props for the position.
|
|
*/
|
|
getElementPosition: function (element) {
|
|
var el = element.element,
|
|
div = this.chart.renderTo;
|
|
if (div && el && el.getBoundingClientRect) {
|
|
var rectEl = el.getBoundingClientRect(),
|
|
rectDiv = div.getBoundingClientRect();
|
|
return {
|
|
x: rectEl.x - rectDiv.x,
|
|
y: rectEl.y - rectDiv.y,
|
|
width: rectEl.width,
|
|
height: rectEl.height
|
|
};
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* Add a new proxy group to the proxy container. Creates the proxy container
|
|
* if it does not exist.
|
|
* @private
|
|
* @param {object} attrs The attributes to set on the new group div.
|
|
*
|
|
* @return {Highcharts.HTMLDOMElement} The new proxy group element.
|
|
*/
|
|
addProxyGroup: function (attrs) {
|
|
var chart = this.chart,
|
|
proxyContainer = chart.a11yProxyContainer;
|
|
|
|
// Add root proxy container if it does not exist
|
|
if (!proxyContainer) {
|
|
chart.a11yProxyContainer = doc.createElement('div');
|
|
chart.a11yProxyContainer.style.position = 'relative';
|
|
}
|
|
// Add it if it is new, else make sure we move it to the end
|
|
if (chart.container.nextSibling !== chart.a11yProxyContainer) {
|
|
chart.renderTo.insertBefore(
|
|
chart.a11yProxyContainer,
|
|
chart.container.nextSibling
|
|
);
|
|
}
|
|
|
|
// Create the group and add it
|
|
var groupDiv = this.createElement('div');
|
|
Object.keys(attrs || {}).forEach(function (prop) {
|
|
if (attrs[prop] !== null) {
|
|
groupDiv.setAttribute(prop, attrs[prop]);
|
|
}
|
|
});
|
|
chart.a11yProxyContainer.appendChild(groupDiv);
|
|
return groupDiv;
|
|
},
|
|
|
|
|
|
/**
|
|
* Utility function for removing an element from the DOM.
|
|
* @private
|
|
* @param {Highcharts.HTMLDOMElement} element The element to remove.
|
|
*/
|
|
removeElement: function (element) {
|
|
if (element && element.parentNode) {
|
|
element.parentNode.removeChild(element);
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* Unhide an element from screen readers. Also unhides parents, and hides
|
|
* siblings that are not explicitly unhidden.
|
|
* @private
|
|
* @param {Highcharts.HTMLDOMElement|Highcharts.SVGDOMElement} element
|
|
* The element to unhide
|
|
*/
|
|
unhideElementFromScreenReaders: function (element) {
|
|
element.setAttribute('aria-hidden', false);
|
|
if (element === this.chart.renderTo || !element.parentNode) {
|
|
return;
|
|
}
|
|
|
|
// Hide siblings unless their hidden state is already explicitly set
|
|
Array.prototype.forEach.call(
|
|
element.parentNode.childNodes,
|
|
function (node) {
|
|
if (!node.hasAttribute('aria-hidden')) {
|
|
node.setAttribute('aria-hidden', true);
|
|
}
|
|
}
|
|
);
|
|
// Repeat for parent
|
|
this.unhideElementFromScreenReaders(element.parentNode);
|
|
},
|
|
|
|
|
|
/**
|
|
* Should remove any event handlers added, as well as any DOM elements.
|
|
* @private
|
|
*/
|
|
destroyBase: function () {
|
|
// Destroy proxy container
|
|
var chart = this.chart || {},
|
|
component = this;
|
|
this.removeElement(chart.a11yProxyContainer);
|
|
|
|
// Remove event callbacks and dom elements
|
|
this.eventRemovers.forEach(function (remover) {
|
|
remover();
|
|
});
|
|
this.domElements.forEach(function (element) {
|
|
component.removeElement(element);
|
|
});
|
|
this.eventRemovers = [];
|
|
this.domElements = [];
|
|
},
|
|
|
|
|
|
/**
|
|
* Utility function to strip tags from a string. Used for aria-label
|
|
* attributes, painting on a canvas will fail if the text contains tags.
|
|
* @private
|
|
* @param {string} s The string to strip tags from
|
|
* @return {string} The new string.
|
|
*/
|
|
stripTags: function (s) {
|
|
return typeof s === 'string' ? s.replace(/<\/?[^>]+(>|$)/g, '') : s;
|
|
},
|
|
|
|
|
|
/**
|
|
* HTML encode some characters vulnerable for XSS.
|
|
* @private
|
|
* @param {string} html The input string.
|
|
* @return {string} The escaped string.
|
|
*/
|
|
htmlencode: function (html) {
|
|
return html
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
.replace(/\//g, '/');
|
|
},
|
|
|
|
|
|
// Functions to be overridden by derived classes
|
|
|
|
/**
|
|
* Initialize component.
|
|
*/
|
|
init: function () {},
|
|
|
|
/**
|
|
* Get keyboard navigation handler for this component.
|
|
* @return {Highcharts.KeyboardNavigationHandler}
|
|
*/
|
|
getKeyboardNavigation: function () {},
|
|
|
|
/**
|
|
* Called on updates to the chart, including options changes.
|
|
* Note that this is also called on first render of chart.
|
|
*/
|
|
onChartUpdate: function () {},
|
|
|
|
/**
|
|
* Called on every chart render.
|
|
*/
|
|
onChartRender: function () {},
|
|
|
|
/**
|
|
* Called when accessibility is disabled or chart is destroyed.
|
|
* Should call destroyBase to make sure events/elements added are removed.
|
|
*/
|
|
destroy: function () {
|
|
this.destroyBase();
|
|
}
|
|
|
|
};
|
|
|
|
export default AccessibilityComponent;
|