diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ffb455e..15dc586e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +## 1.0.0-beta1 (22.03.2021) + +### New components: + +- [Range](https://mdbootstrap.com/docs/b5/angular/forms/range/) +- [File](https://mdbootstrap.com/docs/b5/angular/forms/file) +- [Switch](https://mdbootstrap.com/docs/b5/angular/forms/switch/) +- [Input group](https://mdbootstrap.com/docs/b5/angular/forms-input-group/) +- [Pills](https://mdbootstrap.com/docs/b5/angular/navigation/pills/) +- [Tabs](https://mdbootstrap.com/docs/b5/angular/navigation/tabs/) + +### Bug fixes: + +- Scrollspy - added `cursor: pointer` styles to scrollspy links, +- Sidenav - resolved problem with errors when `RouterModule` is not imported, +- Sidenav - component will be correctly updated on inputs changes, +- Sidenav - resolved problem with scroll position, +- Sidenav - added components and module exports to main library index. + +### New features: + +- Animations - added new animations: `slideLeft`, `slideRight`, `slideUp`, `slideDown`, +- Sidenav - added focus trap, +- Sidenav - escape button will now close the component. + +--- + ## 1.0.0-alpha4 (08.03.2021) ### New components: @@ -9,14 +36,18 @@ - [Validation](https://mdbootstrap.com/docs/b5/angular/forms/validation/) ### Bug fixes: + - Select - `x options selected` text will be displayed correctly when more than 5 options have been selected, - Select - fixed clear button focusing issue. ### New features: + - Select - added new `displayedLabels` input that allows to change maximum number of comma-separated options labels displayed in the multiselect input, - Select - added new `optionsSelectedLabel` input that allows to customize x options selected text, - Select - added new `filterDebounce` input that allows to add delay to the options list updates when using filter input +--- + ## 1.0.0-alpha3 (22.02.2021) ### New components: @@ -26,6 +57,8 @@ - [Select](https://mdbootstrap.com/docs/b5/angular/forms/select/) - [Scrollbar](https://mdbootstrap.com/docs/b5/angular/methods/scrollbar/) +--- + ## 1.0.0-alpha2 (25.01.2021) ### New components: @@ -36,6 +69,8 @@ - [Input](https://mdbootstrap.com/docs/b5/angular/forms/input-fields/) - [Radio](https://mdbootstrap.com/docs/b5/angular/forms/radio/) +--- + ## 1.0.0-alpha1 (11.01.2021) The initial release of MDB 5 Angular Alpha 1. diff --git a/README.txt b/README.txt index 2d37e158..176e4054 100644 --- a/README.txt +++ b/README.txt @@ -1,6 +1,6 @@ MDB 5 Angular -Version: FREE 1.0.0 Alpha 4 +Version: FREE 1.0.0 Beta 1 Documentation: https://mdbootstrap.com/docs/b5/angular/ diff --git a/package.json b/package.json index 58477fd2..0b17e527 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mdb-angular-ui-kit-free", - "version": "1.0.0-alpha4", + "version": "1.0.0-beta1", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/projects/mdb-angular-ui-kit/CHANGELOG.md b/projects/mdb-angular-ui-kit/CHANGELOG.md index 9ffb455e..15dc586e 100644 --- a/projects/mdb-angular-ui-kit/CHANGELOG.md +++ b/projects/mdb-angular-ui-kit/CHANGELOG.md @@ -1,3 +1,30 @@ +## 1.0.0-beta1 (22.03.2021) + +### New components: + +- [Range](https://mdbootstrap.com/docs/b5/angular/forms/range/) +- [File](https://mdbootstrap.com/docs/b5/angular/forms/file) +- [Switch](https://mdbootstrap.com/docs/b5/angular/forms/switch/) +- [Input group](https://mdbootstrap.com/docs/b5/angular/forms-input-group/) +- [Pills](https://mdbootstrap.com/docs/b5/angular/navigation/pills/) +- [Tabs](https://mdbootstrap.com/docs/b5/angular/navigation/tabs/) + +### Bug fixes: + +- Scrollspy - added `cursor: pointer` styles to scrollspy links, +- Sidenav - resolved problem with errors when `RouterModule` is not imported, +- Sidenav - component will be correctly updated on inputs changes, +- Sidenav - resolved problem with scroll position, +- Sidenav - added components and module exports to main library index. + +### New features: + +- Animations - added new animations: `slideLeft`, `slideRight`, `slideUp`, `slideDown`, +- Sidenav - added focus trap, +- Sidenav - escape button will now close the component. + +--- + ## 1.0.0-alpha4 (08.03.2021) ### New components: @@ -9,14 +36,18 @@ - [Validation](https://mdbootstrap.com/docs/b5/angular/forms/validation/) ### Bug fixes: + - Select - `x options selected` text will be displayed correctly when more than 5 options have been selected, - Select - fixed clear button focusing issue. ### New features: + - Select - added new `displayedLabels` input that allows to change maximum number of comma-separated options labels displayed in the multiselect input, - Select - added new `optionsSelectedLabel` input that allows to customize x options selected text, - Select - added new `filterDebounce` input that allows to add delay to the options list updates when using filter input +--- + ## 1.0.0-alpha3 (22.02.2021) ### New components: @@ -26,6 +57,8 @@ - [Select](https://mdbootstrap.com/docs/b5/angular/forms/select/) - [Scrollbar](https://mdbootstrap.com/docs/b5/angular/methods/scrollbar/) +--- + ## 1.0.0-alpha2 (25.01.2021) ### New components: @@ -36,6 +69,8 @@ - [Input](https://mdbootstrap.com/docs/b5/angular/forms/input-fields/) - [Radio](https://mdbootstrap.com/docs/b5/angular/forms/radio/) +--- + ## 1.0.0-alpha1 (11.01.2021) The initial release of MDB 5 Angular Alpha 1. diff --git a/projects/mdb-angular-ui-kit/assets/scss/free/_range.scss b/projects/mdb-angular-ui-kit/assets/scss/free/_range.scss new file mode 100644 index 00000000..fd70d926 --- /dev/null +++ b/projects/mdb-angular-ui-kit/assets/scss/free/_range.scss @@ -0,0 +1,45 @@ +// range +.range { + position: relative; + + .thumb { + position: absolute; + display: block; + height: 30px; + width: 30px; + top: -35px; + margin-left: -15px; + text-align: center; + border-radius: 50% 50% 50% 0; + transform: scale(0); + transform-origin: bottom; + transition: transform 0.2s ease-in-out; + + &:after { + position: absolute; + display: block; + content: ''; + transform: translateX(-50%); + width: 100%; + height: 100%; + top: 0; + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); + background: #1266f1; + z-index: -1; + } + + .thumb-value { + display: block; + font-size: 12px; + line-height: 30px; + color: rgb(255, 255, 255); + font-weight: 500; + z-index: 2; + } + + &.thumb-active { + transform: scale(1); + } + } +} diff --git a/projects/mdb-angular-ui-kit/assets/scss/free/_scrollspy.scss b/projects/mdb-angular-ui-kit/assets/scss/free/_scrollspy.scss index 5e596509..3163e79a 100644 --- a/projects/mdb-angular-ui-kit/assets/scss/free/_scrollspy.scss +++ b/projects/mdb-angular-ui-kit/assets/scss/free/_scrollspy.scss @@ -1,4 +1,7 @@ // Scrollspy +.scrollspy-link { + cursor: pointer; +} .nav-pills { &.menu-sidebar { diff --git a/projects/mdb-angular-ui-kit/assets/scss/free/forms/_form-file.scss b/projects/mdb-angular-ui-kit/assets/scss/free/forms/_form-file.scss new file mode 100644 index 00000000..0cdb5caf --- /dev/null +++ b/projects/mdb-angular-ui-kit/assets/scss/free/forms/_form-file.scss @@ -0,0 +1,19 @@ +.form-control { + &[type='file'] { + &::-webkit-file-upload-button { + background-color: transparent; + } + } + &:hover { + &:not(:disabled):not([readonly])::-webkit-file-upload-button { + background-color: transparent; + } + } +} + +.form-control-sm { + &::-webkit-file-upload-button { + padding: 0.28rem 0.5rem; + margin: -0.28rem -0.5rem; + } +} diff --git a/projects/mdb-angular-ui-kit/assets/scss/free/forms/_form-range.scss b/projects/mdb-angular-ui-kit/assets/scss/free/forms/_form-range.scss new file mode 100644 index 00000000..e1952dba --- /dev/null +++ b/projects/mdb-angular-ui-kit/assets/scss/free/forms/_form-range.scss @@ -0,0 +1,43 @@ +// Range +// +// Style range inputs the same across browsers. Vendor-specific rules for pseudo +// elements cannot be mixed. As such, there are no shared styles for focus or +// active states on prefixed selectors. + +.form-range { + &:focus { + box-shadow: none; + + // Pseudo-elements must be split across multiple rulesets to have an effect. + // No box-shadow() mixin for focus accessibility. + &::-webkit-slider-thumb { + box-shadow: none; + } + &::-moz-range-thumb { + box-shadow: none; + } + &::-ms-thumb { + box-shadow: none; + } + } + + &::-moz-focus-outer { + border: 0; + } + + &::-webkit-slider-thumb { + margin-top: $form-range-webkit-slider-thumb-margin-top; // Webkit specific + box-shadow: none; + appearance: none; + } + + &::-webkit-slider-runnable-track { + height: $form-range-webkit-slider-runnable-track-height; + border-radius: 0; + } + + &::-moz-range-thumb { + box-shadow: none; + appearance: none; + } +} diff --git a/projects/mdb-angular-ui-kit/assets/scss/free/forms/_input-group.scss b/projects/mdb-angular-ui-kit/assets/scss/free/forms/_input-group.scss index f836f813..4cab77c9 100644 --- a/projects/mdb-angular-ui-kit/assets/scss/free/forms/_input-group.scss +++ b/projects/mdb-angular-ui-kit/assets/scss/free/forms/_input-group.scss @@ -79,3 +79,13 @@ .input-group > [class*='btn-outline-'] + [class*='btn-outline-'] { border-left: 0; } + +.input-group div:not(:first-child).dropdown button.dropdown-toggle { + margin-left: -$input-border-width; + @include border-start-radius(0); +} + +.input-group div:not(:last-child).dropdown button.dropdown-toggle { + margin-right: -$input-border-width; + @include border-end-radius(0); +} diff --git a/projects/mdb-angular-ui-kit/assets/scss/mdb.free.scss b/projects/mdb-angular-ui-kit/assets/scss/mdb.free.scss index 4840fb24..8e7ff4df 100644 --- a/projects/mdb-angular-ui-kit/assets/scss/mdb.free.scss +++ b/projects/mdb-angular-ui-kit/assets/scss/mdb.free.scss @@ -79,9 +79,11 @@ @import './free/tooltip'; @import './free/popover'; @import './free/dropdown'; +@import './free/range'; // MDB FORMS @import './free/forms/form-check'; @import './free/forms/form-control'; +@import './free/forms/form-range'; @import '~@angular/cdk/overlay-prebuilt.css'; diff --git a/projects/mdb-angular-ui-kit/assets/scss/mdb.scss b/projects/mdb-angular-ui-kit/assets/scss/mdb.scss index 467c8597..e45c03aa 100644 --- a/projects/mdb-angular-ui-kit/assets/scss/mdb.scss +++ b/projects/mdb-angular-ui-kit/assets/scss/mdb.scss @@ -83,11 +83,14 @@ @import './free/ripple'; @import './free/validation'; @import './free/scrollspy'; +@import './free/range'; // MDB FORMS @import './free/forms/form-check'; @import './free/forms/form-control'; @import './free/forms/form-select'; +@import './free/forms/form-range'; @import './free/forms/input-group'; +@import './free/forms/form-file'; @import '~@angular/cdk/overlay-prebuilt.css'; diff --git a/projects/mdb-angular-ui-kit/checkbox/checkbox.directive.ts b/projects/mdb-angular-ui-kit/checkbox/checkbox.directive.ts index 7a5d6553..1dcade96 100644 --- a/projects/mdb-angular-ui-kit/checkbox/checkbox.directive.ts +++ b/projects/mdb-angular-ui-kit/checkbox/checkbox.directive.ts @@ -91,6 +91,7 @@ export class MdbCheckboxDirective { } this._checked = !this._checked; this.onChange(this.checked); + this.onCheckboxChange(); } onCheckboxChange(): void { diff --git a/projects/mdb-angular-ui-kit/index.ts b/projects/mdb-angular-ui-kit/index.ts index ea36b7fc..d832aecd 100644 --- a/projects/mdb-angular-ui-kit/index.ts +++ b/projects/mdb-angular-ui-kit/index.ts @@ -13,6 +13,8 @@ import { MdbDropdownModule } from './dropdown/dropdown.module'; import { MdbRippleModule } from './ripple/ripple.module'; import { MdbValidationModule } from './validation/validation.module'; import { MdbScrollspyModule } from './scrollspy/scrollspy.module'; +import { MdbRangeModule } from './range/range.module'; +import { MdbTabsModule } from './tabs/tabs.module'; export { MdbCollapseDirective, MdbCollapseModule } from './collapse/index'; export { @@ -62,6 +64,14 @@ export { MdbScrollspyService, MdbScrollspyModule, } from './scrollspy/index'; +export { MdbRangeComponent, MdbRangeModule } from './range/index'; +export { + MdbTabComponent, + MdbTabContentDirective, + MdbTabTitleDirective, + MdbTabsComponent, + MdbTabsModule, +} from './tabs/index'; const MDB_MODULES = [ MdbCollapseModule, @@ -75,6 +85,8 @@ const MDB_MODULES = [ MdbRippleModule, MdbValidationModule, MdbScrollspyModule, + MdbRangeModule, + MdbTabsModule, ]; @NgModule({ diff --git a/projects/mdb-angular-ui-kit/package.json b/projects/mdb-angular-ui-kit/package.json index 02c7ef27..78637c97 100644 --- a/projects/mdb-angular-ui-kit/package.json +++ b/projects/mdb-angular-ui-kit/package.json @@ -3,7 +3,7 @@ "repository": "https://github.com/mdbootstrap/mdb-angular-ui-kit", "author": "MDBootstrap", "license": "MIT", - "version": "1.0.0-alpha4", + "version": "1.0.0-beta1", "peerDependencies": { "@angular/common": "^11.0.0", "@angular/core": "^11.0.0", diff --git a/projects/mdb-angular-ui-kit/range/index.ts b/projects/mdb-angular-ui-kit/range/index.ts new file mode 100644 index 00000000..4aaf8f92 --- /dev/null +++ b/projects/mdb-angular-ui-kit/range/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/projects/mdb-angular-ui-kit/range/public_api.ts b/projects/mdb-angular-ui-kit/range/public_api.ts new file mode 100644 index 00000000..6d843762 --- /dev/null +++ b/projects/mdb-angular-ui-kit/range/public_api.ts @@ -0,0 +1,2 @@ +export { MdbRangeModule } from './range.module'; +export { MdbRangeComponent } from './range.component'; diff --git a/projects/mdb-angular-ui-kit/range/range.component.html b/projects/mdb-angular-ui-kit/range/range.component.html new file mode 100644 index 00000000..1db32936 --- /dev/null +++ b/projects/mdb-angular-ui-kit/range/range.component.html @@ -0,0 +1,28 @@ + +
+ + + {{ value }} + +
diff --git a/projects/mdb-angular-ui-kit/range/range.component.spec.ts b/projects/mdb-angular-ui-kit/range/range.component.spec.ts new file mode 100644 index 00000000..0e1eea26 --- /dev/null +++ b/projects/mdb-angular-ui-kit/range/range.component.spec.ts @@ -0,0 +1,100 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; +import { MdbRangeModule } from './range.module'; +import { MdbRangeComponent } from './range.component'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; + +const template = ` + +`; + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'range-test', + template, +}) +class TestRangeComponent implements OnInit { + @ViewChild(MdbRangeComponent) _range: MdbRangeComponent; + + rangeControl = new FormControl(50); + ngOnInit(): void { + this.rangeControl.valueChanges.subscribe((val) => console.log(val)); + } +} + +describe('MDB Range', () => { + let fixture: ComponentFixture; + let component: any; + let mdbRange: any; + let thumb: any; + let valueThumb: any; + let input: any; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TestRangeComponent], + imports: [MdbRangeModule, ReactiveFormsModule], + }); + fixture = TestBed.createComponent(TestRangeComponent); + fixture.detectChanges(); + component = fixture.componentInstance; + mdbRange = fixture.debugElement.query(By.css('mdb-range')); + thumb = fixture.debugElement.query(By.css('.thumb')); + valueThumb = fixture.debugElement.query(By.css('.thumb-value')); + input = fixture.debugElement.query(By.css('input')); + }); + + it('should show thumb on mousedown and hide on mauseup', fakeAsync(() => { + expect(thumb.nativeElement.classList.contains('thumb-active')).toBe(false); + + input.nativeElement.dispatchEvent(new MouseEvent('mousedown')); + + fixture.detectChanges(); + flush(); + + expect(thumb.nativeElement.classList.contains('thumb-active')).toBe(true); + + input.nativeElement.dispatchEvent(new MouseEvent('mouseup')); + + fixture.detectChanges(); + flush(); + + expect(thumb.nativeElement.classList.contains('thumb-active')).toBe(false); + })); + + it('should show input value', () => { + fixture.detectChanges(); + + expect(document.querySelector('.thumb')).not.toBe(null); + expect(valueThumb.nativeElement.textContent).toBe(input.nativeElement.value); + }); + + it('should update thumb value after input', () => { + input.nativeElement.value = 24; + input.nativeElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + expect(valueThumb.nativeElement.textContent).toBe('24'); + }); + + it('should update value after set new FormControl', () => { + component.rangeControl = new FormControl(60); + fixture.detectChanges(); + + expect(valueThumb.nativeElement.textContent).toBe('60'); + expect(input.nativeElement.value).toBe('60'); + }); + + it('should update thumb position', fakeAsync(() => { + const initialThumbStyle = { ...component._range.thumbStyle }; + + component.rangeControl = new FormControl(70); + + fixture.detectChanges(); + flush(); + const newThumbStyle = { ...component._range.thumbStyle }; + + expect(initialThumbStyle.left).not.toBe(newThumbStyle.left); + })); +}); diff --git a/projects/mdb-angular-ui-kit/range/range.component.ts b/projects/mdb-angular-ui-kit/range/range.component.ts new file mode 100644 index 00000000..0390f818 --- /dev/null +++ b/projects/mdb-angular-ui-kit/range/range.component.ts @@ -0,0 +1,111 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + forwardRef, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +export const RANGE_VALUE_ACCESOR: any = { + provide: NG_VALUE_ACCESSOR, + // tslint:disable-next-line: no-use-before-declare + useExisting: forwardRef(() => MdbRangeComponent), + multi: true, +}; +@Component({ + // tslint:disable-next-line: component-selector + selector: 'mdb-range', + templateUrl: 'range.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [RANGE_VALUE_ACCESOR], +}) +export class MdbRangeComponent implements ControlValueAccessor, AfterViewInit { + @ViewChild('input') input: ElementRef; + @ViewChild('thumb') thumb: ElementRef; + @ViewChild('thumbValue') thumbValue: ElementRef; + + @Input() id: string; + @Input() required: boolean; + @Input() name: string; + @Input() value: string; + @Input() disabled: boolean; + @Input() min = 0; + @Input() max = 100; + @Input() step: number; + @Input() default: boolean; + @Input() defaultRangeCounterClass: string; + + @Output() rangeValueChange = new EventEmitter(); + + public visibility = false; + public thumbStyle: any; + + @HostListener('change', ['$event']) onchange(event: any): void { + this.onChange(event.target.value); + } + + @HostListener('input') onInput(): void { + this.rangeValueChange.emit({ value: this.value }); + this.focusRangeInput(); + } + + constructor(private _cdRef: ChangeDetectorRef) {} + + ngAfterViewInit(): void { + this.thumbPositionUpdate(); + } + + focusRangeInput(): void { + this.input.nativeElement.focus(); + this.visibility = true; + } + + blurRangeInput(): void { + this.input.nativeElement.blur(); + this.visibility = false; + } + + thumbPositionUpdate(): void { + const rangeInput = this.input.nativeElement; + const inputValue = rangeInput.value; + const minValue = rangeInput.min ? rangeInput.min : 0; + const maxValue = rangeInput.max ? rangeInput.max : 100; + const newValue = Number(((inputValue - minValue) * 100) / (maxValue - minValue)); + + this.value = inputValue; + this.thumbStyle = { left: `calc(${newValue}% + (${8 - newValue * 0.15}px))` }; + } + + // Control Value Accessor Methods + onChange = (_: any) => {}; + onTouched = () => {}; + + writeValue(value: any): void { + this.value = value; + + this._cdRef.markForCheck(); + + setTimeout(() => { + this.thumbPositionUpdate(); + }, 0); + } + + registerOnChange(fn: (_: any) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } +} diff --git a/projects/mdb-angular-ui-kit/range/range.module.ts b/projects/mdb-angular-ui-kit/range/range.module.ts new file mode 100644 index 00000000..3347ea45 --- /dev/null +++ b/projects/mdb-angular-ui-kit/range/range.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +import { MdbRangeComponent } from './range.component'; + +@NgModule({ + imports: [CommonModule, FormsModule], + declarations: [MdbRangeComponent], + exports: [MdbRangeComponent], +}) +export class MdbRangeModule {} diff --git a/projects/mdb-angular-ui-kit/scrollspy/scrollspy-link.directive.ts b/projects/mdb-angular-ui-kit/scrollspy/scrollspy-link.directive.ts index ac23bf03..46b750af 100644 --- a/projects/mdb-angular-ui-kit/scrollspy/scrollspy-link.directive.ts +++ b/projects/mdb-angular-ui-kit/scrollspy/scrollspy-link.directive.ts @@ -46,6 +46,9 @@ export class MdbScrollspyLinkDirective implements OnInit { } } + @HostBinding('class.scrollspy-link') + scrollspyLink = true; + @HostBinding('class.active') active = false; diff --git a/projects/mdb-angular-ui-kit/tabs/index.ts b/projects/mdb-angular-ui-kit/tabs/index.ts new file mode 100644 index 00000000..4aaf8f92 --- /dev/null +++ b/projects/mdb-angular-ui-kit/tabs/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/projects/mdb-angular-ui-kit/tabs/public_api.ts b/projects/mdb-angular-ui-kit/tabs/public_api.ts new file mode 100644 index 00000000..f088ab22 --- /dev/null +++ b/projects/mdb-angular-ui-kit/tabs/public_api.ts @@ -0,0 +1,5 @@ +export { MdbTabComponent } from './tab.component'; +export { MdbTabContentDirective } from './tab-content.directive'; +export { MdbTabTitleDirective } from './tab-title.directive'; +export { MdbTabsComponent } from './tabs.component'; +export { MdbTabsModule } from './tabs.module'; diff --git a/projects/mdb-angular-ui-kit/tabs/tab-content.directive.ts b/projects/mdb-angular-ui-kit/tabs/tab-content.directive.ts new file mode 100644 index 00000000..2ce9ad5f --- /dev/null +++ b/projects/mdb-angular-ui-kit/tabs/tab-content.directive.ts @@ -0,0 +1,12 @@ +import { Directive, InjectionToken, TemplateRef } from '@angular/core'; + +export const MDB_TAB_CONTENT = new InjectionToken('MdbTabContentDirective'); + +@Directive({ + // tslint:disable-next-line: directive-selector + selector: '[mdbTabContent]', + providers: [{ provide: MDB_TAB_CONTENT, useExisting: MdbTabContentDirective }], +}) +export class MdbTabContentDirective { + constructor(public template: TemplateRef) {} +} diff --git a/projects/mdb-angular-ui-kit/tabs/tab-outlet.directive.ts b/projects/mdb-angular-ui-kit/tabs/tab-outlet.directive.ts new file mode 100644 index 00000000..8c645120 --- /dev/null +++ b/projects/mdb-angular-ui-kit/tabs/tab-outlet.directive.ts @@ -0,0 +1,54 @@ +import { CdkPortalOutlet } from '@angular/cdk/portal'; +import { DOCUMENT } from '@angular/common'; +import { + ComponentFactoryResolver, + Directive, + Inject, + Input, + OnDestroy, + OnInit, + ViewContainerRef, +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { MdbTabComponent } from './tab.component'; + +@Directive({ + // tslint:disable-next-line: directive-selector + selector: '[mdbTabPortalOutlet]', +}) +// tslint:disable-next-line: directive-class-suffix +export class MdbTabPortalOutlet extends CdkPortalOutlet implements OnInit, OnDestroy { + readonly _destroy$: Subject = new Subject(); + + @Input() tab: MdbTabComponent; + + constructor( + _cfr: ComponentFactoryResolver, + _vcr: ViewContainerRef, + @Inject(DOCUMENT) _document: any + ) { + super(_cfr, _vcr, _document); + } + + ngOnInit(): void { + super.ngOnInit(); + + if ((this.tab.shouldAttach || this.tab.active) && !this.hasAttached()) { + this.attach(this.tab.content); + } else { + // tslint:disable-next-line: deprecation + this.tab.activeStateChange$.pipe(takeUntil(this._destroy$)).subscribe((isActive) => { + if (isActive && !this.hasAttached()) { + this.attach(this.tab.content); + } + }); + } + } + + ngOnDestroy(): void { + this._destroy$.next(); + this._destroy$.complete(); + super.ngOnDestroy(); + } +} diff --git a/projects/mdb-angular-ui-kit/tabs/tab-title.directive.ts b/projects/mdb-angular-ui-kit/tabs/tab-title.directive.ts new file mode 100644 index 00000000..87a9bc65 --- /dev/null +++ b/projects/mdb-angular-ui-kit/tabs/tab-title.directive.ts @@ -0,0 +1,12 @@ +import { Directive, InjectionToken, TemplateRef } from '@angular/core'; + +export const MDB_TAB_TITLE = new InjectionToken('MdbTabTitleDirective'); + +@Directive({ + // tslint:disable-next-line: directive-selector + selector: '[mdbTabTitle]', + providers: [{ provide: MDB_TAB_TITLE, useExisting: MdbTabTitleDirective }], +}) +export class MdbTabTitleDirective { + constructor(public template: TemplateRef) {} +} diff --git a/projects/mdb-angular-ui-kit/tabs/tab.component.html b/projects/mdb-angular-ui-kit/tabs/tab.component.html new file mode 100644 index 00000000..cd48c06b --- /dev/null +++ b/projects/mdb-angular-ui-kit/tabs/tab.component.html @@ -0,0 +1 @@ + diff --git a/projects/mdb-angular-ui-kit/tabs/tab.component.ts b/projects/mdb-angular-ui-kit/tabs/tab.component.ts new file mode 100644 index 00000000..77d69a7d --- /dev/null +++ b/projects/mdb-angular-ui-kit/tabs/tab.component.ts @@ -0,0 +1,92 @@ +import { TemplatePortal } from '@angular/cdk/portal'; +import { + Component, + ContentChild, + ElementRef, + Input, + OnInit, + Renderer2, + TemplateRef, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { MDB_TAB_CONTENT } from './tab-content.directive'; +import { MDB_TAB_TITLE } from './tab-title.directive'; + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'mdb-tab', + templateUrl: './tab.component.html', +}) +export class MdbTabComponent implements OnInit { + @ContentChild(MDB_TAB_CONTENT, { read: TemplateRef, static: true }) + _lazyContent: TemplateRef; + + @ContentChild(MDB_TAB_TITLE, { read: TemplateRef, static: true }) + _titleContent: TemplateRef; + + @ViewChild(TemplateRef, { static: true }) _content: TemplateRef; + + readonly activeStateChange$: Subject = new Subject(); + + @Input() disabled = false; + @Input() title: string; + + get active(): boolean { + return this._active; + } + + get content(): TemplatePortal | null { + return this._contentPortal; + } + + get titleContent(): TemplatePortal | null { + return this._titlePortal; + } + + get shouldAttach(): boolean { + return this._lazyContent === undefined; + } + + private _contentPortal: TemplatePortal | null = null; + private _titlePortal: TemplatePortal | null = null; + + // tslint:disable-next-line: adjacent-overload-signatures + set active(value: boolean) { + if (value) { + this._renderer.addClass(this._elementRef.nativeElement, 'show'); + this._renderer.addClass(this._elementRef.nativeElement, 'active'); + } else { + this._renderer.removeClass(this._elementRef.nativeElement, 'show'); + this._renderer.removeClass(this._elementRef.nativeElement, 'active'); + } + + this._active = value; + this.activeStateChange$.next(value); + } + private _active = false; + + constructor( + private _elementRef: ElementRef, + private _renderer: Renderer2, + private _vcr: ViewContainerRef + ) {} + + ngOnInit(): void { + this._createContentPortal(); + + if (this._titleContent) { + this._createTitlePortal(); + } + } + + private _createContentPortal(): void { + const content = this._lazyContent || this._content; + this._contentPortal = new TemplatePortal(content, this._vcr); + } + + private _createTitlePortal(): void { + this._titlePortal = new TemplatePortal(this._titleContent, this._vcr); + } +} diff --git a/projects/mdb-angular-ui-kit/tabs/tabs.component.html b/projects/mdb-angular-ui-kit/tabs/tabs.component.html new file mode 100644 index 00000000..5fd3e207 --- /dev/null +++ b/projects/mdb-angular-ui-kit/tabs/tabs.component.html @@ -0,0 +1,54 @@ + + +
+ + +
+ +
+
+
diff --git a/projects/mdb-angular-ui-kit/tabs/tabs.component.ts b/projects/mdb-angular-ui-kit/tabs/tabs.component.ts new file mode 100644 index 00000000..0bbb014f --- /dev/null +++ b/projects/mdb-angular-ui-kit/tabs/tabs.component.ts @@ -0,0 +1,109 @@ +import { + AfterContentInit, + Component, + ContentChildren, + EventEmitter, + HostBinding, + Input, + OnDestroy, + Output, + QueryList, +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { MdbTabComponent } from './tab.component'; + +export class MdbTabChange { + index: number; + tab: MdbTabComponent; +} + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'mdb-tabs', + templateUrl: './tabs.component.html', +}) +export class MdbTabsComponent implements AfterContentInit, OnDestroy { + @ContentChildren(MdbTabComponent) tabs: QueryList; + + readonly _destroy$: Subject = new Subject(); + + @Input() fill = false; + @Input() justified = false; + @Input() pills = false; + + @HostBinding('class.row') + @Input() + vertical = false; + + private _selectedIndex: number; + + @Output() activeTabChange: EventEmitter = new EventEmitter(); + + constructor() {} + + ngAfterContentInit(): void { + const firstActiveTabIndex = this.tabs.toArray().findIndex((tab) => !tab.disabled); + + this.setActiveTab(firstActiveTabIndex); + // tslint:disable-next-line: deprecation + this.tabs.changes.pipe(takeUntil(this._destroy$)).subscribe(() => { + const hasActiveTab = this.tabs.find((tab) => tab.active); + + if (!hasActiveTab) { + const closestTabIndex = this._getClosestTabIndex(this._selectedIndex); + + if (closestTabIndex !== -1) { + this.setActiveTab(closestTabIndex); + } + } + }); + } + + setActiveTab(index: number): void { + const activeTab = this.tabs.toArray()[index]; + + if (!activeTab || (activeTab && activeTab.disabled)) { + return; + } + + this.tabs.forEach((tab) => (tab.active = tab === activeTab)); + this._selectedIndex = index; + + const tabChangeEvent = this._getTabChangeEvent(index, activeTab); + this.activeTabChange.emit(tabChangeEvent); + } + + private _getTabChangeEvent(index: number, tab: MdbTabComponent): MdbTabChange { + const event = new MdbTabChange(); + event.index = index; + event.tab = tab; + + return event; + } + + private _getClosestTabIndex(index: number): number { + const tabs = this.tabs.toArray(); + const tabsLength = tabs.length; + if (!tabsLength) { + return -1; + } + + for (let i = 1; i <= tabsLength; i += 1) { + const prevIndex = index - i; + const nextIndex = index + i; + if (tabs[prevIndex] && !tabs[prevIndex].disabled) { + return prevIndex; + } + if (tabs[nextIndex] && !tabs[nextIndex].disabled) { + return nextIndex; + } + } + return -1; + } + + ngOnDestroy(): void { + this._destroy$.next(); + this._destroy$.complete(); + } +} diff --git a/projects/mdb-angular-ui-kit/tabs/tabs.module.ts b/projects/mdb-angular-ui-kit/tabs/tabs.module.ts new file mode 100644 index 00000000..679136a3 --- /dev/null +++ b/projects/mdb-angular-ui-kit/tabs/tabs.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MdbTabComponent } from './tab.component'; +import { MdbTabsComponent } from './tabs.component'; +import { PortalModule } from '@angular/cdk/portal'; +import { MdbTabContentDirective } from './tab-content.directive'; +import { MdbTabPortalOutlet } from './tab-outlet.directive'; +import { MdbTabTitleDirective } from './tab-title.directive'; + +@NgModule({ + declarations: [ + MdbTabComponent, + MdbTabContentDirective, + MdbTabTitleDirective, + MdbTabPortalOutlet, + MdbTabsComponent, + ], + imports: [CommonModule, PortalModule], + exports: [ + MdbTabComponent, + MdbTabContentDirective, + MdbTabTitleDirective, + MdbTabPortalOutlet, + MdbTabsComponent, + ], +}) +export class MdbTabsModule {} diff --git a/projects/mdb-angular-ui-kit/tabs/tabs.spec.ts b/projects/mdb-angular-ui-kit/tabs/tabs.spec.ts new file mode 100644 index 00000000..79e0e5cb --- /dev/null +++ b/projects/mdb-angular-ui-kit/tabs/tabs.spec.ts @@ -0,0 +1,118 @@ +import { Component, QueryList, ViewChild, ViewChildren } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { MdbTabComponent } from './tab.component'; +import { MdbTabsComponent } from './tabs.component'; +import { MdbTabsModule } from './tabs.module'; + +const tabsTemplate = ` + + Tab 1 content + Tab 2 content + Tab 3 content + +`; + +@Component({ + template: tabsTemplate, +}) +export class TabsTestComponent { + pills = false; + fill = false; + justified = false; + + @ViewChild(MdbTabsComponent) tabsComponent: MdbTabsComponent; + @ViewChildren(MdbTabComponent) tabComponents: QueryList; +} + +describe('NbTabsetComponent', () => { + let fixture: ComponentFixture; + let component: TabsTestComponent; + let tabsComponent: MdbTabsComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + declarations: [TabsTestComponent], + imports: [MdbTabsModule], + }); + + fixture = TestBed.createComponent(TabsTestComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + tick(); + + tabsComponent = component.tabsComponent; + })); + + it('should activate first available tab', () => { + fixture.detectChanges(); + + const tabs = component.tabComponents.toArray(); + + expect(tabs[0].active).toBe(false); + expect(tabs[1].active).toBe(true); + }); + + it('should change active tab on tab button click', () => { + fixture.detectChanges(); + + const tabs = component.tabComponents.toArray(); + const tabLinks = fixture.debugElement.queryAll(By.css('.nav-link')); + + expect(tabs[1].active).toBe(true); + + tabLinks[2].nativeElement.click(); + fixture.detectChanges(); + + expect(tabs[2].active).toBe(true); + }); + + it('should add active class to active tab link', () => { + fixture.detectChanges(); + + const tabLinks = fixture.debugElement.queryAll(By.css('.nav-link')); + + expect(tabLinks[1].nativeElement.classList.contains('active')).toBe(true); + + tabLinks[2].nativeElement.click(); + fixture.detectChanges(); + + expect(tabLinks[2].nativeElement.classList.contains('active')).toBe(true); + }); + + it('should add disabled class to disabled tab link', () => { + fixture.detectChanges(); + + const tabLinks = fixture.debugElement.queryAll(By.css('.nav-link')); + + expect(tabLinks[0].nativeElement.classList.contains('disabled')).toBe(true); + }); + + it('should add nav-pills class if pills input is set to true', () => { + component.pills = true; + fixture.detectChanges(); + + const tabNav = fixture.debugElement.query(By.css('.nav')); + + expect(tabNav.nativeElement.classList.contains('nav-pills')).toBe(true); + }); + + it('should add nav-fill class if fill input is set to true', () => { + component.fill = true; + fixture.detectChanges(); + + const tabNav = fixture.debugElement.query(By.css('.nav')); + + expect(tabNav.nativeElement.classList.contains('nav-fill')).toBe(true); + }); + + it('should add nav-justified class if justified input is set to true', () => { + component.justified = true; + fixture.detectChanges(); + + const tabNav = fixture.debugElement.query(By.css('.nav')); + + expect(tabNav.nativeElement.classList.contains('nav-justified')).toBe(true); + }); +});