diff --git a/docs/structure.ts b/docs/structure.ts index 1cb4438e0f..2925e8d08b 100644 --- a/docs/structure.ts +++ b/docs/structure.ts @@ -293,6 +293,15 @@ export const structure = [ 'NbCheckboxComponent', ], }, + { + type: 'tabs', + name: 'Radio', + icon: 'radio.svg', + source: [ + 'NbRadioComponent', + 'NbRadioGroupComponent', + ], + }, { type: 'tabs', name: 'Select', diff --git a/src/framework/theme/components/radio/_radio.component.theme.scss b/src/framework/theme/components/radio/_radio.component.theme.scss new file mode 100644 index 0000000000..9268bebb96 --- /dev/null +++ b/src/framework/theme/components/radio/_radio.component.theme.scss @@ -0,0 +1,163 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +@mixin input-border-color($color) { + input:checked ~ .radio-indicator, input:hover:not(:disabled) ~ .radio-indicator { + border-color: $color; + } +} + +@mixin nb-input-status-color($origin-border-color) { + @include input-border-color($origin-border-color); + &.success { + @include input-border-color(nb-theme(color-success)); + } + &.warning { + @include input-border-color(nb-theme(color-warning)); + } + &.danger { + @include input-border-color(nb-theme(color-danger)); + } +} + +@mixin nb-radio($size, $color) { + &::before { + content: ''; + position: absolute; + background-color: $color; + height: calc(#{$size} * 0.6); + width: calc(#{$size} * 0.6); + border: solid $color; + border-radius: 50%; + } +} + +@mixin set-style($bg, $size, $border-size, $border-color) { + background-color: $bg; + width: $size; + height: $size; + border: $border-size solid $border-color; +} + +@mixin description-style { + color: nb-theme(radio-fg); + padding-left: 0.25rem; +} + +@mixin nb-radio-theme() { + nb-radio { + display: block; + + label { + position: relative; + display: inline-flex; + margin: 0; + min-height: inherit; + padding: 0.375rem 1.5rem; + } + + .radio-indicator { + @include set-style( + nb-theme(radio-bg), + nb-theme(radio-size), + nb-theme(radio-border-size), + nb-theme(radio-border-color) + ); + border-radius: 50%; + position: absolute; + top: 50%; + transform: translateY(-50%); + left: 0; + flex: none; + display: flex; + justify-content: center; + align-items: center; + + @include nb-radio(nb-theme(radio-size), nb-theme(radio-checkmark)); + + &::before { + width: 0; + height: 0; + transition: all 0.1s; + } + } + + input { + position: absolute; + opacity: 0; + z-index: -1; + + &:checked ~ .radio-indicator { + @include set-style( + nb-theme(radio-checked-bg), + nb-theme(radio-checked-size), + nb-theme(radio-checked-border-size), + nb-theme(radio-checked-border-color) + ); + display: flex; + justify-content: center; + align-items: center; + @include nb-radio( + nb-theme(radio-checked-size), + nb-theme(radio-checked-checkmark) + ); + } + + &:disabled ~ { + .radio-indicator { + @include set-style( + nb-theme(radio-disabled-bg), + nb-theme(radio-disabled-size), + nb-theme(radio-disabled-border-size), + nb-theme(radio-border-color) + ); + opacity: 0.5; + display: flex; + justify-content: center; + align-items: center; + @include nb-radio( + nb-theme(radio-disabled-size), + nb-theme(radio-checkmark) + ); + } + + .radio-description { + opacity: 0.5; + @include description-style; + } + } + + &:disabled:checked ~ { + .radio-indicator { + @include set-style( + nb-theme(radio-disabled-bg), + nb-theme(radio-disabled-size), + nb-theme(radio-disabled-border-size), + nb-theme(radio-checked-border-color) + ); + opacity: 0.5; + display: flex; + justify-content: center; + align-items: center; + @include nb-radio( + nb-theme(radio-disabled-size), + nb-theme(radio-disabled-checkmark) + ); + } + .radio-description { + opacity: 0.5; + @include description-style; + } + } + } + + @include nb-input-status-color(nb-theme(radio-checked-border-color)); + + .radio-description { + @include description-style; + } + } +} diff --git a/src/framework/theme/components/radio/radio-group.component.ts b/src/framework/theme/components/radio/radio-group.component.ts new file mode 100644 index 0000000000..13899309ae --- /dev/null +++ b/src/framework/theme/components/radio/radio-group.component.ts @@ -0,0 +1,174 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { + AfterContentInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChildren, + EventEmitter, + forwardRef, + Input, + OnDestroy, + Output, + QueryList, +} from '@angular/core'; +import { NbRadioComponent } from './radio.component'; +import { merge } from 'rxjs'; +import { takeWhile } from 'rxjs/operators'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { convertToBoolProperty } from '../helpers'; + + +/** + * The `NbRadioGroupComponent` is the wrapper for `nb-radio` button. + * It provides form bindings: + * + * ```html + * + * Option 1 + * Option 2 + * Option 3 + * + * ``` + * + * Also, you can use `value` and `valueChange` for binding without forms. + * + * ```html + * + * Option 1 + * Option 2 + * Option 3 + * + * ``` + * + * Radio items name has to be provided through `name` input property of the radio group. + * + * ```html + * + * ... + * + * ``` + * + * Also, you can disable the whole group using `disabled` attribute. + * + * ```html + * + * ... + * + * ``` + * */ +@Component({ + selector: 'nb-radio-group', + template: ` + `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NbRadioGroupComponent), + multi: true, + }, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NbRadioGroupComponent implements AfterContentInit, OnDestroy, ControlValueAccessor { + + @ContentChildren(NbRadioComponent, { descendants: true }) radios: QueryList; + + @Input('value') + set setValue(value: any) { + this.value = value; + this.updateValues(); + } + + @Input('name') + set setName(name: string){ + this.name = name; + this.updateNames(); + } + + @Input('disabled') + set setDisabled(disabled: boolean) { + this.disabled = convertToBoolProperty(disabled); + this.updateDisabled(); + } + + @Output() valueChange: EventEmitter = new EventEmitter(); + + protected disabled: boolean; + protected value: any; + protected name: string; + protected alive: boolean = true; + protected onChange = (value: any) => {}; + + constructor(protected cd: ChangeDetectorRef) {} + + ngAfterContentInit() { + this.updateNames(); + this.updateValues(); + this.updateDisabled(); + this.subscribeOnRadiosValueChange(); + } + + ngOnDestroy() { + this.alive = false; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + } + + writeValue(value: any): void { + this.value = value; + } + + protected updateNames() { + if (this.radios) { + this.radios.forEach((radio: NbRadioComponent) => radio.name = this.name); + this.markRadiosForCheck(); + } + } + + protected updateValues() { + if (this.radios) { + this.radios.forEach((radio: NbRadioComponent) => radio.checked = radio.value === this.value); + this.markRadiosForCheck(); + } + } + + protected updateDisabled() { + if (this.radios) { + this.radios.forEach((radio: NbRadioComponent) => { + if (!radio.hasOwnProperty('disabled')) { + radio.setDisabled = this.disabled + } + }); + this.markRadiosForCheck(); + } + } + + protected subscribeOnRadiosValueChange() { + merge(...this.radios.map((radio: NbRadioComponent) => radio.valueChange)) + .pipe(takeWhile(() => this.alive)) + .subscribe((value: any) => { + this.writeValue(value); + this.propagateValue(value); + }); + } + + protected propagateValue(value: any) { + this.valueChange.emit(value); + this.onChange(value); + } + + protected markRadiosForCheck() { + this.radios.forEach((radio: NbRadioComponent) => radio.markForCheck()); + } +} diff --git a/src/framework/theme/components/radio/radio.component.ts b/src/framework/theme/components/radio/radio.component.ts new file mode 100644 index 0000000000..e36d894f15 --- /dev/null +++ b/src/framework/theme/components/radio/radio.component.ts @@ -0,0 +1,120 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output } from '@angular/core'; + +import { convertToBoolProperty } from '../helpers'; + + +/** + * The `NbRadioComponent` provides the same functionality as native `` + * with Nebular styles and animations. + * + * @stacked-example(Showcase, radio/radio-showcase.component) + * + * ### Installation + * + * Import `NbRadioModule` to your feature module. + * + * ```ts + * @NgModule({ + * imports: [ + * // ... + * NbRadioModule, + * ], + * }) + * export class PageModule { } + * ``` + * + * ### Usage + * + * Radio buttons should be wrapped in `nb-radio-group` to provide form bindings. + * + * ```html + * + * Option 1 + * Option 2 + * Option 3 + * + * ``` + * + * You can disable some radios in the group using a `disabled` attribute. + * + * @stacked-example(Disabled, radio/radio-disabled.component) + * + * + * @styles + * + * radio-bg + * radio-fg + * radio-size + * radio-border-size + * radio-border-color + * radio-checkmark + * radio-checked-bg + * radio-checked-size + * radio-checked-border-size + * radio-checked-border-color + * radio-checked-checkmark + * radio-disabled-bg + * radio-disabled-size + * radio-disabled-border-size + * radio-disabled-border-color + * radio-disabled-checkmark + * */ +@Component({ + selector: 'nb-radio', + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NbRadioComponent { + @Input() name: string; + + @Input() checked: boolean; + + @Input() value: any; + + @Input('disabled') + set setDisabled(disabled: boolean) { + this.disabled = convertToBoolProperty(disabled); + } + + @Output() valueChange: EventEmitter = new EventEmitter(); + + disabled: boolean; + + constructor(protected cd: ChangeDetectorRef) {} + + markForCheck() { + this.cd.markForCheck(); + this.cd.detectChanges(); + } + + onChange(event: Event) { + event.stopPropagation(); + this.checked = true; + this.valueChange.emit(this.value); + } + + onClick(event: Event) { + event.stopPropagation(); + } +} diff --git a/src/framework/theme/components/radio/radio.module.ts b/src/framework/theme/components/radio/radio.module.ts new file mode 100644 index 0000000000..372068fbce --- /dev/null +++ b/src/framework/theme/components/radio/radio.module.ts @@ -0,0 +1,19 @@ +/* + * @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 { NbRadioComponent } from './radio.component'; +import { NbRadioGroupComponent } from './radio-group.component'; + + +@NgModule({ + imports: [], + exports: [NbRadioComponent, NbRadioGroupComponent], + declarations: [NbRadioComponent, NbRadioGroupComponent], +}) +export class NbRadioModule { +} diff --git a/src/framework/theme/components/radio/radio.spec.ts b/src/framework/theme/components/radio/radio.spec.ts new file mode 100644 index 0000000000..cf47708060 --- /dev/null +++ b/src/framework/theme/components/radio/radio.spec.ts @@ -0,0 +1,56 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NbRadioModule } from './radio.module'; +import { NbRadioComponent } from './radio.component'; + +import { Component, DebugElement, EventEmitter, Input, Output } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +@Component({ + selector: 'nb-radio-test', + template: ` + + 1 + 2 + 3 + + `, +}) +export class NbRadioTestComponent { + @Input() value; + @Output() valueChange = new EventEmitter(); +} + +describe('radio', () => { + let fixture: ComponentFixture; + let comp: NbRadioTestComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NbRadioModule], + declarations: [NbRadioTestComponent], + }); + + fixture = TestBed.createComponent(NbRadioTestComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render radios', () => { + const radios: DebugElement[] = fixture.debugElement.queryAll(By.directive(NbRadioComponent)); + expect(radios.length).toBe(3); + }); + + it('should fire value when selected', done => { + const secondRadio: DebugElement = fixture.debugElement.queryAll(By.directive(NbRadioComponent))[1]; + comp.valueChange.subscribe(done); + const input = secondRadio.query(By.css('input')); + input.nativeElement.click(); + }); +}); diff --git a/src/framework/theme/index.ts b/src/framework/theme/index.ts index 00a96b53f6..4635962b4b 100644 --- a/src/framework/theme/index.ts +++ b/src/framework/theme/index.ts @@ -80,3 +80,4 @@ export * from './components/select/select.module'; export * from './components/window'; export * from './components/datepicker/datepicker.module'; export * from './components/datepicker/datepicker.directive'; +export * from './components/radio/radio.module'; diff --git a/src/framework/theme/styles/global/_components.scss b/src/framework/theme/styles/global/_components.scss index 0104a3857b..c4530a2caa 100644 --- a/src/framework/theme/styles/global/_components.scss +++ b/src/framework/theme/styles/global/_components.scss @@ -36,6 +36,7 @@ @import '../../components/tooltip/tooltip.component.theme'; @import '../../components/window/window.component.theme'; @import '../../components/datepicker/datepicker-container.component.theme'; +@import '../../components/radio/radio.component.theme'; @mixin nb-theme-components() { @@ -71,4 +72,5 @@ @include nb-tooltip-theme(); @include nb-window-theme(); @include nb-datepicker-theme(); + @include nb-radio-theme(); } diff --git a/src/framework/theme/styles/themes/_cosmic.scss b/src/framework/theme/styles/themes/_cosmic.scss index 1182c2e201..8c8a664039 100644 --- a/src/framework/theme/styles/themes/_cosmic.scss +++ b/src/framework/theme/styles/themes/_cosmic.scss @@ -171,6 +171,9 @@ $theme: ( datepicker-border: color-primary, datepicker-shadow: shadow, + + radio-checked-border-color: color-primary, + radio-checked-checkmark: color-primary, ); // register the theme diff --git a/src/framework/theme/styles/themes/_default.scss b/src/framework/theme/styles/themes/_default.scss index 9227bdaa52..eb526ecd5f 100644 --- a/src/framework/theme/styles/themes/_default.scss +++ b/src/framework/theme/styles/themes/_default.scss @@ -426,8 +426,6 @@ $theme: ( checkbox-disabled-border-color: color-fg-heading, checkbox-disabled-checkmark: color-fg-heading, - radio-fg: color-success, - modal-font-size: font-size, modal-line-height: line-height, modal-font-weight: font-weight-normal, @@ -662,6 +660,23 @@ $theme: ( datepicker-border-radius: radius, datepicker-shadow: none, datepicker-arrow-size: 11px, + + radio-bg: transparent, + radio-fg: color-fg-text, + radio-size: 1.25rem, + radio-border-size: 2px, + radio-border-color: form-control-border-color, + radio-checkmark: transparent, + radio-checked-bg: transparent, + radio-checked-size: 1.25rem, + radio-checked-border-size: 2px, + radio-checked-border-color: color-success, + radio-checked-checkmark: color-success, + radio-disabled-bg: transparent, + radio-disabled-size: 1.25rem, + radio-disabled-border-size: 2px, + radio-disabled-border-color: color-fg-heading, + radio-disabled-checkmark: color-fg-heading, ); // register the theme diff --git a/src/playground/playground-routing.module.ts b/src/playground/playground-routing.module.ts index 46f3ed7fe7..baa6dce4ef 100644 --- a/src/playground/playground-routing.module.ts +++ b/src/playground/playground-routing.module.ts @@ -199,6 +199,8 @@ import { NbDatepickerShowcaseComponent } from './datepicker/datepicker-showcase. import { NbDatepickerFormsComponent } from './datepicker/datepicker-forms.component'; import { NbDatepickerValidationComponent } from './datepicker/datepicker-validation.component'; import { NbRangepickerShowcaseComponent } from './datepicker/rangepicker-showcase.component'; +import { NbRadioShowcaseComponent } from './radio/radio-showcase.component'; +import { NbRadioDisabledComponent } from './radio/radio-disabled.component'; export const routes: Routes = [ @@ -313,6 +315,19 @@ export const routes: Routes = [ }, ], }, + { + path: 'radio', + children: [ + { + path: 'radio-showcase.component', + component: NbRadioShowcaseComponent, + }, + { + path: 'radio-disabled.component', + component: NbRadioDisabledComponent, + }, + ], + }, { path: 'button', children: [ diff --git a/src/playground/playground.module.ts b/src/playground/playground.module.ts index 012394f463..40ee60bfc7 100644 --- a/src/playground/playground.module.ts +++ b/src/playground/playground.module.ts @@ -41,7 +41,7 @@ import { NbDialogModule, NbSelectModule, NbWindowModule, - NbDatepickerModule, + NbDatepickerModule, NbRadioModule, } from '@nebular/theme'; import { NbPlaygroundRoutingModule } from './playground-routing.module'; @@ -248,6 +248,8 @@ import { NbDatepickerShowcaseComponent } from './datepicker/datepicker-showcase. import { NbDatepickerFormsComponent } from './datepicker/datepicker-forms.component'; import { NbDatepickerValidationComponent } from './datepicker/datepicker-validation.component'; import { NbRangepickerShowcaseComponent } from './datepicker/rangepicker-showcase.component'; +import { NbRadioShowcaseComponent } from './radio/radio-showcase.component'; +import { NbRadioDisabledComponent } from './radio/radio-disabled.component'; export const NB_MODULES = [ NbCardModule, @@ -288,6 +290,7 @@ export const NB_MODULES = [ NbSelectModule, NbWindowModule.forRoot(), NbDatepickerModule, + NbRadioModule, ]; export const NB_EXAMPLE_COMPONENTS = [ @@ -488,6 +491,8 @@ export const NB_EXAMPLE_COMPONENTS = [ NbDatepickerFormsComponent, NbDatepickerValidationComponent, NbRangepickerShowcaseComponent, + NbRadioShowcaseComponent, + NbRadioDisabledComponent, ]; @NgModule({ diff --git a/src/playground/radio/radio-disabled.component.html b/src/playground/radio/radio-disabled.component.html new file mode 100644 index 0000000000..f9dd1a721d --- /dev/null +++ b/src/playground/radio/radio-disabled.component.html @@ -0,0 +1,15 @@ + + Selected Option: {{ option }} + + + + + {{ option.label }} + + + + + diff --git a/src/playground/radio/radio-disabled.component.ts b/src/playground/radio/radio-disabled.component.ts new file mode 100644 index 0000000000..7e99892f83 --- /dev/null +++ b/src/playground/radio/radio-disabled.component.ts @@ -0,0 +1,22 @@ +/* + * @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: 'nb-radio-disabled', + templateUrl: './radio-disabled.component.html', +}) +export class NbRadioDisabledComponent { + options = [ + { value: 'This is value 1', label: 'Option 1' }, + { value: 'This is value 2', label: 'Option 2', disabled: true }, + { value: 'This is value 3', label: 'Option 3' }, + { value: 'This is value 4', label: 'Option 4', disabled: true }, + { value: 'This is value 5', label: 'Option 5' }, + ]; + option; +} diff --git a/src/playground/radio/radio-showcase.component.html b/src/playground/radio/radio-showcase.component.html new file mode 100644 index 0000000000..1ab65f71ef --- /dev/null +++ b/src/playground/radio/radio-showcase.component.html @@ -0,0 +1,14 @@ + + Selected Option: {{ option }} + + + + + {{ option.label }} + + + + + diff --git a/src/playground/radio/radio-showcase.component.ts b/src/playground/radio/radio-showcase.component.ts new file mode 100644 index 0000000000..09e6735a23 --- /dev/null +++ b/src/playground/radio/radio-showcase.component.ts @@ -0,0 +1,21 @@ +/* + * @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: 'nb-radio-showcase', + templateUrl: './radio-showcase.component.html', +}) +export class NbRadioShowcaseComponent { + options = [ + { value: 'This is value 1', label: 'Option 1' }, + { value: 'This is value 2', label: 'Option 2' }, + { value: 'This is value 3', label: 'Option 3' }, + { value: 'This is value 4', label: 'Option 4' }, + ]; + option; +}