diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4848fd..7e50147a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.0.0-alpha2 (25.01.2021) + +### New components: + +- [Popover](https://mdbootstrap.com/docs/b5/angular/components/popovers/) +- [Tooltip](https://mdbootstrap.com/docs/b5/angular/components/tooltips/) +- [Checkbox](https://mdbootstrap.com/docs/b5/angular/forms/checkbox/) +- [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/package.json b/package.json index 821340eb..9e3e71ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mdb-angular-ui-kit-free", - "version": "1.0.0-alpha1", + "version": "1.0.0-alpha2", "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 7a4848fd..7e50147a 100644 --- a/projects/mdb-angular-ui-kit/CHANGELOG.md +++ b/projects/mdb-angular-ui-kit/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.0.0-alpha2 (25.01.2021) + +### New components: + +- [Popover](https://mdbootstrap.com/docs/b5/angular/components/popovers/) +- [Tooltip](https://mdbootstrap.com/docs/b5/angular/components/tooltips/) +- [Checkbox](https://mdbootstrap.com/docs/b5/angular/forms/checkbox/) +- [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/_popover.scss b/projects/mdb-angular-ui-kit/assets/scss/free/_popover.scss new file mode 100644 index 00000000..11bad618 --- /dev/null +++ b/projects/mdb-angular-ui-kit/assets/scss/free/_popover.scss @@ -0,0 +1,12 @@ +// Popover + +.popover { + border: 0; + box-shadow: $popover-box-shadow; + position: unset; + opacity: 0; +} + +.popover-header { + background-color: $popover-background-color; +} diff --git a/projects/mdb-angular-ui-kit/assets/scss/free/_tooltip.scss b/projects/mdb-angular-ui-kit/assets/scss/free/_tooltip.scss new file mode 100644 index 00000000..bab23c22 --- /dev/null +++ b/projects/mdb-angular-ui-kit/assets/scss/free/_tooltip.scss @@ -0,0 +1,10 @@ +// Tooltip + +.tooltip-inner { + color: $tooltip-inner-color; + padding: $tooltip-inner-padding-y $tooltip-inner-padding-x; + font-size: $tooltip-inner-font-size; + background-color: $tooltip-inner-background-color; + border-radius: $tooltip-inner-border-radius; + opacity: 0; +} diff --git a/projects/mdb-angular-ui-kit/assets/scss/free/forms/_form-check.scss b/projects/mdb-angular-ui-kit/assets/scss/free/forms/_form-check.scss new file mode 100644 index 00000000..64ce3302 --- /dev/null +++ b/projects/mdb-angular-ui-kit/assets/scss/free/forms/_form-check.scss @@ -0,0 +1,287 @@ +// +// Material styles for check / radio / switch +// + +.form-check { + .form-check-input { + margin-left: $form-check-input-margin-left * -1; + + &[type='radio'] { + margin-left: $form-check-input-radio-margin-left * -1; + } + } + + margin-bottom: 0; + min-height: auto; +} + +.form-check-input { + position: relative; + width: $form-check-input-width-md; + height: $form-check-input-height; + background-color: $form-check-input-background-color; + border: $form-check-input-border-width solid $form-check-input-border-color; + + &:before { + content: ''; + position: absolute; + box-shadow: $form-check-input-before-box-shadow; + border-radius: $form-check-input-before-border-radius; + width: $form-check-input-before-width; + height: $form-check-input-before-height; + background-color: transparent; + opacity: 0; + pointer-events: none; + transform: $form-check-input-before-transform; + } + + &:hover { + cursor: pointer; + + &:before { + opacity: $form-check-input-hover-before-opacity; + box-shadow: $form-check-input-hover-before-box-shadow; + } + } + + &:focus { + box-shadow: none; + border-color: $form-check-input-focus-border-color; + transition: $form-check-input-focus-transition; + + &:before { + opacity: $form-check-input-focus-before-opacity; + box-shadow: $form-check-input-focus-before-box-shadow; + transform: $form-check-input-focus-before-transform; + transition: $form-check-input-focus-before-transition; + } + } + + &:checked { + border-color: $form-check-input-checked-border-color; + + &:before { + opacity: $form-check-input-checked-before-opacity; + } + + &:after { + content: ''; + position: absolute; + } + + &:focus { + border-color: $form-check-input-checked-focus-border-color; + + &:before { + box-shadow: $form-check-input-checked-focus-before-box-shadow; + transform: $form-check-input-checked-focus-before-transform; + transition: $form-check-input-checked-focus-before-transition; + } + } + } + + &:indeterminate { + &:focus { + &:before { + box-shadow: $form-check-input-indeterminate-focus-before-box-shadow; + } + } + } + + &[type='checkbox'] { + border-radius: $form-check-input-checkbox-border-radius; + + &:focus { + &:after { + content: ''; + position: absolute; + width: $form-check-input-checkbox-focus-after-width; + height: $form-check-input-checkbox-focus-after-height; + z-index: 1; + display: block; + border-radius: 0; + background-color: $form-check-input-checkbox-focus-after-background-color; + } + } + + &:checked { + background-image: none; + background-color: $form-check-input-checkbox-checked-background-color; + + &:after { + display: block; + transform: $form-check-input-checkbox-checked-after-transform #{'/*!rtl:ignore*/'}; + border-width: $form-check-input-checkbox-checked-after-border-width; + border-color: $form-check-input-checkbox-checked-after-border-color; + width: $form-check-input-checkbox-checked-after-width; + height: $form-check-input-checkbox-checked-after-height; + border-style: solid; + border-top: 0; + border-left: 0 #{'/*!rtl:ignore*/'}; + margin-left: $form-check-input-checkbox-checked-after-margin-left; + margin-top: $form-check-input-checkbox-checked-after-margin-top; + background-color: transparent; + } + + &:focus { + background-color: $form-check-input-checkbox-checked-focus-background-color; + } + } + + &:indeterminate { + background-image: none; + background-color: transparent; + border-color: $form-check-input-indeterminate-border-color; + + &:after { + display: block; + transform: $form-check-input-indeterminate-checked-after-transform #{'/*!rtl:ignore*/'}; + border-width: $form-check-input-indeterminate-checked-after-border-width; + border-color: $form-check-input-indeterminate-checked-after-border-color; + width: $form-check-input-indeterminate-checked-after-width; + height: $form-check-input-indeterminate-checked-after-height; + border-style: solid; + border-top: 0; + border-left: 0 #{'/*!rtl:ignore*/'}; + margin-left: $form-check-input-indeterminate-checked-after-margin-left; + margin-top: 0; + } + + &:focus { + background-color: $form-check-input-indeterminate-focus-background-color; + border-color: $form-check-input-indeterminate-focus-border-color; + } + } + } + + &[type='radio'] { + border-radius: $form-check-input-radio-border-radius; + width: $form-check-input-radio-width; + height: $form-check-input-radio-height; + + &:before { + width: $form-check-input-radio-before-width; + height: $form-check-input-radio-before-height; + } + + &:after { + content: ''; + position: absolute; + width: $form-check-input-radio-after-width; + height: $form-check-input-radio-after-height; + z-index: 1; + display: block; + border-radius: $form-check-input-radio-after-border-radius; + background-color: $form-check-input-radio-after-background-color; + } + + &:checked { + background-image: none; + background-color: $form-check-input-radio-checked-background-color; + + &:after { + border-radius: $form-check-input-radio-checked-after-border-radius; + width: $form-check-input-radio-checked-after-width; + height: $form-check-input-radio-checked-after-height; + border-color: $form-check-input-radio-checked-after-border-color; + background-color: $form-check-input-radio-checked-after-background-color; + margin-top: $form-check-input-radio-checked-after-margin-top; + margin-left: $form-check-input-radio-checked-after-margin-left; + transition: $form-check-input-radio-checked-after-transition; + } + + &:focus { + background-color: $form-check-input-radio-checked-focus-background-color; + } + } + } +} + +.form-check-label { + &:hover { + cursor: pointer; + } +} + +// +// Switch +// + +.form-switch { + padding-left: $form-switch-padding-left; + + .form-check-input { + background-image: none; + border-width: 0; + border-radius: $form-switch-form-check-input-border-radius; + width: $form-switch-form-check-input-width; + height: $form-switch-form-check-input-height; + background-color: $form-switch-form-check-input-background-color; + margin-top: $form-switch-form-check-input-margin-top; + margin-right: $form-switch-form-check-input-margin-right; + + &:after { + content: ''; + position: absolute; + border: none; + z-index: 2; + border-radius: $form-switch-form-check-input-after-border-radius; + width: $form-switch-form-check-input-after-width; + height: $form-switch-form-check-input-after-height; + background-color: $form-switch-form-check-input-after-background-color; + margin-top: $form-switch-form-check-input-after-margin-top; + box-shadow: $form-switch-form-check-input-after-box-shadow; + transition: $form-switch-form-check-input-after-transition; + } + + &:focus { + background-image: none; + + &:before { + box-shadow: $form-switch-form-check-input-focus-before-box-shadow; + transform: $form-switch-form-check-input-focus-before-transform; + transition: $form-switch-form-check-input-focus-before-transition; + } + + &:after { + border-radius: $form-switch-form-check-input-focus-after-border-radius; + width: $form-switch-form-check-input-focus-after-width; + height: $form-switch-form-check-input-focus-after-height; + } + } + + &:checked { + background-image: none; + + &:focus { + background-image: none; + + &:before { + margin-left: $form-switch-form-check-input-checked-focus-before-margin-left; + box-shadow: $form-switch-form-check-input-checked-focus-before-box-shadow; + transform: $form-switch-form-check-input-checked-focus-before-transform; + transition: $form-switch-form-check-input-checked-focus-before-transition; + } + } + + &[type='checkbox'] { + background-image: none; + + &:after { + content: ''; + position: absolute; + border: none; + z-index: 2; + border-radius: $form-switch-form-check-input-checked-checkbox-after-border-radius; + width: $form-switch-form-check-input-checked-checkbox-after-width; + height: $form-switch-form-check-input-checked-checkbox-after-height; + background-color: $form-switch-form-check-input-checked-checkbox-after-background-color; + margin-top: $form-switch-form-check-input-checked-checkbox-after-margin-top; + margin-left: $form-switch-form-check-input-checked-checkbox-after-margin-left; + box-shadow: $form-switch-form-check-input-checked-checkbox-after-box-shadow; + transition: $form-switch-form-check-input-checked-checkbox-after-transition; + } + } + } + } +} diff --git a/projects/mdb-angular-ui-kit/assets/scss/free/forms/_form-control.scss b/projects/mdb-angular-ui-kit/assets/scss/free/forms/_form-control.scss new file mode 100644 index 00000000..a41c7f4c --- /dev/null +++ b/projects/mdb-angular-ui-kit/assets/scss/free/forms/_form-control.scss @@ -0,0 +1,206 @@ +// +// Material styles for form control - form outline +// + +mdb-form-control { + display: block; +} + +.form-control { + min-height: auto; + padding-top: 5.28px; + padding-bottom: 3.28px; + transition: all 0.1s linear; + &:focus { + box-shadow: none; + transition: all 0.1s linear; + border-color: #1266f1; + box-shadow: inset 0px 0px 0px 1px #1266f1; + } + &.form-control-sm { + font-size: 0.775rem; + line-height: 1.5; + } + &.form-control-lg { + line-height: 2.15; + border-radius: 0.25rem; + } +} + +.form-outline { + position: relative; + .form-control { + min-height: auto; + padding-top: $input-padding-top; + padding-bottom: $input-padding-bottom; + padding-left: $input-padding-left; + padding-right: $input-padding-right; + border: 0; + background: transparent; + transition: $input-transition; + ~ .form-label { + position: absolute; + top: 0; + left: $form-label-left; + padding-top: $form-label-padding-top; + pointer-events: none; + transform-origin: 0 0; + transition: $form-label-transition; + color: $form-label-color; + margin-bottom: 0; + } + ~ .form-notch { + display: flex; + position: absolute; + left: 0; + top: 0; + width: 100%; + max-width: 100%; + height: 100%; + text-align: left; + pointer-events: none; + div { + pointer-events: none; + border: $border-width solid; + border-color: $form-notch-div-border-color; + box-sizing: border-box; + background: transparent; + } + .form-notch-leading { + left: 0; + top: 0; + height: 100%; + width: $form-notch-leading-width; + border-right: none; + border-radius: $form-notch-leading-border-radius 0 0 $form-notch-leading-border-radius; + } + .form-notch-middle { + flex: 0 0 auto; + width: auto; + max-width: calc(100% - #{$form-notch-middle-max-width}); + height: 100%; + border-right: none; + border-left: none; + } + .form-notch-trailing { + flex-grow: 1; + height: 100%; + border-left: none; + border-radius: 0 $form-notch-trailing-border-radius $form-notch-trailing-border-radius 0; + } + } + &:not(.placeholder-active)::placeholder { + opacity: 0; + } + &:focus, + &.active { + &::placeholder { + opacity: 1; + } + } + &:focus { + box-shadow: none !important; + } + &:focus ~ .form-label, + &.active ~ .form-label { + transform: $input-focus-active-label-transform; + } + &:focus ~ .form-label { + color: $input-focus-label-color; + } + &:focus ~ .form-notch .form-notch-middle, + &.active ~ .form-notch .form-notch-middle { + border-top: none; + border-right: none; + border-left: none; + transition: $input-transition; + } + &:focus ~ .form-notch .form-notch-middle { + border-bottom: $input-focus-border-width solid; + border-color: $input-focus-border-color; + } + &:focus ~ .form-notch .form-notch-leading, + &.active ~ .form-notch .form-notch-leading { + border-right: none; + transition: $input-transition; + } + &:focus ~ .form-notch .form-notch-leading { + border-top: $input-focus-border-width solid $input-focus-border-color; + border-bottom: $input-focus-border-width solid $input-focus-border-color; + border-left: $input-focus-border-width solid $input-focus-border-color; + } + &:focus ~ .form-notch .form-notch-trailing, + &.active ~ .form-notch .form-notch-trailing { + border-left: none; + transition: $input-transition; + } + &:focus ~ .form-notch .form-notch-trailing { + border-top: $input-focus-border-width solid; + border-bottom: $input-focus-border-width solid; + border-right: $input-focus-border-width solid; + border-color: $input-focus-border-color; + } + &:disabled, + &.disabled, + &[readonly] { + background-color: $input-disabled-background-color; + } + &.form-control-lg { + font-size: $input-font-size-lg; + line-height: $input-line-height-lg; + padding-left: $input-padding-left-lg; + padding-right: $input-padding-right-lg; + ~ .form-label { + padding-top: $form-label-padding-top-lg; + } + &:focus ~ .form-label, + &.active ~ .form-label { + transform: $input-focus-active-label-transform-lg; + } + } + &.form-control-sm { + padding-left: $input-padding-left-sm; + padding-right: $input-padding-right-sm; + padding-top: $input-padding-top-sm; + padding-bottom: $input-padding-bottom-sm; + font-size: $input-font-size-sm; + line-height: $input-line-height-sm; + ~ .form-label { + padding-top: $form-label-padding-top-sm; + font-size: $form-label-font-size-sm; + } + &:focus ~ .form-label, + &.active ~ .form-label { + transform: $input-focus-active-label-transform-sm; + } + } + } + + &.form-white { + .form-control { + color: $form-white-input-color; + ~ .form-label { + color: $form-white-label-color; + } + ~ .form-notch { + div { + border-color: $form-white-notch-div-border-color; + } + } + &:focus ~ .form-label { + color: $form-white-input-focus-label-color; + } + &:focus ~ .form-notch .form-notch-middle { + border-color: $form-white-input-focus-border-color; + } + &:focus ~ .form-notch .form-notch-leading { + border-top: $input-focus-border-width solid $form-white-input-focus-border-color; + border-bottom: $input-focus-border-width solid $form-white-input-focus-border-color; + border-left: $input-focus-border-width solid $form-white-input-focus-border-color; + } + &:focus ~ .form-notch .form-notch-trailing { + border-color: $form-white-input-focus-border-color; + } + } + } +} 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 9876e788..0ac3962c 100644 --- a/projects/mdb-angular-ui-kit/assets/scss/mdb.free.scss +++ b/projects/mdb-angular-ui-kit/assets/scss/mdb.free.scss @@ -76,3 +76,11 @@ @import './free/progress'; @import './free/list-group'; @import './free/close'; +@import './free/tooltip'; +@import './free/popover'; + +// MDB FORMS +@import './free/forms/form-check'; +@import './free/forms/form-control'; + +@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 827b5461..b949565a 100644 --- a/projects/mdb-angular-ui-kit/assets/scss/mdb.scss +++ b/projects/mdb-angular-ui-kit/assets/scss/mdb.scss @@ -75,3 +75,11 @@ @import './free/progress'; @import './free/list-group'; @import './free/close'; +@import './free/tooltip'; +@import './free/popover'; + +// MDB FORMS +@import './free/forms/form-check'; +@import './free/forms/form-control'; + +@import '~@angular/cdk/overlay-prebuilt.css'; diff --git a/projects/mdb-angular-ui-kit/checkbox/checkbox.directive.spec.ts b/projects/mdb-angular-ui-kit/checkbox/checkbox.directive.spec.ts new file mode 100644 index 00000000..18cfeace --- /dev/null +++ b/projects/mdb-angular-ui-kit/checkbox/checkbox.directive.spec.ts @@ -0,0 +1,80 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, DebugElement } from '@angular/core'; +import { MdbCheckboxModule } from './index'; +import { By } from '@angular/platform-browser'; + +describe('MDB Checkbox', () => { + let checkbox: BasicCheckboxComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let checkboxInput: DebugElement; + let checkboxLabel: DebugElement; + let checkboxWrapper: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [BasicCheckboxComponent], + imports: [MdbCheckboxModule], + }); + + fixture = TestBed.createComponent(BasicCheckboxComponent); + checkbox = fixture.componentInstance; + element = fixture.elementRef.nativeElement; + checkboxInput = fixture.debugElement.query(By.css('input')); + checkboxLabel = fixture.debugElement.query(By.css('label')); + checkboxWrapper = fixture.debugElement.query(By.css('.form-check')); + }); + + it('Should be checked if checked input is set to true', () => { + checkbox.checked = true; + fixture.detectChanges(); + expect(checkboxInput.nativeElement.checked).toBe(true); + }); + + it('Should be unchecked if checked input is set to false', () => { + checkbox.checked = false; + fixture.detectChanges(); + expect(checkboxInput.nativeElement.checked).toBe(false); + }); + + it('Should be disabled if disabled input is set to true', () => { + checkbox.disabled = true; + fixture.detectChanges(); + expect(checkboxInput.nativeElement.disabled).toBe(true); + }); + + it('Should be enabled if disabled input is set to false', () => { + checkbox.disabled = false; + fixture.detectChanges(); + expect(checkboxInput.nativeElement.disabled).toBe(false); + }); + + it('Should toggle checked state when clicked', () => { + checkbox.checked = false; + fixture.detectChanges(); + checkboxInput.nativeElement.click(); + fixture.detectChanges(); + expect(checkboxInput.nativeElement.checked).toBe(true); + + checkboxInput.nativeElement.click(); + fixture.detectChanges(); + expect(checkboxInput.nativeElement.checked).toBe(false); + }); +}); + +const basicTemplate = ` +
+ + +
+`; + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'checkbox-test', + template: basicTemplate, +}) +class BasicCheckboxComponent { + checked = false; + disabled = false; +} diff --git a/projects/mdb-angular-ui-kit/checkbox/checkbox.directive.ts b/projects/mdb-angular-ui-kit/checkbox/checkbox.directive.ts new file mode 100644 index 00000000..7a5d6553 --- /dev/null +++ b/projects/mdb-angular-ui-kit/checkbox/checkbox.directive.ts @@ -0,0 +1,120 @@ +import { + EventEmitter, + forwardRef, + Input, + Output, + Directive, + HostBinding, + HostListener, +} from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; + +export const MDB_CHECKBOX_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + // tslint:disable-next-line: no-use-before-declare + useExisting: forwardRef(() => MdbCheckboxDirective), + multi: true, +}; + +export class MdbCheckboxChange { + element: MdbCheckboxDirective; + checked: boolean; +} + +@Directive({ + // tslint:disable-next-line: directive-selector + selector: '[mdbCheckbox]', + providers: [MDB_CHECKBOX_VALUE_ACCESSOR], +}) +export class MdbCheckboxDirective { + @Input('checked') + get checked(): boolean { + return this._checked; + } + set checked(value: boolean) { + this._checked = value; + } + private _checked = false; + + @Input('value') + get value(): any { + return this._value; + } + set value(value: any) { + this._value = value; + } + private _value: any = null; + + @Input('disabled') + get disabled(): boolean { + return this._disabled; + } + set disabled(value: boolean) { + this._disabled = value; + } + private _disabled = false; + + @Output() checkboxChange: EventEmitter = new EventEmitter(); + + @HostBinding('disabled') + get isDisabled(): boolean { + return this._disabled; + } + + @HostBinding('checked') + get isChecked(): boolean { + return this._checked; + } + + @HostListener('click') + onCheckboxClick(): void { + this.toggle(); + } + + @HostListener('blur') + onBlur(): void { + this.onTouched(); + } + + constructor() {} + + get changeEvent(): MdbCheckboxChange { + const newChangeEvent = new MdbCheckboxChange(); + newChangeEvent.element = this; + newChangeEvent.checked = this.checked; + return newChangeEvent; + } + + toggle(): void { + if (this.disabled) { + return; + } + this._checked = !this._checked; + this.onChange(this.checked); + } + + onCheckboxChange(): void { + this.checkboxChange.emit(this.changeEvent); + } + + // Control Value Accessor Methods + onChange = (_: any) => {}; + onTouched = () => {}; + + writeValue(value: any): void { + this.value = value; + this.checked = !!value; + } + + 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/checkbox/checkbox.module.ts b/projects/mdb-angular-ui-kit/checkbox/checkbox.module.ts new file mode 100644 index 00000000..5268ccc6 --- /dev/null +++ b/projects/mdb-angular-ui-kit/checkbox/checkbox.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +import { MdbCheckboxDirective } from './checkbox.directive'; + +@NgModule({ + declarations: [MdbCheckboxDirective], + exports: [MdbCheckboxDirective], + imports: [CommonModule, FormsModule], +}) +export class MdbCheckboxModule {} diff --git a/projects/mdb-angular-ui-kit/checkbox/index.ts b/projects/mdb-angular-ui-kit/checkbox/index.ts new file mode 100644 index 00000000..4aaf8f92 --- /dev/null +++ b/projects/mdb-angular-ui-kit/checkbox/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/projects/mdb-angular-ui-kit/checkbox/package.json b/projects/mdb-angular-ui-kit/checkbox/package.json new file mode 100644 index 00000000..07b75057 --- /dev/null +++ b/projects/mdb-angular-ui-kit/checkbox/package.json @@ -0,0 +1,11 @@ +{ + "name": "mdb-angular-ui-kit/checkbox", + "private": true, + "ngPackage": { + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "public_api.ts" + }, + "dest": "../../dist/checkbox" + } +} diff --git a/projects/mdb-angular-ui-kit/checkbox/public_api.ts b/projects/mdb-angular-ui-kit/checkbox/public_api.ts new file mode 100644 index 00000000..fcbb1357 --- /dev/null +++ b/projects/mdb-angular-ui-kit/checkbox/public_api.ts @@ -0,0 +1,6 @@ +export { + MdbCheckboxDirective, + MdbCheckboxChange, + MDB_CHECKBOX_VALUE_ACCESSOR, +} from './checkbox.directive'; +export { MdbCheckboxModule } from './checkbox.module'; diff --git a/projects/mdb-angular-ui-kit/forms/form-control.component.html b/projects/mdb-angular-ui-kit/forms/form-control.component.html new file mode 100644 index 00000000..81f48f48 --- /dev/null +++ b/projects/mdb-angular-ui-kit/forms/form-control.component.html @@ -0,0 +1,6 @@ + +
+
+
+
+
diff --git a/projects/mdb-angular-ui-kit/forms/form-control.component.ts b/projects/mdb-angular-ui-kit/forms/form-control.component.ts new file mode 100644 index 00000000..3c4b1037 --- /dev/null +++ b/projects/mdb-angular-ui-kit/forms/form-control.component.ts @@ -0,0 +1,92 @@ +import { + Component, + ChangeDetectionStrategy, + HostBinding, + ViewChild, + ContentChild, + ElementRef, + AfterViewInit, + AfterContentInit, + Renderer2, + OnDestroy, +} from '@angular/core'; +import { MdbAbstractFormControl } from './form-control'; +import { MdbInputDirective } from './input.directive'; +import { MdbLabelDirective } from './label.directive'; +import { ContentObserver } from '@angular/cdk/observers'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'mdb-form-control', + templateUrl: './form-control.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MdbFormControlComponent implements AfterViewInit, AfterContentInit, OnDestroy { + @ViewChild('notchLeading', { static: true }) _notchLeading: ElementRef; + @ViewChild('notchMiddle', { static: true }) _notchMiddle: ElementRef; + @ContentChild(MdbInputDirective, { static: true, read: ElementRef }) _input: ElementRef; + @ContentChild(MdbAbstractFormControl, { static: true }) _formControl: MdbAbstractFormControl; + @ContentChild(MdbLabelDirective, { static: true, read: ElementRef }) _label: ElementRef; + + @HostBinding('class.form-outline') outline = true; + + constructor(private _renderer: Renderer2, private _contentObserver: ContentObserver) {} + + readonly _destroy$: Subject = new Subject(); + + private _notchLeadingLength = 9; + private _labelMarginLeft = 0; + private _labelGapPadding = 8; + private _labelScale = 0.8; + + ngAfterViewInit(): void {} + + ngAfterContentInit(): void { + this._updateBorderGap(); + this._updateLabelActiveState(); + + this._contentObserver + .observe(this._label.nativeElement) + .pipe(takeUntil(this._destroy$)) + .subscribe(() => { + this._updateBorderGap(); + }); + + this._formControl.stateChanges.pipe(takeUntil(this._destroy$)).subscribe(() => { + this._updateLabelActiveState(); + this._updateBorderGap(); + }); + } + + ngOnDestroy(): void { + this._destroy$.next(); + this._destroy$.unsubscribe(); + } + + private _getLabelWidth(): number { + return this._label.nativeElement.clientWidth * this._labelScale + this._labelGapPadding; + } + + private _updateBorderGap(): void { + const notchLeadingWidth = `${this._labelMarginLeft + this._notchLeadingLength}px`; + const notchMiddleWidth = `${this._getLabelWidth()}px`; + + this._renderer.setStyle(this._notchLeading.nativeElement, 'width', notchLeadingWidth); + this._renderer.setStyle(this._notchMiddle.nativeElement, 'width', notchMiddleWidth); + this._renderer.setStyle(this._label.nativeElement, 'margin-left', `${this._labelMarginLeft}px`); + } + + private _updateLabelActiveState(): void { + if (this._isLabelActive()) { + this._renderer.addClass(this._input.nativeElement, 'active'); + } else { + this._renderer.removeClass(this._input.nativeElement, 'active'); + } + } + + private _isLabelActive(): boolean { + return this._formControl && this._formControl.labelActive; + } +} diff --git a/projects/mdb-angular-ui-kit/forms/form-control.spec.ts b/projects/mdb-angular-ui-kit/forms/form-control.spec.ts new file mode 100644 index 00000000..5eb1f26f --- /dev/null +++ b/projects/mdb-angular-ui-kit/forms/form-control.spec.ts @@ -0,0 +1,52 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { MdbFormsModule } from './index'; +import { MdbFormControlComponent } from './form-control.component'; + +describe('MDB Checkbox', () => { + let component: BasicFormControlComponent; + let fixture: ComponentFixture; + let wrapper: DebugElement; + let input: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [BasicFormControlComponent], + imports: [MdbFormsModule], + }); + + fixture = TestBed.createComponent(BasicFormControlComponent); + component = fixture.componentInstance; + wrapper = fixture.debugElement.query(By.directive(MdbFormControlComponent)); + input = fixture.debugElement.query(By.css('input')); + }); + + it('Should add outline class to the wrapper element', () => { + fixture.detectChanges(); + expect(wrapper.nativeElement.classList.contains('form-outline')).toBe(true); + }); + + it('Should toggle input active class on value change', () => { + input.nativeElement.value = 'Test'; + fixture.detectChanges(); + expect(input.nativeElement.classList.contains('active')).toBe(true); + }); +}); + +const basicTemplate = ` + + + + +`; + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'form-control-test', + template: basicTemplate, +}) +class BasicFormControlComponent { + disabled = false; + readonly = false; +} diff --git a/projects/mdb-angular-ui-kit/forms/form-control.ts b/projects/mdb-angular-ui-kit/forms/form-control.ts new file mode 100644 index 00000000..18e1265c --- /dev/null +++ b/projects/mdb-angular-ui-kit/forms/form-control.ts @@ -0,0 +1,9 @@ +import { Observable } from 'rxjs'; +import { Directive } from '@angular/core'; + +@Directive() +// tslint:disable-next-line: directive-class-suffix +export abstract class MdbAbstractFormControl { + readonly stateChanges: Observable; + readonly labelActive: boolean; +} diff --git a/projects/mdb-angular-ui-kit/forms/forms.module.ts b/projects/mdb-angular-ui-kit/forms/forms.module.ts new file mode 100644 index 00000000..69d9a85e --- /dev/null +++ b/projects/mdb-angular-ui-kit/forms/forms.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +import { MdbFormControlComponent } from './form-control.component'; +import { MdbInputDirective } from './input.directive'; +import { MdbLabelDirective } from './label.directive'; + +@NgModule({ + declarations: [MdbFormControlComponent, MdbInputDirective, MdbLabelDirective], + exports: [MdbFormControlComponent, MdbInputDirective, MdbLabelDirective], + imports: [CommonModule, FormsModule], +}) +export class MdbFormsModule {} diff --git a/projects/mdb-angular-ui-kit/forms/index.ts b/projects/mdb-angular-ui-kit/forms/index.ts new file mode 100644 index 00000000..4aaf8f92 --- /dev/null +++ b/projects/mdb-angular-ui-kit/forms/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/projects/mdb-angular-ui-kit/forms/input.directive.ts b/projects/mdb-angular-ui-kit/forms/input.directive.ts new file mode 100644 index 00000000..646383ac --- /dev/null +++ b/projects/mdb-angular-ui-kit/forms/input.directive.ts @@ -0,0 +1,78 @@ +import { Directive, ElementRef, HostBinding, HostListener, Input, Renderer2 } from '@angular/core'; +import { Subject } from 'rxjs'; +import { MdbAbstractFormControl } from './form-control'; + +@Directive({ + // tslint:disable-next-line: directive-selector + selector: '[mdbInput]', + exportAs: 'mdbInput', + providers: [{ provide: MdbAbstractFormControl, useExisting: MdbInputDirective }], +}) +// tslint:disable-next-line: component-class-suffix +export class MdbInputDirective implements MdbAbstractFormControl { + constructor(private _elementRef: ElementRef, private _renderer: Renderer2) {} + + readonly stateChanges: Subject = new Subject(); + + private _focused = false; + + @HostBinding('disabled') + @Input('disabled') + get disabled(): boolean { + return this._disabled; + } + set disabled(value: boolean) { + this._disabled = value; + } + private _disabled = false; + + @Input('readonly') + get readonly(): boolean { + return this._readonly; + } + set readonly(value: boolean) { + if (value) { + this._renderer.setAttribute(this._elementRef.nativeElement, 'readonly', ''); + } else { + this._renderer.removeAttribute(this._elementRef.nativeElement, 'readonly'); + } + this._readonly = value; + } + private _readonly = false; + + @Input() + get value(): string { + return this._elementRef.nativeElement.value; + } + set value(value: string) { + if (value !== this.value) { + this._elementRef.nativeElement.value = value; + this.stateChanges.next(); + } + } + private _value: any; + + @HostListener('focus') + _onFocus(): void { + this._focused = true; + this.stateChanges.next(); + } + + @HostListener('blur') + _onBlur(): void { + this._focused = false; + this.stateChanges.next(); + } + + get hasValue(): boolean { + return this._elementRef.nativeElement.value !== ''; + } + + get focused(): boolean { + return this._focused; + } + + get labelActive(): boolean { + return this.focused || this.hasValue; + } +} diff --git a/projects/mdb-angular-ui-kit/forms/input.spec.ts b/projects/mdb-angular-ui-kit/forms/input.spec.ts new file mode 100644 index 00000000..aca3571d --- /dev/null +++ b/projects/mdb-angular-ui-kit/forms/input.spec.ts @@ -0,0 +1,47 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { MdbFormsModule } from './index'; + +describe('MDB Checkbox', () => { + let component: BasicInputComponent; + let fixture: ComponentFixture; + let input: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [BasicInputComponent], + imports: [MdbFormsModule], + }); + + fixture = TestBed.createComponent(BasicInputComponent); + component = fixture.componentInstance; + input = fixture.debugElement.query(By.css('input')); + }); + + it('Should be disabled if disabled input is set to true', () => { + component.disabled = true; + fixture.detectChanges(); + expect(input.nativeElement.disabled).toBe(true); + }); + + it('Should be readonly if readonly input is set to true', () => { + component.readonly = true; + fixture.detectChanges(); + expect(input.nativeElement.hasAttribute('readonly')).toBe(true); + }); +}); + +const basicTemplate = ` + +`; + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'input-test', + template: basicTemplate, +}) +class BasicInputComponent { + disabled = false; + readonly = false; +} diff --git a/projects/mdb-angular-ui-kit/forms/label.directive.ts b/projects/mdb-angular-ui-kit/forms/label.directive.ts new file mode 100644 index 00000000..0312a2a6 --- /dev/null +++ b/projects/mdb-angular-ui-kit/forms/label.directive.ts @@ -0,0 +1,11 @@ +import { Directive, ElementRef } from '@angular/core'; + +@Directive({ + // tslint:disable-next-line: directive-selector + selector: '[mdbLabel]', + exportAs: 'mdbLabel', +}) +// tslint:disable-next-line: component-class-suffix +export class MdbLabelDirective { + constructor() {} +} diff --git a/projects/mdb-angular-ui-kit/forms/package.json b/projects/mdb-angular-ui-kit/forms/package.json new file mode 100644 index 00000000..8bc86d79 --- /dev/null +++ b/projects/mdb-angular-ui-kit/forms/package.json @@ -0,0 +1,11 @@ +{ + "name": "mdb-angular-ui-kit/forms", + "private": true, + "ngPackage": { + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "public_api.ts" + }, + "dest": "../../dist/forms" + } +} diff --git a/projects/mdb-angular-ui-kit/forms/public_api.ts b/projects/mdb-angular-ui-kit/forms/public_api.ts new file mode 100644 index 00000000..5d997b91 --- /dev/null +++ b/projects/mdb-angular-ui-kit/forms/public_api.ts @@ -0,0 +1,4 @@ +export { MdbFormControlComponent } from './form-control.component'; +export { MdbInputDirective } from './input.directive'; +export { MdbLabelDirective } from './label.directive'; +export { MdbFormsModule } from './forms.module'; diff --git a/projects/mdb-angular-ui-kit/index.ts b/projects/mdb-angular-ui-kit/index.ts index dde35d4b..40f5c137 100644 --- a/projects/mdb-angular-ui-kit/index.ts +++ b/projects/mdb-angular-ui-kit/index.ts @@ -3,10 +3,42 @@ import { NgModule } from '@angular/core'; // MDB Angular UI Kit Free Modules import { MdbCollapseModule } from './collapse/collapse.module'; +import { MdbCheckboxModule } from './checkbox/checkbox.module'; +import { MdbRadioModule } from './radio/radio.module'; +import { MdbTooltipModule } from './tooltip/tooltip.module'; +import { MdbPopoverModule } from './popover/popover.module'; +import { MdbFormsModule } from './forms/forms.module'; export { MdbCollapseDirective, MdbCollapseModule } from './collapse/index'; +export { + MdbCheckboxDirective, + MdbCheckboxModule, + MdbCheckboxChange, + MDB_CHECKBOX_VALUE_ACCESSOR, +} from './checkbox/index'; +export { + MdbRadioDirective, + MdbRadioGroupDirective, + MdbRadioModule, + MDB_RADIO_GROUP_VALUE_ACCESSOR, +} from './radio/index'; +export { MdbTooltipDirective, MdbTooltipModule, MdbTooltipPosition } from './tooltip/index'; +export { MdbPopoverDirective, MdbPopoverModule, MdbPopoverPosition } from './popover/index'; +export { + MdbFormControlComponent, + MdbInputDirective, + MdbLabelDirective, + MdbFormsModule, +} from './forms/index'; -const MDB_MODULES = [MdbCollapseModule]; +const MDB_MODULES = [ + MdbCollapseModule, + MdbCheckboxModule, + MdbRadioModule, + MdbTooltipModule, + MdbPopoverModule, + MdbFormsModule, +]; @NgModule({ declarations: [], diff --git a/projects/mdb-angular-ui-kit/package.json b/projects/mdb-angular-ui-kit/package.json index 59c9bd52..769c1849 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-alpha1", + "version": "1.0.0-alpha2", "peerDependencies": { "@angular/common": "^11.0.0", "@angular/core": "^11.0.0", diff --git a/projects/mdb-angular-ui-kit/popover/index.ts b/projects/mdb-angular-ui-kit/popover/index.ts new file mode 100644 index 00000000..4aaf8f92 --- /dev/null +++ b/projects/mdb-angular-ui-kit/popover/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/projects/mdb-angular-ui-kit/popover/package.json b/projects/mdb-angular-ui-kit/popover/package.json new file mode 100644 index 00000000..4ea6a26a --- /dev/null +++ b/projects/mdb-angular-ui-kit/popover/package.json @@ -0,0 +1,11 @@ +{ + "name": "mdb-angular-ui-kit/popover", + "private": true, + "ngPackage": { + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "public_api.ts" + }, + "dest": "../../dist/popover" + } +} diff --git a/projects/mdb-angular-ui-kit/popover/popover.component.html b/projects/mdb-angular-ui-kit/popover/popover.component.html new file mode 100644 index 00000000..3d148861 --- /dev/null +++ b/projects/mdb-angular-ui-kit/popover/popover.component.html @@ -0,0 +1,14 @@ +
+
+ {{ title }} +
+
+
+ {{ content }} +
+
diff --git a/projects/mdb-angular-ui-kit/popover/popover.component.ts b/projects/mdb-angular-ui-kit/popover/popover.component.ts new file mode 100644 index 00000000..38e79b6e --- /dev/null +++ b/projects/mdb-angular-ui-kit/popover/popover.component.ts @@ -0,0 +1,47 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnInit, +} from '@angular/core'; +import { trigger, style, animate, transition, state, AnimationEvent } from '@angular/animations'; +import { Subject } from 'rxjs'; +@Component({ + // tslint:disable-next-line: component-selector + selector: 'mdb-popover', + templateUrl: 'popover.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger('fade', [ + state('visible', style({ opacity: 1 })), + state('hidden', style({ opacity: 0 })), + transition('visible <=> hidden', animate('150ms linear')), + transition(':enter', animate('150ms linear')), + ]), + ], +}) +export class MdbPopoverComponent implements OnInit { + @Input() title: string; + @Input() content: string; + @Input() template: boolean; + @Input() animation: boolean; + + readonly _hidden: Subject = new Subject(); + + animationState = 'hidden'; + + constructor(private _cdRef: ChangeDetectorRef) {} + + ngOnInit(): void {} + + markForCheck(): void { + this._cdRef.markForCheck(); + } + + onAnimationEnd(event: AnimationEvent): void { + if (event.toState === 'hidden') { + this._hidden.next(); + } + } +} diff --git a/projects/mdb-angular-ui-kit/popover/popover.directive.spec.ts b/projects/mdb-angular-ui-kit/popover/popover.directive.spec.ts new file mode 100644 index 00000000..0ff07011 --- /dev/null +++ b/projects/mdb-angular-ui-kit/popover/popover.directive.spec.ts @@ -0,0 +1,206 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { MdbPopoverModule } from './index'; +import { MdbPopoverDirective } from './popover.directive'; +import { MdbPopoverComponent } from './popover.component'; +import { By } from '@angular/platform-browser'; +import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +describe('MDB Popover', () => { + describe('after init', () => { + let fixture: ComponentFixture; + let element: any; + let component: any; + let directive: any; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MdbPopoverModule, BrowserAnimationsModule], + declarations: [TestPopoverComponent], + }).overrideModule(BrowserDynamicTestingModule, { + set: { + entryComponents: [MdbPopoverComponent], + }, + }); + fixture = TestBed.createComponent(TestPopoverComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should open tooltip after mouseenter and close after mouseout', () => { + fixture.detectChanges(); + + directive = fixture.debugElement + .query(By.directive(MdbPopoverDirective)) + .injector.get(MdbPopoverDirective) as MdbPopoverDirective; + + const onOpen = spyOn(directive, 'show'); + const onClose = spyOn(directive, 'hide'); + + const buttonEl = element.querySelector('button'); + + buttonEl.dispatchEvent(new Event('mouseenter')); + fixture.detectChanges(); + + expect(directive.show).toHaveBeenCalled(); + + directive._open = true; + buttonEl.dispatchEvent(new Event('mouseleave')); + fixture.detectChanges(); + + expect(directive.hide).toHaveBeenCalled(); + }); + + it('should set popover header and title', () => { + jest.useFakeTimers(); + const buttonEl = fixture.nativeElement.querySelector('button'); + + buttonEl.dispatchEvent(new Event('mouseenter')); + jest.runAllTimers(); + + fixture.detectChanges(); + const popoverContent = document.querySelector('.popover-body').textContent; + const popoverTitle = document.querySelector('.popover-header').textContent; + + expect(popoverContent).toMatch(component.testMdbPopover); + expect(popoverTitle).toMatch(component.testMdbPopoverTitle); + }); + + it('should set placement', () => { + jest.useFakeTimers(); + const buttonEl = fixture.nativeElement.querySelector('button'); + + buttonEl.dispatchEvent(new Event('mouseenter')); + jest.runAllTimers(); + + fixture.detectChanges(); + directive = fixture.debugElement + .query(By.directive(MdbPopoverDirective)) + .injector.get(MdbPopoverDirective) as MdbPopoverDirective; + + const placement = directive._overlayRef._config.positionStrategy._lastPosition.originY; + expect(placement).toMatch('top'); + }); + }); + + describe('onInit', () => { + it('should open/close tooltip after click', () => { + let fixture: ComponentFixture; + let directive: any; + let component: any; + let element: any; + + TestBed.configureTestingModule({ + imports: [MdbPopoverModule, BrowserAnimationsModule], + declarations: [TestPopoverComponent2], + }).overrideModule(BrowserDynamicTestingModule, { + set: { + entryComponents: [MdbPopoverComponent], + }, + }); + fixture = TestBed.createComponent(TestPopoverComponent2); + component = fixture.componentInstance; + element = fixture.nativeElement; + fixture.detectChanges(); + + directive = fixture.debugElement + .query(By.directive(MdbPopoverDirective)) + .injector.get(MdbPopoverDirective) as MdbPopoverDirective; + + const onOpen = spyOn(directive, 'show'); + const onClose = spyOn(directive, 'hide'); + + const buttonEl = fixture.nativeElement.querySelector('button'); + + buttonEl.dispatchEvent(new Event('click')); + fixture.detectChanges(); + + expect(directive.show).toHaveBeenCalled(); + + directive._open = true; + buttonEl.dispatchEvent(new Event('click')); + fixture.detectChanges(); + + expect(directive.hide).toHaveBeenCalled(); + }); + + it('should prevent open', () => { + let fixture: ComponentFixture; + let directive: any; + let component: any; + let element: any; + + TestBed.configureTestingModule({ + imports: [MdbPopoverModule, BrowserAnimationsModule], + declarations: [TestPopoverComponent3], + }).overrideModule(BrowserDynamicTestingModule, { + set: { + entryComponents: [MdbPopoverComponent], + }, + }); + fixture = TestBed.createComponent(TestPopoverComponent3); + component = fixture.componentInstance; + element = fixture.nativeElement; + fixture.detectChanges(); + + directive = fixture.debugElement + .query(By.directive(MdbPopoverDirective)) + .injector.get(MdbPopoverDirective) as MdbPopoverDirective; + + const onOpen = spyOn(directive, 'show'); + + const buttonEl = fixture.nativeElement.querySelector('button'); + + buttonEl.dispatchEvent(new Event('click')); + fixture.detectChanges(); + + expect(directive.show).not.toHaveBeenCalled(); + }); + }); +}); + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'test-tooltip', + template: ` `, +}) +// tslint:disable-next-line: component-class-suffix +class TestPopoverComponent { + testTrigger = 'hover'; + testMdbPopover = 'popoverTitle'; + testMdbPopoverTitle = 'popoverTitle'; + testPlacement = 'top'; + testDisabled = false; +} + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'test-popover2', + template: ` `, +}) +// tslint:disable-next-line: component-class-suffix +class TestPopoverComponent2 {} + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'test-popover2', + template: ` `, +}) +// tslint:disable-next-line: component-class-suffix +class TestPopoverComponent3 {} diff --git a/projects/mdb-angular-ui-kit/popover/popover.directive.ts b/projects/mdb-angular-ui-kit/popover/popover.directive.ts new file mode 100644 index 00000000..02fcbe48 --- /dev/null +++ b/projects/mdb-angular-ui-kit/popover/popover.directive.ts @@ -0,0 +1,226 @@ +import { + ComponentRef, + Directive, + ElementRef, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, +} from '@angular/core'; +import { + ConnectedPosition, + Overlay, + OverlayConfig, + OverlayPositionBuilder, + OverlayRef, +} from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { MdbPopoverComponent } from './popover.component'; +import { fromEvent, Subject } from 'rxjs'; +import { first, takeUntil } from 'rxjs/operators'; + +@Directive({ + // tslint:disable-next-line: directive-selector + selector: '[mdbPopover]', + exportAs: 'mdbPopover', +}) +// tslint:disable-next-line:component-class-suffix +export class MdbPopoverDirective implements OnInit, OnDestroy { + @Input() mdbPopover = ''; + @Input() mdbPopoverTitle = ''; + @Input() popoverDisabled = false; + @Input() placement = 'top'; + @Input() template = false; + @Input() animation = true; + @Input() trigger = 'click'; + @Input() delayShow = 0; + @Input() delayHide = 0; + @Input() offset = 4; + + @Output() popoverShow: EventEmitter = new EventEmitter(); + @Output() popoverShown: EventEmitter = new EventEmitter(); + @Output() popoverHide: EventEmitter = new EventEmitter(); + @Output() popoverHidden: EventEmitter = new EventEmitter(); + + private _overlayRef: OverlayRef; + private _tooltipRef: ComponentRef; + private _open = false; + private _showTimeout: any = 0; + private _hideTimeout: any = 0; + + readonly _destroy$: Subject = new Subject(); + + constructor( + private _overlay: Overlay, + private _overlayPositionBuilder: OverlayPositionBuilder, + private _elementRef: ElementRef + ) {} + + ngOnInit(): void { + if (this.popoverDisabled) { + return; + } + + this._bindTriggerEvents(); + this._createOverlay(); + } + + ngOnDestroy(): void { + this._destroy$.next(); + this._destroy$.unsubscribe(); + } + + private _bindTriggerEvents(): void { + const triggers = this.trigger.split(' '); + + triggers.forEach((trigger) => { + if (trigger === 'click') { + fromEvent(this._elementRef.nativeElement, trigger) + .pipe(takeUntil(this._destroy$)) + .subscribe(() => this.toggle()); + } else if (trigger !== 'manual') { + const evIn = trigger === 'hover' ? 'mouseenter' : 'focusin'; + const evOut = trigger === 'hover' ? 'mouseleave' : 'focusout'; + + fromEvent(this._elementRef.nativeElement, evIn) + .pipe(takeUntil(this._destroy$)) + .subscribe(() => this.show()); + fromEvent(this._elementRef.nativeElement, evOut) + .pipe(takeUntil(this._destroy$)) + .subscribe(() => this.hide()); + } + }); + } + + private _createOverlayConfig(): OverlayConfig { + const positionStrategy = this._overlayPositionBuilder + .flexibleConnectedTo(this._elementRef) + .withPositions(this._getPosition()); + const overlayConfig = new OverlayConfig({ + hasBackdrop: false, + scrollStrategy: this._overlay.scrollStrategies.reposition(), + positionStrategy, + }); + + return overlayConfig; + } + + private _createOverlay(): void { + this._overlayRef = this._overlay.create(this._createOverlayConfig()); + } + + private _getPosition(): ConnectedPosition[] { + let position; + + const positionTop = { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: -this.offset, + }; + + const positionBottom = { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetY: this.offset, + }; + + const positionRight = { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: this.offset, + }; + + const positionLeft = { + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + offsetX: -this.offset, + }; + + switch (this.placement) { + case 'top': + position = [positionTop, positionBottom]; + break; + case 'bottom': + position = [positionBottom, positionTop]; + break; + case 'left': + position = [positionLeft, positionRight, positionTop, positionBottom]; + break; + case 'right': + position = [positionRight, positionLeft, positionTop, positionBottom]; + break; + default: + break; + } + + return position; + } + + show(): void { + if (this._open) { + this._overlayRef.detach(); + } + + if (this._hideTimeout) { + clearTimeout(this._hideTimeout); + this._hideTimeout = null; + } + + this._showTimeout = setTimeout(() => { + const tooltipPortal = new ComponentPortal(MdbPopoverComponent); + + this.popoverShow.emit(this); + this._open = true; + + this._tooltipRef = this._overlayRef.attach(tooltipPortal); + this._tooltipRef.instance.content = this.mdbPopover; + this._tooltipRef.instance.title = this.mdbPopoverTitle; + this._tooltipRef.instance.template = this.template; + this._tooltipRef.instance.animation = this.animation; + this._tooltipRef.instance.animationState = 'visible'; + + this._tooltipRef.instance.markForCheck(); + + this.popoverShown.emit(this); + }, this.delayShow); + } + + hide(): void { + if (!this._open) { + return; + } + + if (this._showTimeout) { + clearTimeout(this._showTimeout); + this._showTimeout = null; + } + + this._hideTimeout = setTimeout(() => { + this.popoverHide.emit(this); + this._tooltipRef.instance._hidden.pipe(first()).subscribe(() => { + this._overlayRef.detach(); + this._open = false; + this.popoverShown.emit(this); + }); + this._tooltipRef.instance.animationState = 'hidden'; + this._tooltipRef.instance.markForCheck(); + }, this.delayHide); + } + + toggle(): void { + if (this._open) { + this.hide(); + } else { + this.show(); + } + } +} diff --git a/projects/mdb-angular-ui-kit/popover/popover.module.ts b/projects/mdb-angular-ui-kit/popover/popover.module.ts new file mode 100644 index 00000000..e5315adc --- /dev/null +++ b/projects/mdb-angular-ui-kit/popover/popover.module.ts @@ -0,0 +1,12 @@ +import { MdbPopoverDirective } from './popover.directive'; +import { NgModule } from '@angular/core'; +import { OverlayModule } from '@angular/cdk/overlay'; +import { CommonModule } from '@angular/common'; +import { MdbPopoverComponent } from './popover.component'; + +@NgModule({ + imports: [CommonModule, OverlayModule], + declarations: [MdbPopoverDirective, MdbPopoverComponent], + exports: [MdbPopoverDirective, MdbPopoverComponent], +}) +export class MdbPopoverModule {} diff --git a/projects/mdb-angular-ui-kit/popover/popover.types.ts b/projects/mdb-angular-ui-kit/popover/popover.types.ts new file mode 100644 index 00000000..4648f6bf --- /dev/null +++ b/projects/mdb-angular-ui-kit/popover/popover.types.ts @@ -0,0 +1 @@ +export type MdbPopoverPosition = 'top' | 'right' | 'bottom' | 'left'; diff --git a/projects/mdb-angular-ui-kit/popover/public_api.ts b/projects/mdb-angular-ui-kit/popover/public_api.ts new file mode 100644 index 00000000..a83047bf --- /dev/null +++ b/projects/mdb-angular-ui-kit/popover/public_api.ts @@ -0,0 +1,3 @@ +export { MdbPopoverDirective } from './popover.directive'; +export { MdbPopoverModule } from './popover.module'; +export { MdbPopoverPosition } from './popover.types'; diff --git a/projects/mdb-angular-ui-kit/radio/index.ts b/projects/mdb-angular-ui-kit/radio/index.ts new file mode 100644 index 00000000..4aaf8f92 --- /dev/null +++ b/projects/mdb-angular-ui-kit/radio/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/projects/mdb-angular-ui-kit/radio/package.json b/projects/mdb-angular-ui-kit/radio/package.json new file mode 100644 index 00000000..ca66a9f9 --- /dev/null +++ b/projects/mdb-angular-ui-kit/radio/package.json @@ -0,0 +1,11 @@ +{ + "name": "mdb-angular-ui-kit/radio", + "private": true, + "ngPackage": { + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "public_api.ts" + }, + "dest": "../../dist/radio" + } +} diff --git a/projects/mdb-angular-ui-kit/radio/public_api.ts b/projects/mdb-angular-ui-kit/radio/public_api.ts new file mode 100644 index 00000000..67dd43dd --- /dev/null +++ b/projects/mdb-angular-ui-kit/radio/public_api.ts @@ -0,0 +1,3 @@ +export { MdbRadioDirective } from './radio-button.directive'; +export { MdbRadioGroupDirective, MDB_RADIO_GROUP_VALUE_ACCESSOR } from './radio-group.directive'; +export { MdbRadioModule } from './radio.module'; diff --git a/projects/mdb-angular-ui-kit/radio/radio-button.directive.ts b/projects/mdb-angular-ui-kit/radio/radio-button.directive.ts new file mode 100644 index 00000000..8577ce74 --- /dev/null +++ b/projects/mdb-angular-ui-kit/radio/radio-button.directive.ts @@ -0,0 +1,72 @@ +import { Directive, HostBinding, Input } from '@angular/core'; + +@Directive({ + // tslint:disable-next-line: directive-selector + selector: '[mdbRadio]', +}) +export class MdbRadioDirective { + @Input() + get name(): string { + return this._name; + } + set name(value: string) { + this._name = value; + } + private _name: string; + + @Input('checked') + get checked(): boolean { + return this._checked; + } + set checked(value: boolean) { + this._checked = value; + } + private _checked = false; + + @Input('value') + get value(): any { + return this._value; + } + set value(value: any) { + this._value = value; + } + private _value: any = null; + + @Input('disabled') + get disabled(): boolean { + return this._disabled; + } + set disabled(value: boolean) { + this._disabled = value; + } + private _disabled = false; + + @HostBinding('disabled') + get isDisabled(): boolean { + return this._disabled; + } + + @HostBinding('checked') + get isChecked(): boolean { + return this._checked; + } + + @HostBinding('attr.name') + get nameAttr(): string { + return this.name; + } + + constructor() {} + + _updateName(value: string): void { + this._name = value; + } + + _updateChecked(value: boolean): void { + this._checked = value; + } + + _updateDisabledState(value: boolean): void { + this._disabled = value; + } +} diff --git a/projects/mdb-angular-ui-kit/radio/radio-group.directive.spec.ts b/projects/mdb-angular-ui-kit/radio/radio-group.directive.spec.ts new file mode 100644 index 00000000..685e4877 --- /dev/null +++ b/projects/mdb-angular-ui-kit/radio/radio-group.directive.spec.ts @@ -0,0 +1,75 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { MdbRadioModule } from './index'; + +describe('MDB Checkbox', () => { + let component: BasicRadioGroupComponent; + let fixture: ComponentFixture; + let nativeElement: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [BasicRadioGroupComponent], + imports: [MdbRadioModule], + }); + + fixture = TestBed.createComponent(BasicRadioGroupComponent); + component = fixture.componentInstance; + nativeElement = fixture.elementRef.nativeElement; + }); + + it('Should disable all inputs if the group is disabled', () => { + component.disabled = true; + fixture.detectChanges(); + const inputs = nativeElement.querySelectorAll('input'); + + expect(inputs[0].disabled).toBe(true); + expect(inputs[1].disabled).toBe(true); + }); + + it('Should set inputs name to the group name', () => { + component.name = 'test name'; + fixture.detectChanges(); + const inputs = nativeElement.querySelectorAll('input'); + + expect(inputs[0].getAttribute('name')).toBe('test name'); + expect(inputs[1].getAttribute('name')).toBe('test name'); + }); +}); + +const basicTemplate = ` +
+
+ + +
+ +
+ + +
+
+`; + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'radio-group-test', + template: basicTemplate, +}) +class BasicRadioGroupComponent { + checked = false; + name = 'mdb-radio-group'; + disabled = false; +} diff --git a/projects/mdb-angular-ui-kit/radio/radio-group.directive.ts b/projects/mdb-angular-ui-kit/radio/radio-group.directive.ts new file mode 100644 index 00000000..8bd4aa45 --- /dev/null +++ b/projects/mdb-angular-ui-kit/radio/radio-group.directive.ts @@ -0,0 +1,128 @@ +import { + AfterContentInit, + ContentChildren, + Directive, + forwardRef, + Input, + OnDestroy, + QueryList, +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { from, Subject } from 'rxjs'; +import { startWith, switchMap, takeUntil } from 'rxjs/operators'; +import { MdbRadioDirective } from './radio-button.directive'; + +export const MDB_RADIO_GROUP_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + // tslint:disable-next-line: no-use-before-declare + useExisting: forwardRef(() => MdbRadioGroupDirective), + multi: true, +}; + +@Directive({ + // tslint:disable-next-line: directive-selector + selector: '[mdbRadioGroup]', + providers: [MDB_RADIO_GROUP_VALUE_ACCESSOR], +}) +export class MdbRadioGroupDirective implements ControlValueAccessor, AfterContentInit, OnDestroy { + @ContentChildren(MdbRadioDirective, { descendants: true }) radios: QueryList; + + @Input() + get value(): any { + return this._value; + } + set value(value: any) { + this._value = value; + if (this.radios) { + this._updateChecked(); + } + } + private _value: any; + + @Input() + get name(): string { + return this._name; + } + set name(name: string) { + this._name = name; + if (this.radios) { + this._updateNames(); + } + } + private _name: string; + + @Input() + get disabled(): boolean { + return this._disabled; + } + set disabled(disabled: boolean) { + this._disabled = disabled; + + if (this.radios) { + this._updateDisabled(); + } + } + private _disabled = false; + + private _destroy$ = new Subject(); + + onChange = (_: any) => {}; + onTouched = () => {}; + + ngAfterContentInit(): void { + this._updateNames(); + this._updateDisabled(); + + this.radios.changes + .pipe( + startWith(this.radios), + switchMap((radios: QueryList) => from(Promise.resolve(radios))), + takeUntil(this._destroy$) + ) + .subscribe(() => this._updateRadiosState()); + } + + ngOnDestroy(): void { + this._destroy$.next(); + this._destroy$.complete(); + } + + private _updateRadiosState(): void { + this._updateNames(); + this._updateChecked(); + this._updateDisabled(); + } + + private _updateNames(): void { + this.radios.forEach((radio: MdbRadioDirective) => radio._updateName(this.name)); + } + + private _updateChecked(): void { + this.radios.forEach((radio: MdbRadioDirective) => { + const isChecked = radio.value === this._value; + radio._updateChecked(isChecked); + }); + } + + private _updateDisabled(): void { + this.radios.forEach((radio: MdbRadioDirective) => radio._updateDisabledState(this._disabled)); + } + + // Control value accessor methods + registerOnChange(fn: (value: any) => any): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this._disabled = isDisabled; + this._updateDisabled(); + } + + writeValue(value: any): void { + this.value = value; + } +} diff --git a/projects/mdb-angular-ui-kit/radio/radio.directive.spec.ts b/projects/mdb-angular-ui-kit/radio/radio.directive.spec.ts new file mode 100644 index 00000000..ab9faa44 --- /dev/null +++ b/projects/mdb-angular-ui-kit/radio/radio.directive.spec.ts @@ -0,0 +1,72 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { MdbRadioModule } from './index'; + +describe('MDB Checkbox', () => { + let component: BasicRadioComponent; + let fixture: ComponentFixture; + let nativeElement: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [BasicRadioComponent], + imports: [MdbRadioModule], + }); + + fixture = TestBed.createComponent(BasicRadioComponent); + component = fixture.componentInstance; + nativeElement = fixture.elementRef.nativeElement; + }); + + it('Should disable input element if disabled is set to true', () => { + component.disabled = true; + fixture.detectChanges(); + const input = nativeElement.querySelector('input'); + + expect(input.hasAttribute('disabled')).toBe(true); + }); + + it('Should correctly update name attribute', () => { + component.name = 'test name'; + fixture.detectChanges(); + + const input = nativeElement.querySelector('input'); + + expect(input.getAttribute('name')).toBe('test name'); + }); + + it('Should correctly update input checked state', () => { + component.checked = true; + fixture.detectChanges(); + + const input = nativeElement.querySelector('input'); + + expect(input.checked).toBe(true); + }); +}); + +const basicTemplate = ` +
+ + +
+`; + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'radio-test', + template: basicTemplate, +}) +class BasicRadioComponent { + checked = false; + name = 'mdb-radio'; + disabled = false; +} diff --git a/projects/mdb-angular-ui-kit/radio/radio.module.ts b/projects/mdb-angular-ui-kit/radio/radio.module.ts new file mode 100644 index 00000000..a265ecd4 --- /dev/null +++ b/projects/mdb-angular-ui-kit/radio/radio.module.ts @@ -0,0 +1,12 @@ +import { MdbRadioDirective } from './radio-button.directive'; +import { MdbRadioGroupDirective } from './radio-group.directive'; +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +@NgModule({ + declarations: [MdbRadioDirective, MdbRadioGroupDirective], + exports: [MdbRadioDirective, MdbRadioGroupDirective], + imports: [CommonModule, FormsModule], +}) +export class MdbRadioModule {} diff --git a/projects/mdb-angular-ui-kit/schematics/ng-add/mdb-setup.ts b/projects/mdb-angular-ui-kit/schematics/ng-add/mdb-setup.ts index 0e0d41f9..8cf1dcc5 100644 --- a/projects/mdb-angular-ui-kit/schematics/ng-add/mdb-setup.ts +++ b/projects/mdb-angular-ui-kit/schematics/ng-add/mdb-setup.ts @@ -25,6 +25,7 @@ export default function (options: Schema): any { addAngularAnimationsModule(options), addStylesImports(options), addRobotoFontToIndexHtml(options), + updateAppComponentContent(), ]); } return; @@ -142,3 +143,66 @@ function addStylesImports(options: Schema): any { host.commitUpdate(recorder); }; } + +function updateAppComponentContent(): any { + return async (host: Tree, context: SchematicContext) => { + const filePath = './src/app/app.component.html'; + const logger = context.logger; + const buffer = host.read(filePath); + + if (!buffer) { + logger.error('No buffer'); + return; + } + + const fileContent = buffer.toString(); + + const defaultContent = + `\n` + + `\n` + + `\n` + + `\n` + + `\n` + + `\n` + + `\n` + + `\n`; + + const newContent = + `
\n` + + `
\n` + + `
\n` + + ` \n` + + `
Thank you for using our product. We're glad you're with us.
\n` + + `

MDB Team

\n` + + `

\n` + + ` PS. We'll be releasing "How to build your first project with MDB 5 Angular" tutorial soon.\n` + + `

\n` + + ` Join now\n` + + `
\n` + + `
\n` + + `
`; + + const hasNewContent = fileContent.includes(newContent); + const hasDefaultContent = fileContent.includes(defaultContent); + + if (hasNewContent || !hasDefaultContent) { + return; + } + + const recorder = host.beginUpdate(filePath); + + recorder.remove(0, fileContent.length); + recorder.insertLeft(0, newContent); + host.commitUpdate(recorder); + }; +} diff --git a/projects/mdb-angular-ui-kit/tooltip/index.ts b/projects/mdb-angular-ui-kit/tooltip/index.ts new file mode 100644 index 00000000..4aaf8f92 --- /dev/null +++ b/projects/mdb-angular-ui-kit/tooltip/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/projects/mdb-angular-ui-kit/tooltip/package.json b/projects/mdb-angular-ui-kit/tooltip/package.json new file mode 100644 index 00000000..57092ff7 --- /dev/null +++ b/projects/mdb-angular-ui-kit/tooltip/package.json @@ -0,0 +1,11 @@ +{ + "name": "mdb-angular-ui-kit/tooltip", + "private": true, + "ngPackage": { + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "public_api.ts" + }, + "dest": "../../dist/tooltip" + } +} diff --git a/projects/mdb-angular-ui-kit/tooltip/public_api.ts b/projects/mdb-angular-ui-kit/tooltip/public_api.ts new file mode 100644 index 00000000..bc12b7e5 --- /dev/null +++ b/projects/mdb-angular-ui-kit/tooltip/public_api.ts @@ -0,0 +1,3 @@ +export { MdbTooltipDirective } from './tooltip.directive'; +export { MdbTooltipModule } from './tooltip.module'; +export { MdbTooltipPosition } from './tooltip.types'; diff --git a/projects/mdb-angular-ui-kit/tooltip/tooltip.component.html b/projects/mdb-angular-ui-kit/tooltip/tooltip.component.html new file mode 100644 index 00000000..1b58b751 --- /dev/null +++ b/projects/mdb-angular-ui-kit/tooltip/tooltip.component.html @@ -0,0 +1,17 @@ +
+
+ {{ title }} +
diff --git a/projects/mdb-angular-ui-kit/tooltip/tooltip.component.ts b/projects/mdb-angular-ui-kit/tooltip/tooltip.component.ts new file mode 100644 index 00000000..ba64824f --- /dev/null +++ b/projects/mdb-angular-ui-kit/tooltip/tooltip.component.ts @@ -0,0 +1,46 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnInit, +} from '@angular/core'; +import { trigger, style, animate, transition, state, AnimationEvent } from '@angular/animations'; +import { Subject } from 'rxjs'; +@Component({ + // tslint:disable-next-line: component-selector + selector: 'mdb-tooltip', + templateUrl: 'tooltip.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger('fade', [ + state('visible', style({ opacity: 1 })), + state('hidden', style({ opacity: 0 })), + transition('visible => hidden', animate('150ms linear')), + transition(':enter', animate('150ms linear')), + ]), + ], +}) +export class MdbTooltipComponent implements OnInit { + @Input() title: string; + @Input() html: boolean; + @Input() animation: boolean; + + readonly _hidden: Subject = new Subject(); + + animationState = 'hidden'; + + constructor(private _cdRef: ChangeDetectorRef) {} + + ngOnInit(): void {} + + markForCheck(): void { + this._cdRef.markForCheck(); + } + + onAnimationEnd(event: AnimationEvent): void { + if (event.toState === 'hidden') { + this._hidden.next(); + } + } +} diff --git a/projects/mdb-angular-ui-kit/tooltip/tooltip.directive.spec.ts b/projects/mdb-angular-ui-kit/tooltip/tooltip.directive.spec.ts new file mode 100644 index 00000000..9165662e --- /dev/null +++ b/projects/mdb-angular-ui-kit/tooltip/tooltip.directive.spec.ts @@ -0,0 +1,201 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { MdbTooltipModule } from './index'; +import { MdbTooltipDirective } from './tooltip.directive'; +import { MdbTooltipComponent } from './tooltip.component'; +import { By } from '@angular/platform-browser'; +import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +describe('MDB Tooltip', () => { + describe('after init', () => { + let fixture: ComponentFixture; + let element: any; + let component: any; + let directive: any; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MdbTooltipModule, BrowserAnimationsModule], + declarations: [TestTooltipComponent], + }).overrideModule(BrowserDynamicTestingModule, { + set: { + entryComponents: [MdbTooltipComponent], + }, + }); + fixture = TestBed.createComponent(TestTooltipComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should open tooltip after mouseenter and close after mouseout', () => { + fixture.detectChanges(); + + directive = fixture.debugElement + .query(By.directive(MdbTooltipDirective)) + .injector.get(MdbTooltipDirective) as MdbTooltipDirective; + + const onOpen = spyOn(directive, 'show'); + const onClose = spyOn(directive, 'hide'); + + const buttonEl = fixture.nativeElement.querySelector('button'); + + buttonEl.dispatchEvent(new Event('mouseenter')); + fixture.detectChanges(); + + expect(directive.show).toHaveBeenCalled(); + + directive.open = true; + buttonEl.dispatchEvent(new Event('mouseleave')); + fixture.detectChanges(); + + expect(directive.hide).toHaveBeenCalled(); + }); + + it('should set tooltip title', () => { + jest.useFakeTimers(); + const buttonEl = fixture.nativeElement.querySelector('button'); + + buttonEl.dispatchEvent(new Event('mouseenter')); + jest.runAllTimers(); + + fixture.detectChanges(); + const tooltip = document.querySelector('.tooltip-inner'); + expect(tooltip.textContent).toMatch(component.testMdbTooltip); + }); + + it('should set placement', () => { + jest.useFakeTimers(); + const buttonEl = fixture.nativeElement.querySelector('button'); + + buttonEl.dispatchEvent(new Event('mouseenter')); + jest.runAllTimers(); + + fixture.detectChanges(); + directive = fixture.debugElement + .query(By.directive(MdbTooltipDirective)) + .injector.get(MdbTooltipDirective) as MdbTooltipDirective; + + const placement = directive._overlayRef._config.positionStrategy._lastPosition.originY; + expect(placement).toMatch('top'); + }); + }); + + describe('onInit', () => { + it('should open/close tooltip after click', () => { + let fixture: ComponentFixture; + let directive: any; + let component: any; + let element: any; + + TestBed.configureTestingModule({ + imports: [MdbTooltipModule], + declarations: [TestTooltipComponent2], + }).overrideModule(BrowserDynamicTestingModule, { + set: { + entryComponents: [MdbTooltipComponent], + }, + }); + fixture = TestBed.createComponent(TestTooltipComponent2); + component = fixture.componentInstance; + element = fixture.nativeElement; + fixture.detectChanges(); + + directive = fixture.debugElement + .query(By.directive(MdbTooltipDirective)) + .injector.get(MdbTooltipDirective) as MdbTooltipDirective; + + const onOpen = spyOn(directive, 'show'); + const onClose = spyOn(directive, 'hide'); + + const buttonEl = fixture.nativeElement.querySelector('button'); + + buttonEl.dispatchEvent(new Event('click')); + fixture.detectChanges(); + + expect(directive.show).toHaveBeenCalled(); + + directive._open = true; + buttonEl.dispatchEvent(new Event('click')); + fixture.detectChanges(); + + expect(directive.hide).toHaveBeenCalled(); + }); + }); + + it('should prevent open', () => { + let fixture: ComponentFixture; + let directive: any; + let component: any; + let element: any; + + TestBed.configureTestingModule({ + imports: [MdbTooltipModule], + declarations: [TestTooltipComponent3], + }).overrideModule(BrowserDynamicTestingModule, { + set: { + entryComponents: [MdbTooltipComponent], + }, + }); + fixture = TestBed.createComponent(TestTooltipComponent3); + component = fixture.componentInstance; + element = fixture.nativeElement; + fixture.detectChanges(); + + directive = fixture.debugElement + .query(By.directive(MdbTooltipDirective)) + .injector.get(MdbTooltipDirective) as MdbTooltipDirective; + + const onOpen = spyOn(directive, 'show'); + + const buttonEl = fixture.nativeElement.querySelector('button'); + + buttonEl.dispatchEvent(new Event('click')); + fixture.detectChanges(); + + expect(directive.show).not.toHaveBeenCalled(); + }); +}); + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'test-tooltip', + template: ` `, +}) +// tslint:disable-next-line: component-class-suffix +class TestTooltipComponent { + testTrigger = 'hover'; + testMdbTooltip = 'tooltipTitle'; + testPlacement = 'top'; + testDisabled = false; +} + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'test-tooltip2', + template: ` `, +}) +// tslint:disable-next-line: component-class-suffix +class TestTooltipComponent2 {} + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'test-tooltip2', + template: ` `, +}) +// tslint:disable-next-line: component-class-suffix +class TestTooltipComponent3 {} diff --git a/projects/mdb-angular-ui-kit/tooltip/tooltip.directive.ts b/projects/mdb-angular-ui-kit/tooltip/tooltip.directive.ts new file mode 100644 index 00000000..d0c312c3 --- /dev/null +++ b/projects/mdb-angular-ui-kit/tooltip/tooltip.directive.ts @@ -0,0 +1,227 @@ +import { + ComponentRef, + Directive, + ElementRef, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + Renderer2, +} from '@angular/core'; +import { + ConnectedPosition, + Overlay, + OverlayConfig, + OverlayPositionBuilder, + OverlayRef, +} from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { MdbTooltipComponent } from './tooltip.component'; +import { MdbTooltipPosition } from './tooltip.types'; +import { fromEvent, Subject } from 'rxjs'; +import { first, takeUntil } from 'rxjs/operators'; + +@Directive({ + // tslint:disable-next-line: directive-selector + selector: '[mdbTooltip]', + exportAs: 'mdbTooltip', +}) +// tslint:disable-next-line:component-class-suffix +export class MdbTooltipDirective implements OnInit, OnDestroy { + @Input() mdbTooltip = ''; + @Input() tooltipDisabled = false; + @Input() placement: MdbTooltipPosition = 'top'; + @Input() html = false; + @Input() animation = true; + @Input() trigger = 'hover focus'; + @Input() delayShow = 0; + @Input() delayHide = 0; + @Input() offset = 4; + + @Output() tooltipShow: EventEmitter = new EventEmitter(); + @Output() tooltipShown: EventEmitter = new EventEmitter(); + @Output() tooltipHide: EventEmitter = new EventEmitter(); + @Output() tooltipHidden: EventEmitter = new EventEmitter(); + + private _overlayRef: OverlayRef; + private _tooltipRef: ComponentRef; + private _open = false; + private _showTimeout: any = 0; + private _hideTimeout: any = 0; + + readonly _destroy$: Subject = new Subject(); + + constructor( + private _overlay: Overlay, + private _overlayPositionBuilder: OverlayPositionBuilder, + private _elementRef: ElementRef, + private _renderer: Renderer2 + ) {} + + ngOnInit(): void { + if (this.tooltipDisabled) { + return; + } + + this._bindTriggerEvents(); + this._createOverlay(); + } + + ngOnDestroy(): void { + this._destroy$.next(); + this._destroy$.unsubscribe(); + } + + private _bindTriggerEvents(): void { + const triggers = this.trigger.split(' '); + + triggers.forEach((trigger) => { + if (trigger === 'click') { + fromEvent(this._elementRef.nativeElement, trigger) + .pipe(takeUntil(this._destroy$)) + .subscribe(() => this.toggle()); + } else if (trigger !== 'manual') { + const evIn = trigger === 'hover' ? 'mouseenter' : 'focusin'; + const evOut = trigger === 'hover' ? 'mouseleave' : 'focusout'; + + fromEvent(this._elementRef.nativeElement, evIn) + .pipe(takeUntil(this._destroy$)) + .subscribe(() => this.show()); + fromEvent(this._elementRef.nativeElement, evOut) + .pipe(takeUntil(this._destroy$)) + .subscribe(() => this.hide()); + } + }); + } + + private _createOverlayConfig(): OverlayConfig { + const positionStrategy = this._overlayPositionBuilder + .flexibleConnectedTo(this._elementRef) + .withPositions(this._getPosition()); + const overlayConfig = new OverlayConfig({ + hasBackdrop: false, + scrollStrategy: this._overlay.scrollStrategies.reposition(), + positionStrategy, + }); + + return overlayConfig; + } + + private _createOverlay(): void { + this._overlayRef = this._overlay.create(this._createOverlayConfig()); + } + + private _getPosition(): ConnectedPosition[] { + let position; + + const positionTop = { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: -this.offset, + }; + + const positionBottom = { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetY: this.offset, + }; + + const positionRight = { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: this.offset, + }; + + const positionLeft = { + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + offsetX: -this.offset, + }; + + switch (this.placement) { + case 'top': + position = [positionTop, positionBottom]; + break; + case 'bottom': + position = [positionBottom, positionTop]; + break; + case 'left': + position = [positionLeft, positionRight]; + break; + case 'right': + position = [positionRight, positionLeft]; + break; + default: + break; + } + + return position; + } + + show(): void { + if (this._open) { + this._overlayRef.detach(); + } + + if (this._hideTimeout) { + clearTimeout(this._hideTimeout); + this._hideTimeout = null; + } + + this._showTimeout = setTimeout(() => { + const tooltipPortal = new ComponentPortal(MdbTooltipComponent); + + this.tooltipShow.emit(this); + this._open = true; + + this._tooltipRef = this._overlayRef.attach(tooltipPortal); + this._tooltipRef.instance.title = this.mdbTooltip; + this._tooltipRef.instance.html = this.html; + this._tooltipRef.instance.animation = this.animation; + this._tooltipRef.instance.animationState = 'visible'; + + this._tooltipRef.instance.markForCheck(); + + this.tooltipShown.emit(this); + }, this.delayShow); + } + + hide(): void { + if (!this._open) { + return; + } + + if (this._showTimeout) { + clearTimeout(this._showTimeout); + this._showTimeout = null; + } + + this._hideTimeout = setTimeout(() => { + this.tooltipHide.emit(this); + this._tooltipRef.instance._hidden.pipe(first()).subscribe(() => { + this._overlayRef.detach(); + this._open = false; + this.tooltipShown.emit(this); + }); + this._tooltipRef.instance.animationState = 'hidden'; + this._tooltipRef.instance.markForCheck(); + }, this.delayHide); + } + + toggle(): void { + if (this._open) { + this.hide(); + } else { + this.show(); + } + } +} diff --git a/projects/mdb-angular-ui-kit/tooltip/tooltip.module.ts b/projects/mdb-angular-ui-kit/tooltip/tooltip.module.ts new file mode 100644 index 00000000..845b1bfc --- /dev/null +++ b/projects/mdb-angular-ui-kit/tooltip/tooltip.module.ts @@ -0,0 +1,12 @@ +import { MdbTooltipDirective } from './tooltip.directive'; +import { NgModule } from '@angular/core'; +import { OverlayModule } from '@angular/cdk/overlay'; +import { CommonModule } from '@angular/common'; +import { MdbTooltipComponent } from './tooltip.component'; + +@NgModule({ + imports: [CommonModule, OverlayModule], + declarations: [MdbTooltipDirective, MdbTooltipComponent], + exports: [MdbTooltipDirective, MdbTooltipComponent], +}) +export class MdbTooltipModule {} diff --git a/projects/mdb-angular-ui-kit/tooltip/tooltip.types.ts b/projects/mdb-angular-ui-kit/tooltip/tooltip.types.ts new file mode 100644 index 00000000..677da187 --- /dev/null +++ b/projects/mdb-angular-ui-kit/tooltip/tooltip.types.ts @@ -0,0 +1 @@ +export type MdbTooltipPosition = 'top' | 'right' | 'bottom' | 'left';