Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(material/select): add page down/up button functionality #25508

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/cdk/a11y/key-manager/list-key-manager.ts
Original file line number Diff line number Diff line change
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to simplify this and the PAGE_DOWN handling using the _setActiveItemByDelta method instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During writing the code I first had the same intention, but in my opinion this method can only be used to move 1 up or down and not an defined number or did i misunderstand anything?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Let's keep it like this for now.

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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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