Skip to content

Commit c946631

Browse files
mmalerbajelbourn
authored andcommitted
feat(focus-trap): allow setting initially focused element (#4577)
1 parent 0b5b624 commit c946631

File tree

9 files changed

+98
-34
lines changed

9 files changed

+98
-34
lines changed

src/demo-app/sidenav/sidenav-demo.html

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,27 @@ <h2>Dynamic Alignment Sidenav</h2>
6161
<button (click)="invert = !invert">Change sides</button>
6262
</div>
6363
</md-sidenav-container>
64+
65+
<h2>Sidenav with focus attributes</h2>
66+
67+
<md-sidenav-container class="demo-sidenav-container">
68+
<md-sidenav #focusSidenav>
69+
<md-nav-list>
70+
<a md-list-item routerLink>Link</a>
71+
<a md-list-item routerLink cdk-focus-region-start>Focus region start</a>
72+
<a md-list-item routerLink>Link</a>
73+
<a md-list-item routerLink cdk-focus-initial>Initially focused</a>
74+
<a md-list-item routerLink cdk-focus-region-end>Focus region end</a>
75+
<a md-list-item routerLink>Link</a>
76+
</md-nav-list>
77+
</md-sidenav>
78+
79+
<div class="demo-sidenav-content">
80+
<h1>My Content</h1>
81+
82+
<div>
83+
<header>Sidenav</header>
84+
<button md-button (click)="focusSidenav.toggle()">Toggle Drawer</button>
85+
</div>
86+
</div>
87+
</md-sidenav-container>

src/lib/core/a11y/focus-trap.spec.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ describe('FocusTrap', () => {
1414
FocusTrapWithBindings,
1515
SimpleFocusTrap,
1616
FocusTrapTargets,
17-
FocusTrapWithSvg
17+
FocusTrapWithSvg,
1818
],
1919
providers: [InteractivityChecker, Platform, FocusTrapFactory]
2020
});
@@ -104,6 +104,13 @@ describe('FocusTrap', () => {
104104
focusTrapInstance = fixture.componentInstance.focusTrapDirective.focusTrap;
105105
});
106106

107+
it('should be able to set initial focus target', () => {
108+
// Because we can't mimic a real tab press focus change in a unit test, just call the
109+
// focus event handler directly.
110+
focusTrapInstance.focusInitialElement();
111+
expect(document.activeElement.id).toBe('middle');
112+
});
113+
107114
it('should be able to prioritize the first focus target', () => {
108115
// Because we can't mimic a real tab press focus change in a unit test, just call the
109116
// focus event handler directly.
@@ -131,7 +138,6 @@ describe('FocusTrap', () => {
131138
expect(() => focusTrapInstance.focusLastTabbableElement()).not.toThrow();
132139
});
133140
});
134-
135141
});
136142

137143

@@ -167,8 +173,11 @@ class FocusTrapWithBindings {
167173
template: `
168174
<div cdkTrapFocus>
169175
<input>
170-
<button id="last" cdk-focus-end></button>
171-
<button id="first" cdk-focus-start>SAVE</button>
176+
<button>before</button>
177+
<button id="first" cdk-focus-region-start></button>
178+
<button id="middle" cdk-focus-initial></button>
179+
<button id="last" cdk-focus-region-end></button>
180+
<button>after</button>
172181
<input>
173182
</div>
174183
`

src/lib/core/a11y/focus-trap.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ export class FocusTrap {
8181
});
8282
}
8383

84+
focusInitialElementWhenReady() {
85+
this._ngZone.onMicrotaskEmpty.first().subscribe(() => this.focusInitialElement());
86+
}
87+
8488
/**
8589
* Waits for microtask queue to empty, then focuses
8690
* the first tabbable element within the focus trap region.
@@ -97,27 +101,53 @@ export class FocusTrap {
97101
this._ngZone.onMicrotaskEmpty.first().subscribe(() => this.focusLastTabbableElement());
98102
}
99103

104+
/**
105+
* Get the specified boundary element of the trapped region.
106+
* @param bound The boundary to get (start or end of trapped region).
107+
* @returns The boundary element.
108+
*/
109+
private _getRegionBoundary(bound: 'start' | 'end'): HTMLElement | null {
110+
let markers = [
111+
...Array.prototype.slice.call(this._element.querySelectorAll(`[cdk-focus-region-${bound}]`)),
112+
// Deprecated version of selector, for temporary backwards comparability:
113+
...Array.prototype.slice.call(this._element.querySelectorAll(`[cdk-focus-${bound}]`)),
114+
];
115+
116+
markers.forEach((el: HTMLElement) => {
117+
if (el.hasAttribute(`cdk-focus-${bound}`)) {
118+
console.warn(`Found use of deprecated attribute 'cdk-focus-${bound}',` +
119+
` use 'cdk-focus-region-${bound}' instead.`, el);
120+
}
121+
});
122+
123+
if (bound == 'start') {
124+
return markers.length ? markers[0] : this._getFirstTabbableElement(this._element);
125+
}
126+
return markers.length ?
127+
markers[markers.length - 1] : this._getLastTabbableElement(this._element);
128+
}
129+
130+
/** Focuses the element that should be focused when the focus trap is initialized. */
131+
focusInitialElement() {
132+
let redirectToElement = this._element.querySelector('[cdk-focus-initial]') as HTMLElement;
133+
if (redirectToElement) {
134+
redirectToElement.focus();
135+
} else {
136+
this.focusFirstTabbableElement();
137+
}
138+
}
139+
100140
/** Focuses the first tabbable element within the focus trap region. */
101141
focusFirstTabbableElement() {
102-
let redirectToElement = this._element.querySelector('[cdk-focus-start]') as HTMLElement ||
103-
this._getFirstTabbableElement(this._element);
104-
142+
let redirectToElement = this._getRegionBoundary('start');
105143
if (redirectToElement) {
106144
redirectToElement.focus();
107145
}
108146
}
109147

110148
/** Focuses the last tabbable element within the focus trap region. */
111149
focusLastTabbableElement() {
112-
let focusTargets = this._element.querySelectorAll('[cdk-focus-end]');
113-
let redirectToElement: HTMLElement = null;
114-
115-
if (focusTargets.length) {
116-
redirectToElement = focusTargets[focusTargets.length - 1] as HTMLElement;
117-
} else {
118-
redirectToElement = this._getLastTabbableElement(this._element);
119-
}
120-
150+
let redirectToElement = this._getRegionBoundary('end');
121151
if (redirectToElement) {
122152
redirectToElement.focus();
123153
}

src/lib/dialog/dialog-container.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export class MdDialogContainer extends BasePortalHost {
125125
// If were to attempt to focus immediately, then the content of the dialog would not yet be
126126
// ready in instances where change detection has to run first. To deal with this, we simply
127127
// wait for the microtask queue to be empty.
128-
this._focusTrap.focusFirstTabbableElementWhenReady();
128+
this._focusTrap.focusInitialElementWhenReady();
129129
}
130130

131131
/** Restores focus to the element that was focused before the dialog opened. */

src/lib/list/_list-theme.scss

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
border-top-color: mat-color($foreground, divider);
2121
}
2222

23-
.mat-nav-list .mat-list-item-content {
23+
.mat-nav-list .mat-list-item {
24+
outline: none;
25+
2426
&:hover, &.mat-list-item-focus {
2527
background: mat-color($background, 'hover');
2628
}

src/lib/list/list-item.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<div class="mat-list-item-content" [class.mat-list-item-focus]="_hasFocus">
1+
<div class="mat-list-item-content">
22
<div class="mat-list-item-ripple" md-ripple
33
[mdRippleTrigger]="_getHostElement()"
44
[mdRippleDisabled]="!isRippleEnabled()">

src/lib/list/list.spec.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,19 @@ describe('MdList', () => {
2828

2929
it('should add and remove focus class on focus/blur', () => {
3030
let fixture = TestBed.createComponent(ListWithOneAnchorItem);
31-
let listItem = fixture.debugElement.query(By.directive(MdListItem));
32-
let listItemDiv = fixture.debugElement.query(By.css('.mat-list-item-content'));
3331
fixture.detectChanges();
34-
expect(listItemDiv.nativeElement.classList).not.toContain('mat-list-item-focus');
32+
let listItem = fixture.debugElement.query(By.directive(MdListItem));
33+
let listItemEl = fixture.debugElement.query(By.css('.mat-list-item'));
34+
35+
expect(listItemEl.nativeElement.classList).not.toContain('mat-list-item-focus');
3536

3637
listItem.componentInstance._handleFocus();
3738
fixture.detectChanges();
38-
expect(listItemDiv.nativeElement.classList).toContain('mat-list-item-focus');
39+
expect(listItemEl.nativeElement.classList).toContain('mat-list-item-focus');
3940

4041
listItem.componentInstance._handleBlur();
4142
fixture.detectChanges();
42-
expect(listItemDiv.nativeElement.classList).not.toContain('mat-list-item-focus');
43+
expect(listItemEl.nativeElement.classList).not.toContain('mat-list-item-focus');
4344
});
4445

4546
it('should not apply any additional class to a list without lines', () => {

src/lib/list/list.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import {
2+
AfterContentInit,
23
Component,
3-
ViewEncapsulation,
4-
ContentChildren,
54
ContentChild,
6-
QueryList,
5+
ContentChildren,
76
Directive,
87
ElementRef,
98
Input,
109
Optional,
10+
QueryList,
1111
Renderer2,
12-
AfterContentInit,
12+
ViewEncapsulation
1313
} from '@angular/core';
14-
import {MdLine, MdLineSetter, coerceBooleanProperty} from '../core';
14+
import {coerceBooleanProperty, MdLine, MdLineSetter} from '../core';
1515

1616
@Directive({
1717
selector: 'md-divider, mat-divider'
@@ -128,8 +128,6 @@ export class MdListItem implements AfterContentInit {
128128
private _disableRipple: boolean = false;
129129
private _isNavList: boolean = false;
130130

131-
_hasFocus: boolean = false;
132-
133131
/**
134132
* Whether the ripple effect on click should be disabled. This applies only to list items that are
135133
* part of a nav list. The value of `disableRipple` on the `md-nav-list` overrides this flag.
@@ -166,11 +164,11 @@ export class MdListItem implements AfterContentInit {
166164
}
167165

168166
_handleFocus() {
169-
this._hasFocus = true;
167+
this._renderer.addClass(this._element.nativeElement, 'mat-list-item-focus');
170168
}
171169

172170
_handleBlur() {
173-
this._hasFocus = false;
171+
this._renderer.removeClass(this._element.nativeElement, 'mat-list-item-focus');
174172
}
175173

176174
/** Retrieves the DOM element of the component host. */

src/lib/sidenav/sidenav.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export class MdSidenav implements AfterContentInit, OnDestroy {
131131
this._elementFocusedBeforeSidenavWasOpened = document.activeElement as HTMLElement;
132132

133133
if (this.isFocusTrapEnabled && this._focusTrap) {
134-
this._focusTrap.focusFirstTabbableElementWhenReady();
134+
this._focusTrap.focusInitialElementWhenReady();
135135
}
136136
});
137137

0 commit comments

Comments
 (0)