Skip to content

Commit 8542552

Browse files
committed
refactor(dropdown): signal inputs, host bindings, cleanup, tests
1 parent 46ddca6 commit 8542552

8 files changed

+514
-217
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,69 @@
1-
import { DropdownCloseDirective } from './dropdown-close.directive';
2-
import { TestBed } from '@angular/core/testing';
1+
import { Component, DebugElement, ElementRef, Renderer2, viewChild } from '@angular/core';
2+
import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing';
3+
import { By } from '@angular/platform-browser';
34
import { DropdownService } from '../dropdown.service';
5+
import { DropdownCloseDirective } from './dropdown-close.directive';
6+
import { ButtonCloseDirective } from '../../button';
7+
import { DropdownComponent } from '../dropdown/dropdown.component';
8+
import { DropdownMenuDirective } from '../dropdown-menu/dropdown-menu.directive';
9+
10+
class MockElementRef extends ElementRef {}
11+
12+
@Component({
13+
template: `
14+
<c-dropdown #dropdown="cDropdown" visible>
15+
<div cDropdownMenu>
16+
<button cButtonClose cDropdownClose [disabled]="disabled" [dropdownComponent]="dropdown" tabIndex="0"></button>
17+
</div>
18+
</c-dropdown>
19+
`,
20+
imports: [ButtonCloseDirective, DropdownComponent, DropdownMenuDirective, DropdownCloseDirective]
21+
})
22+
class TestComponent {
23+
disabled = false;
24+
readonly dropdown = viewChild(DropdownComponent);
25+
}
426

527
describe('DropdownCloseDirective', () => {
6-
it('should create an instance', () => {
28+
let component: TestComponent;
29+
let fixture: ComponentFixture<TestComponent>;
30+
let elementRef: DebugElement;
31+
32+
beforeEach(() => {
733
TestBed.configureTestingModule({
8-
providers: [DropdownService]
34+
imports: [TestComponent],
35+
providers: [{ provide: ElementRef, useClass: MockElementRef }, Renderer2, DropdownService]
936
});
37+
38+
fixture = TestBed.createComponent(TestComponent);
39+
component = fixture.componentInstance;
40+
elementRef = fixture.debugElement.query(By.directive(DropdownCloseDirective));
41+
component.disabled = false;
42+
fixture.detectChanges(); // initial binding
43+
});
44+
45+
it('should create an instance', () => {
1046
TestBed.runInInjectionContext(() => {
1147
const directive = new DropdownCloseDirective();
1248
expect(directive).toBeTruthy();
1349
});
1450
});
51+
52+
it('should have css classes and attributes', fakeAsync(() => {
53+
expect(elementRef.nativeElement).not.toHaveClass('disabled');
54+
expect(elementRef.nativeElement.getAttribute('aria-disabled')).toBeNull();
55+
expect(elementRef.nativeElement.getAttribute('tabindex')).toBe('0');
56+
component.disabled = true;
57+
fixture.detectChanges();
58+
expect(elementRef.nativeElement).toHaveClass('disabled');
59+
expect(elementRef.nativeElement.getAttribute('aria-disabled')).toBe('true');
60+
expect(elementRef.nativeElement.getAttribute('tabindex')).toBe('-1');
61+
}));
62+
63+
it('should call event handling functions', fakeAsync(() => {
64+
expect(component.dropdown()?.visible()).toBeTrue();
65+
elementRef.nativeElement.dispatchEvent(new Event('click'));
66+
elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
67+
expect(component.dropdown()?.visible()).toBeFalse();
68+
}));
1569
});
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,64 @@
1-
import { AfterViewInit, Directive, HostBinding, HostListener, inject, Input } from '@angular/core';
1+
import { AfterViewInit, booleanAttribute, Directive, inject, input, linkedSignal } from '@angular/core';
22
import { DropdownService } from '../dropdown.service';
33
import { DropdownComponent } from '../dropdown/dropdown.component';
44

55
@Directive({
66
selector: '[cDropdownClose]',
7-
exportAs: 'cDropdownClose'
7+
exportAs: 'cDropdownClose',
8+
host: {
9+
'[class.disabled]': 'disabled()',
10+
'[attr.aria-disabled]': 'disabled() || null',
11+
'[attr.tabindex]': 'tabIndex()',
12+
'(click)': 'onClick($event)',
13+
'(keyup)': 'onKeyUp($event)'
14+
}
815
})
916
export class DropdownCloseDirective implements AfterViewInit {
1017
#dropdownService = inject(DropdownService);
1118
dropdown? = inject(DropdownComponent, { optional: true });
1219

1320
/**
1421
* Disables a dropdown-close directive.
15-
* @type boolean
22+
* @return boolean
1623
* @default undefined
1724
*/
18-
@Input() disabled?: boolean;
25+
readonly disabledInput = input(undefined, { transform: booleanAttribute, alias: 'disabled' });
26+
27+
readonly disabled = linkedSignal({
28+
source: this.disabledInput,
29+
computation: (value) => value || null
30+
});
1931

20-
@Input() dropdownComponent?: DropdownComponent;
32+
readonly dropdownComponent = input<DropdownComponent>();
2133

2234
ngAfterViewInit(): void {
23-
if (this.dropdownComponent) {
24-
this.dropdown = this.dropdownComponent;
25-
this.#dropdownService = this.dropdownComponent?.dropdownService;
35+
const dropdownComponent = this.dropdownComponent();
36+
if (dropdownComponent) {
37+
this.dropdown = dropdownComponent;
38+
this.#dropdownService = dropdownComponent?.dropdownService;
2639
}
2740
}
2841

29-
@HostBinding('class')
30-
get hostClasses(): any {
31-
return {
32-
disabled: this.disabled
33-
};
34-
}
42+
readonly tabIndexInput = input<string | number | null>(null, { alias: 'tabIndex' });
3543

36-
@HostBinding('attr.tabindex')
37-
@Input()
38-
set tabIndex(value: string | number | null) {
39-
this._tabIndex = value;
40-
}
41-
get tabIndex() {
42-
return this.disabled ? '-1' : this._tabIndex;
43-
}
44-
private _tabIndex: string | number | null = null;
44+
readonly tabIndex = linkedSignal({
45+
source: this.tabIndexInput,
46+
computation: (value) => (this.disabled() ? '-1' : value)
47+
});
4548

46-
@HostBinding('attr.aria-disabled')
47-
get isDisabled(): boolean | null {
48-
return this.disabled || null;
49+
onClick($event: MouseEvent): void {
50+
this.handleToggle();
4951
}
5052

51-
@HostListener('click', ['$event'])
52-
private onClick($event: MouseEvent): void {
53-
!this.disabled && this.#dropdownService.toggle({ visible: false, dropdown: this.dropdown });
53+
onKeyUp($event: KeyboardEvent): void {
54+
if ($event.key === 'Enter') {
55+
this.handleToggle();
56+
}
5457
}
5558

56-
@HostListener('keyup', ['$event'])
57-
private onKeyUp($event: KeyboardEvent): void {
58-
if ($event.key === 'Enter') {
59-
!this.disabled && this.#dropdownService.toggle({ visible: false, dropdown: this.dropdown });
59+
private handleToggle(): void {
60+
if (!this.disabled()) {
61+
this.#dropdownService.toggle({ visible: false, dropdown: this.dropdown });
6062
}
6163
}
6264
}
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,54 @@
1-
import { ElementRef } from '@angular/core';
2-
import { TestBed } from '@angular/core/testing';
1+
import { Component, DebugElement, ElementRef, Renderer2, viewChild } from '@angular/core';
2+
import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing';
3+
34
import { DropdownItemDirective } from './dropdown-item.directive';
45
import { DropdownService } from '../dropdown.service';
6+
import { DropdownComponent } from '../dropdown/dropdown.component';
7+
import { DropdownMenuDirective } from '../dropdown-menu/dropdown-menu.directive';
8+
import { By } from '@angular/platform-browser';
9+
import { DOCUMENT } from '@angular/common';
510

611
class MockElementRef extends ElementRef {}
712

13+
@Component({
14+
template: `
15+
<c-dropdown #dropdown="cDropdown" visible>
16+
<ul cDropdownMenu>
17+
<li>
18+
<button cDropdownItem [active]="active" [disabled]="disabled" tabIndex="0" #item="cDropdownItem">
19+
Action
20+
</button>
21+
</li>
22+
</ul>
23+
</c-dropdown>
24+
`,
25+
imports: [DropdownComponent, DropdownMenuDirective, DropdownItemDirective]
26+
})
27+
class TestComponent {
28+
disabled = false;
29+
active = false;
30+
readonly dropdown = viewChild(DropdownComponent);
31+
readonly item = viewChild(DropdownItemDirective);
32+
}
33+
834
describe('DropdownItemDirective', () => {
35+
let component: TestComponent;
36+
let fixture: ComponentFixture<TestComponent>;
37+
let elementRef: DebugElement;
38+
let document: Document;
39+
940
beforeEach(() => {
1041
TestBed.configureTestingModule({
11-
providers: [{ provide: ElementRef, useClass: MockElementRef }, DropdownService]
42+
imports: [TestComponent],
43+
providers: [{ provide: ElementRef, useClass: MockElementRef }, Renderer2, DropdownService]
1244
});
45+
46+
document = TestBed.inject(DOCUMENT);
47+
fixture = TestBed.createComponent(TestComponent);
48+
component = fixture.componentInstance;
49+
elementRef = fixture.debugElement.query(By.directive(DropdownItemDirective));
50+
component.disabled = false;
51+
fixture.detectChanges(); // initial binding
1352
});
1453

1554
it('should create an instance', () => {
@@ -18,4 +57,32 @@ describe('DropdownItemDirective', () => {
1857
expect(directive).toBeTruthy();
1958
});
2059
});
60+
61+
it('should have css classes and attributes', fakeAsync(() => {
62+
expect(elementRef.nativeElement).not.toHaveClass('disabled');
63+
expect(elementRef.nativeElement.getAttribute('aria-disabled')).toBeNull();
64+
expect(elementRef.nativeElement.getAttribute('aria-current')).toBeNull();
65+
expect(elementRef.nativeElement.getAttribute('tabindex')).toBe('0');
66+
component.disabled = true;
67+
component.active = true;
68+
fixture.detectChanges();
69+
expect(elementRef.nativeElement).toHaveClass('disabled');
70+
expect(elementRef.nativeElement.getAttribute('aria-disabled')).toBe('true');
71+
expect(elementRef.nativeElement.getAttribute('aria-current')).toBe('true');
72+
expect(elementRef.nativeElement.getAttribute('tabindex')).toBe('-1');
73+
}));
74+
75+
it('should call event handling functions', fakeAsync(() => {
76+
expect(component.dropdown()?.visible()).toBeTrue();
77+
elementRef.nativeElement.dispatchEvent(new Event('click'));
78+
elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
79+
elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
80+
fixture.detectChanges();
81+
elementRef.nativeElement.focus();
82+
// @ts-ignore
83+
const label = component.item()?.getLabel() ?? undefined;
84+
expect(label).toBe('Action');
85+
component.item()?.focus();
86+
expect(document.activeElement).toBe(elementRef.nativeElement);
87+
}));
2188
});

0 commit comments

Comments
 (0)