Skip to content

Commit 8ca3155

Browse files
authored
feat(material/select): add page down/up button functionality (#25508)
1 parent a210167 commit 8ca3155

File tree

4 files changed

+254
-0
lines changed

4 files changed

+254
-0
lines changed

src/cdk/a11y/key-manager/list-key-manager.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
hasModifierKey,
2222
HOME,
2323
END,
24+
PAGE_UP,
25+
PAGE_DOWN,
2426
} from '@angular/cdk/keycodes';
2527
import {debounceTime, filter, map, tap} from 'rxjs/operators';
2628

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

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

200+
/**
201+
* Configures the key manager to activate every 10th, configured or first/last element in up/down direction
202+
* respectively when the Page-Up or Page-Down key is pressed.
203+
* @param enabled Whether pressing the Page-Up or Page-Down key activates the first/last item.
204+
* @param delta Whether pressing the Home or End key activates the first/last item.
205+
*/
206+
withPageUpDown(enabled: boolean = true, delta: number = 10): this {
207+
this._pageUpAndDown = {enabled, delta};
208+
return this;
209+
}
210+
197211
/**
198212
* Sets the active item to the item at the index specified.
199213
* @param index The index of the item to be set as active.
@@ -280,6 +294,25 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
280294
return;
281295
}
282296

297+
case PAGE_UP:
298+
if (this._pageUpAndDown.enabled && isModifierAllowed) {
299+
const targetIndex = this._activeItemIndex - this._pageUpAndDown.delta;
300+
this._setActiveItemByIndex(targetIndex > 0 ? targetIndex : 0, 1);
301+
break;
302+
} else {
303+
return;
304+
}
305+
306+
case PAGE_DOWN:
307+
if (this._pageUpAndDown.enabled && isModifierAllowed) {
308+
const targetIndex = this._activeItemIndex + this._pageUpAndDown.delta;
309+
const itemsLength = this._getItemsArray().length;
310+
this._setActiveItemByIndex(targetIndex < itemsLength ? targetIndex : itemsLength - 1, -1);
311+
break;
312+
} else {
313+
return;
314+
}
315+
283316
default:
284317
if (isModifierAllowed || hasModifierKey(event, 'shiftKey')) {
285318
// Attempt to use the `event.key` which also maps it to the user's keyboard language,

src/material/select/select.spec.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
UP_ARROW,
1212
A,
1313
ESCAPE,
14+
PAGE_DOWN,
15+
PAGE_UP,
1416
} from '@angular/cdk/keycodes';
1517
import {OverlayContainer} from '@angular/cdk/overlay';
1618
import {ScrollDispatcher} from '@angular/cdk/scrolling';
@@ -419,6 +421,39 @@ describe('MDC-based MatSelect', () => {
419421
flush();
420422
}));
421423

424+
it('should select first/last options via the PAGE_DOWN/PAGE_UP keys on a closed select with less than 10 options', fakeAsync(() => {
425+
const formControl = fixture.componentInstance.control;
426+
const firstOption = fixture.componentInstance.options.first;
427+
const lastOption = fixture.componentInstance.options.last;
428+
429+
expect(formControl.value).withContext('Expected no initial value.').toBeFalsy();
430+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(-1);
431+
432+
const endEvent = dispatchKeyboardEvent(select, 'keydown', PAGE_DOWN);
433+
434+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(7);
435+
expect(endEvent.defaultPrevented).toBe(true);
436+
expect(lastOption.selected)
437+
.withContext('Expected last option to be selected.')
438+
.toBe(true);
439+
expect(formControl.value)
440+
.withContext('Expected value from last option to have been set on the model.')
441+
.toBe(lastOption.value);
442+
443+
const homeEvent = dispatchKeyboardEvent(select, 'keydown', PAGE_UP);
444+
445+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(0);
446+
expect(homeEvent.defaultPrevented).toBe(true);
447+
expect(firstOption.selected)
448+
.withContext('Expected first option to be selected.')
449+
.toBe(true);
450+
expect(formControl.value)
451+
.withContext('Expected value from first option to have been set on the model.')
452+
.toBe(firstOption.value);
453+
454+
flush();
455+
}));
456+
422457
it('should resume focus from selected item after selecting via click', fakeAsync(() => {
423458
const formControl = fixture.componentInstance.control;
424459
const options = fixture.componentInstance.options.toArray();
@@ -1490,6 +1525,37 @@ describe('MDC-based MatSelect', () => {
14901525
expect(event.defaultPrevented).toBe(true);
14911526
}));
14921527

1528+
it('should focus the last option when pressing PAGE_DOWN with less than 10 options', fakeAsync(() => {
1529+
fixture.componentInstance.control.setValue('pizza-1');
1530+
fixture.detectChanges();
1531+
1532+
trigger.click();
1533+
fixture.detectChanges();
1534+
flush();
1535+
1536+
const event = dispatchKeyboardEvent(trigger, 'keydown', PAGE_DOWN);
1537+
fixture.detectChanges();
1538+
1539+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(7);
1540+
expect(event.defaultPrevented).toBe(true);
1541+
}));
1542+
1543+
it('should focus the first option when pressing PAGE_UP with index < 10', fakeAsync(() => {
1544+
fixture.componentInstance.control.setValue('pizza-1');
1545+
fixture.detectChanges();
1546+
1547+
trigger.click();
1548+
fixture.detectChanges();
1549+
flush();
1550+
1551+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBeLessThan(10);
1552+
const event = dispatchKeyboardEvent(trigger, 'keydown', PAGE_UP);
1553+
fixture.detectChanges();
1554+
1555+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(0);
1556+
expect(event.defaultPrevented).toBe(true);
1557+
}));
1558+
14931559
it('should be able to set extra classes on the panel', fakeAsync(() => {
14941560
trigger.click();
14951561
fixture.detectChanges();
@@ -2347,6 +2413,66 @@ describe('MDC-based MatSelect', () => {
23472413
.toBe(1173);
23482414
}));
23492415

2416+
it('should scroll 10 to the top or to first element when pressing PAGE_UP', fakeAsync(() => {
2417+
for (let i = 0; i < 18; i++) {
2418+
dispatchKeyboardEvent(host, 'keydown', DOWN_ARROW);
2419+
fixture.detectChanges();
2420+
}
2421+
2422+
expect(panel.scrollTop)
2423+
.withContext('Expected panel to be scrolled down.')
2424+
.toBeGreaterThan(0);
2425+
2426+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(18);
2427+
2428+
dispatchKeyboardEvent(host, 'keydown', PAGE_UP);
2429+
fixture.detectChanges();
2430+
2431+
// <top padding> + <option amount> * <option height>
2432+
// 8 + 8 × 48
2433+
expect(panel.scrollTop).withContext('Expected panel to be scrolled to the top').toBe(392);
2434+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(8);
2435+
2436+
dispatchKeyboardEvent(host, 'keydown', PAGE_UP);
2437+
fixture.detectChanges();
2438+
2439+
// 8px is the top padding of the panel.
2440+
expect(panel.scrollTop).withContext('Expected panel to be scrolled to the top').toBe(8);
2441+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(0);
2442+
}));
2443+
2444+
it('should scroll 10 to the bottom of the panel when pressing PAGE_DOWN', fakeAsync(() => {
2445+
dispatchKeyboardEvent(host, 'keydown', PAGE_DOWN);
2446+
fixture.detectChanges();
2447+
2448+
// <top padding> + <option amount> * <option height> - <panel height> =
2449+
// 8 + 11 * 48 - 275 = 261
2450+
expect(panel.scrollTop)
2451+
.withContext('Expected panel to be scrolled 10 to the bottom')
2452+
.toBe(261);
2453+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(10);
2454+
2455+
dispatchKeyboardEvent(host, 'keydown', PAGE_DOWN);
2456+
fixture.detectChanges();
2457+
2458+
// <top padding> + <option amount> * <option height> - <panel height> =
2459+
// 8 + 21 * 48 - 275 = 741
2460+
expect(panel.scrollTop)
2461+
.withContext('Expected panel to be scrolled 10 to the bottom')
2462+
.toBe(741);
2463+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(20);
2464+
2465+
dispatchKeyboardEvent(host, 'keydown', PAGE_DOWN);
2466+
fixture.detectChanges();
2467+
2468+
// <top padding> + <option amount> * <option height> - <panel height> =
2469+
// 8 + 30 * 48 - 275 = 1173
2470+
expect(panel.scrollTop)
2471+
.withContext('Expected panel to be scrolled 10 to the bottom')
2472+
.toBe(1173);
2473+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(29);
2474+
}));
2475+
23502476
it('should scroll to the active option when typing', fakeAsync(() => {
23512477
for (let i = 0; i < 15; i++) {
23522478
// Press the letter 'o' 15 times since all the options are named 'Option <index>'
@@ -4209,6 +4335,51 @@ describe('MDC-based MatSelect', () => {
42094335
const fixture = TestBed.createComponent(SelectInNgContainer);
42104336
expect(() => fixture.detectChanges()).not.toThrow();
42114337
}));
4338+
describe('page up/down with disabled options', () => {
4339+
let fixture: ComponentFixture<BasicSelectWithFirstAndLastOptionDisabled>;
4340+
let host: HTMLElement;
4341+
4342+
beforeEach(waitForAsync(() =>
4343+
configureMatSelectTestingModule([BasicSelectWithFirstAndLastOptionDisabled])));
4344+
4345+
beforeEach(fakeAsync(() => {
4346+
fixture = TestBed.createComponent(BasicSelectWithFirstAndLastOptionDisabled);
4347+
4348+
fixture.detectChanges();
4349+
fixture.componentInstance.select.open();
4350+
fixture.detectChanges();
4351+
flush();
4352+
fixture.detectChanges();
4353+
4354+
host = fixture.debugElement.query(By.css('mat-select'))!.nativeElement;
4355+
}));
4356+
4357+
it('should scroll to the second one pressing PAGE_UP, because the first one is disabled', fakeAsync(() => {
4358+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(1);
4359+
4360+
dispatchKeyboardEvent(host, 'keydown', PAGE_UP);
4361+
fixture.detectChanges();
4362+
4363+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(1);
4364+
4365+
dispatchKeyboardEvent(host, 'keydown', PAGE_UP);
4366+
fixture.detectChanges();
4367+
4368+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(1);
4369+
}));
4370+
4371+
it('should scroll by PAGE_DOWN to the one before the last, because last one is disabled', fakeAsync(() => {
4372+
dispatchKeyboardEvent(host, 'keydown', PAGE_DOWN);
4373+
fixture.detectChanges();
4374+
4375+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(6);
4376+
4377+
dispatchKeyboardEvent(host, 'keydown', PAGE_DOWN);
4378+
fixture.detectChanges();
4379+
4380+
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(6);
4381+
}));
4382+
});
42124383
});
42134384

42144385
@Component({
@@ -5091,3 +5262,51 @@ class SelectInsideDynamicFormGroup {
50915262
});
50925263
}
50935264
}
5265+
@Component({
5266+
selector: 'basic-select',
5267+
template: `
5268+
<div [style.height.px]="heightAbove"></div>
5269+
<mat-form-field>
5270+
<mat-label *ngIf="hasLabel">Select a food</mat-label>
5271+
<mat-select placeholder="Food" [formControl]="control" [required]="isRequired"
5272+
[tabIndex]="tabIndexOverride" [aria-describedby]="ariaDescribedBy"
5273+
[aria-label]="ariaLabel" [aria-labelledby]="ariaLabelledby"
5274+
[panelClass]="panelClass" [disableRipple]="disableRipple"
5275+
[typeaheadDebounceInterval]="typeaheadDebounceInterval">
5276+
<mat-option *ngFor="let food of foods" [value]="food.value" [disabled]="food.disabled">
5277+
{{ food.viewValue }}
5278+
</mat-option>
5279+
</mat-select>
5280+
<mat-hint *ngIf="hint">{{ hint }}</mat-hint>
5281+
</mat-form-field>
5282+
<div [style.height.px]="heightBelow"></div>
5283+
`,
5284+
})
5285+
class BasicSelectWithFirstAndLastOptionDisabled {
5286+
foods: any[] = [
5287+
{value: 'steak-0', viewValue: 'Steak', disabled: true},
5288+
{value: 'pizza-1', viewValue: 'Pizza'},
5289+
{value: 'tacos-2', viewValue: 'Tacos'},
5290+
{value: 'sandwich-3', viewValue: 'Sandwich'},
5291+
{value: 'chips-4', viewValue: 'Chips'},
5292+
{value: 'eggs-5', viewValue: 'Eggs'},
5293+
{value: 'pasta-6', viewValue: 'Pasta'},
5294+
{value: 'sushi-7', viewValue: 'Sushi', disabled: true},
5295+
];
5296+
control = new FormControl<string | null>(null);
5297+
isRequired: boolean;
5298+
heightAbove = 0;
5299+
heightBelow = 0;
5300+
hasLabel = true;
5301+
hint: string;
5302+
tabIndexOverride: number;
5303+
ariaDescribedBy: string;
5304+
ariaLabel: string;
5305+
ariaLabelledby: string;
5306+
panelClass = ['custom-one', 'custom-two'];
5307+
disableRipple: boolean;
5308+
typeaheadDebounceInterval: number;
5309+
5310+
@ViewChild(MatSelect, {static: true}) select: MatSelect;
5311+
@ViewChildren(MatOption) options: QueryList<MatOption>;
5312+
}

src/material/select/select.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,7 @@ export abstract class _MatSelectBase<C>
914914
.withVerticalOrientation()
915915
.withHorizontalOrientation(this._isRtl() ? 'rtl' : 'ltr')
916916
.withHomeAndEnd()
917+
.withPageUpDown()
917918
.withAllowedModifierKeys(['shiftKey']);
918919

919920
this._keyManager.tabOut.pipe(takeUntil(this._destroy)).subscribe(() => {

tools/public_api_guard/cdk/a11y.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
358358
withAllowedModifierKeys(keys: ListKeyManagerModifierKey[]): this;
359359
withHomeAndEnd(enabled?: boolean): this;
360360
withHorizontalOrientation(direction: 'ltr' | 'rtl' | null): this;
361+
withPageUpDown(enabled?: boolean, delta?: number): this;
361362
withTypeAhead(debounceInterval?: number): this;
362363
withVerticalOrientation(enabled?: boolean): this;
363364
withWrap(shouldWrap?: boolean): this;

0 commit comments

Comments
 (0)