Skip to content

Commit

Permalink
refactor(dropdown): signal inputs, host bindings, cleanup, tests
Browse files Browse the repository at this point in the history
  • Loading branch information
xidedix committed Feb 7, 2025
1 parent 46ddca6 commit 8542552
Show file tree
Hide file tree
Showing 8 changed files with 514 additions and 217 deletions.
Original file line number Diff line number Diff line change
@@ -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: `
<c-dropdown #dropdown="cDropdown" visible>
<div cDropdownMenu>
<button cButtonClose cDropdownClose [disabled]="disabled" [dropdownComponent]="dropdown" tabIndex="0"></button>
</div>
</c-dropdown>
`,
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<TestComponent>;
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();
}));
});
Original file line number Diff line number Diff line change
@@ -1,62 +1,64 @@
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);
dropdown? = inject(DropdownComponent, { optional: true });

/**
* 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<DropdownComponent>();

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<string | number | null>(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 });
}
}
}
Original file line number Diff line number Diff line change
@@ -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: `
<c-dropdown #dropdown="cDropdown" visible>
<ul cDropdownMenu>
<li>
<button cDropdownItem [active]="active" [disabled]="disabled" tabIndex="0" #item="cDropdownItem">
Action
</button>
</li>
</ul>
</c-dropdown>
`,
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<TestComponent>;
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', () => {
Expand All @@ -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);
}));
});
Loading

0 comments on commit 8542552

Please sign in to comment.