diff --git a/src/app/playground-components.ts b/src/app/playground-components.ts index c75c09c336..039204222f 100644 --- a/src/app/playground-components.ts +++ b/src/app/playground-components.ts @@ -1405,6 +1405,29 @@ export const PLAYGROUND_COMPONENTS: ComponentLink[] = [ }, ], }, + { + path: 'form-field', + children: [ + { + path: 'form-field-showcase.component', + link: '/form-field/form-field-showcase.component', + component: 'FormFieldShowcaseComponent', + name: 'Form Field Showcase', + }, + { + path: 'form-field-password.component', + link: '/form-field/form-field-password.component', + component: 'FormFieldPasswordComponent', + name: 'Form Field Password', + }, + { + path: 'form-field-input.component', + link: '/form-field/form-field-input.component', + component: 'FormFieldInputComponent', + name: 'Form Field Input', + }, + ], + }, { path: 'context-menu', children: [ diff --git a/src/framework/theme/components/button/_button.component.theme.scss b/src/framework/theme/components/button/_button.component.theme.scss index acf6b67cc2..b38fd33529 100644 --- a/src/framework/theme/components/button/_button.component.theme.scss +++ b/src/framework/theme/components/button/_button.component.theme.scss @@ -33,6 +33,8 @@ font-size: nb-theme(button-#{$size}-text-font-size); height: nb-theme(button-#{$size}-icon-size); width: nb-theme(button-#{$size}-icon-size); + margin-top: nb-theme(button-#{$size}-icon-vertical-margin); + margin-bottom: nb-theme(button-#{$size}-icon-vertical-margin); } $icon-offset: nb-theme(button-#{$size}-icon-offset); @@ -44,6 +46,12 @@ @include nb-ltr(margin-left, $icon-offset); @include nb-rtl(margin-right, $icon-offset); } + + @each $appearance in ('filled', 'outline', 'ghost', 'hero') { + &.icon-start.icon-end.appearance-#{$appearance} { + padding: nb-theme(icon-button-#{$appearance}-#{$size}-padding); + } + } } } diff --git a/src/framework/theme/components/button/button.component.ts b/src/framework/theme/components/button/button.component.ts index bd5470b844..ec7912dfb1 100644 --- a/src/framework/theme/components/button/button.component.ts +++ b/src/framework/theme/components/button/button.component.ts @@ -87,22 +87,27 @@ export type NbButtonAppearance = 'filled' | 'outline' | 'ghost' | 'hero'; * button-tiny-text-font-size: * button-tiny-text-line-height: * button-tiny-icon-size: + * button-tiny-icon-vertical-margin: * button-tiny-icon-offset: * button-small-text-font-size: * button-small-text-line-height: * button-small-icon-size: + * button-small-icon-vertical-margin: * button-small-icon-offset: * button-medium-text-font-size: * button-medium-text-line-height: * button-medium-icon-size: + * button-medium-icon-vertical-margin: * button-medium-icon-offset: * button-large-text-font-size: * button-large-text-line-height: * button-large-icon-size: + * button-large-icon-vertical-margin: * button-large-icon-offset: * button-giant-text-font-size: * button-giant-text-line-height: * button-giant-icon-size: + * button-giant-icon-vertical-margin: * button-giant-icon-offset: * button-rectangle-border-radius: * button-semi-round-border-radius: diff --git a/src/framework/theme/components/cdk/a11y/a11y.module.ts b/src/framework/theme/components/cdk/a11y/a11y.module.ts index 204a9dc450..89500b3f3d 100644 --- a/src/framework/theme/components/cdk/a11y/a11y.module.ts +++ b/src/framework/theme/components/cdk/a11y/a11y.module.ts @@ -1,8 +1,12 @@ -import { ModuleWithProviders, NgModule } from '@angular/core'; +import { ModuleWithProviders, NgModule, Injectable } from '@angular/core'; import { NbFocusTrapFactoryService } from './focus-trap'; import { NbFocusKeyManagerFactoryService } from './focus-key-manager'; import { NbActiveDescendantKeyManagerFactoryService } from './descendant-key-manager'; +import { FocusMonitor } from '@angular/cdk/a11y'; + +@Injectable() +export class NbFocusMonitor extends FocusMonitor {} @NgModule({}) export class NbA11yModule { @@ -13,6 +17,7 @@ export class NbA11yModule { NbFocusTrapFactoryService, NbFocusKeyManagerFactoryService, NbActiveDescendantKeyManagerFactoryService, + { provide: NbFocusMonitor, useClass: FocusMonitor }, ], }; } diff --git a/src/framework/theme/components/chat/chat-form.component.spec.ts b/src/framework/theme/components/chat/chat-form.component.spec.ts index d679cbcaae..ef016b66b2 100644 --- a/src/framework/theme/components/chat/chat-form.component.spec.ts +++ b/src/framework/theme/components/chat/chat-form.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NbChatFormComponent, NbChatModule } from '@nebular/theme'; +import { NbChatFormComponent, NbChatModule, NbThemeModule } from '@nebular/theme'; describe('NbChatFormComponent', () => { let fixture: ComponentFixture; @@ -7,7 +7,7 @@ describe('NbChatFormComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ NbChatModule.forRoot() ], + imports: [ NbThemeModule.forRoot(), NbChatModule.forRoot() ], }); fixture = TestBed.createComponent(NbChatFormComponent); diff --git a/src/framework/theme/components/form-field/_form-field.component.theme.scss b/src/framework/theme/components/form-field/_form-field.component.theme.scss new file mode 100644 index 0000000000..fa811e76ee --- /dev/null +++ b/src/framework/theme/components/form-field/_form-field.component.theme.scss @@ -0,0 +1,76 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +@mixin nb-form-field-theme() { + .nb-form-field-addon { + display: flex; + justify-content: center; + align-items: center; + z-index: 1; + @include nb-component-animation(color); + + &-disabled { + color: nb-theme(form-field-addon-disabled-text-color); + } + } + + @each $status in nb-get-statuses() { + .nb-form-field-addon-#{$status} { + color: nb-theme(form-field-addon-#{$status}-text-color); + + &-highlight { + color: nb-theme(form-field-addon-#{$status}-highlight-text-color); + } + } + } + + @each $size in nb-get-sizes() { + $addon-height: nb-theme(form-field-addon-#{$size}-height); + $addon-width: nb-theme(form-field-addon-#{$size}-width); + + .nb-form-field-prefix-#{$size}, + .nb-form-field-suffix-#{$size} { + height: $addon-height; + width: $addon-width; + font-size: nb-theme(form-field-addon-#{$size}-font-size); + line-height: nb-theme(form-field-addon-#{$size}-line-height); + font-weight: nb-theme(form-field-addon-#{$size}-font-weight); + + nb-icon { + font-size: nb-theme(form-field-addon-#{$size}-icon-size); + line-height: nb-theme(form-field-addon-#{$size}-icon-size); + } + } + + .nb-form-field-prefix-#{$size} { + @include nb-ltr(margin-right, calc(#{$addon-width} * -1)); + @include nb-rtl(margin-left, calc(#{$addon-width} * -1)); + } + + .nb-form-field-suffix-#{$size} { + @include nb-ltr(margin-left, calc(#{$addon-width} * -1)); + @include nb-rtl(margin-right, calc(#{$addon-width} * -1)); + } + } +} + +@mixin nb-form-field-with-prefix($selector, $size) { + $addon-width: nb-theme(form-field-addon-#{$size}-width); + + .nb-form-field-control-with-prefix #{$selector} { + @include nb-ltr(padding-left, $addon-width); + @include nb-rtl(padding-right, $addon-width); + } +} + +@mixin nb-form-field-with-suffix($selector, $size) { + $addon-width: nb-theme(form-field-addon-#{$size}-width); + + .nb-form-field-control-with-suffix #{$selector} { + @include nb-ltr(padding-right, $addon-width); + @include nb-rtl(padding-left, $addon-width); + } +} diff --git a/src/framework/theme/components/form-field/form-field-control.ts b/src/framework/theme/components/form-field/form-field-control.ts new file mode 100644 index 0000000000..829135fb2d --- /dev/null +++ b/src/framework/theme/components/form-field/form-field-control.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Injectable } from '@angular/core'; +import { NbComponentStatus } from '../component-status'; +import { NbComponentSize } from '../component-size'; +import { Observable } from 'rxjs'; + +/* + * Class used as injection token to provide form element. + **/ +@Injectable() +export abstract class NbFormFieldControl { + status$: Observable; + size$: Observable; + focused$: Observable; + disabled$: Observable; +} + +/* + * Optional config to be provided on NbFormFieldControl to alter default settings. + **/ +@Injectable() +export class NbFormFieldControlConfig { + supportsPrefix = true; + supportsSuffix = true; +} + +export interface NbFormControlState { + status: NbComponentStatus; + size: NbComponentSize; + focused: boolean; + disabled: boolean; +} diff --git a/src/framework/theme/components/form-field/form-field.component.html b/src/framework/theme/components/form-field/form-field.component.html new file mode 100644 index 0000000000..78fb130149 --- /dev/null +++ b/src/framework/theme/components/form-field/form-field.component.html @@ -0,0 +1,12 @@ +
+ +
+ +
+ +
+ +
+ +
diff --git a/src/framework/theme/components/form-field/form-field.component.scss b/src/framework/theme/components/form-field/form-field.component.scss new file mode 100644 index 0000000000..4c4ee1647e --- /dev/null +++ b/src/framework/theme/components/form-field/form-field.component.scss @@ -0,0 +1,10 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +:host { + display: flex; + align-items: center; +} diff --git a/src/framework/theme/components/form-field/form-field.component.ts b/src/framework/theme/components/form-field/form-field.component.ts new file mode 100644 index 0000000000..95a6137070 --- /dev/null +++ b/src/framework/theme/components/form-field/form-field.component.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { + Component, + ChangeDetectionStrategy, + ContentChild, + AfterContentChecked, + ChangeDetectorRef, + ContentChildren, + QueryList, + AfterContentInit, + OnDestroy, +} from '@angular/core'; +import { merge, Subject, Observable, combineLatest, ReplaySubject } from 'rxjs'; +import { takeUntil, distinctUntilChanged, map } from 'rxjs/operators'; + +import { NbPrefixDirective } from './prefix.directive'; +import { NbSuffixDirective } from './suffix.directive'; +import { NbFormFieldControl, NbFormControlState, NbFormFieldControlConfig } from './form-field-control'; + +export type NbFormControlAddon = 'prefix' | 'suffix'; + +function throwFormControlElementNotFound() { + throw new Error(`NbFormFieldComponent must contain [nbInput]`) +} + +/* + * NbFormFieldComponent + * + * @styles + * + * form-field-addon-basic-text-color: + * form-field-addon-basic-highlight-text-color: + * form-field-addon-primary-text-color: + * form-field-addon-primary-highlight-text-color: + * form-field-addon-success-text-color: + * form-field-addon-success-highlight-text-color: + * form-field-addon-info-text-color: + * form-field-addon-info-highlight-text-color: + * form-field-addon-warning-text-color: + * form-field-addon-warning-highlight-text-color: + * form-field-addon-danger-text-color: + * form-field-addon-danger-highlight-text-color: + * form-field-addon-control-text-color: + * form-field-addon-control-highlight-text-color: + * form-field-addon-disabled-text-color: + * form-field-addon-tiny-height: + * form-field-addon-tiny-width: + * form-field-addon-tiny-icon-size: + * form-field-addon-tiny-font-size: + * form-field-addon-tiny-line-height: + * form-field-addon-tiny-font-weight: + * form-field-addon-small-height: + * form-field-addon-small-width: + * form-field-addon-small-icon-size: + * form-field-addon-small-font-size: + * form-field-addon-small-line-height: + * form-field-addon-small-font-weight: + * form-field-addon-medium-height: + * form-field-addon-medium-width: + * form-field-addon-medium-icon-size: + * form-field-addon-medium-font-size: + * form-field-addon-medium-line-height: + * form-field-addon-medium-font-weight: + * form-field-addon-large-height: + * form-field-addon-large-width: + * form-field-addon-large-icon-size: + * form-field-addon-large-font-size: + * form-field-addon-large-line-height: + * form-field-addon-large-font-weight: + * form-field-addon-giant-height: + * form-field-addon-giant-width: + * form-field-addon-giant-icon-size: + * form-field-addon-giant-font-size: + * form-field-addon-giant-line-height: + * form-field-addon-giant-font-weight: + **/ +@Component({ + selector: 'nb-form-field', + styleUrls: ['./form-field.component.scss'], + templateUrl: './form-field.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NbFormFieldComponent implements AfterContentChecked, AfterContentInit, OnDestroy { + + protected readonly destroy$ = new Subject(); + + protected formControlState$ = new ReplaySubject(1); + prefixClasses$: Observable = this.formControlState$.pipe(map(s => this.getAddonClasses('prefix', s))); + suffixClasses$: Observable = this.formControlState$.pipe(map(s => this.getAddonClasses('suffix', s))); + + @ContentChildren(NbPrefixDirective, { descendants: true }) prefix: QueryList; + @ContentChildren(NbSuffixDirective, { descendants: true }) suffix: QueryList; + + @ContentChild(NbFormFieldControl, { static: false }) formControl: NbFormFieldControl; + @ContentChild(NbFormFieldControlConfig, { static: false }) formControlConfig: NbFormFieldControlConfig; + + constructor(protected cd: ChangeDetectorRef) { + } + + ngAfterContentChecked() { + if (!this.formControl) { + throwFormControlElementNotFound(); + } + } + + ngAfterContentInit() { + this.subscribeToFormControlStateChange(); + this.subscribeToAddonChange(); + } + + ngOnDestroy() { + this.destroy$.next(); + } + + shouldShowPrefix(): boolean { + return this.getFormControlConfig().supportsPrefix && !!this.prefix.length; + } + + shouldShowSuffix(): boolean { + return this.getFormControlConfig().supportsSuffix && !!this.suffix.length; + } + + protected subscribeToFormControlStateChange() { + const { disabled$, focused$, size$, status$ } = this.formControl; + + combineLatest([disabled$, focused$, size$, status$]) + .pipe( + map(([disabled, focused, size, status]) => ({ disabled, focused, size, status })), + distinctUntilChanged((oldState, state) => this.isStatesEqual(oldState, state)), + takeUntil(this.destroy$), + ) + .subscribe(this.formControlState$); + } + + protected subscribeToAddonChange() { + merge(this.prefix.changes, this.suffix.changes) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.cd.markForCheck()); + } + + protected getAddonClasses(addon: NbFormControlAddon, state: NbFormControlState): string[] { + const classes = [ + 'nb-form-field-addon', + `nb-form-field-${addon}-${state.size}`, + ]; + + if (state.disabled) { + classes.push(`nb-form-field-addon-disabled`); + } else if (state.focused) { + classes.push(`nb-form-field-addon-${state.status}-highlight`); + } else { + classes.push(`nb-form-field-addon-${state.status}`); + } + + return classes; + } + + protected getFormControlConfig(): NbFormFieldControlConfig { + return this.formControlConfig || new NbFormFieldControlConfig(); + } + + protected isStatesEqual(oldState: NbFormControlState, state: NbFormControlState): boolean { + return oldState.status === state.status && + oldState.disabled === state.disabled && + oldState.focused === state.focused && + oldState.size === state.size; + } +} diff --git a/src/framework/theme/components/form-field/form-field.module.ts b/src/framework/theme/components/form-field/form-field.module.ts new file mode 100644 index 0000000000..6bfb0dec4b --- /dev/null +++ b/src/framework/theme/components/form-field/form-field.module.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { NbFormFieldComponent } from './form-field.component'; +import { NbPrefixDirective } from './prefix.directive'; +import { NbSuffixDirective } from './suffix.directive'; + +const COMPONENTS = [ + NbFormFieldComponent, + NbPrefixDirective, + NbSuffixDirective, +]; + +@NgModule({ + imports: [ CommonModule ], + declarations: [ ...COMPONENTS ], + exports: [ ...COMPONENTS ], +}) +export class NbFormFieldModule { +} diff --git a/src/framework/theme/components/form-field/prefix.directive.ts b/src/framework/theme/components/form-field/prefix.directive.ts new file mode 100644 index 0000000000..6f6678c081 --- /dev/null +++ b/src/framework/theme/components/form-field/prefix.directive.ts @@ -0,0 +1,7 @@ +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[nbPrefix]', +}) +export class NbPrefixDirective { +} diff --git a/src/framework/theme/components/form-field/suffix.directive.ts b/src/framework/theme/components/form-field/suffix.directive.ts new file mode 100644 index 0000000000..22a4645bf7 --- /dev/null +++ b/src/framework/theme/components/form-field/suffix.directive.ts @@ -0,0 +1,7 @@ +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[nbSuffix]', +}) +export class NbSuffixDirective { +} diff --git a/src/framework/theme/components/input/_input.directive.theme.scss b/src/framework/theme/components/input/_input.directive.theme.scss index a6da6cddb6..b228086e28 100644 --- a/src/framework/theme/components/input/_input.directive.theme.scss +++ b/src/framework/theme/components/input/_input.directive.theme.scss @@ -35,4 +35,9 @@ @include input-sizes(); @include input-shapes(); } + + @each $size in nb-get-sizes() { + @include nb-form-field-with-prefix('[nbInput].size-#{$size}', $size); + @include nb-form-field-with-suffix('[nbInput].size-#{$size}', $size); + } } diff --git a/src/framework/theme/components/input/input.directive.ts b/src/framework/theme/components/input/input.directive.ts index 3ebb52871e..df03b4686f 100644 --- a/src/framework/theme/components/input/input.directive.ts +++ b/src/framework/theme/components/input/input.directive.ts @@ -4,12 +4,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. */ -import { Directive, Input, HostBinding } from '@angular/core'; +import { + Directive, + Input, + HostBinding, + OnDestroy, + OnInit, + ElementRef, + SimpleChanges, + OnChanges, + DoCheck, +} from '@angular/core'; +import { Subject, BehaviorSubject } from 'rxjs'; +import { map, finalize, takeUntil } from 'rxjs/operators'; import { convertToBoolProperty, emptyStatusWarning } from '../helpers'; import { NbComponentSize } from '../component-size'; import { NbComponentShape } from '../component-shape'; import { NbComponentStatus } from '../component-status'; +import { NbFormFieldControl } from '../form-field/form-field-control'; +import { NbFocusMonitor } from '../cdk/a11y/a11y.module'; /** * Basic input directive. @@ -54,6 +68,14 @@ import { NbComponentStatus } from '../component-status'; * Or you can bind control with form controls or ngModel: * @stacked-example(Input form binding, input/input-form.component) * + * Use `` to add custom content to the input field. + * First import `NbFormFieldModule`. Then put the input field and custom content into + * `` and add `nbPrefix` or `nbSuffix` directive to the custom content. + * `nbPrefix` puts content before input and `nbSuffix` after. + * + * @stacked-example(Input with icon, form-field/form-field-input.component) + * @stacked-example(Input with button, form-field/form-field-password.component) + * * @styles * * input-border-style: @@ -192,8 +214,13 @@ import { NbComponentStatus } from '../component-status'; */ @Directive({ selector: 'input[nbInput],textarea[nbInput]', + providers: [ + { provide: NbFormFieldControl, useExisting: NbInputDirective }, + ], }) -export class NbInputDirective { +export class NbInputDirective implements DoCheck, OnChanges, OnInit, OnDestroy, NbFormFieldControl { + + protected destroy$ = new Subject(); /** * Field size modifications. Possible values: `small`, `medium` (default), `large`. @@ -238,6 +265,42 @@ export class NbInputDirective { } private _fullWidth = false; + constructor( + protected elementRef: ElementRef, + protected focusMonitor: NbFocusMonitor, + ) { + } + + ngDoCheck() { + const isDisabled = this.elementRef.nativeElement.disabled; + if (isDisabled !== this.disabled$.value) { + this.disabled$.next(isDisabled); + } + } + + ngOnChanges({ fieldStatus, fieldSize }: SimpleChanges) { + if (status) { + this.status$.next(this.status); + } + if (fieldSize) { + this.size$.next(this.fieldSize); + } + } + + ngOnInit() { + this.focusMonitor.monitor(this.elementRef) + .pipe( + map(origin => !!origin), + finalize(() => this.focusMonitor.stopMonitoring(this.elementRef)), + takeUntil(this.destroy$), + ) + .subscribe(this.focused$); + } + + ngOnDestroy() { + this.destroy$.next(); + } + @HostBinding('class.size-tiny') get tiny() { return this.fieldSize === 'tiny'; @@ -312,4 +375,24 @@ export class NbInputDirective { get round() { return this.shape === 'round'; } + + /* + * @docs-private + **/ + status$ = new BehaviorSubject(this.status); + + /* + * @docs-private + **/ + size$ = new BehaviorSubject(this.fieldSize); + + /* + * @docs-private + **/ + focused$ = new BehaviorSubject(false); + + /* + * @docs-private + **/ + disabled$ = new BehaviorSubject(false); } diff --git a/src/framework/theme/components/input/input.spec.ts b/src/framework/theme/components/input/input.spec.ts index 5be382cb59..16ed0df52d 100644 --- a/src/framework/theme/components/input/input.spec.ts +++ b/src/framework/theme/components/input/input.spec.ts @@ -6,7 +6,7 @@ import { Component, Input } from '@angular/core' import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NbInputDirective } from '@nebular/theme'; +import { NbInputDirective, NbThemeModule } from '@nebular/theme'; import { By } from '@angular/platform-browser'; import { NbComponentStatus } from '../component-status'; import { NbComponentSize } from '../component-size'; @@ -38,7 +38,7 @@ describe('Directive: NbInput', () => { beforeEach(() => { fixture = TestBed.configureTestingModule({ - imports: [ NbInputModule ], + imports: [ NbThemeModule.forRoot(), NbInputModule ], declarations: [ InputTestComponent ], }) .createComponent(InputTestComponent); diff --git a/src/framework/theme/components/select/_select-filled.scss b/src/framework/theme/components/select/_select-filled.scss index aad9ce2c6e..d1d59b4816 100644 --- a/src/framework/theme/components/select/_select-filled.scss +++ b/src/framework/theme/components/select/_select-filled.scss @@ -1,3 +1,5 @@ +@import '../form-field/form-field.component.theme'; + @mixin select-filled { nb-select.appearance-filled .select-button { border-style: nb-theme(select-filled-border-style); @@ -10,6 +12,8 @@ @include nb-ltr(padding-right, nb-theme(select-icon-offset)); @include nb-rtl(padding-left, nb-theme(select-icon-offset)); } + + @include nb-form-field-with-prefix('nb-select.appearance-filled.size-#{$size} .select-button', $size); } @each $status in nb-get-statuses() { diff --git a/src/framework/theme/components/select/_select-hero.scss b/src/framework/theme/components/select/_select-hero.scss index be761964a9..f7ed5df0ac 100644 --- a/src/framework/theme/components/select/_select-hero.scss +++ b/src/framework/theme/components/select/_select-hero.scss @@ -1,3 +1,5 @@ +@import '../form-field/form-field.component.theme'; + @mixin select-hero { nb-select.appearance-hero .select-button { border: none; @@ -9,6 +11,7 @@ @include nb-ltr(padding-right, nb-theme(select-icon-offset)); @include nb-rtl(padding-left, nb-theme(select-icon-offset)); } + @include nb-form-field-with-prefix('nb-select.appearance-hero.size-#{$size} .select-button', $size); } @each $status in nb-get-statuses() { diff --git a/src/framework/theme/components/select/_select-outline.scss b/src/framework/theme/components/select/_select-outline.scss index 445f00e5c1..831088b03a 100644 --- a/src/framework/theme/components/select/_select-outline.scss +++ b/src/framework/theme/components/select/_select-outline.scss @@ -1,3 +1,5 @@ +@import '../form-field/form-field.component.theme'; + @mixin select-outline { nb-select.appearance-outline .select-button { border-style: nb-theme(select-outline-border-style); @@ -65,5 +67,7 @@ @include nb-ltr(padding-right, nb-theme(select-icon-offset)); @include nb-rtl(padding-left, nb-theme(select-icon-offset)); } + + @include nb-form-field-with-prefix('nb-select.appearance-outline.size-#{$size} .select-button', $size); } } diff --git a/src/framework/theme/components/select/select.component.ts b/src/framework/theme/components/select/select.component.ts index 0f320d818f..781ed44d75 100644 --- a/src/framework/theme/components/select/select.component.ts +++ b/src/framework/theme/components/select/select.component.ts @@ -23,10 +23,12 @@ import { Output, QueryList, ViewChild, + SimpleChanges, + OnChanges, } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { merge, Subject } from 'rxjs'; -import { startWith, switchMap, takeUntil, filter } from 'rxjs/operators'; +import { merge, Subject, BehaviorSubject } from 'rxjs'; +import { startWith, switchMap, takeUntil, filter, map, finalize } from 'rxjs/operators'; import { NbAdjustableConnectedPositionStrategy, @@ -46,6 +48,8 @@ import { NB_DOCUMENT } from '../../theme.options'; import { NbOptionComponent } from '../option/option.component'; import { convertToBoolProperty } from '../helpers'; import { NB_SELECT_INJECTION_TOKEN } from './select-injection-tokens'; +import { NbFormFieldControl, NbFormFieldControlConfig } from '../form-field/form-field-control'; +import { NbFocusMonitor } from '../cdk/a11y/a11y.module'; export type NbSelectAppearance = 'outline' | 'filled' | 'hero'; @@ -56,6 +60,12 @@ export type NbSelectAppearance = 'outline' | 'filled' | 'hero'; export class NbSelectLabelComponent { } +export function nbSelectFormFieldControlConfigFactory() { + const config = new NbFormFieldControlConfig(); + config.supportsSuffix = false; + return config; +} + /** * The `NbSelectComponent` provides a capability to select one of the passed items. * @@ -491,9 +501,12 @@ export class NbSelectLabelComponent { multi: true, }, { provide: NB_SELECT_INJECTION_TOKEN, useExisting: NbSelectComponent }, + { provide: NbFormFieldControl, useExisting: NbSelectComponent }, + { provide: NbFormFieldControlConfig, useFactory: nbSelectFormFieldControlConfigFactory }, ], }) -export class NbSelectComponent implements AfterViewInit, AfterContentInit, OnDestroy, ControlValueAccessor { +export class NbSelectComponent implements OnChanges, AfterViewInit, AfterContentInit, OnDestroy, + ControlValueAccessor, NbFormFieldControl { /** * Select size, available sizes: @@ -682,13 +695,34 @@ export class NbSelectComponent implements AfterViewInit, AfterContentInit, On protected onChange: Function = () => {}; protected onTouched: Function = () => {}; + /* + * @docs-private + **/ + status$ = new BehaviorSubject(this.status); + + /* + * @docs-private + **/ + size$ = new BehaviorSubject(this.size); + + /* + * @docs-private + **/ + focused$ = new BehaviorSubject(false); + + /* + * @docs-private + **/ + disabled$ = new BehaviorSubject(this.disabled); + constructor(@Inject(NB_DOCUMENT) protected document, protected overlay: NbOverlayService, protected hostRef: ElementRef, protected positionBuilder: NbPositionBuilderService, protected triggerStrategyBuilder: NbTriggerStrategyBuilderService, protected cd: ChangeDetectorRef, - protected focusKeyManagerFactoryService: NbFocusKeyManagerFactoryService>) { + protected focusKeyManagerFactoryService: NbFocusKeyManagerFactoryService>, + protected focusMonitor: NbFocusMonitor) { } /** @@ -732,6 +766,18 @@ export class NbSelectComponent implements AfterViewInit, AfterContentInit, On return this.selectionModel[0].content; } + ngOnChanges({ disabled, status, size}: SimpleChanges) { + if (disabled) { + this.disabled$.next(disabled.currentValue); + } + if (status) { + this.status$.next(status.currentValue); + } + if (size) { + this.size$.next(size.currentValue); + } + } + ngAfterContentInit() { this.options.changes .pipe( @@ -753,6 +799,7 @@ export class NbSelectComponent implements AfterViewInit, AfterContentInit, On ngAfterViewInit() { this.triggerStrategy = this.createTriggerStrategy(); + this.subscribeOnButtonFocus(); this.subscribeOnTriggers(); this.subscribeOnOptionClick(); } @@ -993,6 +1040,16 @@ export class NbSelectComponent implements AfterViewInit, AfterContentInit, On }); } + protected subscribeOnButtonFocus() { + this.focusMonitor.monitor(this.button) + .pipe( + map(origin => !!origin), + finalize(() => this.focusMonitor.stopMonitoring(this.button)), + takeUntil(this.destroy$), + ) + .subscribe(this.focused$); + } + protected getContainer() { return this.ref && this.ref.hasAttached() && > { location: { diff --git a/src/framework/theme/components/select/select.spec.ts b/src/framework/theme/components/select/select.spec.ts index 8e7f1f9407..3e0ac477a4 100644 --- a/src/framework/theme/components/select/select.spec.ts +++ b/src/framework/theme/components/select/select.spec.ts @@ -602,7 +602,7 @@ describe('Component: NbSelectComponent', () => { })); it(`should not call dispose on uninitialized resources`, () => { - const selectFixture = new NbSelectComponent(null, null, null, null, null, null, null); + const selectFixture = new NbSelectComponent(null, null, null, null, null, null, null, null); expect(() => selectFixture.ngOnDestroy()).not.toThrow(); }); diff --git a/src/framework/theme/public_api.ts b/src/framework/theme/public_api.ts index 505480e051..91d11bde62 100644 --- a/src/framework/theme/public_api.ts +++ b/src/framework/theme/public_api.ts @@ -212,3 +212,8 @@ export * from './components/icon/icon-pack'; export * from './components/icon/icon-libraries'; export * from './components/toggle/toggle.module'; export * from './components/toggle/toggle.component'; +export * from './components/form-field/form-field.module'; +export * from './components/form-field/form-field.component'; +export * from './components/form-field/prefix.directive'; +export * from './components/form-field/suffix.directive'; +export * from './components/form-field/form-field-control'; diff --git a/src/framework/theme/styles/global/_components.scss b/src/framework/theme/styles/global/_components.scss index 732eeeb2b7..5234f30931 100644 --- a/src/framework/theme/styles/global/_components.scss +++ b/src/framework/theme/styles/global/_components.scss @@ -40,6 +40,7 @@ @import '../../components/radio/radio.component.theme'; @import '../../components/tree-grid/tree-grid.component.theme'; @import '../../components/icon/icon.component.theme'; +@import '../../components/form-field/form-field.component.theme'; @mixin nb-theme-components() { @@ -79,4 +80,5 @@ @include nb-radio-theme(); @include nb-tree-grid-theme(); @include nb-icon-theme(); + @include nb-form-field-theme(); } diff --git a/src/framework/theme/styles/themes/_mapping.scss b/src/framework/theme/styles/themes/_mapping.scss index d6218ce4ae..1e9d189bf6 100644 --- a/src/framework/theme/styles/themes/_mapping.scss +++ b/src/framework/theme/styles/themes/_mapping.scss @@ -528,26 +528,31 @@ $eva-mapping: ( button-tiny-text-font-size: text-button-tiny-font-size, button-tiny-text-line-height: text-button-tiny-line-height, button-tiny-icon-size: 0.75rem, + button-tiny-icon-vertical-margin: -0.125rem, button-tiny-icon-offset: 0.375rem, button-small-text-font-size: text-button-small-font-size, button-small-text-line-height: text-button-small-line-height, button-small-icon-size: 1rem, + button-small-icon-vertical-margin: -0.125rem, button-small-icon-offset: 0.375rem, button-medium-text-font-size: text-button-medium-font-size, button-medium-text-line-height: text-button-medium-line-height, button-medium-icon-size: 1.25rem, + button-medium-icon-vertical-margin: -0.125rem, button-medium-icon-offset: 0.5rem, button-large-text-font-size: text-button-large-font-size, button-large-text-line-height: text-button-large-line-height, button-large-icon-size: 1.5rem, + button-large-icon-vertical-margin: -0.125rem, button-large-icon-offset: 0.75rem, button-giant-text-font-size: text-button-giant-font-size, button-giant-text-line-height: text-button-giant-line-height, button-giant-icon-size: 1.5rem, + button-giant-icon-vertical-margin: -0.125rem, button-giant-icon-offset: 0.75rem, button-rectangle-border-radius: border-radius, @@ -1005,6 +1010,30 @@ $eva-mapping: ( button-hero-control-disabled-background-color: color-basic-transparent-300, button-hero-control-disabled-text-color: text-disabled-color, + icon-button-filled-tiny-padding: 0.4375rem 0.3125rem, + icon-button-filled-small-padding: 0.5625rem 0.4375rem, + icon-button-filled-medium-padding: 0.6875rem 0.5625rem, + icon-button-filled-large-padding: 0.8125rem 0.6875rem, + icon-button-filled-giant-padding: 1.0625rem 0.9375rem, + + icon-button-outline-tiny-padding: 0.4375rem 0.3125rem, + icon-button-outline-small-padding: 0.5625rem 0.4375rem, + icon-button-outline-medium-padding: 0.6875rem 0.5625rem, + icon-button-outline-large-padding: 0.8125rem 0.6875rem, + icon-button-outline-giant-padding: 1.0625rem 0.9375rem, + + icon-button-ghost-tiny-padding: 0.4375rem 0.3125rem, + icon-button-ghost-small-padding: 0.5625rem 0.4375rem, + icon-button-ghost-medium-padding: 0.6875rem 0.5625rem, + icon-button-ghost-large-padding: 0.8125rem 0.6875rem, + icon-button-ghost-giant-padding: 1.0625rem 0.9375rem, + + icon-button-hero-tiny-padding: 0.5rem 0.375rem, + icon-button-hero-small-padding: 0.5625rem 0.5rem, + icon-button-hero-medium-padding: 0.75rem 0.5625rem, + icon-button-hero-large-padding: 0.875rem 0.6875rem, + icon-button-hero-giant-padding: 1.0625rem 1rem, + input-border-style: solid, input-border-width: 1px, input-outline-color: outline-color, @@ -2706,4 +2735,50 @@ $eva-mapping: ( toggle-control-disabled-checked-switcher-checkmark-color: color-basic-100, toggle-control-disabled-text-color: text-control-color, + form-field-addon-basic-text-color: color-basic-600, + form-field-addon-basic-highlight-text-color: color-primary-500, + form-field-addon-primary-text-color: color-primary-500, + form-field-addon-primary-highlight-text-color: color-primary-600, + form-field-addon-success-text-color: color-success-500, + form-field-addon-success-highlight-text-color: color-success-600, + form-field-addon-info-text-color: color-info-500, + form-field-addon-info-highlight-text-color: color-info-600, + form-field-addon-warning-text-color: color-warning-500, + form-field-addon-warning-highlight-text-color: color-warning-600, + form-field-addon-danger-text-color: color-danger-500, + form-field-addon-danger-highlight-text-color: color-danger-600, + form-field-addon-control-text-color: color-control-default, + form-field-addon-control-highlight-text-color: color-control-default, + form-field-addon-disabled-text-color: text-disabled-color, + + form-field-addon-tiny-height: 1.5rem, + form-field-addon-tiny-width: form-field-addon-tiny-height, + form-field-addon-tiny-icon-size: button-tiny-icon-size, + form-field-addon-tiny-font-size: text-button-tiny-font-size, + form-field-addon-tiny-line-height: text-button-tiny-line-height, + form-field-addon-tiny-font-weight: text-button-font-weight, + form-field-addon-small-height: 2rem, + form-field-addon-small-width: form-field-addon-small-height, + form-field-addon-small-icon-size: button-small-icon-size, + form-field-addon-small-font-size: text-button-small-font-size, + form-field-addon-small-line-height: text-button-small-line-height, + form-field-addon-small-font-weight: text-button-font-weight, + form-field-addon-medium-height: 2.5rem, + form-field-addon-medium-width: form-field-addon-medium-height, + form-field-addon-medium-icon-size: button-medium-icon-size, + form-field-addon-medium-font-size: text-button-medium-font-size, + form-field-addon-medium-line-height: text-button-medium-line-height, + form-field-addon-medium-font-weight: text-button-font-weight, + form-field-addon-large-height: 3rem, + form-field-addon-large-width: form-field-addon-large-height, + form-field-addon-large-icon-size: button-large-icon-size, + form-field-addon-large-font-size: text-button-large-font-size, + form-field-addon-large-line-height: text-button-large-line-height, + form-field-addon-large-font-weight: text-button-font-weight, + form-field-addon-giant-height: 3.5rem, + form-field-addon-giant-width: form-field-addon-giant-height, + form-field-addon-giant-icon-size: button-giant-icon-size, + form-field-addon-giant-font-size: text-button-giant-font-size, + form-field-addon-giant-line-height: text-button-giant-line-height, + form-field-addon-giant-font-weight: text-button-font-weight, ); diff --git a/src/playground/with-layout/form-field/form-field-input.component.ts b/src/playground/with-layout/form-field/form-field-input.component.ts new file mode 100644 index 0000000000..971c5bd7db --- /dev/null +++ b/src/playground/with-layout/form-field/form-field-input.component.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; + +@Component({ + template: ` + + + + + + + + + + + `, +}) +export class FormFieldInputComponent { +} diff --git a/src/playground/with-layout/form-field/form-field-password.component.ts b/src/playground/with-layout/form-field/form-field-password.component.ts new file mode 100644 index 0000000000..b02ca85096 --- /dev/null +++ b/src/playground/with-layout/form-field/form-field-password.component.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; + +@Component({ + template: ` + + + + + + + + + + + `, +}) +export class FormFieldPasswordComponent { + showPassword = true; + + getInputType() { + if (this.showPassword) { + return 'text'; + } + return 'password'; + } + + toggleShowPassword() { + this.showPassword = !this.showPassword; + } +} diff --git a/src/playground/with-layout/form-field/form-field-routing.module.ts b/src/playground/with-layout/form-field/form-field-routing.module.ts new file mode 100644 index 0000000000..7e9c5b09ff --- /dev/null +++ b/src/playground/with-layout/form-field/form-field-routing.module.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { NgModule } from '@angular/core'; +import { RouterModule, Route} from '@angular/router'; +import { FormFieldShowcaseComponent } from './form-field-showcase.component'; +import { FormFieldPasswordComponent } from './form-field-password.component'; +import { FormFieldInputComponent } from './form-field-input.component'; + +const routes: Route[] = [ + { + path: 'form-field-showcase.component', + component: FormFieldShowcaseComponent, + }, + { + path: 'form-field-password.component', + component: FormFieldPasswordComponent, + }, + { + path: 'form-field-input.component', + component: FormFieldInputComponent, + }, +]; + +@NgModule({ + imports: [ RouterModule.forChild(routes) ], + exports: [ RouterModule ], +}) +export class FormFieldRoutingModule {} diff --git a/src/playground/with-layout/form-field/form-field-showcase.component.ts b/src/playground/with-layout/form-field/form-field-showcase.component.ts new file mode 100644 index 0000000000..1f33091562 --- /dev/null +++ b/src/playground/with-layout/form-field/form-field-showcase.component.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'form-field-showcase-component', + template: ` + + + + + + + + + + + + + 1 + + + + + + + `, +}) +export class FormFieldShowcaseComponent { +} diff --git a/src/playground/with-layout/form-field/form-field.module.ts b/src/playground/with-layout/form-field/form-field.module.ts new file mode 100644 index 0000000000..7c98356495 --- /dev/null +++ b/src/playground/with-layout/form-field/form-field.module.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + NbFormFieldModule, + NbInputModule, + NbIconModule, + NbButtonModule, + NbCardModule, + NbSelectModule, +} from '@nebular/theme'; + +import { FormFieldRoutingModule } from './form-field-routing.module'; +import { FormFieldShowcaseComponent } from './form-field-showcase.component'; +import { FormFieldPasswordComponent } from './form-field-password.component'; +import { FormFieldInputComponent } from './form-field-input.component'; + +@NgModule({ + declarations: [ + FormFieldShowcaseComponent, + FormFieldPasswordComponent, + FormFieldInputComponent, + ], + imports: [ + NbFormFieldModule, + NbInputModule, + NbIconModule, + NbButtonModule, + NbCardModule, + NbSelectModule, + FormFieldRoutingModule, + CommonModule, + ], +}) +export class FormFieldModule {} diff --git a/src/playground/with-layout/with-layout-routing.module.ts b/src/playground/with-layout/with-layout-routing.module.ts index 944004c98a..09665aa970 100644 --- a/src/playground/with-layout/with-layout-routing.module.ts +++ b/src/playground/with-layout/with-layout-routing.module.ts @@ -158,6 +158,10 @@ const routes: Route[] = [ path: 'toggle', loadChildren: () => import('./toggle/toggle.module').then(m => m.ToggleModule), }, + { + path: 'form-field', + loadChildren: () => import('./form-field/form-field.module').then(m => m.FormFieldModule), + }, ], }, ];