Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Keyboard Navigation #152

Merged
merged 10 commits into from
Aug 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading