|
1 |
| -import {Component, ViewEncapsulation, ViewChild, ElementRef, Input, NgZone} from '@angular/core'; |
| 1 | +import { |
| 2 | + Directive, |
| 3 | + ElementRef, |
| 4 | + Input, |
| 5 | + NgZone, |
| 6 | + OnDestroy, |
| 7 | + AfterContentInit, |
| 8 | + Injectable, |
| 9 | +} from '@angular/core'; |
2 | 10 | import {InteractivityChecker} from './interactivity-checker';
|
3 | 11 | import {coerceBooleanProperty} from '../coercion/boolean-property';
|
4 | 12 |
|
5 | 13 |
|
6 | 14 | /**
|
7 |
| - * Directive for trapping focus within a region. |
| 15 | + * Class that allows for trapping focus within a DOM element. |
8 | 16 | *
|
9 |
| - * NOTE: This directive currently uses a very simple (naive) approach to focus trapping. |
| 17 | + * NOTE: This class currently uses a very simple (naive) approach to focus trapping. |
10 | 18 | * It assumes that the tab order is the same as DOM order, which is not necessarily true.
|
11 | 19 | * Things like tabIndex > 0, flex `order`, and shadow roots can cause to two to misalign.
|
12 | 20 | * This will be replaced with a more intelligent solution before the library is considered stable.
|
13 | 21 | */
|
14 |
| -@Component({ |
15 |
| - moduleId: module.id, |
16 |
| - selector: 'cdk-focus-trap, focus-trap', |
17 |
| - templateUrl: 'focus-trap.html', |
18 |
| - encapsulation: ViewEncapsulation.None, |
19 |
| -}) |
20 | 22 | export class FocusTrap {
|
21 |
| - @ViewChild('trappedContent') trappedContent: ElementRef; |
| 23 | + private _startAnchor: HTMLElement; |
| 24 | + private _endAnchor: HTMLElement; |
22 | 25 |
|
23 | 26 | /** Whether the focus trap is active. */
|
24 |
| - @Input() |
25 |
| - get disabled(): boolean { return this._disabled; } |
26 |
| - set disabled(val: boolean) { this._disabled = coerceBooleanProperty(val); } |
27 |
| - private _disabled: boolean = false; |
| 27 | + get enabled(): boolean { return this._enabled; } |
| 28 | + set enabled(val: boolean) { |
| 29 | + this._enabled = val; |
28 | 30 |
|
29 |
| - constructor(private _checker: InteractivityChecker, private _ngZone: NgZone) { } |
| 31 | + if (this._startAnchor && this._endAnchor) { |
| 32 | + this._startAnchor.tabIndex = this._endAnchor.tabIndex = this._enabled ? 0 : -1; |
| 33 | + } |
| 34 | + } |
| 35 | + private _enabled: boolean = true; |
| 36 | + |
| 37 | + constructor( |
| 38 | + private _element: HTMLElement, |
| 39 | + private _checker: InteractivityChecker, |
| 40 | + private _ngZone: NgZone, |
| 41 | + deferAnchors = false) { |
| 42 | + |
| 43 | + if (!deferAnchors) { |
| 44 | + this.attachAnchors(); |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + /** Destroys the focus trap by cleaning up the anchors. */ |
| 49 | + destroy() { |
| 50 | + if (this._startAnchor && this._startAnchor.parentNode) { |
| 51 | + this._startAnchor.parentNode.removeChild(this._startAnchor); |
| 52 | + } |
| 53 | + |
| 54 | + if (this._endAnchor && this._endAnchor.parentNode) { |
| 55 | + this._endAnchor.parentNode.removeChild(this._endAnchor); |
| 56 | + } |
| 57 | + |
| 58 | + this._startAnchor = this._endAnchor = null; |
| 59 | + } |
30 | 60 |
|
31 | 61 | /**
|
32 |
| - * Waits for microtask queue to empty, then focuses the first tabbable element within the focus |
33 |
| - * trap region. |
| 62 | + * Inserts the anchors into the DOM. This is usually done automatically |
| 63 | + * in the constructor, but can be deferred for cases like directives with `*ngIf`. |
34 | 64 | */
|
35 |
| - focusFirstTabbableElementWhenReady() { |
36 |
| - this._ngZone.onMicrotaskEmpty.first().subscribe(() => { |
37 |
| - this.focusFirstTabbableElement(); |
| 65 | + attachAnchors(): void { |
| 66 | + if (!this._startAnchor) { |
| 67 | + this._startAnchor = this._createAnchor(); |
| 68 | + } |
| 69 | + |
| 70 | + if (!this._endAnchor) { |
| 71 | + this._endAnchor = this._createAnchor(); |
| 72 | + } |
| 73 | + |
| 74 | + this._ngZone.runOutsideAngular(() => { |
| 75 | + this._element |
| 76 | + .insertAdjacentElement('beforebegin', this._startAnchor) |
| 77 | + .addEventListener('focus', () => this.focusLastTabbableElement()); |
| 78 | + |
| 79 | + this._element |
| 80 | + .insertAdjacentElement('afterend', this._endAnchor) |
| 81 | + .addEventListener('focus', () => this.focusFirstTabbableElement()); |
38 | 82 | });
|
39 | 83 | }
|
40 | 84 |
|
41 | 85 | /**
|
42 |
| - * Waits for microtask queue to empty, then focuses the last tabbable element within the focus |
43 |
| - * trap region. |
| 86 | + * Waits for microtask queue to empty, then focuses |
| 87 | + * the first tabbable element within the focus trap region. |
44 | 88 | */
|
45 |
| - focusLastTabbableElementWhenReady() { |
46 |
| - this._ngZone.onMicrotaskEmpty.first().subscribe(() => { |
47 |
| - this.focusLastTabbableElement(); |
48 |
| - }); |
| 89 | + focusFirstTabbableElementWhenReady() { |
| 90 | + this._ngZone.onMicrotaskEmpty.first().subscribe(() => this.focusFirstTabbableElement()); |
49 | 91 | }
|
50 | 92 |
|
51 | 93 | /**
|
52 |
| - * Focuses the first tabbable element within the focus trap region. |
| 94 | + * Waits for microtask queue to empty, then focuses |
| 95 | + * the last tabbable element within the focus trap region. |
53 | 96 | */
|
| 97 | + focusLastTabbableElementWhenReady() { |
| 98 | + this._ngZone.onMicrotaskEmpty.first().subscribe(() => this.focusLastTabbableElement()); |
| 99 | + } |
| 100 | + |
| 101 | + /** Focuses the first tabbable element within the focus trap region. */ |
54 | 102 | focusFirstTabbableElement() {
|
55 |
| - let rootElement = this.trappedContent.nativeElement; |
56 |
| - let redirectToElement = rootElement.querySelector('[cdk-focus-start]') as HTMLElement || |
57 |
| - this._getFirstTabbableElement(rootElement); |
| 103 | + let redirectToElement = this._element.querySelector('[cdk-focus-start]') as HTMLElement || |
| 104 | + this._getFirstTabbableElement(this._element); |
58 | 105 |
|
59 | 106 | if (redirectToElement) {
|
60 | 107 | redirectToElement.focus();
|
61 | 108 | }
|
62 | 109 | }
|
63 | 110 |
|
64 |
| - /** |
65 |
| - * Focuses the last tabbable element within the focus trap region. |
66 |
| - */ |
| 111 | + /** Focuses the last tabbable element within the focus trap region. */ |
67 | 112 | focusLastTabbableElement() {
|
68 |
| - let rootElement = this.trappedContent.nativeElement; |
69 |
| - let focusTargets = rootElement.querySelectorAll('[cdk-focus-end]'); |
| 113 | + let focusTargets = this._element.querySelectorAll('[cdk-focus-end]'); |
70 | 114 | let redirectToElement: HTMLElement = null;
|
71 | 115 |
|
72 | 116 | if (focusTargets.length) {
|
73 | 117 | redirectToElement = focusTargets[focusTargets.length - 1] as HTMLElement;
|
74 | 118 | } else {
|
75 |
| - redirectToElement = this._getLastTabbableElement(rootElement); |
| 119 | + redirectToElement = this._getLastTabbableElement(this._element); |
76 | 120 | }
|
77 | 121 |
|
78 | 122 | if (redirectToElement) {
|
@@ -114,4 +158,81 @@ export class FocusTrap {
|
114 | 158 |
|
115 | 159 | return null;
|
116 | 160 | }
|
| 161 | + |
| 162 | + /** Creates an anchor element. */ |
| 163 | + private _createAnchor(): HTMLElement { |
| 164 | + let anchor = document.createElement('div'); |
| 165 | + anchor.tabIndex = this._enabled ? 0 : -1; |
| 166 | + anchor.classList.add('cdk-visually-hidden'); |
| 167 | + anchor.classList.add('cdk-focus-trap-anchor'); |
| 168 | + return anchor; |
| 169 | + } |
| 170 | +} |
| 171 | + |
| 172 | + |
| 173 | +/** Factory that allows easy instantiation of focus traps. */ |
| 174 | +@Injectable() |
| 175 | +export class FocusTrapFactory { |
| 176 | + constructor(private _checker: InteractivityChecker, private _ngZone: NgZone) { } |
| 177 | + |
| 178 | + create(element: HTMLElement, deferAnchors = false): FocusTrap { |
| 179 | + return new FocusTrap(element, this._checker, this._ngZone, deferAnchors); |
| 180 | + } |
| 181 | +} |
| 182 | + |
| 183 | + |
| 184 | +/** |
| 185 | + * Directive for trapping focus within a region. |
| 186 | + * @deprecated |
| 187 | + */ |
| 188 | +@Directive({ |
| 189 | + selector: 'cdk-focus-trap', |
| 190 | +}) |
| 191 | +export class FocusTrapDeprecatedDirective implements OnDestroy, AfterContentInit { |
| 192 | + focusTrap: FocusTrap; |
| 193 | + |
| 194 | + /** Whether the focus trap is active. */ |
| 195 | + @Input() |
| 196 | + get disabled(): boolean { return !this.focusTrap.enabled; } |
| 197 | + set disabled(val: boolean) { |
| 198 | + this.focusTrap.enabled = !coerceBooleanProperty(val); |
| 199 | + } |
| 200 | + |
| 201 | + constructor(private _elementRef: ElementRef, private _focusTrapFactory: FocusTrapFactory) { |
| 202 | + this.focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, true); |
| 203 | + } |
| 204 | + |
| 205 | + ngOnDestroy() { |
| 206 | + this.focusTrap.destroy(); |
| 207 | + } |
| 208 | + |
| 209 | + ngAfterContentInit() { |
| 210 | + this.focusTrap.attachAnchors(); |
| 211 | + } |
| 212 | +} |
| 213 | + |
| 214 | + |
| 215 | +/** Directive for trapping focus within a region. */ |
| 216 | +@Directive({ |
| 217 | + selector: '[cdkTrapFocus]' |
| 218 | +}) |
| 219 | +export class FocusTrapDirective implements OnDestroy, AfterContentInit { |
| 220 | + focusTrap: FocusTrap; |
| 221 | + |
| 222 | + /** Whether the focus trap is active. */ |
| 223 | + @Input('cdkTrapFocus') |
| 224 | + get enabled(): boolean { return this.focusTrap.enabled; } |
| 225 | + set enabled(val: boolean) { this.focusTrap.enabled = val; } |
| 226 | + |
| 227 | + constructor(private _elementRef: ElementRef, private _focusTrapFactory: FocusTrapFactory) { |
| 228 | + this.focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, true); |
| 229 | + } |
| 230 | + |
| 231 | + ngOnDestroy() { |
| 232 | + this.focusTrap.destroy(); |
| 233 | + } |
| 234 | + |
| 235 | + ngAfterContentInit() { |
| 236 | + this.focusTrap.attachAnchors(); |
| 237 | + } |
117 | 238 | }
|
0 commit comments