Skip to content

Commit

Permalink
feat(select): keyboard support (#1417)
Browse files Browse the repository at this point in the history
  • Loading branch information
yggg committed May 27, 2019
1 parent 522acfe commit f8a5c9c
Show file tree
Hide file tree
Showing 9 changed files with 104 additions and 31 deletions.
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

0 comments on commit f8a5c9c

Please sign in to comment.