From c535347a9ce51544cb2b35fa2a0918d29d9054d2 Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 15:05:23 +0100 Subject: [PATCH 01/14] refactor(carousel-item): add attribute role = "group" --- .../carousel/carousel-item/carousel-item.component.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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) { From 713ecd280e4d5d26007f9490faf61985149e07df Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 15:07:18 +0100 Subject: [PATCH 02/14] refactor(carousel-inner): add aria-live "off" for interval > 0, otherwise "polite" --- .../carousel-inner/carousel-inner.component.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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'); } } From 84e30a1a64ae1c1e6dd7f1d2e52d92b7b417259c Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 15:08:33 +0100 Subject: [PATCH 03/14] fix(carousel-control): allow custom content (regression) --- .../carousel-control/carousel-control.component.html | 10 +++------- .../carousel-control/carousel-control.component.ts | 8 +------- 2 files changed, 4 insertions(+), 14 deletions(-) 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(); From 86a6aafb4f4f5bebf9942fd0bcd359a043342593 Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 15:10:03 +0100 Subject: [PATCH 04/14] refactor(carousel): add interval to carousel state --- projects/coreui-angular/src/lib/carousel/carousel-state.ts | 6 ++++-- .../coreui-angular/src/lib/carousel/carousel-state.type.ts | 7 ++++--- .../src/lib/carousel/carousel/carousel.component.ts | 5 ++++- 3 files changed, 12 insertions(+), 6 deletions(-) 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/carousel.component.ts b/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.ts index 99cf64bf..016a06ce 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(); From 8f9afe4f5ba80ee57b052c9242e000d2e1bfbccb Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 15:10:55 +0100 Subject: [PATCH 05/14] fix(carousel.config): set default interval to 0 --- projects/coreui-angular/src/lib/carousel/carousel.config.ts | 2 +- .../src/lib/carousel/carousel/carousel.component.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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(() => { From 46ddca6e530af3ddc145038b16476ca53e9446fc Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 21:02:58 +0100 Subject: [PATCH 06/14] fix(theme.directive): use colorScheme if dark not set --- projects/coreui-angular/src/lib/shared/theme.directive.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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(); }); From 8542552abbab1ecf4100e6f2105b0d2148ca38af Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 21:04:35 +0100 Subject: [PATCH 07/14] refactor(dropdown): signal inputs, host bindings, cleanup, tests --- .../dropdown-close.directive.spec.ts | 62 ++++- .../dropdown-close.directive.ts | 68 +++--- .../dropdown-item.directive.spec.ts | 73 +++++- .../dropdown-item/dropdown-item.directive.ts | 91 ++++---- .../dropdown-menu.directive.spec.ts | 84 ++++++- .../dropdown-menu/dropdown-menu.directive.ts | 65 +++--- .../dropdown/dropdown.component.spec.ts | 72 +++++- .../dropdown/dropdown/dropdown.component.ts | 216 +++++++++--------- 8 files changed, 514 insertions(+), 217 deletions(-) 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; } From 72b50c9d46c188aaba66c2fab47cb8e345d00309 Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 23:02:10 +0100 Subject: [PATCH 08/14] fix(carousel): when paused (interval=0) and manually changed slide, it does not restart when interval>0 --- .../src/lib/carousel/carousel/carousel.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 016a06ce..9805aa90 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.ts @@ -194,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) { @@ -217,6 +217,7 @@ export class CarouselComponent implements OnInit, OnDestroy, AfterContentInit { } this.activeItemInterval = typeof nextItem.interval === 'number' && nextItem.interval > -1 ? nextItem.interval : this.interval(); + console.log('activeItemInterval', nextItem.interval, this.activeItemInterval); const direction = this.direction(); const isLastItem = (nextItem.active === nextItem.lastItemIndex && direction === 'next') || From 1d72060a10298574a7ebe6a7ecf8956f921638d8 Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 23:03:09 +0100 Subject: [PATCH 09/14] refactor(progress-bar): set default value=0 --- .../coreui-angular/src/lib/progress/progress-bar.directive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From d571ca87e67a5fc29c847c83965079884bdd30ac Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 23:04:50 +0100 Subject: [PATCH 10/14] refactor(dropdown-item): set default value of disabled prop to false --- .../src/lib/dropdown/dropdown-item/dropdown-item.directive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 209c24af..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 @@ -40,7 +40,7 @@ export class DropdownItemDirective implements FocusableOption { * @return boolean * @default undefined */ - readonly disabledInput = input(undefined, { transform: booleanAttribute, alias: 'disabled' }); + readonly disabledInput = input(false, { transform: booleanAttribute, alias: 'disabled' }); readonly disabledEffect = linkedSignal({ source: this.disabledInput, From 2f69c84dd9a4c6b84550d0146bbc2cdedb3a6a58 Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 23:06:19 +0100 Subject: [PATCH 11/14] refactor(dropdown-close): set default value of disabled prop to false --- .../lib/dropdown/dropdown-close/dropdown-close.directive.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5eb9b525..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 @@ -20,9 +20,9 @@ export class DropdownCloseDirective implements AfterViewInit { /** * Disables a dropdown-close directive. * @return boolean - * @default undefined + * @default false */ - readonly disabledInput = input(undefined, { transform: booleanAttribute, alias: 'disabled' }); + readonly disabledInput = input(false, { transform: booleanAttribute, alias: 'disabled' }); readonly disabled = linkedSignal({ source: this.disabledInput, From 044a9eaeef5bb294648c09e56d4fede9a9f71790 Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 23:14:10 +0100 Subject: [PATCH 12/14] chore(dependencies): update --- package.json | 2 +- projects/coreui-angular/package.json | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 86e2cda4..6e9aa6b6 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,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..023cdb38 100644 --- a/projects/coreui-angular/package.json +++ b/projects/coreui-angular/package.json @@ -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": { From e43518af6ddc1a3c0c7d24694f173983e558e84d Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 23:15:12 +0100 Subject: [PATCH 13/14] chore(release): ship v5.3.13 --- CHANGELOG.md | 17 ++++++++++++++ package-lock.json | 35 +++++++++++++++++++--------- package.json | 3 ++- projects/coreui-angular/package.json | 2 +- 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6673eb5b..11bd23a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,23 @@ ### [@coreui/angular](https://coreui.io/angular/) changelog +--- + +#### `5.3.13` + +- 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..75660615 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coreui-angular-dev", - "version": "5.3.12", + "version": "5.3.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coreui-angular-dev", - "version": "5.3.12", + "version": "5.3.13", "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 6e9aa6b6..3376f077 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coreui-angular-dev", - "version": "5.3.12", + "version": "5.3.13", "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", diff --git a/projects/coreui-angular/package.json b/projects/coreui-angular/package.json index 023cdb38..67d48460 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.13", "description": "CoreUI Components Library for Angular", "copyright": "Copyright 2025 creativeLabs Łukasz Holeczek", "license": "MIT", From dbfa3f51d6483f44ec8a8ffc8a16904d04c5aa5f Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 7 Feb 2025 23:25:03 +0100 Subject: [PATCH 14/14] chore(release): ship v5.3.14 --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- projects/coreui-angular/package.json | 2 +- .../src/lib/carousel/carousel/carousel.component.ts | 1 - 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11bd23a8..bd8913af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ --- -#### `5.3.13` +#### `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" diff --git a/package-lock.json b/package-lock.json index 75660615..fb09b52d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coreui-angular-dev", - "version": "5.3.13", + "version": "5.3.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coreui-angular-dev", - "version": "5.3.13", + "version": "5.3.14", "license": "MIT", "dependencies": { "@angular/animations": "^19.1.5", diff --git a/package.json b/package.json index 3376f077..e5e20cc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coreui-angular-dev", - "version": "5.3.13", + "version": "5.3.14", "description": "CoreUI Components Library for Angular", "copyright": "Copyright 2025 creativeLabs Łukasz Holeczek", "license": "MIT", diff --git a/projects/coreui-angular/package.json b/projects/coreui-angular/package.json index 67d48460..dc2a1c55 100644 --- a/projects/coreui-angular/package.json +++ b/projects/coreui-angular/package.json @@ -1,6 +1,6 @@ { "name": "@coreui/angular", - "version": "5.3.13", + "version": "5.3.14", "description": "CoreUI Components Library for Angular", "copyright": "Copyright 2025 creativeLabs Łukasz Holeczek", "license": "MIT", 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 9805aa90..03f3fe26 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.ts @@ -217,7 +217,6 @@ export class CarouselComponent implements OnInit, OnDestroy, AfterContentInit { } this.activeItemInterval = typeof nextItem.interval === 'number' && nextItem.interval > -1 ? nextItem.interval : this.interval(); - console.log('activeItemInterval', nextItem.interval, this.activeItemInterval); const direction = this.direction(); const isLastItem = (nextItem.active === nextItem.lastItemIndex && direction === 'next') ||