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(select): keyboard support #1417

Merged
merged 12 commits into from
Apr 25, 2019
1 change: 1 addition & 0 deletions scripts/gulp/tasks/bundle/rollup-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const ROLLUP_GLOBALS = {
'@angular/cdk/scrolling': 'ng.cdk.scrolling',
'@angular/cdk/table': 'ng.cdk.table',
'@angular/cdk/bidi': 'ng.cdk.bidi',
'@angular/cdk/keycodes': 'ng.cdk.keycodes',


// RxJS dependencies
Expand Down
4 changes: 4 additions & 0 deletions src/framework/theme/components/cdk/a11y/focus-key-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { FocusableOption, FocusKeyManager } from '@angular/cdk/a11y';

export type NbFocusableOption = FocusableOption;
export class NbFocusKeyManager<T> extends FocusKeyManager<T> {}
2 changes: 2 additions & 0 deletions src/framework/theme/components/cdk/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export * from './overlay';
export * from './a11y/focus-key-manager';
export * from './a11y/a11y.module';
export * from './a11y/focus-trap';
export * from './adapter/overlay-container-adapter';
export * from './adapter/scroll-dispatcher-adapter';
export * from './adapter/viewport-ruler-adapter';
export * from './keycodes/keycodes';
1 change: 1 addition & 0 deletions src/framework/theme/components/cdk/keycodes/keycodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '@angular/cdk/keycodes';
7 changes: 6 additions & 1 deletion src/framework/theme/components/select/option.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@
@import '../../styles/core/mixins';

:host {
display: block;
display: flex;
@include nb-component-animation(background-color, color);

&:hover {
cursor: pointer;
}

nb-checkbox {
display: flex;
pointer-events: none;

::ng-deep .label {
padding: 0;
}
}
}

Expand Down
41 changes: 28 additions & 13 deletions src/framework/theme/components/select/option.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import {
Output,
} from '@angular/core';
import { Observable, Subject } from 'rxjs';

import { convertToBoolProperty } from '../helpers';
import { NbFocusableOption } from '../cdk';
import { NbSelectComponent } from './select.component';


Expand All @@ -28,20 +30,15 @@ import { NbSelectComponent } from './select.component';
styleUrls: ['./option.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<nb-checkbox *ngIf="withCheckbox" [(ngModel)]="selected">
<ng-container *ngTemplateOutlet="content"></ng-container>
<nb-checkbox *ngIf="withCheckbox"
[value]="selected"
[disabled]="disabledAttribute"
aria-hidden="true">
</nb-checkbox>

<ng-container *ngIf="!withCheckbox">
<ng-container *ngTemplateOutlet="content"></ng-container>
</ng-container>

<ng-template #content>
<ng-content></ng-content>
</ng-template>
<ng-content></ng-content>
`,
})
export class NbOptionComponent<T> implements OnDestroy {
export class NbOptionComponent<T> implements OnDestroy, NbFocusableOption {

protected disabledByGroup = false;

Expand Down Expand Up @@ -110,9 +107,19 @@ export class NbOptionComponent<T> implements OnDestroy {
return disabled ? '' : null;
}

@HostListener('click')
onClick() {
@HostBinding('tabIndex')
get tabindex() {
return '-1';
}

@HostListener('click', ['$event'])
@HostListener('keydown.space', ['$event'])
@HostListener('keydown.enter', ['$event'])
onClick(event: Event) {
this.click$.next(this);

// Prevent scroll on space click, etc.
event.preventDefault();
}

select() {
Expand Down Expand Up @@ -147,4 +154,12 @@ export class NbOptionComponent<T> implements OnDestroy {
this.cd.markForCheck();
}
}

focus(): void {
this.elementRef.nativeElement.focus();
}

getLabel(): string {
return this.content;
}
}
2 changes: 2 additions & 0 deletions src/framework/theme/components/select/select.component.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<button [disabled]="disabled"
[ngClass]="selectButtonClasses"
(blur)="trySetTouched()"
(keydown.arrowDown)="show()"
(keydown.arrowUp)="show()"
class="select-button"
type="button"
#selectButton>
Expand Down
62 changes: 52 additions & 10 deletions src/framework/theme/components/select/select.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,13 @@ import {
Inject,
Input,
OnDestroy,
OnInit,
Output,
QueryList,
ViewChild,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { merge, Observable, Subject } from 'rxjs';
import { startWith, switchMap, takeWhile } from 'rxjs/operators';
import { startWith, switchMap, takeWhile, filter } from 'rxjs/operators';

import {
NbAdjustableConnectedPositionStrategy,
Expand All @@ -45,8 +44,9 @@ import {
import { NbComponentSize } from '../component-size';
import { NbComponentShape } from '../component-shape';
import { NbComponentStatus } from '../component-status';
import { NbOptionComponent } from './option.component';
import { NB_DOCUMENT } from '../../theme.options';
import { NbFocusKeyManager, ESCAPE } from '../cdk';
import { NbOptionComponent } from './option.component';
import { convertToBoolProperty } from '../helpers';

export type NbSelectAppearance = 'outline' | 'filled' | 'hero';
Expand Down Expand Up @@ -386,7 +386,8 @@ export class NbSelectLabelComponent {
},
],
})
export class NbSelectComponent<T> implements OnInit, AfterViewInit, AfterContentInit, OnDestroy, ControlValueAccessor {
export class NbSelectComponent<T> implements AfterViewInit, AfterContentInit, OnDestroy, ControlValueAccessor {

/**
* Select size, available sizes:
* `tiny`, `small`, `medium` (default), `large`, `giant`
Expand Down Expand Up @@ -565,6 +566,8 @@ export class NbSelectComponent<T> implements OnInit, AfterViewInit, AfterContent

protected alive: boolean = true;

protected keyManager: NbFocusKeyManager<NbOptionComponent<T>>;

/**
* If a user assigns value before content nb-options's rendered the value will be putted in this variable.
* And then applied after content rendered.
Expand Down Expand Up @@ -634,10 +637,6 @@ export class NbSelectComponent<T> implements OnInit, AfterViewInit, AfterContent
return this.selectionModel[0].content;
}

ngOnInit() {
this.createOverlay();
}

ngAfterContentInit() {
if (this.queue) {
// Call 'writeValue' when current change detection run is finished.
Expand All @@ -655,7 +654,6 @@ export class NbSelectComponent<T> implements OnInit, AfterViewInit, AfterContent
this.triggerStrategy = this.createTriggerStrategy();

this.subscribeOnTriggers();
this.subscribeOnPositionChange();
this.subscribeOnOptionClick();
}

Expand All @@ -672,7 +670,8 @@ export class NbSelectComponent<T> implements OnInit, AfterViewInit, AfterContent

show() {
if (this.isHidden) {
this.ref.attach(this.portal);
this.attachToOverlay();
this.setActiveOption();
this.cd.markForCheck();
}
}
Expand Down Expand Up @@ -777,12 +776,35 @@ export class NbSelectComponent<T> implements OnInit, AfterViewInit, AfterContent
this.emitSelected(this.selectionModel.map((opt: NbOptionComponent<T>) => opt.value));
}

protected attachToOverlay() {
if (!this.ref) {
this.createOverlay();
this.subscribeOnPositionChange();
this.createKeyManager();
this.subscribeOnOverlayKeys();
}

this.ref.attach(this.portal);
}

protected setActiveOption() {
if (this.selectionModel.length) {
this.keyManager.setActiveItem(this.selectionModel[ 0 ]);
} else {
this.keyManager.setFirstItemActive();
}
}

protected createOverlay() {
const scrollStrategy = this.createScrollStrategy();
this.positionStrategy = this.createPositionStrategy();
this.ref = this.overlay.create({ positionStrategy: this.positionStrategy, scrollStrategy });
}

protected createKeyManager(): void {
this.keyManager = new NbFocusKeyManager<NbOptionComponent<T>>(this.options).withTypeAhead(200);
}

protected createPositionStrategy(): NbAdjustableConnectedPositionStrategy {
return this.positionBuilder
.connectedTo(this.hostRef)
Expand Down Expand Up @@ -842,6 +864,26 @@ export class NbSelectComponent<T> implements OnInit, AfterViewInit, AfterContent
});
}

protected subscribeOnOverlayKeys(): void {
this.ref.keydownEvents()
.pipe(
takeWhile(() => this.alive),
filter(() => this.isOpen),
)
.subscribe((event: KeyboardEvent) => {
if (event.keyCode === ESCAPE) {
this.button.nativeElement.focus();
this.hide();
} else {
this.keyManager.onKeydown(event);
}
});

this.keyManager.tabOut
.pipe(takeWhile(() => this.alive))
.subscribe(() => this.hide());
}

protected getContainer() {
return this.ref && this.ref.hasAttached() && <ComponentRef<any>> {
location: {
Expand Down
15 changes: 8 additions & 7 deletions src/framework/theme/components/select/select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { NbLayoutModule } from '../layout/layout.module';
import { NbOptionComponent } from './option.component';
import { NbOptionGroupComponent } from './option-group.component';

const eventMock = { preventDefault() {} } as Event;

const TEST_GROUPS = [
{
Expand Down Expand Up @@ -582,7 +583,7 @@ describe('NbSelectComponent - falsy values', () => {
select.selected = testComponent.truthyOption.value;
fixture.detectChanges();

testComponent.noValueOption.onClick();
testComponent.noValueOption.onClick(eventMock);
fixture.detectChanges();

expect(select.selectionModel.length).toEqual(0);
Expand All @@ -592,7 +593,7 @@ describe('NbSelectComponent - falsy values', () => {
select.selected = testComponent.truthyOption.value;
fixture.detectChanges();

testComponent.nullOption.onClick();
testComponent.nullOption.onClick(eventMock);
fixture.detectChanges();

expect(select.selectionModel.length).toEqual(0);
Expand All @@ -602,7 +603,7 @@ describe('NbSelectComponent - falsy values', () => {
select.selected = testComponent.truthyOption.value;
fixture.detectChanges();

testComponent.undefinedOption.onClick();
testComponent.undefinedOption.onClick(eventMock);
fixture.detectChanges();

expect(select.selectionModel.length).toEqual(0);
Expand All @@ -612,7 +613,7 @@ describe('NbSelectComponent - falsy values', () => {
select.selected = testComponent.truthyOption.value;
fixture.detectChanges();

testComponent.falseOption.onClick();
testComponent.falseOption.onClick(eventMock);
fixture.detectChanges();

expect(select.selectionModel.length).toEqual(1);
Expand All @@ -622,7 +623,7 @@ describe('NbSelectComponent - falsy values', () => {
select.selected = testComponent.truthyOption.value;
fixture.detectChanges();

testComponent.zeroOption.onClick();
testComponent.zeroOption.onClick(eventMock);
fixture.detectChanges();

expect(select.selectionModel.length).toEqual(1);
Expand All @@ -632,7 +633,7 @@ describe('NbSelectComponent - falsy values', () => {
select.selected = testComponent.truthyOption.value;
fixture.detectChanges();

testComponent.emptyStringOption.onClick();
testComponent.emptyStringOption.onClick(eventMock);
fixture.detectChanges();

expect(select.selectionModel.length).toEqual(1);
Expand All @@ -642,7 +643,7 @@ describe('NbSelectComponent - falsy values', () => {
select.selected = testComponent.truthyOption.value;
fixture.detectChanges();

testComponent.nanOption.onClick();
testComponent.nanOption.onClick(eventMock);
fixture.detectChanges();

expect(select.selectionModel.length).toEqual(1);
Expand Down