From e06d3a7dc0814d27f083cf684cd900b0cab3f701 Mon Sep 17 00:00:00 2001 From: Sergey Andrievskiy Date: Thu, 11 Oct 2018 22:44:49 +0300 Subject: [PATCH] fix(form controls): mark as touched (#864) --- .../components/checkbox/checkbox.component.ts | 15 +++++-- .../datepicker/datepicker.component.ts | 18 ++++++++- .../datepicker/datepicker.directive.ts | 24 +++++++++-- .../components/radio/radio-group.component.ts | 40 +++++++++++++++++-- .../theme/components/radio/radio.component.ts | 3 ++ .../theme/components/radio/radio.spec.ts | 2 + .../components/select/select.component.html | 3 +- .../components/select/select.component.ts | 34 ++++++++++++++-- 8 files changed, 122 insertions(+), 17 deletions(-) diff --git a/src/framework/theme/components/checkbox/checkbox.component.ts b/src/framework/theme/components/checkbox/checkbox.component.ts index 9f4369364f..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'; @@ -58,7 +58,8 @@ import { convertToBoolProperty } from '../helpers'; + (change)="value = !value" + (blur)="setTouched()"> @@ -123,9 +124,10 @@ export class NbCheckboxComponent implements ControlValueAccessor { set value(val) { this._value = val; this.onChange(val); - this.onTouched(); } + constructor(private changeDetector: ChangeDetectorRef) {} + registerOnChange(fn: any) { this.onChange = fn; } @@ -135,10 +137,15 @@ export class NbCheckboxComponent implements ControlValueAccessor { } writeValue(val: any) { - this.value = val; + this._value = val; + this.changeDetector.detectChanges(); } setDisabledState(val: boolean) { this.disabled = convertToBoolProperty(val); } + + setTouched() { + this.onTouched(); + } } 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) { diff --git a/src/framework/theme/components/radio/radio-group.component.ts b/src/framework/theme/components/radio/radio-group.component.ts index 179d74b2da..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 { takeWhile } 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'; /** @@ -104,14 +109,21 @@ export class NbRadioGroupComponent implements AfterContentInit, OnDestroy, Contr protected name: string; protected alive: boolean = true; 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(); this.updateValues(); this.updateDisabled(); this.subscribeOnRadiosValueChange(); + this.subscribeOnRadiosBlur(); } ngOnDestroy() { @@ -123,6 +135,7 @@ export class NbRadioGroupComponent implements AfterContentInit, OnDestroy, Contr } registerOnTouched(fn: any): void { + this.onTouched = fn; } writeValue(value: any): void { @@ -171,4 +184,23 @@ export class NbRadioGroupComponent implements AfterContentInit, OnDestroy, Contr protected markRadiosForCheck() { this.radios.forEach((radio: NbRadioComponent) => radio.markForCheck()); } + + protected subscribeOnRadiosBlur() { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const hostElement = this.hostElement.nativeElement; + fromEvent(hostElement, 'focusin') + .pipe( + 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 6a28ec0df1..675a408591 100644 --- a/src/framework/theme/components/radio/radio.component.ts +++ b/src/framework/theme/components/radio/radio.component.ts @@ -87,6 +87,7 @@ import { convertToBoolProperty } from '../helpers'; styleUrls: ['./radio.component.scss'], }) export class NbRadioComponent { + @Input() name: string; @Input() checked: boolean; @@ -100,6 +101,8 @@ export class NbRadioComponent { @Output() valueChange: EventEmitter = new EventEmitter(); + @Output() blur: EventEmitter = new EventEmitter(); + disabled: boolean; constructor(protected cd: ChangeDetectorRef) {} 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); 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); + } }