diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6673eb5b..bd8913af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,23 @@
### [@coreui/angular](https://coreui.io/angular/) changelog
+---
+
+#### `5.3.14`
+
+- fix(carousel): when paused (interval=0) and manually changed slide, it does not restart when interval>0
+- refactor(carousel-item): add attribute role = "group"
+- refactor(carousel-inner): add aria-live "off" for interval > 0, otherwise "polite"
+- fix(carousel-control): allow custom content (regression)
+- refactor(carousel): add interval to carousel state
+- fix(carousel.config): set default interval to 0
+- fix(theme.directive): use colorScheme if dark not set
+- refactor(progress-bar): set default value=0
+- refactor(dropdown): signal inputs, host bindings, cleanup, tests
+- refactor(dropdown-item): set default value of disabled prop to false
+- refactor(dropdown-close): set default value of disabled prop to false
+- chore(dependencies): update
+
---
#### `5.3.12`
diff --git a/package-lock.json b/package-lock.json
index 2532f92f..fb09b52d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "coreui-angular-dev",
- "version": "5.3.12",
+ "version": "5.3.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coreui-angular-dev",
- "version": "5.3.12",
+ "version": "5.3.14",
"license": "MIT",
"dependencies": {
"@angular/animations": "^19.1.5",
@@ -39,7 +39,7 @@
"@types/node": "^22.13.1",
"angular-eslint": "^19.0.2",
"copyfiles": "^2.4.1",
- "eslint": "^9.19.0",
+ "eslint": "^9.20.0",
"jasmine-core": "^5.5.0",
"karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0",
@@ -3024,9 +3024,9 @@
}
},
"node_modules/@eslint/js": {
- "version": "9.19.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz",
- "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==",
+ "version": "9.20.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.20.0.tgz",
+ "integrity": "sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8200,18 +8200,18 @@
}
},
"node_modules/eslint": {
- "version": "9.19.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz",
- "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==",
+ "version": "9.20.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.0.tgz",
+ "integrity": "sha512-aL4F8167Hg4IvsW89ejnpTwx+B/UQRzJPGgbIOl+4XqffWsahVVsLEWoZvnrVuwpWmnRd7XeXmQI1zlKcFDteA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.19.0",
- "@eslint/core": "^0.10.0",
+ "@eslint/core": "^0.11.0",
"@eslint/eslintrc": "^3.2.0",
- "@eslint/js": "9.19.0",
+ "@eslint/js": "9.20.0",
"@eslint/plugin-kit": "^0.2.5",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@@ -8289,6 +8289,19 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/eslint/node_modules/@eslint/core": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.11.0.tgz",
+ "integrity": "sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
"node_modules/eslint/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
diff --git a/package.json b/package.json
index 86e2cda4..e5e20cc8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "coreui-angular-dev",
- "version": "5.3.12",
+ "version": "5.3.14",
"description": "CoreUI Components Library for Angular",
"copyright": "Copyright 2025 creativeLabs Łukasz Holeczek",
"license": "MIT",
@@ -11,6 +11,7 @@
"build:lib:prod": "ng build coreui-angular",
"postbuild:lib:prod": "npm run build --prefix projects/coreui-angular",
"test:lib:dev": "ng test coreui-angular",
+ "test:lib:cov": "ng test --watch --code-coverage coreui-angular ",
"test:lib:prod": "ng test coreui-angular --karma-config=projects/coreui-angular/karma.conf.github.js",
"prepublish:lib": "npm run prepublish:icons && ng lint coreui-angular && ng test coreui-angular --watch=false && npm run build:lib:prod",
"publish:lib": "cd dist/coreui-angular/ && npm publish --tag next --dry-run",
@@ -69,7 +70,7 @@
"@types/node": "^22.13.1",
"angular-eslint": "^19.0.2",
"copyfiles": "^2.4.1",
- "eslint": "^9.19.0",
+ "eslint": "^9.20.0",
"jasmine-core": "^5.5.0",
"karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0",
diff --git a/projects/coreui-angular/package.json b/projects/coreui-angular/package.json
index 07071ea7..dc2a1c55 100644
--- a/projects/coreui-angular/package.json
+++ b/projects/coreui-angular/package.json
@@ -1,6 +1,6 @@
{
"name": "@coreui/angular",
- "version": "5.3.12",
+ "version": "5.3.14",
"description": "CoreUI Components Library for Angular",
"copyright": "Copyright 2025 creativeLabs Łukasz Holeczek",
"license": "MIT",
@@ -23,13 +23,13 @@
},
"sideEffects": false,
"peerDependencies": {
- "@angular/animations": "^19.1.5",
- "@angular/cdk": "^19.1.3",
- "@angular/common": "^19.1.5",
- "@angular/core": "^19.1.5",
- "@angular/router": "^19.1.5",
+ "@angular/animations": "^19.1.0",
+ "@angular/cdk": "^19.1.0",
+ "@angular/common": "^19.1.0",
+ "@angular/core": "^19.1.0",
+ "@angular/router": "^19.1.0",
"@coreui/coreui": "^5.2.0",
- "@coreui/icons-angular": "~5.3.9",
+ "@coreui/icons-angular": "~5.3.12",
"rxjs": "^7.8.1"
},
"repository": {
diff --git a/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.html b/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.html
index 76066c0d..a157f590 100644
--- a/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.html
+++ b/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.html
@@ -1,8 +1,4 @@
-@if (hasContent()) {
-
-
-
-} @else {
-
+
+
{{ caption() }}
-}
+
diff --git a/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.ts b/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.ts
index 12d9375a..979f5a8c 100644
--- a/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.ts
+++ b/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.ts
@@ -1,4 +1,4 @@
-import { Component, computed, ElementRef, inject, input, linkedSignal, viewChild } from '@angular/core';
+import { Component, computed, inject, input, linkedSignal } from '@angular/core';
import { CarouselState } from '../carousel-state';
@@ -49,12 +49,6 @@ export class CarouselControlComponent {
return `carousel-control-${this.direction()}-icon`;
});
- readonly content = viewChild('content', { read: ElementRef });
-
- readonly hasContent = computed(() => {
- return this.content()?.nativeElement.childNodes.length ?? false;
- });
-
onKeyUp($event: KeyboardEvent): void {
if ($event.key === 'Enter') {
this.#play();
diff --git a/projects/coreui-angular/src/lib/carousel/carousel-inner/carousel-inner.component.ts b/projects/coreui-angular/src/lib/carousel/carousel-inner/carousel-inner.component.ts
index 76cf221e..838b27ee 100644
--- a/projects/coreui-angular/src/lib/carousel/carousel-inner/carousel-inner.component.ts
+++ b/projects/coreui-angular/src/lib/carousel/carousel-inner/carousel-inner.component.ts
@@ -19,7 +19,8 @@ import { carouselPlay } from '../carousel.animation';
host: {
class: 'carousel-inner',
'[@carouselPlay]': 'slideType()',
- '[@.disabled]': '!animate()'
+ '[@.disabled]': '!animate()',
+ '[attr.aria-live]': 'ariaLive()'
}
})
export class CarouselInnerComponent implements AfterContentInit, AfterContentChecked {
@@ -27,6 +28,7 @@ export class CarouselInnerComponent implements AfterContentInit, AfterContentChe
readonly activeIndex = signal(undefined);
readonly animate = signal(true);
+ readonly interval = signal(0);
readonly slide = signal({ left: true });
readonly transition = signal('crossfade');
@@ -34,6 +36,10 @@ export class CarouselInnerComponent implements AfterContentInit, AfterContentChe
return { left: this.slide().left, type: this.transition() };
});
+ readonly ariaLive = computed(() => {
+ return this.interval() ? 'off' : 'polite';
+ });
+
readonly contentItems = contentChildren(CarouselItemComponent);
readonly #prevContentItems = signal([]);
@@ -48,8 +54,9 @@ export class CarouselInnerComponent implements AfterContentInit, AfterContentChe
const nextDirection = state?.direction;
if (this.activeIndex() !== nextIndex) {
this.animate.set(state?.animate ?? false);
- this.slide.set({ left: nextDirection === 'next' });
this.activeIndex.set(state?.activeItemIndex);
+ this.interval.set(state?.interval ?? 0);
+ this.slide.set({ left: nextDirection === 'next' });
this.transition.set(state?.transition ?? 'slide');
}
}
diff --git a/projects/coreui-angular/src/lib/carousel/carousel-item/carousel-item.component.ts b/projects/coreui-angular/src/lib/carousel/carousel-item/carousel-item.component.ts
index 1db5b881..2d6122a8 100644
--- a/projects/coreui-angular/src/lib/carousel/carousel-item/carousel-item.component.ts
+++ b/projects/coreui-angular/src/lib/carousel/carousel-item/carousel-item.component.ts
@@ -10,7 +10,8 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
exportAs: 'cCarouselItem',
host: {
class: 'carousel-item',
- '[class.active]': 'active()'
+ '[class.active]': 'active()',
+ '[attr.role]': 'role()'
}
})
export class CarouselItemComponent {
@@ -38,6 +39,13 @@ export class CarouselItemComponent {
*/
readonly interval = input(-1);
+ /**
+ * Carousel item role.
+ * @return string
+ * @default 'group'
+ */
+ readonly role = input('group');
+
constructor() {
this.#carouselService.carouselIndex$.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((nextIndex) => {
if ('active' in nextIndex) {
diff --git a/projects/coreui-angular/src/lib/carousel/carousel-state.ts b/projects/coreui-angular/src/lib/carousel/carousel-state.ts
index ab311e38..99f84859 100644
--- a/projects/coreui-angular/src/lib/carousel/carousel-state.ts
+++ b/projects/coreui-angular/src/lib/carousel/carousel-state.ts
@@ -12,7 +12,8 @@ export class CarouselState {
animate: true,
items: [],
direction: 'next',
- transition: 'slide'
+ transition: 'slide',
+ interval: 0
};
get state(): ICarouselState {
@@ -75,7 +76,8 @@ export class CarouselState {
animate: true,
items: [],
direction: 'next',
- transition: 'slide'
+ transition: 'slide',
+ interval: 0
};
}
}
diff --git a/projects/coreui-angular/src/lib/carousel/carousel-state.type.ts b/projects/coreui-angular/src/lib/carousel/carousel-state.type.ts
index 6b549ddb..cfc2660d 100644
--- a/projects/coreui-angular/src/lib/carousel/carousel-state.type.ts
+++ b/projects/coreui-angular/src/lib/carousel/carousel-state.type.ts
@@ -1,17 +1,18 @@
import { CarouselItemComponent } from './carousel-item/carousel-item.component';
export interface ICarouselOptions {
- interval?: number;
- animate?: boolean;
activeIndex?: number;
+ animate?: boolean;
direction?: 'next' | 'prev';
+ interval?: number;
transition?: 'slide' | 'crossfade';
}
export interface ICarouselState {
activeItemIndex?: number;
animate?: boolean;
- items?: CarouselItemComponent[];
direction?: 'next' | 'prev';
+ interval?: number;
+ items?: CarouselItemComponent[];
transition?: 'slide' | 'crossfade';
}
diff --git a/projects/coreui-angular/src/lib/carousel/carousel.config.ts b/projects/coreui-angular/src/lib/carousel/carousel.config.ts
index 8680c6e3..140d4bb0 100644
--- a/projects/coreui-angular/src/lib/carousel/carousel.config.ts
+++ b/projects/coreui-angular/src/lib/carousel/carousel.config.ts
@@ -9,5 +9,5 @@ export class CarouselConfig {
/* Default direction of auto changing of slides */
direction: 'next' | 'prev' = 'next';
/* Default interval of auto changing of slides */
- interval = 3000;
+ interval = 0;
}
diff --git a/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.spec.ts b/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.spec.ts
index 1b10f19b..56623ebc 100644
--- a/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.spec.ts
+++ b/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.spec.ts
@@ -41,7 +41,7 @@ describe('CarouselComponent', () => {
expect(component.activeIndex()).toBe(0);
expect(component.animate()).toBe(true);
expect(component.direction()).toBe('next');
- expect(component.interval()).toBe(3000);
+ expect(component.interval()).toBe(0);
});
it('should call timer functions', fakeAsync(() => {
diff --git a/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.ts b/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.ts
index 99cf64bf..03f3fe26 100644
--- a/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.ts
+++ b/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.ts
@@ -100,7 +100,9 @@ export class CarouselComponent implements OnInit, OnDestroy, AfterContentInit {
});
readonly #intervalEffect = effect(() => {
- this.interval() ? this.setTimer() : this.resetTimer();
+ const interval = this.interval();
+ this.#carouselState.state = { interval: interval };
+ interval ? this.setTimer() : this.resetTimer();
});
/**
@@ -156,6 +158,7 @@ export class CarouselComponent implements OnInit, OnDestroy, AfterContentInit {
this.#carouselState.state = {
activeItemIndex: this.activeIndex(),
animate: this.animate(),
+ interval: this.interval(),
transition: this.transition()
};
this.setListeners();
@@ -191,7 +194,7 @@ export class CarouselComponent implements OnInit, OnDestroy, AfterContentInit {
#visible: boolean = true;
setTimer(): void {
- const interval = this.activeItemInterval || 0;
+ const interval = this.activeItemInterval || this.interval();
const direction = this.direction();
this.resetTimer();
if (interval > 0) {
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..74b381fc 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
- * @default undefined
+ * @return boolean
+ * @default false
*/
- @Input() disabled?: boolean;
+ readonly disabledInput = input(false, { 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..431f1f94 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(false, { 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;
}
diff --git a/projects/coreui-angular/src/lib/progress/progress-bar.directive.ts b/projects/coreui-angular/src/lib/progress/progress-bar.directive.ts
index 31fb2baa..6e972d0e 100644
--- a/projects/coreui-angular/src/lib/progress/progress-bar.directive.ts
+++ b/projects/coreui-angular/src/lib/progress/progress-bar.directive.ts
@@ -64,7 +64,7 @@ export class ProgressBarDirective {
* @return number
* @default 0
*/
- readonly value = input(undefined, { transform: numberAttribute });
+ readonly value = input(0, { transform: numberAttribute });
/**
* Set the progress bar variant to optional striped.
diff --git a/projects/coreui-angular/src/lib/shared/theme.directive.ts b/projects/coreui-angular/src/lib/shared/theme.directive.ts
index 3ad57826..d3cc3a3e 100644
--- a/projects/coreui-angular/src/lib/shared/theme.directive.ts
+++ b/projects/coreui-angular/src/lib/shared/theme.directive.ts
@@ -1,4 +1,4 @@
-import { booleanAttribute, Directive, effect, ElementRef, inject, input, Renderer2 } from '@angular/core';
+import { booleanAttribute, Directive, effect, ElementRef, inject, input, Renderer2, untracked } from '@angular/core';
@Directive({
selector: '[cTheme]',
@@ -22,7 +22,7 @@ export class ThemeDirective {
readonly dark = input(false, { transform: booleanAttribute });
readonly #darkChange = effect(() => {
- const darkTheme = this.dark();
+ const darkTheme = this.dark() || untracked(this.colorScheme) === 'dark';
darkTheme ? this.setTheme('dark') : this.unsetTheme();
});