diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown-close/dropdown-close.directive.spec.ts b/projects/coreui-angular/src/lib/dropdown/dropdown-close/dropdown-close.directive.spec.ts index e85a9a4f..ebe72672 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown-close/dropdown-close.directive.spec.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown-close/dropdown-close.directive.spec.ts @@ -1,15 +1,69 @@ -import { DropdownCloseDirective } from './dropdown-close.directive'; -import { TestBed } from '@angular/core/testing'; +import { Component, DebugElement, ElementRef, Renderer2, viewChild } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { DropdownService } from '../dropdown.service'; +import { DropdownCloseDirective } from './dropdown-close.directive'; +import { ButtonCloseDirective } from '../../button'; +import { DropdownComponent } from '../dropdown/dropdown.component'; +import { DropdownMenuDirective } from '../dropdown-menu/dropdown-menu.directive'; + +class MockElementRef extends ElementRef {} + +@Component({ + template: ` + +
+ +
+
+ `, + imports: [ButtonCloseDirective, DropdownComponent, DropdownMenuDirective, DropdownCloseDirective] +}) +class TestComponent { + disabled = false; + readonly dropdown = viewChild(DropdownComponent); +} describe('DropdownCloseDirective', () => { - it('should create an instance', () => { + let component: TestComponent; + let fixture: ComponentFixture; + let elementRef: DebugElement; + + beforeEach(() => { TestBed.configureTestingModule({ - providers: [DropdownService] + imports: [TestComponent], + providers: [{ provide: ElementRef, useClass: MockElementRef }, Renderer2, DropdownService] }); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + elementRef = fixture.debugElement.query(By.directive(DropdownCloseDirective)); + component.disabled = false; + fixture.detectChanges(); // initial binding + }); + + it('should create an instance', () => { TestBed.runInInjectionContext(() => { const directive = new DropdownCloseDirective(); expect(directive).toBeTruthy(); }); }); + + it('should have css classes and attributes', fakeAsync(() => { + expect(elementRef.nativeElement).not.toHaveClass('disabled'); + expect(elementRef.nativeElement.getAttribute('aria-disabled')).toBeNull(); + expect(elementRef.nativeElement.getAttribute('tabindex')).toBe('0'); + component.disabled = true; + fixture.detectChanges(); + expect(elementRef.nativeElement).toHaveClass('disabled'); + expect(elementRef.nativeElement.getAttribute('aria-disabled')).toBe('true'); + expect(elementRef.nativeElement.getAttribute('tabindex')).toBe('-1'); + })); + + it('should call event handling functions', fakeAsync(() => { + expect(component.dropdown()?.visible()).toBeTrue(); + elementRef.nativeElement.dispatchEvent(new Event('click')); + elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + expect(component.dropdown()?.visible()).toBeFalse(); + })); }); diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown-close/dropdown-close.directive.ts b/projects/coreui-angular/src/lib/dropdown/dropdown-close/dropdown-close.directive.ts index 5f9adf60..5eb9b525 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown-close/dropdown-close.directive.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown-close/dropdown-close.directive.ts @@ -1,10 +1,17 @@ -import { AfterViewInit, Directive, HostBinding, HostListener, inject, Input } from '@angular/core'; +import { AfterViewInit, booleanAttribute, Directive, inject, input, linkedSignal } from '@angular/core'; import { DropdownService } from '../dropdown.service'; import { DropdownComponent } from '../dropdown/dropdown.component'; @Directive({ selector: '[cDropdownClose]', - exportAs: 'cDropdownClose' + exportAs: 'cDropdownClose', + host: { + '[class.disabled]': 'disabled()', + '[attr.aria-disabled]': 'disabled() || null', + '[attr.tabindex]': 'tabIndex()', + '(click)': 'onClick($event)', + '(keyup)': 'onKeyUp($event)' + } }) export class DropdownCloseDirective implements AfterViewInit { #dropdownService = inject(DropdownService); @@ -12,51 +19,46 @@ export class DropdownCloseDirective implements AfterViewInit { /** * Disables a dropdown-close directive. - * @type boolean + * @return boolean * @default undefined */ - @Input() disabled?: boolean; + readonly disabledInput = input(undefined, { transform: booleanAttribute, alias: 'disabled' }); + + readonly disabled = linkedSignal({ + source: this.disabledInput, + computation: (value) => value || null + }); - @Input() dropdownComponent?: DropdownComponent; + readonly dropdownComponent = input(); ngAfterViewInit(): void { - if (this.dropdownComponent) { - this.dropdown = this.dropdownComponent; - this.#dropdownService = this.dropdownComponent?.dropdownService; + const dropdownComponent = this.dropdownComponent(); + if (dropdownComponent) { + this.dropdown = dropdownComponent; + this.#dropdownService = dropdownComponent?.dropdownService; } } - @HostBinding('class') - get hostClasses(): any { - return { - disabled: this.disabled - }; - } + readonly tabIndexInput = input(null, { alias: 'tabIndex' }); - @HostBinding('attr.tabindex') - @Input() - set tabIndex(value: string | number | null) { - this._tabIndex = value; - } - get tabIndex() { - return this.disabled ? '-1' : this._tabIndex; - } - private _tabIndex: string | number | null = null; + readonly tabIndex = linkedSignal({ + source: this.tabIndexInput, + computation: (value) => (this.disabled() ? '-1' : value) + }); - @HostBinding('attr.aria-disabled') - get isDisabled(): boolean | null { - return this.disabled || null; + onClick($event: MouseEvent): void { + this.handleToggle(); } - @HostListener('click', ['$event']) - private onClick($event: MouseEvent): void { - !this.disabled && this.#dropdownService.toggle({ visible: false, dropdown: this.dropdown }); + onKeyUp($event: KeyboardEvent): void { + if ($event.key === 'Enter') { + this.handleToggle(); + } } - @HostListener('keyup', ['$event']) - private onKeyUp($event: KeyboardEvent): void { - if ($event.key === 'Enter') { - !this.disabled && this.#dropdownService.toggle({ visible: false, dropdown: this.dropdown }); + private handleToggle(): void { + if (!this.disabled()) { + this.#dropdownService.toggle({ visible: false, dropdown: this.dropdown }); } } } diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown-item/dropdown-item.directive.spec.ts b/projects/coreui-angular/src/lib/dropdown/dropdown-item/dropdown-item.directive.spec.ts index 14e49371..961f5a94 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown-item/dropdown-item.directive.spec.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown-item/dropdown-item.directive.spec.ts @@ -1,15 +1,54 @@ -import { ElementRef } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { Component, DebugElement, ElementRef, Renderer2, viewChild } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; + import { DropdownItemDirective } from './dropdown-item.directive'; import { DropdownService } from '../dropdown.service'; +import { DropdownComponent } from '../dropdown/dropdown.component'; +import { DropdownMenuDirective } from '../dropdown-menu/dropdown-menu.directive'; +import { By } from '@angular/platform-browser'; +import { DOCUMENT } from '@angular/common'; class MockElementRef extends ElementRef {} +@Component({ + template: ` + +
    +
  • + +
  • +
+
+ `, + imports: [DropdownComponent, DropdownMenuDirective, DropdownItemDirective] +}) +class TestComponent { + disabled = false; + active = false; + readonly dropdown = viewChild(DropdownComponent); + readonly item = viewChild(DropdownItemDirective); +} + describe('DropdownItemDirective', () => { + let component: TestComponent; + let fixture: ComponentFixture; + let elementRef: DebugElement; + let document: Document; + beforeEach(() => { TestBed.configureTestingModule({ - providers: [{ provide: ElementRef, useClass: MockElementRef }, DropdownService] + imports: [TestComponent], + providers: [{ provide: ElementRef, useClass: MockElementRef }, Renderer2, DropdownService] }); + + document = TestBed.inject(DOCUMENT); + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + elementRef = fixture.debugElement.query(By.directive(DropdownItemDirective)); + component.disabled = false; + fixture.detectChanges(); // initial binding }); it('should create an instance', () => { @@ -18,4 +57,32 @@ describe('DropdownItemDirective', () => { expect(directive).toBeTruthy(); }); }); + + it('should have css classes and attributes', fakeAsync(() => { + expect(elementRef.nativeElement).not.toHaveClass('disabled'); + expect(elementRef.nativeElement.getAttribute('aria-disabled')).toBeNull(); + expect(elementRef.nativeElement.getAttribute('aria-current')).toBeNull(); + expect(elementRef.nativeElement.getAttribute('tabindex')).toBe('0'); + component.disabled = true; + component.active = true; + fixture.detectChanges(); + expect(elementRef.nativeElement).toHaveClass('disabled'); + expect(elementRef.nativeElement.getAttribute('aria-disabled')).toBe('true'); + expect(elementRef.nativeElement.getAttribute('aria-current')).toBe('true'); + expect(elementRef.nativeElement.getAttribute('tabindex')).toBe('-1'); + })); + + it('should call event handling functions', fakeAsync(() => { + expect(component.dropdown()?.visible()).toBeTrue(); + elementRef.nativeElement.dispatchEvent(new Event('click')); + elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + fixture.detectChanges(); + elementRef.nativeElement.focus(); + // @ts-ignore + const label = component.item()?.getLabel() ?? undefined; + expect(label).toBe('Action'); + component.item()?.focus(); + expect(document.activeElement).toBe(elementRef.nativeElement); + })); }); diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown-item/dropdown-item.directive.ts b/projects/coreui-angular/src/lib/dropdown/dropdown-item/dropdown-item.directive.ts index a35b6e4d..209c24af 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown-item/dropdown-item.directive.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown-item/dropdown-item.directive.ts @@ -1,4 +1,4 @@ -import { Directive, ElementRef, HostBinding, HostListener, inject, Input } from '@angular/core'; +import { booleanAttribute, computed, Directive, ElementRef, inject, input, linkedSignal } from '@angular/core'; import { FocusableOption, FocusOrigin } from '@angular/cdk/a11y'; import { DropdownService } from '../dropdown.service'; import { DropdownComponent } from '../dropdown/dropdown.component'; @@ -6,7 +6,15 @@ import { DropdownComponent } from '../dropdown/dropdown.component'; @Directive({ selector: '[cDropdownItem]', exportAs: 'cDropdownItem', - host: { class: 'dropdown-item' } + host: { + class: 'dropdown-item', + '[class]': 'hostClasses()', + '[attr.tabindex]': 'tabIndex()', + '[attr.aria-current]': 'ariaCurrent()', + '[attr.aria-disabled]': 'disabled || null', + '(click)': 'onClick($event)', + '(keyup)': 'onKeyUp($event)' + } }) export class DropdownItemDirective implements FocusableOption { readonly #elementRef: ElementRef = inject(ElementRef); @@ -15,22 +23,37 @@ export class DropdownItemDirective implements FocusableOption { /** * Set active state to a dropdown-item. - * @type boolean + * @return boolean * @default undefined */ - @Input() active?: boolean; + readonly active = input(); + /** * Configure dropdown-item close dropdown behavior. - * @type boolean + * @return boolean * @default true */ - @Input() autoClose: boolean = true; + readonly autoClose = input(true); + /** * Disables a dropdown-item. - * @type boolean + * @return boolean * @default undefined */ - @Input() disabled?: boolean; + readonly disabledInput = input(undefined, { transform: booleanAttribute, alias: 'disabled' }); + + readonly disabledEffect = linkedSignal({ + source: this.disabledInput, + computation: (value) => value + }); + + set disabled(value) { + this.disabledEffect.set(value); + } + + get disabled() { + return this.disabledEffect(); + } focus(origin?: FocusOrigin | undefined): void { this.#elementRef?.nativeElement?.focus(); @@ -40,50 +63,38 @@ export class DropdownItemDirective implements FocusableOption { return this.#elementRef?.nativeElement?.textContent.trim(); } - @HostBinding('attr.aria-current') - get ariaCurrent(): string | null { - return this.active ? 'true' : null; - } + readonly ariaCurrent = computed(() => { + return this.active() ? 'true' : null; + }); - @HostBinding('class') - get hostClasses(): any { + readonly hostClasses = computed(() => { return { 'dropdown-item': true, - active: this.active, + active: this.active(), disabled: this.disabled - }; - } - - @HostBinding('attr.tabindex') - @Input() - set tabIndex(value: string | number | null) { - this._tabIndex = value; - } + } as Record; + }); - get tabIndex() { - return this.disabled ? '-1' : this._tabIndex; - } + readonly tabIndexInput = input(null, { alias: 'tabIndex' }); - private _tabIndex: string | number | null = null; + readonly tabIndex = linkedSignal({ + source: this.tabIndexInput, + computation: (value) => (this.disabled ? '-1' : value) + }); - @HostBinding('attr.aria-disabled') - get isDisabled(): boolean | null { - return this.disabled || null; + onClick($event: MouseEvent): void { + this.handleInteraction(); } - @HostListener('click', ['$event']) - private onClick($event: MouseEvent): void { - if (this.autoClose) { - this.#dropdownService.toggle({ visible: 'toggle', dropdown: this.dropdown }); + onKeyUp($event: KeyboardEvent): void { + if ($event.key === 'Enter') { + this.handleInteraction(); } } - @HostListener('keyup', ['$event']) - private onKeyUp($event: KeyboardEvent): void { - if ($event.key === 'Enter') { - if (this.autoClose) { - this.#dropdownService.toggle({ visible: false, dropdown: this.dropdown }); - } + private handleInteraction(): void { + if (this.autoClose()) { + this.#dropdownService.toggle({ visible: 'toggle', dropdown: this.dropdown }); } } } diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown-menu/dropdown-menu.directive.spec.ts b/projects/coreui-angular/src/lib/dropdown/dropdown-menu/dropdown-menu.directive.spec.ts index 3d9a84af..0204f322 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown-menu/dropdown-menu.directive.spec.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown-menu/dropdown-menu.directive.spec.ts @@ -1,16 +1,57 @@ -import { ElementRef, Renderer2 } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { Component, DebugElement, ElementRef, Renderer2, viewChild } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; import { DropdownService } from '../dropdown.service'; import { DropdownMenuDirective } from './dropdown-menu.directive'; +import { DropdownComponent, DropdownToggleDirective } from '../dropdown/dropdown.component'; +import { DOCUMENT } from '@angular/common'; +import { By } from '@angular/platform-browser'; +import { DropdownItemDirective } from '../dropdown-item/dropdown-item.directive'; +import { ButtonDirective } from '../../button'; class MockElementRef extends ElementRef {} +@Component({ + template: ` + + +
    +
  • + +
  • +
+
+ `, + imports: [DropdownComponent, DropdownMenuDirective, DropdownItemDirective, ButtonDirective, DropdownToggleDirective] +}) +class TestComponent { + visible = true; + alignment?: string; + readonly dropdown = viewChild(DropdownComponent); + readonly menu = viewChild(DropdownMenuDirective); + readonly item = viewChild(DropdownItemDirective); +} + describe('DropdownMenuDirective', () => { + let component: TestComponent; + let fixture: ComponentFixture; + let dropdownRef: DebugElement; + let elementRef: DebugElement; + let itemRef: DebugElement; + let document: Document; beforeEach(() => { TestBed.configureTestingModule({ + imports: [TestComponent], providers: [{ provide: ElementRef, useClass: MockElementRef }, Renderer2, DropdownService] }); + document = TestBed.inject(DOCUMENT); + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + dropdownRef = fixture.debugElement.query(By.directive(DropdownComponent)); + elementRef = fixture.debugElement.query(By.directive(DropdownMenuDirective)); + itemRef = fixture.debugElement.query(By.directive(DropdownItemDirective)); + component.visible = true; + fixture.detectChanges(); // initial binding }); it('should create an instance', () => { @@ -18,6 +59,43 @@ describe('DropdownMenuDirective', () => { const directive = new DropdownMenuDirective(); expect(directive).toBeTruthy(); }); - }); + + it('should have css classes', fakeAsync(() => { + component.visible = false; + fixture.detectChanges(); + expect(dropdownRef.nativeElement).not.toHaveClass('show'); + expect(elementRef.nativeElement).toHaveClass('dropdown-menu'); + expect(elementRef.nativeElement).not.toHaveClass('dropdown-menu-end'); + expect(elementRef.nativeElement).not.toHaveClass('dropdown-menu-start'); + expect(elementRef.nativeElement).not.toHaveClass('show'); + component.visible = true; + component.alignment = 'end'; + fixture.detectChanges(); + expect(dropdownRef.nativeElement).toHaveClass('show'); + expect(elementRef.nativeElement).toHaveClass('dropdown-menu-end'); + expect(elementRef.nativeElement).not.toHaveClass('dropdown-menu-start'); + expect(elementRef.nativeElement).toHaveClass('show'); + component.alignment = 'start'; + fixture.detectChanges(); + expect(elementRef.nativeElement).not.toHaveClass('dropdown-menu-end'); + expect(elementRef.nativeElement).toHaveClass('dropdown-menu-start'); + component.alignment = undefined; + fixture.detectChanges(); + expect(elementRef.nativeElement).not.toHaveClass('dropdown-menu-end'); + expect(elementRef.nativeElement).not.toHaveClass('dropdown-menu-start'); + })); + + it('should call event handling functions', fakeAsync(() => { + expect(document.activeElement).not.toEqual(elementRef.nativeElement); + elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space' })); + elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab' })); + component.visible = true; + fixture.detectChanges(); + elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space' })); + elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab' })); + elementRef.nativeElement.focus(); + fixture.detectChanges(); + expect(document.activeElement).toEqual(itemRef.nativeElement); + })); }); diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown-menu/dropdown-menu.directive.ts b/projects/coreui-angular/src/lib/dropdown/dropdown-menu/dropdown-menu.directive.ts index 27e1c333..fa77aa32 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown-menu/dropdown-menu.directive.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown-menu/dropdown-menu.directive.ts @@ -1,14 +1,14 @@ import { AfterContentInit, + computed, ContentChildren, DestroyRef, Directive, ElementRef, forwardRef, - HostBinding, - HostListener, inject, - Input, + input, + linkedSignal, OnInit, QueryList } from '@angular/core'; @@ -17,14 +17,20 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { tap } from 'rxjs/operators'; import { ThemeDirective } from '../../shared/theme.directive'; -import { DropdownService } from '../dropdown.service'; import { DropdownItemDirective } from '../dropdown-item/dropdown-item.directive'; +import { DropdownService } from '../dropdown.service'; @Directive({ selector: '[cDropdownMenu]', exportAs: 'cDropdownMenu', hostDirectives: [{ directive: ThemeDirective, inputs: ['dark'] }], - host: { class: 'dropdown-menu' } + host: { + class: 'dropdown-menu', + '[class]': 'hostClasses()', + '[style]': 'hostStyles()', + '(keydown)': 'onKeyDown($event)', + '(keyup)': 'onKeyUp($event)' + } }) export class DropdownMenuDirective implements OnInit, AfterContentInit { readonly #destroyRef: DestroyRef = inject(DestroyRef); @@ -34,35 +40,42 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { /** * Set alignment of dropdown menu. - * @type {'start' | 'end' } + * @return 'start' | 'end' */ - @Input() alignment?: 'start' | 'end' | string; + readonly alignment = input<'start' | 'end' | string>(); /** * Toggle the visibility of dropdown menu component. - * @type boolean + * @return boolean */ - @Input() visible: boolean = false; + readonly visibleInput = input(false, { alias: 'visible' }); - @HostBinding('class') - get hostClasses(): any { + readonly visible = linkedSignal({ + source: () => this.visibleInput(), + computation: (value) => value + }); + + readonly hostClasses = computed(() => { + const alignment = this.alignment(); + const visible = this.visible(); return { 'dropdown-menu': true, - [`dropdown-menu-${this.alignment}`]: !!this.alignment, - show: this.visible - }; - } + [`dropdown-menu-${alignment}`]: !!alignment, + show: visible + } as Record; + }); - @HostBinding('style') get hostStyles() { + readonly hostStyles = computed(() => { // workaround for popper position calculate (see also: dropdown.component) + const visible = this.visible(); return { - visibility: this.visible ? null : '', - display: this.visible ? null : '' - }; - } + visibility: visible ? null : '', + display: visible ? null : '' + } as Record; + }); - @HostListener('keydown', ['$event']) onKeyDown($event: KeyboardEvent): void { - if (!this.visible) { + onKeyDown($event: KeyboardEvent): void { + if (!this.visible()) { return; } if (['Space', 'ArrowDown'].includes($event.code)) { @@ -71,8 +84,8 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { this.#focusKeyManager.onKeydown($event); } - @HostListener('keyup', ['$event']) onKeyUp($event: KeyboardEvent): void { - if (!this.visible) { + onKeyUp($event: KeyboardEvent): void { + if (!this.visible()) { return; } if (['Tab'].includes($event.key)) { @@ -105,8 +118,8 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { .pipe( tap((state) => { if ('visible' in state) { - this.visible = state.visible === 'toggle' ? !this.visible : state.visible; - if (!this.visible) { + this.visible.update((visible) => (state.visible === 'toggle' ? !visible : state.visible)); + if (!this.visible()) { this.#focusKeyManager?.setActiveItem(-1); } } diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.spec.ts b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.spec.ts index 4dc3dc6a..6c32a2dd 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.spec.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.spec.ts @@ -1,9 +1,12 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; import { DropdownComponent, DropdownToggleDirective } from './dropdown.component'; import { Component, DebugElement, ElementRef } from '@angular/core'; import { DropdownService } from '../dropdown.service'; import { By } from '@angular/platform-browser'; +import { DOCUMENT } from '@angular/common'; +import { DropdownMenuDirective } from '../dropdown-menu/dropdown-menu.directive'; +import { DropdownItemDirective } from '../dropdown-item/dropdown-item.directive'; describe('DropdownComponent', () => { let component: DropdownComponent; @@ -33,16 +36,34 @@ describe('DropdownComponent', () => { class MockElementRef extends ElementRef {} @Component({ - template: '
', - imports: [DropdownToggleDirective] + template: ` + +
+ +
+ `, + imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective] }) -class TestComponent {} +class TestComponent { + variant: 'btn-group' | 'dropdown' | 'input-group' | 'nav-item' | undefined = 'nav-item'; + visible = false; + disabled = false; + caret = true; + split = false; +} describe('DropdownToggleDirective', () => { let component: TestComponent; let fixture: ComponentFixture; let elementRef: DebugElement; + let dropdownRef: DebugElement; let service: DropdownService; + let document: Document; beforeEach(() => { TestBed.configureTestingModule({ @@ -55,10 +76,11 @@ describe('DropdownToggleDirective', () => { // ChangeDetectorRef ] }); - + document = TestBed.inject(DOCUMENT); fixture = TestBed.createComponent(TestComponent); component = fixture.componentInstance; elementRef = fixture.debugElement.query(By.directive(DropdownToggleDirective)); + dropdownRef = fixture.debugElement.query(By.directive(DropdownComponent)); service = new DropdownService(); fixture.detectChanges(); // initial binding @@ -70,4 +92,44 @@ describe('DropdownToggleDirective', () => { expect(directive).toBeTruthy(); }); }); + + it('should have css classes and attributes', fakeAsync(() => { + expect(elementRef.nativeElement).not.toHaveClass('disabled'); + expect(elementRef.nativeElement).toHaveClass('dropdown-toggle'); + expect(elementRef.nativeElement).not.toHaveClass('dropdown-toggle-split'); + component.variant = 'input-group'; + component.disabled = true; + component.split = true; + component.caret = false; + fixture.detectChanges(); + expect(elementRef.nativeElement).toHaveClass('disabled'); + expect(elementRef.nativeElement).not.toHaveClass('dropdown-toggle'); + expect(elementRef.nativeElement).toHaveClass('dropdown-toggle-split'); + expect(elementRef.nativeElement.getAttribute('aria-expanded')).toBe('false'); + component.variant = 'nav-item'; + component.visible = true; + fixture.detectChanges(); + expect(elementRef.nativeElement.getAttribute('aria-expanded')).toBe('true'); + })); + + it('should call event handling functions', fakeAsync(() => { + expect(component.visible).toBeFalse(); + elementRef.nativeElement.dispatchEvent(new MouseEvent('click')); + fixture.detectChanges(); + expect(component.visible).toBeTrue(); + elementRef.nativeElement.dispatchEvent(new MouseEvent('click')); + fixture.detectChanges(); + expect(component.visible).toBeFalse(); + elementRef.nativeElement.dispatchEvent(new MouseEvent('click')); + fixture.detectChanges(); + expect(component.visible).toBeTrue(); + dropdownRef.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); + fixture.detectChanges(); + expect(component.visible).toBeFalse(); + component.visible = true; + fixture.detectChanges(); + document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab' })); + fixture.detectChanges(); + expect(component.visible).toBeFalse(); + })); }); diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts index c14e5390..c2abeccc 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -5,27 +5,25 @@ import { booleanAttribute, ChangeDetectorRef, Component, + computed, ContentChild, DestroyRef, Directive, + effect, ElementRef, - EventEmitter, forwardRef, - HostBinding, - HostListener, Inject, inject, - Input, + input, + linkedSignal, NgZone, - OnChanges, OnDestroy, OnInit, - Output, + output, Renderer2, signal, - SimpleChanges + untracked } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; @@ -41,7 +39,12 @@ export abstract class DropdownToken {} @Directive({ selector: '[cDropdownToggle]', providers: [{ provide: DropdownToken, useExisting: forwardRef(() => DropdownComponent) }], - exportAs: 'cDropdownToggle' + exportAs: 'cDropdownToggle', + host: { + '[class]': 'hostClasses()', + '[attr.aria-expanded]': 'ariaExpanded', + '(click)': 'onClick($event)' + } }) export class DropdownToggleDirective implements AfterViewInit { // injections @@ -51,63 +54,61 @@ export class DropdownToggleDirective implements AfterViewInit { public dropdown = inject(DropdownToken, { optional: true }); /** - * Toggle the disabled state for the toggler. - * @type DropdownComponent | undefined + * Reference to dropdown component. + * @return DropdownComponent | undefined * @default undefined */ - @Input() dropdownComponent?: DropdownComponent; + readonly dropdownComponent = input(); /** * Disables the toggler. - * @type boolean + * @return boolean * @default false */ - @Input({ transform: booleanAttribute }) disabled: boolean = false; + readonly disabled = input(false, { transform: booleanAttribute }); /** * Enables pseudo element caret on toggler. - * @type boolean + * @return boolean */ - @Input() caret = true; + readonly caret = input(true); /** * Create split button dropdowns with virtually the same markup as single button dropdowns, * but with the addition of `.dropdown-toggle-split` class for proper spacing around the dropdown caret. - * @type boolean + * @return boolean * @default false */ - @Input({ transform: booleanAttribute }) split: boolean = false; + readonly split = input(false, { transform: booleanAttribute }); - @HostBinding('class') - get hostClasses(): any { + readonly hostClasses = computed(() => { return { - 'dropdown-toggle': this.caret, - 'dropdown-toggle-split': this.split, - disabled: this.disabled - }; - } + 'dropdown-toggle': this.caret(), + 'dropdown-toggle-split': this.split(), + disabled: this.disabled() + } as Record; + }); readonly #ariaExpanded = signal(false); - @HostBinding('attr.aria-expanded') get ariaExpanded() { return this.#ariaExpanded(); } - @HostListener('click', ['$event']) public onClick($event: MouseEvent): void { $event.preventDefault(); - !this.disabled && this.#dropdownService.toggle({ visible: 'toggle', dropdown: this.dropdown }); + !this.disabled() && this.#dropdownService.toggle({ visible: 'toggle', dropdown: this.dropdown }); } ngAfterViewInit(): void { - if (this.dropdownComponent) { - this.dropdown = this.dropdownComponent; - this.#dropdownService = this.dropdownComponent?.dropdownService; + const dropdownComponent = this.dropdownComponent(); + if (dropdownComponent) { + this.dropdown = dropdownComponent; + this.#dropdownService = dropdownComponent?.dropdownService; } if (this.dropdown) { const dropdown = this.dropdown; - dropdown?.visibleChange?.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((visible) => { + dropdown?.visibleChange?.subscribe((visible) => { this.#ariaExpanded.set(visible); }); } @@ -120,9 +121,14 @@ export class DropdownToggleDirective implements AfterViewInit { styleUrls: ['./dropdown.component.scss'], exportAs: 'cDropdown', providers: [DropdownService], - hostDirectives: [{ directive: ThemeDirective, inputs: ['dark'] }] + hostDirectives: [{ directive: ThemeDirective, inputs: ['dark'] }], + host: { + '[class]': 'hostClasses()', + '[style]': 'hostStyle()', + '(click)': 'onHostClick($event)' + } }) -export class DropdownComponent implements AfterContentInit, OnChanges, OnDestroy, OnInit { +export class DropdownComponent implements AfterContentInit, OnDestroy, OnInit { constructor( @Inject(DOCUMENT) private document: Document, private elementRef: ElementRef, @@ -136,44 +142,52 @@ export class DropdownComponent implements AfterContentInit, OnChanges, OnDestroy /** * Set alignment of dropdown menu. - * @type {'start' | 'end' | { xs: 'start' | 'end' } | { sm: 'start' | 'end' } | { md: 'start' | 'end' } | { lg: 'start' | 'end' } | { xl: 'start' | 'end'} | { xxl: 'start' | 'end'}} + * @return {'start' | 'end' | { xs: 'start' | 'end' } | { sm: 'start' | 'end' } | { md: 'start' | 'end' } | { lg: 'start' | 'end' } | { xl: 'start' | 'end'} | { xxl: 'start' | 'end'}} */ - @Input() alignment?: string; + readonly alignment = input(); - @Input() autoClose: boolean | 'inside' | 'outside' = true; + /** + * Automatically close dropdown when clicking outside the dropdown menu. + */ + readonly autoClose = input(true); /** * Sets a specified direction and location of the dropdown menu. - * @type 'dropup' | 'dropend' | 'dropstart' + * @return 'dropup' | 'dropend' | 'dropstart' */ - @Input() direction?: 'center' | 'dropup' | 'dropup-center' | 'dropend' | 'dropstart'; + readonly direction = input<'center' | 'dropup' | 'dropup-center' | 'dropend' | 'dropstart'>(); /** * Describes the placement of your component after Popper.js has applied all the modifiers * that may have flipped or altered the originally provided placement property. - * @type Placement + * @return Placement */ - @Input() placement: Placement = 'bottom-start'; + readonly placement = input('bottom-start'); /** * If you want to disable dynamic positioning set this property to `false`. - * @type boolean + * @return boolean * @default true */ - @Input({ transform: booleanAttribute }) popper: boolean = true; + readonly popper = input(true, { transform: booleanAttribute }); /** * Optional popper Options object, placement prop takes precedence over - * @type Partial + * @return Partial */ - @Input() + readonly popperOptionsInput = input>({}, { alias: 'popperOptions' }); + + readonly popperOptionsEffect = effect(() => { + this.popperOptions = { ...untracked(this.#popperOptions), ...this.popperOptionsInput() }; + }); + set popperOptions(value: Partial) { - this._popperOptions = { ...this._popperOptions, ...value }; + this.#popperOptions.update((popperOptions) => ({ ...popperOptions, ...value })); } get popperOptions(): Partial { - let placement = this.placement; - switch (this.direction) { + let placement = this.placement(); + switch (this.direction()) { case 'dropup': { placement = 'top-start'; break; @@ -195,49 +209,47 @@ export class DropdownComponent implements AfterContentInit, OnChanges, OnDestroy break; } } - if (this.alignment === 'end') { + if (this.alignment() === 'end') { placement = 'bottom-end'; } - this._popperOptions = { ...this._popperOptions, placement: placement }; - return this._popperOptions; + this.#popperOptions.update((value) => ({ ...value, placement: placement })); + return this.#popperOptions(); } - private _popperOptions: Partial = { - placement: this.placement, + readonly #popperOptions = signal>({ + placement: this.placement(), modifiers: [], strategy: 'absolute' - }; + }); /** * Set the dropdown variant to a btn-group, dropdown, input-group, and nav-item. */ - @Input() variant?: 'btn-group' | 'dropdown' | 'input-group' | 'nav-item' = 'dropdown'; + readonly variant = input<('btn-group' | 'dropdown' | 'input-group' | 'nav-item') | undefined>('dropdown'); /** * Toggle the visibility of dropdown menu component. - * @type boolean + * @return boolean * @default false */ - @Input({ transform: booleanAttribute }) - set visible(value: boolean) { - const _value = value; - if (_value !== this._visible) { - this.activeTrap = _value; - this._visible = _value; - _value ? this.createPopperInstance() : this.destroyPopperInstance(); - this.visibleChange.emit(_value); - } - } + readonly visibleInput = input(false, { transform: booleanAttribute, alias: 'visible' }); - get visible(): boolean { - return this._visible; - } + readonly visible = linkedSignal({ + source: () => this.visibleInput(), + computation: (value) => value + }); - private _visible = false; + readonly visibleEffect = effect(() => { + const visible = this.visible(); + this.activeTrap = visible; + visible ? this.createPopperInstance() : this.destroyPopperInstance(); + this.setVisibleState(visible); + this.visibleChange.emit(visible); + }); - @Output() visibleChange: EventEmitter = new EventEmitter(); + readonly visibleChange = output(); - dropdownContext = { $implicit: this.visible }; + dropdownContext = { $implicit: this.visible() }; @ContentChild(DropdownToggleDirective) _toggler!: DropdownToggleDirective; @ContentChild(DropdownMenuDirective) _menu!: DropdownMenuDirective; @ContentChild(DropdownMenuDirective, { read: ElementRef }) _menuElementRef!: ElementRef; @@ -248,27 +260,26 @@ export class DropdownComponent implements AfterContentInit, OnChanges, OnDestroy private popperInstance!: Instance | undefined; private listeners: (() => void)[] = []; - @HostBinding('class') - get hostClasses(): any { + readonly hostClasses = computed(() => { + const direction = this.direction(); + const variant = this.variant(); return { - dropdown: (this.variant === 'dropdown' || this.variant === 'nav-item') && !this.direction, - [`${this.direction}`]: !!this.direction, - [`${this.variant}`]: !!this.variant, - dropup: this.direction === 'dropup' || this.direction === 'dropup-center', - show: this.visible - }; - } + dropdown: (variant === 'dropdown' || variant === 'nav-item') && !direction, + [`${direction}`]: !!direction, + [`${variant}`]: !!variant, + dropup: direction === 'dropup' || direction === 'dropup-center', + show: this.visible() + } as Record; + }); // todo: find better solution - @HostBinding('style') - get hostStyle(): any { - return this.variant === 'input-group' ? { display: 'contents' } : {}; - } + readonly hostStyle = computed(() => { + return this.variant() === 'input-group' ? { display: 'contents' } : {}; + }); private clickedTarget!: HTMLElement; - @HostListener('click', ['$event']) - private onHostClick($event: MouseEvent): void { + onHostClick($event: MouseEvent): void { this.clickedTarget = $event.target as HTMLElement; } @@ -282,7 +293,7 @@ export class DropdownComponent implements AfterContentInit, OnChanges, OnDestroy ) .subscribe((state) => { if ('visible' in state) { - state?.visible === 'toggle' ? this.toggleDropdown() : (this.visible = state.visible); + state?.visible === 'toggle' ? this.toggleDropdown() : this.visible.set(state.visible); } }); } else { @@ -291,7 +302,7 @@ export class DropdownComponent implements AfterContentInit, OnChanges, OnDestroy } toggleDropdown(): void { - this.visible = !this.visible; + this.visible.update((visible) => !visible); } onClick(event: any): void { @@ -301,19 +312,13 @@ export class DropdownComponent implements AfterContentInit, OnChanges, OnDestroy } ngAfterContentInit(): void { - if (this.variant === 'nav-item') { + if (this.variant() === 'nav-item') { this.renderer.addClass(this._toggler.elementRef.nativeElement, 'nav-link'); } } ngOnInit(): void { - this.setVisibleState(this.visible); - } - - ngOnChanges(changes: SimpleChanges): void { - if (changes['visible'] && !changes['visible'].firstChange) { - this.setVisibleState(changes['visible'].currentValue); - } + this.setVisibleState(this.visible()); } ngOnDestroy(): void { @@ -333,7 +338,7 @@ export class DropdownComponent implements AfterContentInit, OnChanges, OnDestroy // workaround for popper position calculate (see also: dropdown-menu.component) this._menu.elementRef.nativeElement.style.visibility = 'hidden'; this._menu.elementRef.nativeElement.style.display = 'block'; - if (this.popper) { + if (this.popper()) { this.popperInstance = createPopper( this._toggler.elementRef.nativeElement, this._menu.elementRef.nativeElement, @@ -366,15 +371,16 @@ export class DropdownComponent implements AfterContentInit, OnChanges, OnDestroy if (this._toggler?.elementRef.nativeElement.contains(event.target)) { return; } - if (this.autoClose === true) { + const autoClose = this.autoClose(); + if (autoClose === true) { this.setVisibleState(false); return; } - if (this.clickedTarget === target && this.autoClose === 'inside') { + if (this.clickedTarget === target && autoClose === 'inside') { this.setVisibleState(false); return; } - if (this.clickedTarget !== target && this.autoClose === 'outside') { + if (this.clickedTarget !== target && autoClose === 'outside') { this.setVisibleState(false); return; } @@ -382,7 +388,7 @@ export class DropdownComponent implements AfterContentInit, OnChanges, OnDestroy ); this.listeners.push( this.renderer.listen(this.elementRef.nativeElement, 'keyup', (event) => { - if (event.key === 'Escape' && this.autoClose !== false) { + if (event.key === 'Escape' && this.autoClose() !== false) { event.stopPropagation(); this.setVisibleState(false); return; @@ -391,7 +397,11 @@ export class DropdownComponent implements AfterContentInit, OnChanges, OnDestroy ); this.listeners.push( this.renderer.listen(this.document, 'keyup', (event) => { - if (event.key === 'Tab' && this.autoClose !== false && !this.elementRef.nativeElement.contains(event.target)) { + if ( + event.key === 'Tab' && + this.autoClose() !== false && + !this.elementRef.nativeElement.contains(event.target) + ) { this.setVisibleState(false); return; }