This repository has been archived by the owner on Sep 11, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 78
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(chart): Fixed chart selection area focus.
- Loading branch information
1 parent
38c70d7
commit 7ef96a5
Showing
12 changed files
with
337 additions
and
136 deletions.
There are no files selected for viewing
118 changes: 118 additions & 0 deletions
118
libs/barista-components/chart/src/chart-focus-anchor.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
187
libs/barista-components/chart/src/chart-focus-anchor.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.