Skip to content

Commit 1357209

Browse files
committed
fix(autocomplete): scroll options below fold into view
1 parent 152b6b2 commit 1357209

File tree

6 files changed

+85
-2
lines changed

6 files changed

+85
-2
lines changed

src/lib/autocomplete/autocomplete-trigger.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ import 'rxjs/add/observable/merge';
1515
import {Dir} from '../core/rtl/dir';
1616
import 'rxjs/add/operator/startWith';
1717
import 'rxjs/add/operator/switchMap';
18+
import {keyAffectsActiveItem} from '../core/a11y/list-key-manager';
19+
20+
/**
21+
* The following style constants are necessary to save here in order
22+
* to properly calculate the scrollTop of the panel. Because we are not
23+
* actually focusing the active item, scroll must be handled manually.
24+
*/
25+
26+
/** The height of each autocomplete option. */
27+
export const AUTOCOMPLETE_OPTION_HEIGHT = 48;
28+
29+
/** The total height of the autocomplete panel. */
30+
export const MD_AUTOCOMPLETE_PANEL_HEIGHT = 256;
1831

1932
@Directive({
2033
selector: 'input[mdAutocomplete], input[matAutocomplete]',
@@ -116,9 +129,25 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
116129
} else {
117130
this.openPanel();
118131
this._keyManager.onKeydown(event);
132+
if (keyAffectsActiveItem(event)) {
133+
this._scrollToOption();
134+
}
119135
}
120136
}
121137

138+
/**
139+
* Given that we are not actually focusing active options, we must manually adjust scroll
140+
* to reveal options below the fold. First, we find the offset of the option from the top
141+
* of the panel. The new scrollTop will be that offset - the panel height + the option
142+
* height, so the active option will be just visible at the bottom of the panel.
143+
*/
144+
private _scrollToOption(): void {
145+
const optionOffset = this._keyManager.activeItemIndex * AUTOCOMPLETE_OPTION_HEIGHT;
146+
const newScrollTop =
147+
Math.max(0, optionOffset - MD_AUTOCOMPLETE_PANEL_HEIGHT + AUTOCOMPLETE_OPTION_HEIGHT);
148+
this.autocomplete._setScrollTop(newScrollTop);
149+
}
150+
122151
/**
123152
* This method listens to a stream of panel closing actions and resets the
124153
* stream every time the option list changes.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<div class="md-autocomplete-panel" role="listbox" [id]="id" [ngClass]="_getPositionClass()">
2+
<div class="md-autocomplete-panel" role="listbox" [id]="id" [ngClass]="_getPositionClass()" #panel>
33
<ng-content></ng-content>
44
</div>
55
</template>

src/lib/autocomplete/autocomplete.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,26 @@ describe('MdAutocomplete', () => {
451451
});
452452
}));
453453

454+
it('should scroll to active options below the fold', () => {
455+
fixture.componentInstance.trigger.openPanel();
456+
fixture.detectChanges();
457+
458+
const scrollContainer = document.querySelector('.cdk-overlay-pane .md-autocomplete-panel');
459+
460+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
461+
fixture.detectChanges();
462+
expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to scroll.`);
463+
464+
// These down arrows will set the 6th option active, below the fold.
465+
[1,2,3,4, 5].forEach(() => {
466+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
467+
});
468+
fixture.detectChanges();
469+
470+
// Expect option bottom minus the panel height (288 - 256 = 32)
471+
expect(scrollContainer.scrollTop).toEqual(32, `Expected panel to reveal the sixth option.`);
472+
});
473+
454474
});
455475

456476
describe('Fallback positions', () => {

src/lib/autocomplete/autocomplete.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
Component,
33
ContentChildren,
4+
ElementRef,
45
QueryList,
56
TemplateRef,
67
ViewChild,
@@ -29,11 +30,20 @@ export class MdAutocomplete {
2930
positionY: MenuPositionY = 'below';
3031

3132
@ViewChild(TemplateRef) template: TemplateRef<any>;
33+
@ViewChild('panel') panel: ElementRef;
3234
@ContentChildren(MdOption) options: QueryList<MdOption>;
3335

3436
/** Unique ID to be used by autocomplete trigger's "aria-owns" property. */
3537
id: string = `md-autocomplete-${_uniqueAutocompleteIdCounter++}`;
3638

39+
/**
40+
* Sets the panel scrollTop. This allows us to manually scroll to display
41+
* options below the fold, as they are not actually being focused when active.
42+
*/
43+
_setScrollTop(scrollTop: number): void {
44+
this.panel.nativeElement.scrollTop = scrollTop;
45+
}
46+
3747
/** Sets a class on the panel based on its position (used to set y-offset). */
3848
_getPositionClass() {
3949
return {

src/lib/core/a11y/list-key-manager.spec.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {QueryList} from '@angular/core';
22
import {FocusKeyManager} from './focus-key-manager';
33
import {DOWN_ARROW, UP_ARROW, TAB, HOME, END} from '../keyboard/keycodes';
4-
import {ListKeyManager} from './list-key-manager';
4+
import {ListKeyManager, keyAffectsActiveItem} from './list-key-manager';
55
import {ActiveDescendantKeyManager} from './activedescendant-key-manager';
66

77
class FakeFocusable {
@@ -349,6 +349,20 @@ describe('Key managers', () => {
349349

350350
});
351351

352+
it('should determine if keyAffectsActiveItem()', () => {
353+
expect(keyAffectsActiveItem(DOWN_ARROW_EVENT))
354+
.toBe(true, 'Expected DOWN key to return true for affecting active item.');
355+
expect(keyAffectsActiveItem(UP_ARROW_EVENT))
356+
.toBe(true, 'Expected UP key to return true for affecting active item.');
357+
expect(keyAffectsActiveItem(HOME_EVENT))
358+
.toBe(true, 'Expected HOME key to return true for affecting active item.');
359+
expect(keyAffectsActiveItem(END_EVENT))
360+
.toBe(true, 'Expected END key to return true for affecting active item.');
361+
const A_KEY_EVENT = new FakeEvent(65) as KeyboardEvent;
362+
expect(keyAffectsActiveItem(A_KEY_EVENT))
363+
.toBe(false, 'Expected A key to return false for affecting active item.');
364+
});
365+
352366
});
353367

354368
describe('FocusKeyManager', () => {

src/lib/core/a11y/list-key-manager.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,13 @@ export class ListKeyManager<T extends CanDisable> {
174174

175175
}
176176

177+
/**
178+
* Whether the key event is a directional key that will affect the
179+
* currently active item.
180+
*/
181+
export function keyAffectsActiveItem(event: KeyboardEvent): boolean {
182+
return event.keyCode === DOWN_ARROW ||
183+
event.keyCode === UP_ARROW ||
184+
event.keyCode === HOME ||
185+
event.keyCode === END;
186+
}

0 commit comments

Comments
 (0)