Skip to content

Commit

Permalink
Added Keyboard Navigation (#152)
Browse files Browse the repository at this point in the history
* Added Keyboard Navigation

Applied styles for focus outlines

* aria-selected and unit tests

aria selected attribute added to selectable elements in the visual, unit tests added for aria selected and keyboard navigation

* aria-label and unit test

* aria-label updated

* version and minor dependency updates

* Rebase dev branch

* Squash commits

* quick fix

---------

Co-authored-by: Firzinat Khuzeev <[email protected]>
  • Loading branch information
s-ddavydenko and fkhuzeev authored Aug 17, 2023
1 parent ae34a4f commit bfdb018
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 13 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions pbiviz.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
32 changes: 32 additions & 0 deletions src/behavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,14 @@ export interface SankeyDiagramBehaviorOptions extends IBehaviorOptions<Selectabl
interactivityService: IInteractivityService<SelectableDataPoint>;
}

const EnterCode = "Enter";
const SpaceCode = "Space";

export class SankeyDiagramBehavior implements IInteractiveBehavior {
private behaviorOptions: SankeyDiagramBehaviorOptions;
private selectionHandler: ISelectionHandler;


private selectedDataPoints: SelectableDataPoint[];

public static create(): IInteractiveBehavior {
Expand All @@ -74,7 +78,9 @@ export class SankeyDiagramBehavior implements IInteractiveBehavior {
this.selectionHandler = selectionHandler;

this.bindClickEventToNodes();
this.bindKeyboardEventToNodes();
this.bindClickEventToLinks();
this.bindKeyboardEventToLinks();
this.bindClickEventToClearCatcher();
}

Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 17 additions & 8 deletions src/sankeyDiagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -1853,7 +1862,7 @@ export class SankeyDiagram implements IVisual {
.exit()
.remove();

const textPathSelectionEnter = textPathSelectionData
const textPathSelectionEnter = textPathSelectionData
.enter()
.append("textPath");

Expand Down
9 changes: 9 additions & 0 deletions style/visual.less
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,13 @@
stroke-width: 1;
}
}

&:focus {
outline: none;
}

&:focus-visible {
outline: auto 1px;
outline-color: -webkit-focus-ring-color;
}
}
114 changes: 114 additions & 0 deletions test/visualTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand Down

0 comments on commit bfdb018

Please sign in to comment.