From a97d23ee866adc66de47558b57f302a6aaf0cf29 Mon Sep 17 00:00:00 2001 From: Sergey Andrievskiy Date: Tue, 2 Oct 2018 17:45:08 +0300 Subject: [PATCH 1/8] fix(checkbox): don't mark as dirty on writeValue call writeValue method should only set value to form control. https://angular.io/api/forms/ControlValueAccessor#writevalue --- src/framework/theme/components/checkbox/checkbox.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/framework/theme/components/checkbox/checkbox.component.ts b/src/framework/theme/components/checkbox/checkbox.component.ts index 9f4369364f..23ec14c937 100644 --- a/src/framework/theme/components/checkbox/checkbox.component.ts +++ b/src/framework/theme/components/checkbox/checkbox.component.ts @@ -135,7 +135,7 @@ export class NbCheckboxComponent implements ControlValueAccessor { } writeValue(val: any) { - this.value = val; + this._value = val; } setDisabledState(val: boolean) { From 801d461340dde10f8c5a19b743ddb958dcf012c9 Mon Sep 17 00:00:00 2001 From: Sergey Andrievskiy Date: Tue, 2 Oct 2018 17:46:07 +0300 Subject: [PATCH 2/8] fix(checkbox): mark as touched only after blur --- .../theme/components/checkbox/checkbox.component.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/framework/theme/components/checkbox/checkbox.component.ts b/src/framework/theme/components/checkbox/checkbox.component.ts index 23ec14c937..cb4775cca3 100644 --- a/src/framework/theme/components/checkbox/checkbox.component.ts +++ b/src/framework/theme/components/checkbox/checkbox.component.ts @@ -58,7 +58,8 @@ import { convertToBoolProperty } from '../helpers'; + (change)="value = !value" + (blur)="setTouched()"> @@ -123,7 +124,6 @@ export class NbCheckboxComponent implements ControlValueAccessor { set value(val) { this._value = val; this.onChange(val); - this.onTouched(); } registerOnChange(fn: any) { @@ -141,4 +141,8 @@ export class NbCheckboxComponent implements ControlValueAccessor { setDisabledState(val: boolean) { this.disabled = convertToBoolProperty(val); } + + setTouched() { + this.onTouched(); + } } From 24c11f5eed2cdfe52d8e1c5dea7aca8425e4a45d Mon Sep 17 00:00:00 2001 From: Sergey Andrievskiy Date: Tue, 2 Oct 2018 17:46:54 +0300 Subject: [PATCH 3/8] feat(radio): mark as touched --- .../components/radio/radio-group.component.ts | 17 ++++++++++++++- .../theme/components/radio/radio.component.ts | 21 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/framework/theme/components/radio/radio-group.component.ts b/src/framework/theme/components/radio/radio-group.component.ts index 179d74b2da..9c7d791c5c 100644 --- a/src/framework/theme/components/radio/radio-group.component.ts +++ b/src/framework/theme/components/radio/radio-group.component.ts @@ -19,7 +19,7 @@ import { } from '@angular/core'; import { NbRadioComponent } from './radio.component'; import { merge } from 'rxjs'; -import { takeWhile } from 'rxjs/operators'; +import { filter, take, takeWhile, delay } from 'rxjs/operators'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { convertToBoolProperty } from '../helpers'; @@ -104,6 +104,7 @@ export class NbRadioGroupComponent implements AfterContentInit, OnDestroy, Contr protected name: string; protected alive: boolean = true; protected onChange = (value: any) => {}; + protected onTouched = () => {}; constructor(protected cd: ChangeDetectorRef) {} @@ -112,6 +113,7 @@ export class NbRadioGroupComponent implements AfterContentInit, OnDestroy, Contr this.updateValues(); this.updateDisabled(); this.subscribeOnRadiosValueChange(); + this.subscribeOnRadiosBlur(); } ngOnDestroy() { @@ -123,6 +125,7 @@ export class NbRadioGroupComponent implements AfterContentInit, OnDestroy, Contr } registerOnTouched(fn: any): void { + this.onTouched = fn; } writeValue(value: any): void { @@ -171,4 +174,16 @@ export class NbRadioGroupComponent implements AfterContentInit, OnDestroy, Contr protected markRadiosForCheck() { this.radios.forEach((radio: NbRadioComponent) => radio.markForCheck()); } + + protected subscribeOnRadiosBlur() { + merge(...this.radios.map(radio => radio.blur)) + .pipe( + takeWhile(() => this.alive), + // wait for focus to be applied to another radio in case it moved within radio group. + delay(100), + filter(() => !this.radios.some(radio => radio.hasFocus)), + take(1), + ) + .subscribe(() => this.onTouched()); + } } diff --git a/src/framework/theme/components/radio/radio.component.ts b/src/framework/theme/components/radio/radio.component.ts index 6a28ec0df1..a603937c49 100644 --- a/src/framework/theme/components/radio/radio.component.ts +++ b/src/framework/theme/components/radio/radio.component.ts @@ -76,7 +76,9 @@ import { convertToBoolProperty } from '../helpers'; [checked]="checked" [disabled]="disabled" (change)="onChange($event)" - (click)="onClick($event)"> + (click)="onClick($event)" + (focus)="setFocus()" + (blur)="removeFocus()"> @@ -87,6 +89,12 @@ import { convertToBoolProperty } from '../helpers'; styleUrls: ['./radio.component.scss'], }) export class NbRadioComponent { + + private focus: boolean = false; + get hasFocus(): boolean { + return this.focus; + } + @Input() name: string; @Input() checked: boolean; @@ -100,6 +108,8 @@ export class NbRadioComponent { @Output() valueChange: EventEmitter = new EventEmitter(); + @Output() blur: EventEmitter = new EventEmitter(); + disabled: boolean; constructor(protected cd: ChangeDetectorRef) {} @@ -118,4 +128,13 @@ export class NbRadioComponent { onClick(event: Event) { event.stopPropagation(); } + + setFocus() { + this.focus = true; + } + + removeFocus() { + this.focus = false; + this.blur.emit(); + } } From 90f176b727b81cbba96e4dc7af7bfe3988b37fa4 Mon Sep 17 00:00:00 2001 From: Sergey Andrievskiy Date: Wed, 3 Oct 2018 10:35:16 +0300 Subject: [PATCH 4/8] fix(checkbox): detect changes on write value --- .../theme/components/checkbox/checkbox.component.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/framework/theme/components/checkbox/checkbox.component.ts b/src/framework/theme/components/checkbox/checkbox.component.ts index cb4775cca3..ba8fcc971d 100644 --- a/src/framework/theme/components/checkbox/checkbox.component.ts +++ b/src/framework/theme/components/checkbox/checkbox.component.ts @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. */ -import { Component, Input, HostBinding, forwardRef } from '@angular/core'; +import { Component, Input, HostBinding, forwardRef, ChangeDetectorRef } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { convertToBoolProperty } from '../helpers'; @@ -126,6 +126,8 @@ export class NbCheckboxComponent implements ControlValueAccessor { this.onChange(val); } + constructor(private changeDetector: ChangeDetectorRef) {} + registerOnChange(fn: any) { this.onChange = fn; } @@ -136,6 +138,7 @@ export class NbCheckboxComponent implements ControlValueAccessor { writeValue(val: any) { this._value = val; + this.changeDetector.detectChanges(); } setDisabledState(val: boolean) { From b89b7e7cb835cc24d444e3ee88aa381948dbfa43 Mon Sep 17 00:00:00 2001 From: Sergey Andrievskiy Date: Wed, 3 Oct 2018 12:04:42 +0300 Subject: [PATCH 5/8] feat(select): mark touched when control blurred --- .../components/select/select.component.html | 3 +- .../components/select/select.component.ts | 34 +++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/framework/theme/components/select/select.component.html b/src/framework/theme/components/select/select.component.html index 70689f8dea..dd437c5b74 100644 --- a/src/framework/theme/components/select/select.component.html +++ b/src/framework/theme/components/select/select.component.html @@ -8,7 +8,8 @@ [fullWidth]="fullWidth" [outline]="outline" [class.opened]="isOpened" - [ngClass]="overlayPosition"> + [ngClass]="overlayPosition" + (blur)="trySetTouched()"> diff --git a/src/framework/theme/components/select/select.component.ts b/src/framework/theme/components/select/select.component.ts index ab8d7f639b..94d1e5efb9 100644 --- a/src/framework/theme/components/select/select.component.ts +++ b/src/framework/theme/components/select/select.component.ts @@ -41,6 +41,7 @@ import { NbTriggerStrategyBuilder, } from '../cdk'; import { NbOptionComponent } from './option.component'; +import { NbButtonComponent } from '../button/button.component'; import { NB_DOCUMENT } from '../../theme.options'; import { convertToBoolProperty } from '../helpers'; @@ -112,7 +113,7 @@ export class NbSelectLabelComponent { * * @stacked-example(Select statuses, select/select-status.component) * - * There are three select sizes: + * There are four select sizes: * * @stacked-example(Select sizes, select/select-sizes.component) * @@ -233,6 +234,8 @@ export class NbSelectComponent implements OnInit, AfterViewInit, AfterContent * */ @ViewChild(NbPortalDirective) portal: NbPortalDirective; + @ViewChild(NbButtonComponent, { read: ElementRef }) button: ElementRef; + multiple: boolean = false; /** @@ -270,10 +273,11 @@ export class NbSelectComponent implements OnInit, AfterViewInit, AfterContent * Function passed through control value accessor to propagate changes. * */ protected onChange: Function = () => {}; + protected onTouched: Function = () => {}; constructor(@Inject(NB_DOCUMENT) protected document, protected overlay: NbOverlayService, - protected hostRef: ElementRef, + protected hostRef: ElementRef, protected positionBuilder: NbPositionBuilderService, protected cd: ChangeDetectorRef) { } @@ -349,9 +353,12 @@ export class NbSelectComponent implements OnInit, AfterViewInit, AfterContent } registerOnTouched(fn: any): void { + this.onTouched = fn; } setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this.cd.detectChanges(); } writeValue(value: T | T[]): void { @@ -386,6 +393,7 @@ export class NbSelectComponent implements OnInit, AfterViewInit, AfterContent this.selectionModel.forEach((option: NbOptionComponent) => option.deselect()); this.selectionModel = []; this.hide(); + this.button.nativeElement.focus(); this.emitSelected(null); } @@ -413,6 +421,7 @@ export class NbSelectComponent implements OnInit, AfterViewInit, AfterContent this.selectionModel = [option]; option.select(); this.hide(); + this.button.nativeElement.focus(); this.emitSelected(option.value); } @@ -464,7 +473,12 @@ export class NbSelectComponent implements OnInit, AfterViewInit, AfterContent triggerStrategy.hide$ .pipe(takeWhile(() => this.alive)) - .subscribe(() => this.hide()); + .subscribe(($event: Event) => { + this.hide(); + if (!this.isClickedWithinComponent($event)) { + this.onTouched(); + } + }); } protected subscribeOnPositionChange() { @@ -541,4 +555,18 @@ export class NbSelectComponent implements OnInit, AfterViewInit, AfterContent this.selectionModel.push(corresponding); } } + + /** + * Sets touched if focus moved outside of button and overlay, + * ignoring the case when focus moved to options overlay. + */ + trySetTouched() { + if (this.isHidden) { + this.onTouched(); + } + } + + protected isClickedWithinComponent($event: Event) { + return this.hostRef.nativeElement === $event.target || this.hostRef.nativeElement.contains($event.target as Node); + } } From 02ea267962e1d7b6a5d22ccc3313bff8d6eb080f Mon Sep 17 00:00:00 2001 From: Sergey Andrievskiy Date: Fri, 5 Oct 2018 14:33:59 +0300 Subject: [PATCH 6/8] fix(datepicker): mark as touched --- .../datepicker/datepicker.component.ts | 18 +++++++++++++- .../datepicker/datepicker.directive.ts | 24 +++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/framework/theme/components/datepicker/datepicker.component.ts b/src/framework/theme/components/datepicker/datepicker.component.ts index e695927bdb..bcebcdef8f 100644 --- a/src/framework/theme/components/datepicker/datepicker.component.ts +++ b/src/framework/theme/components/datepicker/datepicker.component.ts @@ -151,6 +151,8 @@ export abstract class NbBasePicker extends NbDatepicker implements O * */ protected queue: T; + protected blur$: Subject = new Subject(); + constructor(@Inject(NB_DOCUMENT) protected document, protected positionBuilder: NbPositionBuilderService, protected overlay: NbOverlayService, @@ -172,6 +174,17 @@ export abstract class NbBasePicker extends NbDatepicker implements O return this.onChange$.asObservable(); } + get isShown(): boolean { + return this.ref && this.ref.hasAttached(); + } + + /** + * Emits when datepicker looses focus. + */ + get blur(): Observable { + return this.blur$.asObservable(); + } + protected abstract get pickerValueChange(): Observable; ngOnDestroy() { @@ -250,7 +263,10 @@ export abstract class NbBasePicker extends NbDatepicker implements O protected subscribeOnTriggers() { const triggerStrategy = this.createTriggerStrategy(); triggerStrategy.show$.pipe(takeWhile(() => this.alive)).subscribe(() => this.show()); - triggerStrategy.hide$.pipe(takeWhile(() => this.alive)).subscribe(() => this.hide()); + triggerStrategy.hide$.pipe(takeWhile(() => this.alive)).subscribe(() => { + this.blur$.next(); + this.hide(); + }); } protected instantiatePicker() { diff --git a/src/framework/theme/components/datepicker/datepicker.directive.ts b/src/framework/theme/components/datepicker/datepicker.directive.ts index ec7639cd82..7132bd9bda 100644 --- a/src/framework/theme/components/datepicker/datepicker.directive.ts +++ b/src/framework/theme/components/datepicker/datepicker.directive.ts @@ -15,8 +15,8 @@ import { Validators, } from '@angular/forms'; import { Type } from '@angular/core/src/type'; -import { fromEvent, Observable } from 'rxjs'; -import { map, takeWhile } from 'rxjs/operators'; +import { fromEvent, Observable, merge } from 'rxjs'; +import { map, takeWhile, filter, take } from 'rxjs/operators'; import { NB_DOCUMENT } from '../../theme.options'; import { NbDateService } from '../calendar-kit'; @@ -99,6 +99,10 @@ export abstract class NbDatepicker { abstract hide(); abstract shouldHide(): boolean; + + abstract get isShown(): boolean; + + abstract get blur(): Observable; } export const NB_DATE_ADAPTER = new InjectionToken>('Datepicker Adapter'); @@ -225,8 +229,8 @@ export class NbDatepickerDirective implements OnDestroy, ControlValueAccessor * */ protected picker: NbDatepicker; protected alive: boolean = true; - protected onChange: (D) => void = () => { - }; + protected onChange: (D) => void = () => {}; + protected onTouched: () => void = () => {}; /** * Form control validators will be called in validators context, so, we need to bind them. @@ -276,9 +280,11 @@ export class NbDatepickerDirective implements OnDestroy, ControlValueAccessor } registerOnTouched(fn: any): void { + this.onTouched = fn; } setDisabledState(isDisabled: boolean): void { + this.input.disabled = isDisabled; } /** @@ -367,6 +373,16 @@ export class NbDatepickerDirective implements OnDestroy, ControlValueAccessor this.hidePicker(); } }); + + merge( + this.picker.blur, + fromEvent(this.input, 'blur').pipe( + filter(() => !this.picker.isShown && this.document.activeElement !== this.input), + ), + ).pipe( + takeWhile(() => this.alive), + take(1), + ).subscribe(() => this.onTouched()); } protected writePicker(value: D) { From 911f18379af0a7d35b206fc3c873ae57323c803b Mon Sep 17 00:00:00 2001 From: Sergey Andrievskiy Date: Mon, 8 Oct 2018 14:45:05 +0300 Subject: [PATCH 7/8] refactor(radio): remove delay before marking touched --- .../components/radio/radio-group.component.ts | 35 ++++++++++++++----- .../theme/components/radio/radio.component.ts | 18 +--------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/framework/theme/components/radio/radio-group.component.ts b/src/framework/theme/components/radio/radio-group.component.ts index 9c7d791c5c..ef90cfa199 100644 --- a/src/framework/theme/components/radio/radio-group.component.ts +++ b/src/framework/theme/components/radio/radio-group.component.ts @@ -16,12 +16,17 @@ import { OnDestroy, Output, QueryList, + PLATFORM_ID, + Inject, + ElementRef, } from '@angular/core'; -import { NbRadioComponent } from './radio.component'; -import { merge } from 'rxjs'; -import { filter, take, takeWhile, delay } from 'rxjs/operators'; +import { isPlatformBrowser } from '@angular/common'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { fromEvent, merge } from 'rxjs'; +import { filter, switchMap, take, takeWhile } from 'rxjs/operators'; import { convertToBoolProperty } from '../helpers'; +import { NB_DOCUMENT } from '../../theme.options'; +import { NbRadioComponent } from './radio.component'; /** @@ -106,7 +111,12 @@ export class NbRadioGroupComponent implements AfterContentInit, OnDestroy, Contr protected onChange = (value: any) => {}; protected onTouched = () => {}; - constructor(protected cd: ChangeDetectorRef) {} + constructor( + protected cd: ChangeDetectorRef, + protected hostElement: ElementRef, + @Inject(PLATFORM_ID) protected platformId, + @Inject(NB_DOCUMENT) protected document, + ) {} ngAfterContentInit() { this.updateNames(); @@ -176,12 +186,19 @@ export class NbRadioGroupComponent implements AfterContentInit, OnDestroy, Contr } protected subscribeOnRadiosBlur() { - merge(...this.radios.map(radio => radio.blur)) + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const hostElement = this.hostElement.nativeElement; + fromEvent(hostElement, 'focusin') .pipe( - takeWhile(() => this.alive), - // wait for focus to be applied to another radio in case it moved within radio group. - delay(100), - filter(() => !this.radios.some(radio => radio.hasFocus)), + filter(event => hostElement.contains(event.target as Node)), + switchMap(() => merge( + fromEvent(this.document, 'focusin'), + fromEvent(this.document, 'click'), + )), + filter(event => !hostElement.contains(event.target as Node)), take(1), ) .subscribe(() => this.onTouched()); diff --git a/src/framework/theme/components/radio/radio.component.ts b/src/framework/theme/components/radio/radio.component.ts index a603937c49..675a408591 100644 --- a/src/framework/theme/components/radio/radio.component.ts +++ b/src/framework/theme/components/radio/radio.component.ts @@ -76,9 +76,7 @@ import { convertToBoolProperty } from '../helpers'; [checked]="checked" [disabled]="disabled" (change)="onChange($event)" - (click)="onClick($event)" - (focus)="setFocus()" - (blur)="removeFocus()"> + (click)="onClick($event)"> @@ -90,11 +88,6 @@ import { convertToBoolProperty } from '../helpers'; }) export class NbRadioComponent { - private focus: boolean = false; - get hasFocus(): boolean { - return this.focus; - } - @Input() name: string; @Input() checked: boolean; @@ -128,13 +121,4 @@ export class NbRadioComponent { onClick(event: Event) { event.stopPropagation(); } - - setFocus() { - this.focus = true; - } - - removeFocus() { - this.focus = false; - this.blur.emit(); - } } From cd7c2dbecd70d6c2ebe62103924eb52f49cc0298 Mon Sep 17 00:00:00 2001 From: Sergey Andrievskiy Date: Mon, 8 Oct 2018 16:46:54 +0300 Subject: [PATCH 8/8] test(radio): provide document --- src/framework/theme/components/radio/radio.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/framework/theme/components/radio/radio.spec.ts b/src/framework/theme/components/radio/radio.spec.ts index cf47708060..0ee3dbacb1 100644 --- a/src/framework/theme/components/radio/radio.spec.ts +++ b/src/framework/theme/components/radio/radio.spec.ts @@ -8,6 +8,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NbRadioModule } from './radio.module'; import { NbRadioComponent } from './radio.component'; +import { NB_DOCUMENT } from '../../theme.options'; import { Component, DebugElement, EventEmitter, Input, Output } from '@angular/core'; import { By } from '@angular/platform-browser'; @@ -35,6 +36,7 @@ describe('radio', () => { TestBed.configureTestingModule({ imports: [NbRadioModule], declarations: [NbRadioTestComponent], + providers: [ { provide: NB_DOCUMENT, useValue: document } ], }); fixture = TestBed.createComponent(NbRadioTestComponent);