highcharts
Version:
JavaScript charting framework
474 lines (473 loc) • 16 kB
JavaScript
/* *
*
* (c) 2009-2025 Øystein Moseng
*
* Accessibility component for the range selector.
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* */
'use strict';
import AccessibilityComponent from '../AccessibilityComponent.js';
import Announcer from '../Utils/Announcer.js';
import ChartUtilities from '../Utils/ChartUtilities.js';
const { unhideChartElementFromAT, getAxisRangeDescription } = ChartUtilities;
import KeyboardNavigationHandler from '../KeyboardNavigationHandler.js';
import U from '../../Core/Utilities.js';
const { addEvent, attr } = U;
/* *
*
* Functions
*
* */
/**
* Do we want date input navigation
* @private
*/
function shouldRunInputNavigation(chart) {
return Boolean(chart.rangeSelector &&
chart.rangeSelector.inputGroup &&
chart.rangeSelector.inputGroup.element.style.visibility !== 'hidden' &&
chart.options.rangeSelector.inputEnabled !== false &&
chart.rangeSelector.minInput &&
chart.rangeSelector.maxInput);
}
/* *
*
* Class
*
* */
/**
* The RangeSelectorComponent class
*
* @private
* @class
* @name Highcharts.RangeSelectorComponent
*/
class RangeSelectorComponent extends AccessibilityComponent {
/* *
*
* Functions
*
* */
/* eslint-disable valid-jsdoc */
/**
* Init the component
* @private
*/
init() {
const chart = this.chart;
this.announcer = new Announcer(chart, 'polite');
}
/**
* Called on first render/updates to the chart, including options changes.
*/
onChartUpdate() {
const chart = this.chart, component = this, rangeSelector = chart.rangeSelector;
if (!rangeSelector) {
return;
}
this.updateSelectorVisibility();
this.setDropdownAttrs();
if (rangeSelector.buttons &&
rangeSelector.buttons.length) {
rangeSelector.buttons.forEach((button) => {
component.setRangeButtonAttrs(button);
});
}
// Make sure input boxes are accessible and focusable
if (rangeSelector.maxInput && rangeSelector.minInput) {
['minInput', 'maxInput'].forEach(function (key, i) {
const input = rangeSelector[key];
if (input) {
unhideChartElementFromAT(chart, input);
component.setRangeInputAttrs(input, 'accessibility.rangeSelector.' + (i ? 'max' : 'min') +
'InputLabel');
}
});
}
}
/**
* Hide buttons from AT when showing dropdown, and vice versa.
* @private
*/
updateSelectorVisibility() {
const chart = this.chart;
const rangeSelector = chart.rangeSelector;
const dropdown = (rangeSelector &&
rangeSelector.dropdown);
const buttons = (rangeSelector &&
rangeSelector.buttons ||
[]);
const hideFromAT = (el) => el.setAttribute('aria-hidden', true);
if (rangeSelector &&
rangeSelector.hasVisibleDropdown &&
dropdown) {
unhideChartElementFromAT(chart, dropdown);
buttons.forEach((btn) => hideFromAT(btn.element));
}
else {
if (dropdown) {
hideFromAT(dropdown);
}
buttons.forEach((btn) => unhideChartElementFromAT(chart, btn.element));
}
}
/**
* Set accessibility related attributes on dropdown element.
* @private
*/
setDropdownAttrs() {
const chart = this.chart;
const dropdown = (chart.rangeSelector &&
chart.rangeSelector.dropdown);
if (dropdown) {
const label = chart.langFormat('accessibility.rangeSelector.dropdownLabel', { rangeTitle: chart.options.lang.rangeSelectorZoom });
dropdown.setAttribute('aria-label', label);
dropdown.setAttribute('tabindex', -1);
}
}
/**
* Set attrs for a range button
* @private
*/
setRangeButtonAttrs(button) {
attr(button.element, {
tabindex: -1,
role: 'button'
});
}
/**
* Set attrs for a date input
* @private
*/
setRangeInputAttrs(input, langKey) {
const chart = this.chart;
attr(input, {
tabindex: -1,
'aria-label': chart.langFormat(langKey, { chart: chart })
});
}
/**
* Handle arrow key nav
* @private
*/
onButtonNavKbdArrowKey(keyboardNavigationHandler, keyCode) {
const response = keyboardNavigationHandler.response, keys = this.keyCodes, chart = this.chart, wrapAround = chart.options.accessibility
.keyboardNavigation.wrapAround, direction = (keyCode === keys.left || keyCode === keys.up) ? -1 : 1, didHighlight = chart.highlightRangeSelectorButton(chart.highlightedRangeSelectorItemIx + direction);
if (!didHighlight) {
if (wrapAround) {
keyboardNavigationHandler.init(direction);
return response.success;
}
return response[direction > 0 ? 'next' : 'prev'];
}
return response.success;
}
/**
* Handle keyboard click
* @private
*/
onButtonNavKbdClick(keyboardNavigationHandler) {
const response = keyboardNavigationHandler.response, chart = this.chart, wasDisabled = chart.oldRangeSelectorItemState === 3;
if (!wasDisabled) {
this.fakeClickEvent(chart.rangeSelector.buttons[chart.highlightedRangeSelectorItemIx].element);
}
return response.success;
}
/**
* Called whenever a range selector button has been clicked, either by
* mouse, touch, or kbd/voice/other.
* @private
*/
onAfterBtnClick() {
const chart = this.chart;
const axisRangeDescription = getAxisRangeDescription(chart.xAxis[0]);
const announcement = chart.langFormat('accessibility.rangeSelector.clickButtonAnnouncement', { chart, axisRangeDescription });
if (announcement) {
this.announcer.announce(announcement);
}
}
/**
* Handle move between input elements with Tab key
* @private
*/
onInputKbdMove(direction) {
const chart = this.chart;
const rangeSel = chart.rangeSelector;
const newIx = chart.highlightedInputRangeIx = (chart.highlightedInputRangeIx || 0) + direction;
const newIxOutOfRange = newIx > 1 || newIx < 0;
if (newIxOutOfRange) {
if (chart.accessibility) {
// Ignore focus
chart.accessibility.keyboardNavigation.exiting = true;
chart.accessibility.keyboardNavigation.tabindexContainer
.focus();
return chart.accessibility.keyboardNavigation.move(direction);
}
}
else if (rangeSel) {
const svgEl = rangeSel[newIx ? 'maxDateBox' : 'minDateBox'];
const inputEl = rangeSel[newIx ? 'maxInput' : 'minInput'];
if (svgEl && inputEl) {
chart.setFocusToElement(svgEl, inputEl);
}
}
return true;
}
/**
* Init date input navigation
* @private
*/
onInputNavInit(direction) {
const component = this;
const chart = this.chart;
const buttonIxToHighlight = direction > 0 ? 0 : 1;
const rangeSel = chart.rangeSelector;
const svgEl = (rangeSel &&
rangeSel[buttonIxToHighlight ? 'maxDateBox' : 'minDateBox']);
const minInput = (rangeSel && rangeSel.minInput);
const maxInput = (rangeSel && rangeSel.maxInput);
const inputEl = buttonIxToHighlight ? maxInput : minInput;
chart.highlightedInputRangeIx = buttonIxToHighlight;
if (svgEl && minInput && maxInput) {
chart.setFocusToElement(svgEl, inputEl);
// Tab-press with the input focused does not propagate to chart
// automatically, so we manually catch and handle it when relevant.
if (this.removeInputKeydownHandler) {
this.removeInputKeydownHandler();
}
const keydownHandler = (e) => {
const isTab = (e.which || e.keyCode) === this.keyCodes.tab;
if (isTab &&
component.onInputKbdMove(e.shiftKey ? -1 : 1)) {
e.preventDefault();
e.stopPropagation();
}
};
const minRemover = addEvent(minInput, 'keydown', keydownHandler);
const maxRemover = addEvent(maxInput, 'keydown', keydownHandler);
this.removeInputKeydownHandler = () => {
minRemover();
maxRemover();
};
}
}
/**
* Terminate date input nav
* @private
*/
onInputNavTerminate() {
const rangeSel = (this.chart.rangeSelector || {});
if (rangeSel.maxInput) {
rangeSel.hideInput('max');
}
if (rangeSel.minInput) {
rangeSel.hideInput('min');
}
if (this.removeInputKeydownHandler) {
this.removeInputKeydownHandler();
delete this.removeInputKeydownHandler;
}
}
/**
* Init range selector dropdown nav
* @private
*/
initDropdownNav() {
const chart = this.chart;
const rangeSelector = chart.rangeSelector;
const dropdown = (rangeSelector && rangeSelector.dropdown);
if (rangeSelector && dropdown) {
chart.setFocusToElement(rangeSelector.buttonGroup, dropdown);
if (this.removeDropdownKeydownHandler) {
this.removeDropdownKeydownHandler();
}
// Tab-press with dropdown focused does not propagate to chart
// automatically, so we manually catch and handle it when relevant.
this.removeDropdownKeydownHandler = addEvent(dropdown, 'keydown', (e) => {
const isTab = (e.which || e.keyCode) === this.keyCodes.tab, a11y = chart.accessibility;
if (isTab) {
e.preventDefault();
e.stopPropagation();
if (a11y) {
a11y.keyboardNavigation.move(e.shiftKey ? -1 : 1);
}
}
});
}
}
/**
* Get navigation for the range selector buttons.
* @private
* @return {Highcharts.KeyboardNavigationHandler} The module object.
*/
getRangeSelectorButtonNavigation() {
const chart = this.chart;
const keys = this.keyCodes;
const component = this;
return new KeyboardNavigationHandler(chart, {
keyCodeMap: [
[
[keys.left, keys.right, keys.up, keys.down],
function (keyCode) {
return component.onButtonNavKbdArrowKey(this, keyCode);
}
],
[
[keys.enter, keys.space],
function () {
return component.onButtonNavKbdClick(this);
}
]
],
validate: function () {
return !!(chart.rangeSelector &&
chart.rangeSelector.buttons &&
chart.rangeSelector.buttons.length);
},
init: function (direction) {
const rangeSelector = chart.rangeSelector;
if (rangeSelector && rangeSelector.hasVisibleDropdown) {
component.initDropdownNav();
}
else if (rangeSelector) {
const lastButtonIx = rangeSelector.buttons.length - 1;
chart.highlightRangeSelectorButton(direction > 0 ? 0 : lastButtonIx);
}
},
terminate: function () {
if (component.removeDropdownKeydownHandler) {
component.removeDropdownKeydownHandler();
delete component.removeDropdownKeydownHandler;
}
}
});
}
/**
* Get navigation for the range selector input boxes.
* @private
* @return {Highcharts.KeyboardNavigationHandler}
* The module object.
*/
getRangeSelectorInputNavigation() {
const chart = this.chart;
const component = this;
return new KeyboardNavigationHandler(chart, {
keyCodeMap: [],
validate: function () {
return shouldRunInputNavigation(chart);
},
init: function (direction) {
component.onInputNavInit(direction);
},
terminate: function () {
component.onInputNavTerminate();
}
});
}
/**
* Get keyboard navigation handlers for this component.
* @return {Array<Highcharts.KeyboardNavigationHandler>}
* List of module objects.
*/
getKeyboardNavigation() {
return [
this.getRangeSelectorButtonNavigation(),
this.getRangeSelectorInputNavigation()
];
}
/**
* Remove component traces
*/
destroy() {
if (this.removeDropdownKeydownHandler) {
this.removeDropdownKeydownHandler();
}
if (this.removeInputKeydownHandler) {
this.removeInputKeydownHandler();
}
if (this.announcer) {
this.announcer.destroy();
}
}
}
/* *
*
* Class Namespace
*
* */
(function (RangeSelectorComponent) {
/* *
*
* Declarations
*
* */
/* *
*
* Functions
*
* */
/**
* Highlight range selector button by index.
*
* @private
* @function Highcharts.Chart#highlightRangeSelectorButton
*/
function chartHighlightRangeSelectorButton(ix) {
const buttons = (this.rangeSelector &&
this.rangeSelector.buttons ||
[]);
const curHighlightedIx = this.highlightedRangeSelectorItemIx;
const curSelectedIx = (this.rangeSelector &&
this.rangeSelector.selected);
// Deselect old
if (typeof curHighlightedIx !== 'undefined' &&
buttons[curHighlightedIx] &&
curHighlightedIx !== curSelectedIx) {
buttons[curHighlightedIx].setState(this.oldRangeSelectorItemState || 0);
}
// Select new
this.highlightedRangeSelectorItemIx = ix;
if (buttons[ix]) {
this.setFocusToElement(buttons[ix].box, buttons[ix].element);
if (ix !== curSelectedIx) {
this.oldRangeSelectorItemState = buttons[ix].state;
buttons[ix].setState(1);
}
return true;
}
return false;
}
/**
* Build compositions
* @private
*/
function compose(ChartClass, RangeSelectorClass) {
const chartProto = ChartClass.prototype;
if (!chartProto.highlightRangeSelectorButton) {
chartProto.highlightRangeSelectorButton = (chartHighlightRangeSelectorButton);
addEvent(RangeSelectorClass, 'afterBtnClick', rangeSelectorAfterBtnClick);
}
}
RangeSelectorComponent.compose = compose;
/**
* Range selector does not have destroy-setup for class instance events - so
* we set it on the class and call the component from here.
* @private
*/
function rangeSelectorAfterBtnClick() {
const a11y = this.chart.accessibility;
if (a11y && a11y.components.rangeSelector) {
return a11y.components.rangeSelector.onAfterBtnClick();
}
}
})(RangeSelectorComponent || (RangeSelectorComponent = {}));
/* *
*
* Export Default
*
* */
export default RangeSelectorComponent;