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
1 change: 1 addition & 0 deletions capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"displayNameKey": "Visual_SourceLabels"
}
],

s-ddavydenko marked this conversation as resolved.
Show resolved Hide resolved
"dataViewMappings": [
{
"conditions": [
Expand Down
53 changes: 53 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,6 +103,28 @@ export class SankeyDiagramBehavior implements IInteractiveBehavior {
this.createAnEmptySelectedDataPoints();
}
});
}

private bindKeyboardEventToNodes(): void {
this.behaviorOptions.nodes.on("keydown", (event: KeyboardEvent, node: SankeyDiagramNode) => {
let selectableDataPoints: SelectableDataPoint[] = node.selectableDataPoints;
s-ddavydenko marked this conversation as resolved.
Show resolved Hide resolved
if (node.cloneLink) {
selectableDataPoints = selectableDataPoints.concat(node.cloneLink.selectableDataPoints);
}

this.clearSelection();

if (!sankeyDiagramUtils.areDataPointsSelected(this.selectedDataPoints, selectableDataPoints)) {
selectableDataPoints.forEach((subDataPoint: SelectableDataPoint) => {
this.selectionHandler.handleSelection(subDataPoint, true);
});

this.selectedDataPoints = selectableDataPoints;
} else {
this.createAnEmptySelectedDataPoints();
s-ddavydenko marked this conversation as resolved.
Show resolved Hide resolved
}
});


this.behaviorOptions.nodes.on("contextmenu", (event: PointerEvent, datum: SankeyDiagramNode) => {
if (event) {
Expand All @@ -115,6 +143,30 @@ export class SankeyDiagramBehavior implements IInteractiveBehavior {
this.behaviorOptions.links.on("click", (event: PointerEvent, link: SankeyDiagramLink) => {
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 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) => {
Expand Down Expand Up @@ -157,6 +209,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
node.links.forEach((link: SankeyDiagramLink) => {
Expand Down Expand Up @@ -1757,6 +1762,10 @@ export class SankeyDiagram implements IVisual {
return SankeyDiagram.createLink(link);
}
)
.attr("role", "option")
.attr("tabindex", 0)
.attr("aria-selected", sankeyDiagramUtils.isDataPointSelected)

s-ddavydenko marked this conversation as resolved.
Show resolved Hide resolved
.style("stroke", (link: SankeyDiagramLink) => link.strokeColor)
.style("fill", (link: SankeyDiagramLink) => link.fillColor);

Expand Down Expand Up @@ -1855,7 +1864,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;
}
}
112 changes: 112 additions & 0 deletions test/visualTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,118 @@ describe("SankeyDiagram", () => {
});
});

function timeout(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}

const DefaultWaitForRender: number = 500;
s-ddavydenko marked this conversation as resolved.
Show resolved Hide resolved

describe("Keyboard Navigation check", () =>{
it("links should have attributes tabindex=0, role=option 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");
});
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