Skip to content

Commit

Permalink
feat(material/select): add page down/up button functionality (#25508)
Browse files Browse the repository at this point in the history
  • Loading branch information
forsti0506 committed Sep 22, 2022
1 parent a210167 commit 8ca3155
Show file tree
Hide file tree
Showing 4 changed files with 254 additions and 0 deletions.
33 changes: 33 additions & 0 deletions src/cdk/a11y/key-manager/list-key-manager.ts
Expand Up @@ -21,6 +21,8 @@ import {
hasModifierKey,
HOME,
END,
PAGE_UP,
PAGE_DOWN,
} from '@angular/cdk/keycodes';
import {debounceTime, filter, map, tap} from 'rxjs/operators';

Expand Down Expand Up @@ -50,6 +52,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
private _horizontal: 'ltr' | 'rtl' | null;
private _allowedModifierKeys: ListKeyManagerModifierKey[] = [];
private _homeAndEnd = false;
private _pageUpAndDown = {enabled: false, delta: 10};

/**
* Predicate function that can be used to check whether an item should be skipped
Expand Down Expand Up @@ -194,6 +197,17 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
return this;
}

/**
* Configures the key manager to activate every 10th, configured or first/last element in up/down direction
* respectively when the Page-Up or Page-Down key is pressed.
* @param enabled Whether pressing the Page-Up or Page-Down key activates the first/last item.
* @param delta Whether pressing the Home or End key activates the first/last item.
*/
withPageUpDown(enabled: boolean = true, delta: number = 10): this {
this._pageUpAndDown = {enabled, delta};
return this;
}

/**
* Sets the active item to the item at the index specified.
* @param index The index of the item to be set as active.
Expand Down Expand Up @@ -280,6 +294,25 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
return;
}

case PAGE_UP:
if (this._pageUpAndDown.enabled && isModifierAllowed) {
const targetIndex = this._activeItemIndex - this._pageUpAndDown.delta;
this._setActiveItemByIndex(targetIndex > 0 ? targetIndex : 0, 1);
break;
} else {
return;
}

case PAGE_DOWN:
if (this._pageUpAndDown.enabled && isModifierAllowed) {
const targetIndex = this._activeItemIndex + this._pageUpAndDown.delta;
const itemsLength = this._getItemsArray().length;
this._setActiveItemByIndex(targetIndex < itemsLength ? targetIndex : itemsLength - 1, -1);
break;
} else {
return;
}

default:
if (isModifierAllowed || hasModifierKey(event, 'shiftKey')) {
// Attempt to use the `event.key` which also maps it to the user's keyboard language,
Expand Down
219 changes: 219 additions & 0 deletions src/material/select/select.spec.ts
Expand Up @@ -11,6 +11,8 @@ import {
UP_ARROW,
A,
ESCAPE,
PAGE_DOWN,
PAGE_UP,
} from '@angular/cdk/keycodes';
import {OverlayContainer} from '@angular/cdk/overlay';
import {ScrollDispatcher} from '@angular/cdk/scrolling';
Expand Down Expand Up @@ -419,6 +421,39 @@ describe('MDC-based MatSelect', () => {
flush();
}));

it('should select first/last options via the PAGE_DOWN/PAGE_UP keys on a closed select with less than 10 options', fakeAsync(() => {
const formControl = fixture.componentInstance.control;
const firstOption = fixture.componentInstance.options.first;
const lastOption = fixture.componentInstance.options.last;

expect(formControl.value).withContext('Expected no initial value.').toBeFalsy();
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(-1);

const endEvent = dispatchKeyboardEvent(select, 'keydown', PAGE_DOWN);

expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(7);
expect(endEvent.defaultPrevented).toBe(true);
expect(lastOption.selected)
.withContext('Expected last option to be selected.')
.toBe(true);
expect(formControl.value)
.withContext('Expected value from last option to have been set on the model.')
.toBe(lastOption.value);

const homeEvent = dispatchKeyboardEvent(select, 'keydown', PAGE_UP);

expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(0);
expect(homeEvent.defaultPrevented).toBe(true);
expect(firstOption.selected)
.withContext('Expected first option to be selected.')
.toBe(true);
expect(formControl.value)
.withContext('Expected value from first option to have been set on the model.')
.toBe(firstOption.value);

flush();
}));

it('should resume focus from selected item after selecting via click', fakeAsync(() => {
const formControl = fixture.componentInstance.control;
const options = fixture.componentInstance.options.toArray();
Expand Down Expand Up @@ -1490,6 +1525,37 @@ describe('MDC-based MatSelect', () => {
expect(event.defaultPrevented).toBe(true);
}));

it('should focus the last option when pressing PAGE_DOWN with less than 10 options', fakeAsync(() => {
fixture.componentInstance.control.setValue('pizza-1');
fixture.detectChanges();

trigger.click();
fixture.detectChanges();
flush();

const event = dispatchKeyboardEvent(trigger, 'keydown', PAGE_DOWN);
fixture.detectChanges();

expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(7);
expect(event.defaultPrevented).toBe(true);
}));

it('should focus the first option when pressing PAGE_UP with index < 10', fakeAsync(() => {
fixture.componentInstance.control.setValue('pizza-1');
fixture.detectChanges();

trigger.click();
fixture.detectChanges();
flush();

expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBeLessThan(10);
const event = dispatchKeyboardEvent(trigger, 'keydown', PAGE_UP);
fixture.detectChanges();

expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(0);
expect(event.defaultPrevented).toBe(true);
}));

it('should be able to set extra classes on the panel', fakeAsync(() => {
trigger.click();
fixture.detectChanges();
Expand Down Expand Up @@ -2347,6 +2413,66 @@ describe('MDC-based MatSelect', () => {
.toBe(1173);
}));

it('should scroll 10 to the top or to first element when pressing PAGE_UP', fakeAsync(() => {
for (let i = 0; i < 18; i++) {
dispatchKeyboardEvent(host, 'keydown', DOWN_ARROW);
fixture.detectChanges();
}

expect(panel.scrollTop)
.withContext('Expected panel to be scrolled down.')
.toBeGreaterThan(0);

expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(18);

dispatchKeyboardEvent(host, 'keydown', PAGE_UP);
fixture.detectChanges();

// <top padding> + <option amount> * <option height>
// 8 + 8 × 48
expect(panel.scrollTop).withContext('Expected panel to be scrolled to the top').toBe(392);
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(8);

dispatchKeyboardEvent(host, 'keydown', PAGE_UP);
fixture.detectChanges();

// 8px is the top padding of the panel.
expect(panel.scrollTop).withContext('Expected panel to be scrolled to the top').toBe(8);
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(0);
}));

it('should scroll 10 to the bottom of the panel when pressing PAGE_DOWN', fakeAsync(() => {
dispatchKeyboardEvent(host, 'keydown', PAGE_DOWN);
fixture.detectChanges();

// <top padding> + <option amount> * <option height> - <panel height> =
// 8 + 11 * 48 - 275 = 261
expect(panel.scrollTop)
.withContext('Expected panel to be scrolled 10 to the bottom')
.toBe(261);
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(10);

dispatchKeyboardEvent(host, 'keydown', PAGE_DOWN);
fixture.detectChanges();

// <top padding> + <option amount> * <option height> - <panel height> =
// 8 + 21 * 48 - 275 = 741
expect(panel.scrollTop)
.withContext('Expected panel to be scrolled 10 to the bottom')
.toBe(741);
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(20);

dispatchKeyboardEvent(host, 'keydown', PAGE_DOWN);
fixture.detectChanges();

// <top padding> + <option amount> * <option height> - <panel height> =
// 8 + 30 * 48 - 275 = 1173
expect(panel.scrollTop)
.withContext('Expected panel to be scrolled 10 to the bottom')
.toBe(1173);
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(29);
}));

it('should scroll to the active option when typing', fakeAsync(() => {
for (let i = 0; i < 15; i++) {
// Press the letter 'o' 15 times since all the options are named 'Option <index>'
Expand Down Expand Up @@ -4209,6 +4335,51 @@ describe('MDC-based MatSelect', () => {
const fixture = TestBed.createComponent(SelectInNgContainer);
expect(() => fixture.detectChanges()).not.toThrow();
}));
describe('page up/down with disabled options', () => {
let fixture: ComponentFixture<BasicSelectWithFirstAndLastOptionDisabled>;
let host: HTMLElement;

beforeEach(waitForAsync(() =>
configureMatSelectTestingModule([BasicSelectWithFirstAndLastOptionDisabled])));

beforeEach(fakeAsync(() => {
fixture = TestBed.createComponent(BasicSelectWithFirstAndLastOptionDisabled);

fixture.detectChanges();
fixture.componentInstance.select.open();
fixture.detectChanges();
flush();
fixture.detectChanges();

host = fixture.debugElement.query(By.css('mat-select'))!.nativeElement;
}));

it('should scroll to the second one pressing PAGE_UP, because the first one is disabled', fakeAsync(() => {
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(1);

dispatchKeyboardEvent(host, 'keydown', PAGE_UP);
fixture.detectChanges();

expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(1);

dispatchKeyboardEvent(host, 'keydown', PAGE_UP);
fixture.detectChanges();

expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(1);
}));

it('should scroll by PAGE_DOWN to the one before the last, because last one is disabled', fakeAsync(() => {
dispatchKeyboardEvent(host, 'keydown', PAGE_DOWN);
fixture.detectChanges();

expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(6);

dispatchKeyboardEvent(host, 'keydown', PAGE_DOWN);
fixture.detectChanges();

expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(6);
}));
});
});

@Component({
Expand Down Expand Up @@ -5091,3 +5262,51 @@ class SelectInsideDynamicFormGroup {
});
}
}
@Component({
selector: 'basic-select',
template: `
<div [style.height.px]="heightAbove"></div>
<mat-form-field>
<mat-label *ngIf="hasLabel">Select a food</mat-label>
<mat-select placeholder="Food" [formControl]="control" [required]="isRequired"
[tabIndex]="tabIndexOverride" [aria-describedby]="ariaDescribedBy"
[aria-label]="ariaLabel" [aria-labelledby]="ariaLabelledby"
[panelClass]="panelClass" [disableRipple]="disableRipple"
[typeaheadDebounceInterval]="typeaheadDebounceInterval">
<mat-option *ngFor="let food of foods" [value]="food.value" [disabled]="food.disabled">
{{ food.viewValue }}
</mat-option>
</mat-select>
<mat-hint *ngIf="hint">{{ hint }}</mat-hint>
</mat-form-field>
<div [style.height.px]="heightBelow"></div>
`,
})
class BasicSelectWithFirstAndLastOptionDisabled {
foods: any[] = [
{value: 'steak-0', viewValue: 'Steak', disabled: true},
{value: 'pizza-1', viewValue: 'Pizza'},
{value: 'tacos-2', viewValue: 'Tacos'},
{value: 'sandwich-3', viewValue: 'Sandwich'},
{value: 'chips-4', viewValue: 'Chips'},
{value: 'eggs-5', viewValue: 'Eggs'},
{value: 'pasta-6', viewValue: 'Pasta'},
{value: 'sushi-7', viewValue: 'Sushi', disabled: true},
];
control = new FormControl<string | null>(null);
isRequired: boolean;
heightAbove = 0;
heightBelow = 0;
hasLabel = true;
hint: string;
tabIndexOverride: number;
ariaDescribedBy: string;
ariaLabel: string;
ariaLabelledby: string;
panelClass = ['custom-one', 'custom-two'];
disableRipple: boolean;
typeaheadDebounceInterval: number;

@ViewChild(MatSelect, {static: true}) select: MatSelect;
@ViewChildren(MatOption) options: QueryList<MatOption>;
}
1 change: 1 addition & 0 deletions src/material/select/select.ts
Expand Up @@ -914,6 +914,7 @@ export abstract class _MatSelectBase<C>
.withVerticalOrientation()
.withHorizontalOrientation(this._isRtl() ? 'rtl' : 'ltr')
.withHomeAndEnd()
.withPageUpDown()
.withAllowedModifierKeys(['shiftKey']);

this._keyManager.tabOut.pipe(takeUntil(this._destroy)).subscribe(() => {
Expand Down
1 change: 1 addition & 0 deletions tools/public_api_guard/cdk/a11y.md
Expand Up @@ -358,6 +358,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
withAllowedModifierKeys(keys: ListKeyManagerModifierKey[]): this;
withHomeAndEnd(enabled?: boolean): this;
withHorizontalOrientation(direction: 'ltr' | 'rtl' | null): this;
withPageUpDown(enabled?: boolean, delta?: number): this;
withTypeAhead(debounceInterval?: number): this;
withVerticalOrientation(enabled?: boolean): this;
withWrap(shouldWrap?: boolean): this;
Expand Down

0 comments on commit 8ca3155

Please sign in to comment.