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;
}