diff --git a/spec/bar-chart-spec.js b/spec/bar-chart-spec.js index a744fb022..495dfaa75 100644 --- a/spec/bar-chart-spec.js +++ b/spec/bar-chart-spec.js @@ -1278,6 +1278,81 @@ describe('dc.BarChart', () => { } }); + describe('accessibility bar chart', () => { + + function removeEmptyBins (sourceGroup) { + return { + all:function () { + return sourceGroup.all().filter( d => d.value !== 0); + } + }; + } + + it('internal elements are focusable by keyboard', () => { + chart.keyboardAccessible(true); + chart.render(); + chart.selectAll('rect.bar').each(function () { + const bar = d3.select(this); + expect(bar.attr('tabindex')).toEqual('0'); + }); + }); + + it('initial internal elements are clickable by pressing enter', () => { + chart.keyboardAccessible(true); + const clickHandlerSpy = jasmine.createSpy(); + chart.onClick = clickHandlerSpy; + chart.render(); + + const event = new Event('keydown'); + event.keyCode = 13; + + chart.selectAll('rect.bar').each(function (d) { + this.dispatchEvent(event); + expect(clickHandlerSpy).toHaveBeenCalledWith(d); + clickHandlerSpy.calls.reset(); + }); + }); + + it('newly added internal elements are also clickable from keyboard', () => { + chart.keyboardAccessible(true); + const clickHandlerSpy = jasmine.createSpy(); + chart.onClick = clickHandlerSpy; + + const event = new Event('keydown'); + event.keyCode = 13; + + const stateDimension = data.dimension(d => d.state); + const regionDimension = data.dimension(d => d.region); + const stateGroup = stateDimension.group().reduceSum(d => +d.value); + const ordinalDomainValues = ['California', 'Colorado', 'Delaware', 'Ontario', 'Mississippi', 'Oklahoma']; + + chart + .dimension(stateDimension) + .group(removeEmptyBins(stateGroup)) + .xUnits(dc.units.ordinal) + .x(d3.scaleBand().domain(ordinalDomainValues)) + .elasticX(true) + .barPadding(0) + .outerPadding(0.1) + .transitionDuration(0) + .render(); + + //force creation of new elements on existing chart + regionDimension.filterExact('West'); + chart.redraw(); + regionDimension.filterAll(); + chart.redraw(); + + chart.selectAll('rect.bar').each(function (d) { + this.dispatchEvent(event); + expect(clickHandlerSpy).toHaveBeenCalledWith(d); + clickHandlerSpy.calls.reset(); + }); + + }); + + }); + function nthStack (n) { const stack = d3.select(chart.selectAll('.stack').nodes()[n]); diff --git a/spec/base-mixin-spec.js b/spec/base-mixin-spec.js index 5ad0ec371..d11caeeee 100644 --- a/spec/base-mixin-spec.js +++ b/spec/base-mixin-spec.js @@ -677,4 +677,34 @@ describe('dc.baseMixin', () => { expect(chart.filters().length).toEqual(0); }); }); + + describe('accessibility base svg', () => { + + it('should have default description when keyboardAccessible is true', () => { + chart + .keyboardAccessible(true) + .resetSvg(); + + expect(chart.svg().attr('tabindex')).toEqual('0'); + expect(chart.svg().node().firstChild.innerHTML).toEqual('BaseMixin'); + }); + + it('should have custom description if svgDescription is set', () => { + chart + .svgDescription('I am a chart') + .resetSvg(); + + expect(chart.svg().attr('tabindex')).toEqual('0'); + expect(chart.svg().node().firstChild.innerHTML).toEqual('I am a chart'); + }); + + it('should not have accessibility features if not explicitly enabled', () => { + chart + .resetSvg(); + + expect(chart.svg().attr('tabindex')).toBeNull(); + }); + + + }) }); diff --git a/spec/box-plot-spec.js b/spec/box-plot-spec.js index 6d99eab2b..129aa6b97 100644 --- a/spec/box-plot-spec.js +++ b/spec/box-plot-spec.js @@ -291,6 +291,37 @@ describe('dc.BoxPlot', () => { }); }); + describe('accessibility scatter plot', () => { + + it('internal elements are focusable by keyboard', () => { + chart.keyboardAccessible(true); + chart.render(); + chart.selectAll('circle').each(function () { + const circle = d3.select(this); + expect(circle.attr('tabindex')).toEqual('0'); + }); + }); + + it('internal elements are clickable by pressing enter', () => { + + chart.keyboardAccessible(true); + const clickHandlerSpy = jasmine.createSpy(); + chart.onClick = clickHandlerSpy; + chart.render(); + + const event = new Event('keydown'); + event.keyCode = 13; + + // only boxes are valid targets for keydown events + chart.selectAll('g.box').each(function (d) { + this.dispatchEvent(event); + expect(clickHandlerSpy).toHaveBeenCalledWith(d); + clickHandlerSpy.calls.reset(); + }); + }); + + }); + function box (n) { const nthBox = d3.select(chart.selectAll('g.box').nodes()[n]); nthBox.boxText = function (i) { diff --git a/spec/bubble-chart-spec.js b/spec/bubble-chart-spec.js index 2a053be36..407f4c96f 100644 --- a/spec/bubble-chart-spec.js +++ b/spec/bubble-chart-spec.js @@ -710,4 +710,42 @@ describe('dc.bubbleChart', () => { }); }); }); + + describe('accessibility bubble chart', () => { + + it('DOM order follows x values if keyboardAccessible is set', () => { + // default (alphabetical) sort order would put F ahead of T in DOM order + // keyboardAccessible should instead re-order DOM elements based on x-value + // T value is 198; F value is 220 + chart.keyboardAccessible(true); + chart.render(); + expect(document.querySelectorAll('.node text')[0].innerHTML).toEqual('T') + }); + + it('internal elements are focusable by keyboard', () => { + chart.keyboardAccessible(true); + chart.render(); + chart.selectAll('circle').each(function () { + const bubble = d3.select(this); + expect(bubble.attr('tabindex')).toEqual('0'); + }); + }); + + it('internal elements are clickable by pressing enter', () => { + chart.keyboardAccessible(true); + const clickHandlerSpy = jasmine.createSpy(); + chart.onClick = clickHandlerSpy; + chart.render(); + + const event = new Event('keydown'); + event.keyCode = 13; + + chart.selectAll('circle').each(function (d) { + this.dispatchEvent(event); + expect(clickHandlerSpy).toHaveBeenCalledWith(d); + clickHandlerSpy.calls.reset(); + }); + }); + + }); }); diff --git a/spec/geo-choropleth-chart-spec.js b/spec/geo-choropleth-chart-spec.js index b7c898164..c5361dfa4 100644 --- a/spec/geo-choropleth-chart-spec.js +++ b/spec/geo-choropleth-chart-spec.js @@ -268,4 +268,53 @@ describe('dc.geoChoropleth', () => { expect(chart.geoJsons().filter(e => e.name === 'state').length).toEqual(0); }); }); + + describe('accessibility choropleth', () => { + // create chart without rendering it + let chart; + beforeEach(() => { + const id = 'accessible-choropleth-chart' + chart = new dc.GeoChoroplethChart(`#${id}`); + appendChartID(id); + + chart.dimension(stateDimension) + .group(stateValueSumGroup) + .width(990) + .height(600) + .keyboardAccessible(true) + .colors(['#ccc', '#e2f2ff', '#c4e4ff', '#9ed2ff', '#81c5ff', '#6bbaff', '#51aeff', '#36a2ff', '#1e96ff', '#0089ff']) + .colorDomain([0, 155]) + .overlayGeoJson(geoJson.features, 'state', d => d.properties.name) + .overlayGeoJson(geoJson2.features, 'county') + .transitionDuration(0) + .title(d => `${d.key} : ${d.value ? d.value : 0}`); + }); + + it('internal elements are focusable by keyboard', () => { + + chart.render(); + chart.selectAll('path.dc-tabbable').each(function () { + const state = d3.select(this); + expect(state.attr('tabindex')).toEqual('0'); + }); + }); + + it('internal elements are clickable by pressing enter', () => { + + const clickHandlerSpy = jasmine.createSpy(); + chart.onClick = clickHandlerSpy; + chart.render(); + + const event = new Event('keydown'); + event.keyCode = 13; + + chart.selectAll('path.dc-tabbable').each(function (d) { + this.dispatchEvent(event); + expect(clickHandlerSpy).toHaveBeenCalledWith(d); + clickHandlerSpy.calls.reset(); + }); + }); + + }); + }); diff --git a/spec/heatmap-spec.js b/spec/heatmap-spec.js index a5e0b8815..dd561ef40 100644 --- a/spec/heatmap-spec.js +++ b/spec/heatmap-spec.js @@ -605,4 +605,36 @@ describe('dc.heatmap', () => { }); }); }); + + describe('accessibility heatmap', () => { + + beforeEach(() => { + chart.keyboardAccessible(true); + }) + + it('internal elements are focusable by keyboard', () => { + + chart.render(); + chart.selectAll('rect.heat-box').each(function () { + const bar = d3.select(this); + expect(bar.attr('tabindex')).toEqual('0'); + }); + }); + + it('internal elements are clickable by pressing enter', () => { + + const clickHandlerSpy = jasmine.createSpy(); + chart.boxOnClick = clickHandlerSpy; + chart.render(); + + const event = new Event('keydown'); + event.keyCode = 13; + + chart.selectAll('rect.heat-box').each(function (d) { + this.dispatchEvent(event); + expect(clickHandlerSpy).toHaveBeenCalledWith(d); + clickHandlerSpy.calls.reset(); + }); + }); + }); }); diff --git a/spec/legend-spec.js b/spec/legend-spec.js index 7c212ff8d..fe6ba4082 100644 --- a/spec/legend-spec.js +++ b/spec/legend-spec.js @@ -273,6 +273,31 @@ describe('dc.legend', () => { }); }); + describe('accessible legends', () => { + beforeEach(() => { + chart.legend(new dc.Legend().keyboardAccessible(true)).render(); + }); + + it('legend items should be focusable from keyboard', () => { + + chart.select('g.dc-legend').selectAll('g.dc-legend-item text').each(function () { + const item = d3.select(this); + expect(item.attr('tabindex')).toEqual('0'); + }); + + }); + + it('keyboard focus on legend item should highlight chart item', () => { + + chart + .select('g.dc-legend').select('g.dc-legend-item text') + .on('focus').call(legendItem(0).nodes()[0], legendItem(0).datum()); + + expect(d3.select(chart.selectAll('path.line').nodes()[0]).classed('highlight')).toBeTruthy(); + }); + + }); + function legendItem (n) { return d3.select(chart.selectAll('g.dc-legend g.dc-legend-item').nodes()[n]); } diff --git a/spec/line-chart-spec.js b/spec/line-chart-spec.js index 5fa38722f..c3efb441d 100644 --- a/spec/line-chart-spec.js +++ b/spec/line-chart-spec.js @@ -750,6 +750,22 @@ describe('dc.lineChart', () => { }); }); + describe('accessibility bar chart', () => { + + beforeEach(() => { + chart.keyboardAccessible(true); + chart.brushOn(false); + }) + + it('internal elements are focusable by keyboard', () => { + chart.render(); + chart.selectAll('circle.dot').each(function () { + const dot = d3.select(this); + expect(dot.attr('tabindex')).toEqual('0'); + }); + }); + }); + function lineLabelCount () { expect(chart.selectAll('text.lineLabel').size()).toBe(chart.stack().length * chart.group().all().length); } diff --git a/spec/number-display-spec.js b/spec/number-display-spec.js index d595e8bcc..3d2dbadbd 100644 --- a/spec/number-display-spec.js +++ b/spec/number-display-spec.js @@ -221,4 +221,39 @@ describe('dc.numberDisplay', () => { }); }); }); + + describe('accessibility number display' , () => { + let chart; + beforeEach(() => { + + const id = 'empty-div'; + appendChartID(id); + + chart = new dc.NumberDisplay(`#${id}`) + .transitionDuration(0) + .group(meanGroup) + .formatNumber(d3.format('.3s')) + .valueAccessor(average); + }); + + it('should have aria-live', () => { + chart.ariaLiveRegion(true); + chart.render(); + d3.timerFlush(); + + expect(chart.select('span.number-display').attr('aria-live')).toEqual('polite'); + + }); + + it('should be focusable', () => { + chart.keyboardAccessible(true); + chart.render(); + d3.timerFlush(); + + expect(chart.select('span.number-display').attr('tabindex')).toEqual('0'); + + }); + + }); + }); diff --git a/spec/pie-chart-spec.js b/spec/pie-chart-spec.js index adc1a92a9..5af8aee74 100644 --- a/spec/pie-chart-spec.js +++ b/spec/pie-chart-spec.js @@ -817,5 +817,45 @@ describe('dc.pieChart', () => { valueDimension.filterAll(); }); }); + + describe('accessibility pie chart', () => { + + let chart; + beforeEach(() => { + chart = buildChart('pie-chart-legend'); + chart.keyboardAccessible(true); + + }); + + it('internal elements are focusable by keyboard', () => { + + chart.render(); + chart.selectAll('g.pie-slice').each(function () { + const pie = d3.select(this); + expect(pie.attr('tabindex')).toEqual('0'); + }); + }); + + it('internal elements are clickable by pressing enter', () => { + + const clickHandlerSpy = jasmine.createSpy(); + chart._onClick = clickHandlerSpy; + chart.render(); + + const event = new Event('keydown'); + event.keyCode = 13; + + chart.selectAll('g.pie-slice').each(function (d) { + this.dispatchEvent(event); + expect(clickHandlerSpy).toHaveBeenCalledWith(d); + clickHandlerSpy.calls.reset(); + }); + }); + }); + + + + + }); diff --git a/spec/row-chart-spec.js b/spec/row-chart-spec.js index 36baa1167..ebeb12412 100644 --- a/spec/row-chart-spec.js +++ b/spec/row-chart-spec.js @@ -163,6 +163,40 @@ describe('dc.rowChart', () => { }); }); + describe('accessibility row chart', () => { + + beforeEach(() => { + chart.group(positiveGroupHolder.group); + chart.x(d3.scaleLinear()); + chart.keyboardAccessible(true); + }); + + it('internal elements are focusable by keyboard', () => { + + chart.render(); + chart.selectAll('rect').each(function () { + const row = d3.select(this); + expect(row.attr('tabindex')).toEqual('0'); + }); + }); + + it('internal elements are clickable by pressing enter', () => { + + const clickHandlerSpy = jasmine.createSpy(); + chart._onClick = clickHandlerSpy; + chart.render(); + + const event = new Event('keydown'); + event.keyCode = 13; + + chart.selectAll('rect').each(function (d) { + this.dispatchEvent(event); + expect(clickHandlerSpy).toHaveBeenCalledWith(d); + clickHandlerSpy.calls.reset(); + }); + }); + }); + function itShouldBehaveLikeARowChartWithGroup (groupHolder, N, xAxisTicks) { describe(`for ${groupHolder.groupType} data`, () => { beforeEach(() => { diff --git a/spec/scatter-plot-spec.js b/spec/scatter-plot-spec.js index 701a25318..52250abbb 100644 --- a/spec/scatter-plot-spec.js +++ b/spec/scatter-plot-spec.js @@ -538,6 +538,21 @@ describe('dc.scatterPlot', () => { }); }); + describe('accessibility scatter plot', () => { + + beforeEach(() => { + chart.keyboardAccessible(true); + }) + + it('internal elements are focusable by keyboard', () => { + chart.render(); + chart.selectAll('path.symbol').each(function () { + const dot = d3.select(this); + expect(dot.attr('tabindex')).toEqual('0'); + }); + }); + }); + function nthSymbol (i) { return d3.select(chart.selectAll('path.symbol').nodes()[i]); } diff --git a/spec/sunburst-chart-spec.js b/spec/sunburst-chart-spec.js index 0570139cb..cae6bbd25 100644 --- a/spec/sunburst-chart-spec.js +++ b/spec/sunburst-chart-spec.js @@ -477,4 +477,48 @@ describe('dc.sunburstChart', () => { }); + + describe('accessibility sunburst', () => { + + let chart; + beforeEach(() => { + + const id = 'accessible-sunburst' + appendChartID(id); + chart = new dc.SunburstChart(`#${id}`); + chart + .dimension(countryRegionStateDimension) + .group(countryRegionStateGroup) + .width(width) + .height(height) + .transitionDuration(0) + .keyboardAccessible(true); + }) + + it('internal elements are focusable by keyboard', () => { + + chart.render(); + chart.selectAll('g.pie-slice path').each(function () { + const burst = d3.select(this); + expect(burst.attr('tabindex')).toEqual('0'); + }); + }); + + it('internal elements are clickable by pressing enter', () => { + + const clickHandlerSpy = jasmine.createSpy(); + chart.onClick = clickHandlerSpy; + chart.render(); + + const event = new Event('keydown'); + event.keyCode = 13; + + chart.selectAll('g.pie-slice path').each(function (d) { + this.dispatchEvent(event); + expect(clickHandlerSpy).toHaveBeenCalledWith(d); + clickHandlerSpy.calls.reset(); + }); + }); + }); + }); diff --git a/src/base/base-mixin.js b/src/base/base-mixin.js index 75e2a6898..e16b4d7b3 100644 --- a/src/base/base-mixin.js +++ b/src/base/base-mixin.js @@ -11,6 +11,7 @@ import {logger} from '../core/logger'; import {printers} from '../core/printers'; import {InvalidStateException} from '../core/invalid-state-exception'; import {BadArgumentException} from '../core/bad-argument-exception'; +import {adaptHandler} from '../core/d3compat'; const _defaultFilterHandler = (dimension, filters) => { if (filters.length === 0) { @@ -72,6 +73,8 @@ const _defaultResetFilterHandler = filters => []; export class BaseMixin { constructor () { this.__dcFlag__ = utils.uniqueId(); + this._svgDescription = null + this._keyboardAccessible = false; this._dimension = undefined; this._group = undefined; @@ -521,10 +524,55 @@ export class BaseMixin { generateSvg () { this._svg = this.root().append('svg'); + + if (this._svgDescription || this._keyboardAccessible) { + + this._svg.append('desc') + .attr('id', `desc-id-${this.__dcFlag__}`) + .html(`${this.svgDescription()}`); + + this._svg + .attr('tabindex', '0') + .attr('role', 'img') + .attr('aria-labelledby', `desc-id-${this.__dcFlag__}`); + } + this.sizeSvg(); return this._svg; } + /** + * Set or get description text for the entire SVG graphic. If set, will create a `` element as the first + * child of the SVG with the description text and also make the SVG focusable from keyboard. + * @param {String} [description] + * @returns {String|BaseMixin} + */ + svgDescription (description) { + if (!arguments.length) { + return this._svgDescription || this.constructor.name; + } + + this._svgDescription = description; + return this; + } + + /** + * If set, interactive chart elements like individual bars in a bar chart or symbols in a scatter plot + * will be focusable from keyboard and on pressing Enter or Space will behave as if clicked on. + * + * If `svgDescription` has not been explicitly set, will also set SVG description text to the class + * constructor name, like BarChart or HeatMap, and make the entire SVG focusable. + * @param {Boolean} [keyboardAccessible=false] + * @returns {Boolean|BarChart} + */ + keyboardAccessible (keyboardAccessible) { + if (!arguments.length) { + return this._keyboardAccessible; + } + this._keyboardAccessible = keyboardAccessible; + return this; + } + /** * Set or get the filter printer function. The filter printer function is used to generate human * friendly text for filter value(s) associated with the chart instance. The text will get shown @@ -668,6 +716,28 @@ export class BaseMixin { return result; } + _makeKeyboardAccessible (onClickFunction, ...onClickArgs) { + // called from each chart module's render and redraw methods + const tabElements = this._svg + .selectAll('.dc-tabbable') + .attr('tabindex', 0); + + if (onClickFunction) { + tabElements.on('keydown', adaptHandler((d, event) => { + // trigger only if d is an object undestood by KeyAccessor() + if (event.keyCode === 13 && typeof d === 'object') { + onClickFunction.call(this, d, ...onClickArgs) + } + // special case for space key press - prevent scrolling + if (event.keyCode === 32 && typeof d === 'object') { + onClickFunction.call(this, d, ...onClickArgs) + event.preventDefault(); + } + + })); + } + } + _activateRenderlets (event) { this._listeners.call('pretransition', this, this); if (this.transitionDuration() > 0 && this._svg) { diff --git a/src/base/bubble-mixin.js b/src/base/bubble-mixin.js index a57317c97..f23d25c73 100644 --- a/src/base/bubble-mixin.js +++ b/src/base/bubble-mixin.js @@ -1,4 +1,4 @@ -import { descending, min, max } from 'd3-array'; +import { ascending, descending, min, max } from 'd3-array'; import { scaleLinear } from 'd3-scale'; import {ColorMixin} from './color-mixin'; @@ -32,6 +32,12 @@ export const BubbleMixin = Base => class extends ColorMixin(Base) { this.data(group => { const data = group.all(); + + if (this._keyboardAccessible) { + // sort based on the x value (key) + data.sort((a, b) => ascending(this.keyAccessor()(a), this.keyAccessor()(b))); + } + if (this._sortBubbleSize) { // sort descending so smaller bubbles are on top const radiusAccessor = this.radiusValueAccessor(); diff --git a/src/charts/bar-chart.js b/src/charts/bar-chart.js index 756f5147b..8448c0255 100644 --- a/src/charts/bar-chart.js +++ b/src/charts/bar-chart.js @@ -177,6 +177,7 @@ export class BarChart extends StackMixin { const enter = bars.enter() .append('rect') .attr('class', 'bar') + .classed('dc-tabbable', this._keyboardAccessible) .attr('fill', pluck('data', this.getColor)) .attr('x', d => this._barXPos(d)) .attr('y', this.yAxisHeight()) @@ -192,6 +193,10 @@ export class BarChart extends StackMixin { barsEnterUpdate.on('click', adaptHandler(d => this.onClick(d))); } + if (this._keyboardAccessible) { + this._makeKeyboardAccessible(this.onClick); + } + transition(barsEnterUpdate, this.transitionDuration(), this.transitionDelay()) .attr('x', d => this._barXPos(d)) .attr('y', d => { diff --git a/src/charts/box-plot.js b/src/charts/box-plot.js index f08d36367..454f32f16 100644 --- a/src/charts/box-plot.js +++ b/src/charts/box-plot.js @@ -43,7 +43,7 @@ function defaultWhiskersIQR (k) { */ export class BoxPlot extends CoordinateGridMixin { /** - * Create a BoxP lot. + * Create a Box Plot. * * @example * // create a box plot under #chart-container1 element using the default global chart group @@ -195,12 +195,20 @@ export class BoxPlot extends CoordinateGridMixin { boxesGEnter .attr('class', 'box') + .classed('dc-tabbable', this._keyboardAccessible) .attr('transform', (d, i) => this._boxTransform(d, i)) .call(this._box) .on('click', adaptHandler(d => { this.filter(this.keyAccessor()(d)); this.redrawGroup(); - })); + })) + .selectAll('circle') + .classed('dc-tabbable', this._keyboardAccessible); + + if (this._keyboardAccessible) { + this._makeKeyboardAccessible(this.onClick); + } + return boxesGEnter.merge(boxesG); } @@ -232,6 +240,11 @@ export class BoxPlot extends CoordinateGridMixin { return ((this._maxDataValue() - this._minDataValue()) / this.effectiveHeight()); } + onClick (d) { + this.filter(this.keyAccessor()(d)); + this.redrawGroup(); + } + fadeDeselectedArea (brushSelection) { const chart = this; if (this.hasFilter()) { diff --git a/src/charts/bubble-chart.js b/src/charts/bubble-chart.js index c46b27c92..9cb44eaa2 100644 --- a/src/charts/bubble-chart.js +++ b/src/charts/bubble-chart.js @@ -53,7 +53,7 @@ export class BubbleChart extends BubbleMixin(CoordinateGridMixin) { const data = this.data(); let bubbleG = this.chartBodyG().selectAll(`g.${this.BUBBLE_NODE_CLASS}`) .data(data, d => d.key); - if (this.sortBubbleSize()) { + if (this.sortBubbleSize() || this.keyboardAccessible()) { // update dom order based on sort bubbleG.order(); } @@ -75,6 +75,7 @@ export class BubbleChart extends BubbleMixin(CoordinateGridMixin) { .attr('transform', d => this._bubbleLocator(d)) .append('circle').attr('class', (d, i) => `${this.BUBBLE_CLASS} _${i}`) .on('click', adaptHandler(d => this.onClick(d))) + .classed('dc-tabbable', this._keyboardAccessible) .attr('fill', this.getColor) .attr('r', 0); @@ -85,6 +86,10 @@ export class BubbleChart extends BubbleMixin(CoordinateGridMixin) { .attr('r', d => this.bubbleR(d)) .attr('opacity', d => (this.bubbleR(d) > 0) ? 1 : 0); + if (this._keyboardAccessible) { + this._makeKeyboardAccessible(this.onClick); + } + this._doRenderLabel(bubbleGEnter); this._doRenderTitles(bubbleGEnter); diff --git a/src/charts/bubble-overlay.js b/src/charts/bubble-overlay.js index a16767975..85d3092f1 100644 --- a/src/charts/bubble-overlay.js +++ b/src/charts/bubble-overlay.js @@ -54,6 +54,7 @@ export class BubbleOverlay extends BubbleMixin(BaseMixin) { */ this._g = undefined; this._points = []; + this._keyboardAccessible = false; this.transitionDuration(750); @@ -113,11 +114,16 @@ export class BubbleOverlay extends BubbleMixin(BaseMixin) { if (circle.empty()) { circle = nodeG.append('circle') .attr('class', BUBBLE_CLASS) + .classed('dc-tabbable', this._keyboardAccessible) .attr('r', 0) .attr('fill', this.getColor) .on('click', adaptHandler(d => this.onClick(d))); } + if (this._keyboardAccessible) { + this._makeKeyboardAccessible(this.onClick); + } + transition(circle, this.transitionDuration(), this.transitionDelay()) .attr('r', d => this.bubbleR(d)); diff --git a/src/charts/geo-choropleth-chart.js b/src/charts/geo-choropleth-chart.js index 214d87ce0..f794e6db5 100644 --- a/src/charts/geo-choropleth-chart.js +++ b/src/charts/geo-choropleth-chart.js @@ -63,6 +63,7 @@ export class GeoChoroplethChart extends ColorMixin(BaseMixin) { regionG .append('path') + .classed('dc-tabbable', this._keyboardAccessible) .attr('fill', 'white') .attr('d', this._getGeoPath()); @@ -150,6 +151,10 @@ export class GeoChoroplethChart extends ColorMixin(BaseMixin) { }) .on('click', adaptHandler(d => this.onClick(d, layerIndex))); + if (this._keyboardAccessible) { + this._makeKeyboardAccessible(this.onClick, layerIndex); + } + transition(paths, this.transitionDuration(), this.transitionDelay()).attr('fill', (d, i) => this.getColor(data[this._geoJson(layerIndex).keyAccessor(d)], i)); } diff --git a/src/charts/heatmap.js b/src/charts/heatmap.js index b7eca16d4..b998622cf 100644 --- a/src/charts/heatmap.js +++ b/src/charts/heatmap.js @@ -229,11 +229,16 @@ export class HeatMap extends ColorMixin(MarginMixin) { gEnter.append('rect') .attr('class', 'heat-box') + .classed('dc-tabbable', this._keyboardAccessible) .attr('fill', 'white') .attr('x', (d, i) => cols(this.keyAccessor()(d, i))) .attr('y', (d, i) => rows(this.valueAccessor()(d, i))) .on('click', adaptHandler(this.boxOnClick())); + if (this._keyboardAccessible) { + this._makeKeyboardAccessible(this.boxOnClick); + } + boxes = gEnter.merge(boxes); if (this.renderTitle()) { diff --git a/src/charts/html-legend.js b/src/charts/html-legend.js index a97f2e3fa..f8e7a3f43 100644 --- a/src/charts/html-legend.js +++ b/src/charts/html-legend.js @@ -1,4 +1,5 @@ import {select} from 'd3-selection'; +import {event} from 'd3-selection'; import {pluck, utils} from '../core/utils'; import {adaptHandler} from '../core/d3compat'; @@ -23,6 +24,7 @@ export class HtmlLegend { this._horizontal = false; this._legendItemClass = undefined; this._highlightSelected = false; + this._keyboardAccessible = false; } parent (p) { @@ -67,8 +69,13 @@ export class HtmlLegend { itemEnter.append('span') .attr('class', 'dc-legend-item-label') + .classed('dc-tabbable', this._keyboardAccessible) .attr('title', this._legendText) .text(this._legendText); + + if (this._keyboardAccessible) { + this._makeLegendKeyboardAccessible(); + } } /** @@ -164,6 +171,62 @@ export class HtmlLegend { this._maxItems = utils.isNumber(maxItems) ? maxItems : undefined; return this; } + + /** + * If set, individual legend items will be focusable from keyboard and on pressing Enter or Space + * will behave as if clicked on. + * + * If `svgDescription` on the parent chart has not been explicitly set, will also set the default + * SVG description text to the class constructor name, like BarChart or HeatMap, and make the entire + * SVG focusable. + * @param {Boolean} [keyboardAccessible=false] + * @returns {Boolean|HtmlLegend} + */ + keyboardAccessible (keyboardAccessible) { + if (!arguments.length) { + return this._keyboardAccessible; + } + this._keyboardAccessible = keyboardAccessible; + return this; + } + + _makeLegendKeyboardAccessible () { + + if (!this._parent._svgDescription) { + + this._parent.svg().append('desc') + .attr('id', `desc-id-${this._parent.__dcFlag__}`) + .html(`${this._parent.svgDescription()}`); + + this._parent.svg() + .attr('tabindex', '0') + .attr('role', 'img') + .attr('aria-labelledby', `desc-id-${this._parent.__dcFlag__}`); + } + + const tabElements = this.container() + .selectAll('.dc-legend-item-label.dc-tabbable') + .attr('tabindex', 0); + + tabElements + .on('keydown', d => { + // trigger only if d is an object + if (event.keyCode === 13 && typeof d === 'object') { + d.chart.legendToggle(d) + } + // special case for space key press - prevent scrolling + if (event.keyCode === 32 && typeof d === 'object') { + d.chart.legendToggle(d) + event.preventDefault(); + } + }) + .on('focus', d => { + this._parent.legendHighlight(d); + }) + .on('blur', d => { + this._parent.legendReset(d); + }); + } } export const htmlLegend = () => new HtmlLegend(); diff --git a/src/charts/legend.js b/src/charts/legend.js index d8b990825..a7673e9b7 100644 --- a/src/charts/legend.js +++ b/src/charts/legend.js @@ -29,6 +29,7 @@ export class Legend { this._legendText = pluck('name'); this._maxItems = undefined; this._highlightSelected = false; + this._keyboardAccessible = false; this._g = undefined; } @@ -197,12 +198,68 @@ export class Legend { return this; } + /** + * If set, individual legend items will be focusable from keyboard and on pressing Enter or Space + * will behave as if clicked on. + * + * If `svgDescription` on the parent chart has not been explicitly set, will also set the default + * SVG description text to the class constructor name, like BarChart or HeatMap, and make the entire + * SVG focusable. + * @param {Boolean} [keyboardAccessible=false] + * @returns {Boolean|Legend} + */ + keyboardAccessible (keyboardAccessible) { + if (!arguments.length) { + return this._keyboardAccessible; + } + this._keyboardAccessible = keyboardAccessible; + return this; + } + // Implementation methods _legendItemHeight () { return this._gap + this._itemHeight; } + _makeLegendKeyboardAccessible () { + + if (!this._parent._svgDescription) { + + this._parent.svg().append('desc') + .attr('id', `desc-id-${this._parent.__dcFlag__}`) + .html(`${this._parent.svgDescription()}`); + + this._parent.svg() + .attr('tabindex', '0') + .attr('role', 'img') + .attr('aria-labelledby', `desc-id-${this._parent.__dcFlag__}`); + } + + const tabElements = this._parent.svg() + .selectAll('.dc-legend .dc-tabbable') + .attr('tabindex', 0); + + tabElements + .on('keydown', adaptHandler((d, event) => { + // trigger only if d is an object + if (event.keyCode === 13 && typeof d === 'object') { + d.chart.legendToggle(d) + } + // special case for space key press - prevent scrolling + if (event.keyCode === 32 && typeof d === 'object') { + d.chart.legendToggle(d) + event.preventDefault(); + } + })) + .on('focus', adaptHandler(d => { + this._parent.legendHighlight(d); + })) + .on('blur', adaptHandler(d => { + this._parent.legendReset(d); + })); + } + render () { this._parent.svg().select('g.dc-legend').remove(); this._g = this._parent.svg().append('g') @@ -262,10 +319,15 @@ export class Legend { itemEnter.append('text') .text(self._legendText) + .classed('dc-tabbable', this._keyboardAccessible) .attr('x', self._itemHeight + LABEL_GAP) .attr('y', function () { return self._itemHeight / 2 + (this.clientHeight ? this.clientHeight : 13) / 2 - 2; }); + + if (this._keyboardAccessible) { + this._makeLegendKeyboardAccessible(); + } } let cumulativeLegendTextWidth = 0; diff --git a/src/charts/line-chart.js b/src/charts/line-chart.js index 4ebe091be..a7633fbac 100644 --- a/src/charts/line-chart.js +++ b/src/charts/line-chart.js @@ -379,6 +379,7 @@ export class LineChart extends StackMixin { .enter() .append('circle') .attr('class', DOT_CIRCLE_CLASS) + .classed('dc-tabbable', this._keyboardAccessible) .attr('cx', d => utils.safeNumber(this.x()(d.x))) .attr('cy', d => utils.safeNumber(this.y()(d.y + d.y0))) .attr('r', this._getDotRadius()) @@ -398,6 +399,23 @@ export class LineChart extends StackMixin { }) .merge(dots); + // special case for on-focus for line chart and its dots + if (this._keyboardAccessible) { + + this._svg.selectAll('.dc-tabbable') + .attr('tabindex', 0) + .on('focus', function () { + const dot = select(this); + chart._showDot(dot); + chart._showRefLines(dot, g); + }) + .on('blur', function () { + const dot = select(this); + chart._hideDot(dot); + chart._hideRefLines(g); + }); + } + dotsEnterModify.call(dot => this._doRenderTitle(dot, data)); transition(dotsEnterModify, this.transitionDuration()) diff --git a/src/charts/number-display.js b/src/charts/number-display.js index fad6d54fd..713199859 100644 --- a/src/charts/number-display.js +++ b/src/charts/number-display.js @@ -40,6 +40,7 @@ export class NumberDisplay extends BaseMixin { this._formatNumber = format('.2s'); this._html = {one: '', some: '', none: ''}; this._lastValue = undefined; + this._ariaLiveRegion = false; // dimension not required this._mandatoryAttributes(['group']); @@ -121,7 +122,17 @@ export class NumberDisplay extends BaseMixin { .enter() .append('span') .attr('class', SPAN_CLASS) + .classed('dc-tabbable', this._keyboardAccessible) .merge(span); + + if (this._keyboardAccessible) { + span.attr('tabindex', '0'); + } + + if (this._ariaLiveRegion) { + this.transitionDuration(0); + span.attr('aria-live', 'polite'); + } } { @@ -172,6 +183,23 @@ export class NumberDisplay extends BaseMixin { return this; } + /** + * If set, the Number Display widget will have its aria-live attribute set to 'polite' which will + * notify screen readers when the widget changes its value. Note that setting this method will also + * disable the default transition between the old and the new values. This is to avoid change + * notifications spoken out before the new value finishes re-drawing. It is also advisable to check + * if the widget has appropriately set accessibility description or label. + * @param {Boolean} [ariaLiveRegion=false] + * @returns {Boolean|NumberDisplay} + */ + ariaLiveRegion (ariaLiveRegion) { + if (!arguments.length) { + return this._ariaLiveRegion; + } + this._ariaLiveRegion = ariaLiveRegion; + return this; + } + } export const numberDisplay = (parent, chartGroup) => new NumberDisplay(parent, chartGroup); diff --git a/src/charts/pie-chart.js b/src/charts/pie-chart.js index 285da0b74..cec7c3606 100644 --- a/src/charts/pie-chart.js +++ b/src/charts/pie-chart.js @@ -155,7 +155,8 @@ export class PieChart extends CapMixin(ColorMixin(BaseMixin)) { return slices .enter() .append('g') - .attr('class', (d, i) => `${this._sliceCssClass} _${i}`); + .attr('class', (d, i) => `${this._sliceCssClass} _${i}`) + .classed('dc-tabbable', this._keyboardAccessible); } _createSlicePath (slicesEnter, arcs) { @@ -164,6 +165,10 @@ export class PieChart extends CapMixin(ColorMixin(BaseMixin)) { .on('click', adaptHandler(d => this._onClick(d))) .attr('d', (d, i) => this._safeArc(d, i, arcs)); + if (this._keyboardAccessible) { + this._makeKeyboardAccessible(this._onClick); + } + const tranNodes = transition(slicePath, this.transitionDuration(), this.transitionDelay()); if (tranNodes.attrTween) { const chart = this; diff --git a/src/charts/row-chart.js b/src/charts/row-chart.js index efe8f85e8..fabedf6ea 100644 --- a/src/charts/row-chart.js +++ b/src/charts/row-chart.js @@ -192,9 +192,14 @@ export class RowChart extends CapMixin(ColorMixin(MarginMixin)) { .attr('height', height) .attr('fill', this.getColor) .on('click', adaptHandler(d => this._onClick(d))) + .classed('dc-tabbable', this._keyboardAccessible) .classed('deselected', d => (this.hasFilter()) ? !this._isSelectedRow(d) : false) .classed('selected', d => (this.hasFilter()) ? this._isSelectedRow(d) : false); + if (this._keyboardAccessible) { + this._makeKeyboardAccessible(adaptHandler(d => this._onClick(d))); + } + transition(rect, this.transitionDuration(), this.transitionDelay()) .attr('width', d => Math.abs(this._rootValue() - this._x(this.cappedValueAccessor(d)))) .attr('transform', d => this._translateX(d)); diff --git a/src/charts/scatter-plot.js b/src/charts/scatter-plot.js index 760bca20e..67a9c68c5 100644 --- a/src/charts/scatter-plot.js +++ b/src/charts/scatter-plot.js @@ -1,6 +1,7 @@ import {symbol} from 'd3-shape'; import {select} from 'd3-selection'; import {brush} from 'd3-brush'; +import {ascending} from 'd3-array' import {CoordinateGridMixin} from '../base/coordinate-grid-mixin'; import {optionalTransition, transition} from '../core/core'; @@ -278,8 +279,16 @@ export class ScatterPlot extends CoordinateGridMixin { } _plotOnSVG () { + + const data = this.data(); + + if (this._keyboardAccessible) { + // sort based on the x value (key) + data.sort((a, b) => ascending(this.keyAccessor()(a), this.keyAccessor()(b))); + } + let symbols = this.chartBodyG().selectAll('path.symbol') - .data(this.data()); + .data(data); transition(symbols.exit(), this.transitionDuration(), this.transitionDelay()) .attr('opacity', 0).remove(); @@ -288,12 +297,19 @@ export class ScatterPlot extends CoordinateGridMixin { .enter() .append('path') .attr('class', 'symbol') + .classed('dc-tabbable', this._keyboardAccessible) .attr('opacity', 0) .attr('fill', this.getColor) .attr('transform', d => this._locator(d)) .merge(symbols); - symbols.call(s => this._renderTitles(s, this.data())); + // no click handler - just tabindex for reading out of tooltips + if (this._keyboardAccessible) { + this._makeKeyboardAccessible(); + symbols.order(); + } + + symbols.call(s => this._renderTitles(s, data)); symbols.each((d, i) => { this._filtered[i] = !this.filter() || this.filter().isFiltered([this.keyAccessor()(d), this.valueAccessor()(d)]); diff --git a/src/charts/sunburst-chart.js b/src/charts/sunburst-chart.js index 32a6a2d94..d8fc8f908 100644 --- a/src/charts/sunburst-chart.js +++ b/src/charts/sunburst-chart.js @@ -173,8 +173,13 @@ export class SunburstChart extends ColorMixin(BaseMixin) { const slicePath = slicesEnter.append('path') .attr('fill', (d, i) => this._fill(d, i)) .on('click', adaptHandler(d => this.onClick(d))) + .classed('dc-tabbable', this._keyboardAccessible) .attr('d', d => this._safeArc(arcs, d)); + if (this._keyboardAccessible) { + this._makeKeyboardAccessible(this.onClick); + } + const tranNodes = transition(slicePath, this.transitionDuration()); if (tranNodes.attrTween) { const chart = this; @@ -608,7 +613,7 @@ export class SunburstChart extends ColorMixin(BaseMixin) { const path = d.path || d.key; const filter = filters.HierarchyFilter(path); - // filters are equal to, parents or children of the path. + // filters are equal to parents or children of the path. const filtersList = this._filtersForPath(path); let exactMatch = false; // clear out any filters that cover the path filtered. diff --git a/style/dc.scss b/style/dc.scss index 91c300604..6eb877389 100644 --- a/style/dc.scss +++ b/style/dc.scss @@ -34,7 +34,7 @@ $font_sans_serif: sans-serif; &.bar { stroke: none; cursor: pointer; - &:hover { + &:hover,&:focus { fill-opacity: .5; } } @@ -50,7 +50,10 @@ $font_sans_serif: sans-serif; &.external { fill: $color_black; } - :hover, &.highlight { + &:focus { + fill-opacity: .8; + } + :hover &.highlight { fill-opacity: .8; } } @@ -123,7 +126,7 @@ $font_sans_serif: sans-serif; g { &.state { cursor: pointer; - :hover { + :hover,:focus { fill-opacity: .8; } path { @@ -142,7 +145,7 @@ $font_sans_serif: sans-serif; rect { fill-opacity: 0.8; cursor: pointer; - &:hover { + &:hover,&:focus { fill-opacity: 0.6; } } @@ -173,7 +176,7 @@ $font_sans_serif: sans-serif; .node { font-size: 0.7em; cursor: pointer; - :hover { + :hover,:focus { fill-opacity: .8; } } diff --git a/web-src/examples/filtering.html b/web-src/examples/filtering.html index d9e2f63d4..e8b249fe6 100644 --- a/web-src/examples/filtering.html +++ b/web-src/examples/filtering.html @@ -11,6 +11,7 @@
+

This page demonstrates filtering and accessibility features. Charts can be labelled for screen readers using svgDescription() method and internal chart elements, like bars in a bar chart, can be made interactive as if clicked on by setting the keyboardAccessible() method to true