Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
fix(chart): Fixed chart selection area focus.
Browse files Browse the repository at this point in the history
  • Loading branch information
mikekuss authored and thomaspink committed Aug 25, 2021
1 parent 38c70d7 commit 7ef96a5
Show file tree
Hide file tree
Showing 12 changed files with 337 additions and 136 deletions.
118 changes: 118 additions & 0 deletions libs/barista-components/chart/src/chart-focus-anchor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* @license
* Copyright 2021 Dynatrace LLC
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// tslint:disable no-lifecycle-call no-use-before-declare no-magic-numbers
// tslint:disable no-any max-file-line-count no-unbound-method use-component-selector

import { InteractivityChecker } from '@angular/cdk/a11y';
import { Platform } from '@angular/cdk/platform';
import { CommonModule } from '@angular/common';
import { Component, Provider } from '@angular/core';
import { TestBed, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { createComponent } from '@dynatrace/testing/browser';
import { DtChart } from './chart';
import { DtChartFocusAnchor, DtChartFocusTarget } from './chart-focus-anchor';

/** Mock Chart so the targets have a place to register themselves */
export class MockChart {
_focusTargets = new Set<DtChartFocusTarget>();
}

/**
* Overrides the default InteractivityChecker to alway set the
* ignoreVisibility flag to true, as all DOM nodes in Jest would
* otherwise be detected as not visible and therefore not focusalbe.
*/
export class TestInteractivityChecker extends InteractivityChecker {
isFocusable(element: any): boolean {
return super.isFocusable(element, { ignoreVisibility: true });
}
}

export const TEST_INTERACTIVITY_CHECKER_PROVIDER: Provider = {
provide: InteractivityChecker,
useClass: TestInteractivityChecker,
deps: [Platform],
};

describe('DtChartFocusAnchor', () => {
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
imports: [CommonModule],
providers: [
{ provide: DtChart, useClass: MockChart },
TEST_INTERACTIVITY_CHECKER_PROVIDER,
],
declarations: [
DtChartFocusAnchor,
DtChartFocusTarget,
TestAppSkipFocusElement,
TestAppFindFocusElement,
],
});

TestBed.compileComponents();
}),
);

it('should shift focus to the specified target', () => {
const fixture = createComponent(TestAppSkipFocusElement);
const focusAnchor = fixture.debugElement.query(
By.css('dt-chart-focus-anchor'),
);
const buttonDebugElement = fixture.debugElement.query(
By.css('[dtChartFocusTarget]'),
);

focusAnchor.nativeElement.focus();
fixture.detectChanges();
expect(document.activeElement).toBe(buttonDebugElement.nativeElement);
});

it('should shift focus to the specified target', () => {
const fixture = createComponent(TestAppFindFocusElement);
const focusAnchor = fixture.debugElement.query(
By.css('dt-chart-focus-anchor'),
);
const buttonDebugElement = fixture.debugElement.query(By.css('button'));

focusAnchor.nativeElement.focus();
fixture.detectChanges();
expect(document.activeElement).toBe(buttonDebugElement.nativeElement);
});
});

@Component({
selector: 'test-chart-without-selection-area',
template: `
<dt-chart-focus-anchor nextTarget="nextTarget"></dt-chart-focus-anchor>
<button>Should be skipped</button>
<button dtChartFocusTarget="nextTarget">Should receive focus</button>
`,
})
export class TestAppSkipFocusElement {}

@Component({
selector: 'test-chart-without-selection-area',
template: `
<dt-chart-focus-anchor></dt-chart-focus-anchor>
<div>Should be skipped</div>
<button>Should receive focus</button>
`,
})
export class TestAppFindFocusElement {}
187 changes: 187 additions & 0 deletions libs/barista-components/chart/src/chart-focus-anchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* @license
* Copyright 2021 Dynatrace LLC
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { InteractivityChecker } from '@angular/cdk/a11y';
import { TAB } from '@angular/cdk/keycodes';
import { Directive, ElementRef, Input, OnDestroy } from '@angular/core';
import { DtChart } from './chart';
import { _readKeyCode } from '@dynatrace/barista-components/core';

type NativeFocusTarget = Element & { focus: () => void };

/** Focus event of the DtChartFocusAnchor. */
export interface DtChartFocusAnchorFocusEvent {
nativeEvt: FocusEvent;
anchor: DtChartFocusAnchor;
}

/** Element that redirects focus when received to a next or previous target depending on the tab-direction. */
@Directive({
selector: 'dt-chart-focus-anchor',
host: {
class: 'cdk-visually-hidden',
tabindex: '0',
'aria-hidden': 'true',
'(focus)': '_handleFocus($event)',
'(keydown)': '_handleKeyUp($event)',
},
})
export class DtChartFocusAnchor {
/** The next target to shift focus to. */
@Input() nextTarget: string | undefined;

/** The previous target to shift focus to. */
@Input() prevTarget: string | undefined;

constructor(
private _chart: DtChart,
private _elementRef: ElementRef,
private _checker: InteractivityChecker,
) {}

/** @internal Handle keyup event, capture tab & shift+tab key-presses. */
_handleKeyUp(event: KeyboardEvent): void {
const { nextTarget, prevTarget } = this._findTargets();
if (_readKeyCode(event) === TAB) {
if (event.shiftKey) {
this._focusTarget(prevTarget, event);
} else {
this._focusTarget(nextTarget, event);
}
}
}

/** @internal Handle receiving focus. */
_handleFocus(event: FocusEvent): void {
const { nextTarget, prevTarget } = this._findTargets();

if (event.relatedTarget === nextTarget && prevTarget) {
// shift + tab
this._focusTarget(prevTarget, event);
} else if (nextTarget) {
// tab
this._focusTarget(nextTarget, event);
}
}

/** @internal Focus a target and prevent the native event. */
private _focusTarget(
target: DtChartFocusTarget | NativeFocusTarget | null,
event: FocusEvent | KeyboardEvent,
): void {
if (target) {
if (event.preventDefault) {
event.preventDefault();
}
target.focus();
}
}

/** @internal Find the next/prev target to focus. */
private _findTargets(): {
nextTarget: DtChartFocusTarget | NativeFocusTarget | null;
prevTarget: DtChartFocusTarget | NativeFocusTarget | null;
} {
const element = this._elementRef.nativeElement as Element;
const nextTarget = this._findTarget(
this.nextTarget,
element.nextElementSibling,
);
const prevTarget = this._findTarget(
this.prevTarget,
element.parentElement?.children[0],
(currentElement) => currentElement === element,
);
return { nextTarget, prevTarget };
}

/** @internal Find a target to focus. */
private _findTarget(
targetName?: string,
startingElement?: Element | null,
shouldStopFn?: (currentElement: Element) => boolean,
): DtChartFocusTarget | NativeFocusTarget | null {
if (targetName) {
return this._findKnownTarget(targetName);
} else if (startingElement) {
return (
findNextFocusableElement(
startingElement,
(element) => this._checker.isFocusable(element as HTMLElement),
shouldStopFn,
) || null
);
}
return null;
}

/** @internal Find a known target to focus by name. */
private _findKnownTarget(targetName: string): DtChartFocusTarget | null {
for (const target of this._chart._focusTargets) {
if (target.dtChartFocusTarget === targetName) {
return target;
}
}
return null;
}
}

/** Directive to mare a focusable element by name so it can be found by the anchor. */
@Directive({
selector: '[dtChartFocusTarget]',
})
export class DtChartFocusTarget implements OnDestroy {
@Input() dtChartFocusTarget: string;

constructor(private _chart: DtChart, private _element: ElementRef) {
this._chart._focusTargets.add(this);
}

ngOnDestroy(): void {
this._chart._focusTargets.delete(this);
}

focus(): void {
this._element.nativeElement.focus();
}
}

/** Find the next focusable element after a provided starting element. */
function findNextFocusableElement(
startingElement: Element,
isFocusable: (node: Element) => boolean,
shouldStopFn?: (nextElement: Element) => boolean,
): (Element & { focus: () => void }) | null {
let nextElement: Element | null = startingElement;
const shouldStop = shouldStopFn || (() => false);
while (nextElement && !shouldStop(nextElement)) {
if (isFocusable(nextElement)) {
return nextElement as (Element & { focus: () => void }) | null;
}
if (startingElement.children.length > 0) {
const focusableElement = findNextFocusableElement(
startingElement.children.item(0)!,
isFocusable,
shouldStop,
);
if (focusableElement) {
return focusableElement;
}
}
nextElement = nextElement.nextElementSibling;
}
return null;
}
3 changes: 3 additions & 0 deletions libs/barista-components/chart/src/chart-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { DtChartSelectionAreaAction } from './selection-area/overlay-action';
import { DtChartSelectionArea } from './selection-area/selection-area';
import { DtChartTimestamp } from './timestamp/timestamp';
import { DtChartTooltip } from './tooltip/chart-tooltip';
import { DtChartFocusAnchor, DtChartFocusTarget } from './chart-focus-anchor';

/** components that should be declared and exported */
const COMPONENTS = [
Expand All @@ -40,6 +41,8 @@ const COMPONENTS = [
DtChartTimestamp,
DtChartTooltip,
DtChartSelectionAreaAction,
DtChartFocusAnchor,
DtChartFocusTarget,
];

@NgModule({
Expand Down
3 changes: 3 additions & 0 deletions libs/barista-components/chart/src/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import { DtChartRange } from './range/range';
import { DtChartTimestamp } from './timestamp/timestamp';
import { DtChartTooltip } from './tooltip/chart-tooltip';
import { getPlotBackgroundInfo, retainSeriesVisibility } from './utils';
import { DtChartFocusTarget } from './chart-focus-anchor';

const HIGHCHARTS_PLOT_BACKGROUND = '.highcharts-plot-background';

Expand Down Expand Up @@ -305,6 +306,8 @@ export class DtChart
@ContentChild(DtChartTimestamp)
_timestamp?: DtChartTimestamp;

_focusTargets = new Set<DtChartFocusTarget>();

private _series?: Observable<DtChartSeries[]> | DtChartSeries[];
private _currentSeries?: DtChartSeries[];
private _currentOptions: DtChartOptions;
Expand Down
21 changes: 19 additions & 2 deletions libs/barista-components/chart/src/range/range.html
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
<ng-template let-range>
<dt-chart-focus-anchor
dtChartFocusTarget="overlayStart"
nextTarget=""
prevTarget="rangeNextButton"
></dt-chart-focus-anchor>
<span class="dt-selection-area-overlay-text">{{ range | dtDateRange }}</span>
<ng-content select="[dtChartSelectionAreaAction]"></ng-content>
<button
dt-icon-button
variant="secondary"
(click)="_handleOverlayClose()"
[attr.aria-label]="_ariaLabelClose"
cdkFocusInitial
dtChartFocusTarget="overlayClose"
>
<dt-icon name="abort"></dt-icon>
</button>
<dt-chart-focus-anchor
nextTarget="rangeEndAnchor"
prevTarget="overlayClose"
></dt-chart-focus-anchor>
</ng-template>

<div
*ngIf="!_hidden"
#range
cdkTrapFocus
class="dt-chart-range-container"
tabindex="0"
aria-role="slider"
Expand All @@ -41,6 +49,7 @@
<button
class="dt-chart-right-handle"
aria-role="slider"
dtChartFocusTarget="rangeNextButton"
[attr.aria-valuemin]="_minValue"
[attr.aria-valuemax]="_maxValue"
[attr.aria-label]="_ariaLabelRightHandle"
Expand All @@ -50,4 +59,12 @@
>
<dt-icon name="handle"></dt-icon>
</button>
<dt-chart-focus-anchor
nextTarget="overlayStart"
prevTarget="overlayClose"
></dt-chart-focus-anchor>
<dt-chart-focus-anchor
dtChartFocusTarget="rangeEndAnchor"
prevTarget="overlayClose"
></dt-chart-focus-anchor>
</div>
Loading

0 comments on commit 7ef96a5

Please sign in to comment.