diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d50bc..82ac7a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 3.2.0.0 +* Add keyboard support + ## 3.1.3.0 * Fix a bug with displaying japanese characters in link labels * Update outdated packages diff --git a/package-lock.json b/package-lock.json index 5258cd0..9fc29f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "powerbi-visuals-sankey", - "version": "3.1.3.0", + "version": "3.2.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "powerbi-visuals-sankey", - "version": "3.1.3.0", + "version": "3.2.0.0", "license": "MIT", "dependencies": { "d3-array": "^3.2.4", diff --git a/package.json b/package.json index 617f446..348d455 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "powerbi-visuals-sankey", - "version": "3.1.3.0", + "version": "3.2.0.0", "description": "Sankey is a type of flow diagram in which the width of the series is in proportion to the quantity of the flow. Use it to find major contributions to an overall flow.", "repository": { "type": "git", diff --git a/pbiviz.json b/pbiviz.json index 9a8f495..71f19e6 100644 --- a/pbiviz.json +++ b/pbiviz.json @@ -1,10 +1,10 @@ { "visual": { "name": "SankeyDiagram", - "displayName": "Sankey 3.1.3.0", + "displayName": "Sankey 3.2.0.0", "guid": "sankey02300D1BE6F5427989F3DE31CCA9E0F32020", "visualClassName": "SankeyDiagram", - "version": "3.1.3.0", + "version": "3.2.0.0", "description": "Sankey is a type of flow diagram in which the width of the series is in proportion to the quantity of the flow. Use it to find major contributions to an overall flow.", "supportUrl": "https://community.powerbi.com", "gitHubUrl": "https://github.com/Microsoft/powerbi-visuals-sankey" diff --git a/src/behavior.ts b/src/behavior.ts index acb0bcc..c8e8534 100644 --- a/src/behavior.ts +++ b/src/behavior.ts @@ -52,10 +52,14 @@ export interface SankeyDiagramBehaviorOptions extends IBehaviorOptions; } +const EnterCode = "Enter"; +const SpaceCode = "Space"; + export class SankeyDiagramBehavior implements IInteractiveBehavior { private behaviorOptions: SankeyDiagramBehaviorOptions; private selectionHandler: ISelectionHandler; + private selectedDataPoints: SelectableDataPoint[]; public static create(): IInteractiveBehavior { @@ -74,7 +78,9 @@ export class SankeyDiagramBehavior implements IInteractiveBehavior { this.selectionHandler = selectionHandler; this.bindClickEventToNodes(); + this.bindKeyboardEventToNodes(); this.bindClickEventToLinks(); + this.bindKeyboardEventToLinks(); this.bindClickEventToClearCatcher(); } @@ -97,7 +103,9 @@ export class SankeyDiagramBehavior implements IInteractiveBehavior { this.createAnEmptySelectedDataPoints(); } }); + } + private bindKeyboardEventToNodes(): void { this.behaviorOptions.nodes.on("contextmenu", (event: PointerEvent, datum: SankeyDiagramNode) => { if (event) { this.selectionHandler.handleContextMenu( @@ -130,6 +138,29 @@ export class SankeyDiagramBehavior implements IInteractiveBehavior { }); } + private bindKeyboardEventToLinks(): void { + this.behaviorOptions.links.on("keydown", (event: KeyboardEvent, link: SankeyDiagramLink) => { + if (event.code !== EnterCode && event.code !== SpaceCode) { + return; + } + this.selectionHandler.handleSelection(link, event.ctrlKey || event.metaKey); + this.createAnEmptySelectedDataPoints(); + + }); + + this.behaviorOptions.links.on("contextmenu", (event: PointerEvent, datum: SankeyDiagramLink) => { + if (event) { + this.selectionHandler.handleContextMenu( + datum, + { + x: event.clientX, + y: event.clientY + }); + event.preventDefault(); + } + }); + } + private bindClickEventToClearCatcher(): void { this.behaviorOptions.clearCatcher.on("contextmenu", (event: PointerEvent) => { if (event) { @@ -157,6 +188,7 @@ export class SankeyDiagramBehavior implements IInteractiveBehavior { } public renderSelection(hasSelection: boolean): void { + this.behaviorOptions.links.attr("aria-selected", sankeyDiagramUtils.isDataPointSelected); sankeyDiagramUtils.updateFillOpacity( this.behaviorOptions.links, this.behaviorOptions.interactivityService, diff --git a/src/sankeyDiagram.ts b/src/sankeyDiagram.ts index 2654075..0fe0082 100644 --- a/src/sankeyDiagram.ts +++ b/src/sankeyDiagram.ts @@ -31,7 +31,7 @@ import lodashCloneDeep from "lodash.clonedeep"; // d3 import { select as d3Select, Selection as d3Selection } from "d3-selection"; -import { drag as d3Drag, D3DragEvent} from "d3-drag"; +import { drag as d3Drag, D3DragEvent } from "d3-drag"; import { max as d3Max, min as d3Min } from "d3-array"; import { scaleLog as d3ScaleLog, scaleLinear as d3ScaleLinear, ScaleContinuousNumeric } from "d3-scale"; import { rgb as d3Rgb } from "d3-color"; @@ -94,14 +94,14 @@ import { // powerbi.extensibility.utils.color import { ColorHelper } from "powerbi-visuals-utils-colorutils"; -import { +import { SankeyDiagramSettings, DataLabelsSettings, CyclesDrawType, ViewportSize, SankeyDiagramScaleSettings, FontSizeDefaultOptions - } from "./settings"; +} from "./settings"; import { FormattingSettingsService } from "powerbi-visuals-utils-formattingmodel"; import { @@ -300,10 +300,14 @@ export class SankeyDiagram implements IVisual { this.main = this.root.append("g"); this.links = this.main + .attr("role", "listbox") + .attr("aria-multiselectable", "true") + .attr("tabindex", 0) .append("g") .classed(SankeyDiagram.LinksSelector.className, true); this.nodes = this.main + .append("g") .classed(SankeyDiagram.NodesSelector.className, true); } @@ -314,8 +318,8 @@ export class SankeyDiagram implements IVisual { this.updateViewport(visualUpdateOptions.viewport); const dataView: DataView = visualUpdateOptions - && visualUpdateOptions.dataViews - && visualUpdateOptions.dataViews[0]; + && visualUpdateOptions.dataViews + && visualUpdateOptions.dataViews[0]; this.sankeyDiagramSettings = this.parseSettings(dataView, visualUpdateOptions.dataViews); @@ -408,7 +412,7 @@ export class SankeyDiagram implements IVisual { } /*eslint max-lines-per-function: ["error", 200]*/ - public converter(dataView: DataView) : SankeyDiagramDataView { + public converter(dataView: DataView): SankeyDiagramDataView { const settings = this.sankeyDiagramSettings; if (!dataView @@ -1574,7 +1578,8 @@ export class SankeyDiagram implements IVisual { if (node.y + node.height > self.viewport.height) { node.y = self.viewport.height - node.height; } - node.settings = { x: node.x.toFixed(2), y: node.y.toFixed(2),name: node.label.name + node.settings = { + x: node.x.toFixed(2), y: node.y.toFixed(2), name: node.label.name }; // Update each link related with this node self.main.select(SankeyDiagram.LinksSelector.selectorName) @@ -1755,6 +1760,10 @@ export class SankeyDiagram implements IVisual { return SankeyDiagram.createLinkId(link); } ) + .attr("role", "option") + .attr("tabindex", 0) + .attr("aria-selected", "false") + .attr('aria-label', (link: SankeyDiagramLink) => `${link.source.label.name} to ${link.destination.label.name} weighted at ${link.weight}`) .style("stroke", (link: SankeyDiagramLink) => link.strokeColor) .style("fill", (link: SankeyDiagramLink) => link.fillColor); @@ -1853,7 +1862,7 @@ export class SankeyDiagram implements IVisual { .exit() .remove(); - const textPathSelectionEnter = textPathSelectionData + const textPathSelectionEnter = textPathSelectionData .enter() .append("textPath"); diff --git a/style/visual.less b/style/visual.less index 2605165..ff406ce 100644 --- a/style/visual.less +++ b/style/visual.less @@ -69,4 +69,13 @@ stroke-width: 1; } } + + &:focus { + outline: none; + } + + &:focus-visible { + outline: auto 1px; + outline-color: -webkit-focus-ring-color; + } } diff --git a/test/visualTest.ts b/test/visualTest.ts index d0380ba..57e3359 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -62,6 +62,7 @@ import { } from "./helpers/helpers"; import { DataLabelsSettings, LinkLabelsSettings, SankeyDiagramSettings } from "../src/settings"; +import { isNullOrEmpty } from "powerbi-visuals-utils-formattingutils/lib/src/stringExtensions"; interface SankeyDiagramTestsNode { @@ -803,6 +804,119 @@ describe("SankeyDiagram", () => { }); }); + function timeout(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + const defaultWaitForRender: number = 500; + + describe("Keyboard Navigation check", () =>{ + it("links should have attributes tabindex=0, role=option, aria-label is not null, and aria-selected=false", (done) => { + visualBuilder.updateRenderTimeout(dataView, () => { + // defaults + const someColor: string = "#000000"; + const fontSize: number = 12; + const unit: number = 0; + + visualBuilder.instance.getFormattingModel(); + + let nodes = visualBuilder.linkElements; + nodes.forEach((el: Element) => { + expect(el.getAttribute("role")).toBe("option"); + expect(el.getAttribute("tabindex")).toBe("0"); + expect(el.getAttribute("aria-selected")).toBe("false"); + expect(el.getAttribute("aria-label")).not.toBeNull(); + }); + done(); + },); + }); + + it("enter toggles the correct slice", (done: DoneFn) => { + const enterEvent = new KeyboardEvent("keydown", { code: "Enter", bubbles: true }); + visualBuilder.updateRenderTimeout( + dataView, + async () => { + visualBuilder.linkElements[0].dispatchEvent(enterEvent); + await timeout(defaultWaitForRender); + expect(visualBuilder.linkElements[0].getAttribute("aria-selected")).toBe("true"); + for (const slice of visualBuilder.linkElements) { + if (slice !== visualBuilder.linkElements[0]) { + expect(slice.getAttribute("aria-selected")).toBe("false"); + } + } + + visualBuilder.linkElements[0].dispatchEvent(enterEvent); + await timeout(defaultWaitForRender); + for (const slice of visualBuilder.linkElements) { + expect(slice.getAttribute("aria-selected")).toBe("false"); + } + + done(); + }, + 2, + ); + }); + }); + + it("space toggles the correct slice", (done: DoneFn) => { + const spaceEvent = new KeyboardEvent("keydown", { code: "Space", bubbles: true }); + visualBuilder.updateRenderTimeout( + dataView, + async () => { + visualBuilder.linkElements[0].dispatchEvent(spaceEvent); + await timeout(defaultWaitForRender); + expect(visualBuilder.linkElements[0].getAttribute("aria-selected")).toBe("true"); + for (const slice of visualBuilder.linkElements) { + if (slice !== visualBuilder.linkElements[0]) { + expect(slice.getAttribute("aria-selected")).toBe("false"); + } + } + + visualBuilder.linkElements[0].dispatchEvent(spaceEvent); + await timeout(defaultWaitForRender); + for (const slice of visualBuilder.linkElements) { + expect(slice.getAttribute("aria-selected")).toBe("false"); + } + + done(); + }, + 2, + ); + }); + + it("tab between slices works", (done: DoneFn) => { + const tabEvent = new KeyboardEvent("keydown", { code: "Tab", bubbles: true }); + const enterEvent = new KeyboardEvent("keydown", { code: "Enter", bubbles: true }); + visualBuilder.updateRenderTimeout( + dataView, + async () => { + visualBuilder.linkElements[0].dispatchEvent(enterEvent); + await timeout(defaultWaitForRender); + expect(visualBuilder.linkElements[0].getAttribute("aria-selected")).toBe("true"); + for (const slice of visualBuilder.linkElements) { + if (slice !== visualBuilder.linkElements[0]) { + expect(slice.getAttribute("aria-selected")).toBe("false"); + } + } + + visualBuilder.element.dispatchEvent(tabEvent); + await timeout(defaultWaitForRender); + + visualBuilder.linkElements[1].dispatchEvent(enterEvent); + await timeout(defaultWaitForRender); + expect(visualBuilder.linkElements[1].getAttribute("aria-selected")).toBe("true"); + for (const slice of visualBuilder.linkElements) { + if (slice !== visualBuilder.linkElements[1]) { + expect(slice.getAttribute("aria-selected")).toBe("false"); + } + } + + done(); + }, + 2, + ); + }); + describe("high contrast mode test", () => { const backgroundColor: string = "#00ff00"; const foregroundColor: string = "#ff00ff";