Skip to content

Commit 1468f7a

Browse files
committed
refactor(dropdown): use ThemeDirective composition for dark prop, implement FocusableOption interface for items, allow to select a dropdown-item with up/down arrows, testing update, use @input() transform
1 parent c43ffda commit 1468f7a

File tree

7 files changed

+142
-102
lines changed

7 files changed

+142
-102
lines changed
Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
1+
import { ElementRef } from '@angular/core';
2+
import { TestBed } from '@angular/core/testing';
13
import { DropdownService } from '../dropdown.service';
24
import { DropdownItemDirective } from './dropdown-item.directive';
35

6+
class MockElementRef extends ElementRef {}
7+
48
describe('DropdownItemDirective', () => {
59

10+
beforeEach(() => {
11+
TestBed.configureTestingModule({
12+
providers: [{ provide: ElementRef, useClass: MockElementRef }]
13+
});
14+
});
15+
616
it('should create an instance', () => {
7-
const dropdownService = new DropdownService();
8-
const directive = new DropdownItemDirective(dropdownService);
9-
expect(directive).toBeTruthy();
17+
TestBed.runInInjectionContext(() => {
18+
const dropdownService = new DropdownService();
19+
const directive = new DropdownItemDirective(dropdownService);
20+
expect(directive).toBeTruthy();
21+
});
1022
});
1123
});

projects/coreui-angular/src/lib/dropdown/dropdown-item/dropdown-item.directive.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Directive, HostBinding, HostListener, Input, Optional } from '@angular/core';
1+
import { Directive, ElementRef, HostBinding, HostListener, inject, Input, Optional } from '@angular/core';
2+
import { FocusableOption, FocusOrigin } from '@angular/cdk/a11y';
23
import { DropdownService } from '../dropdown.service';
34
import { DropdownComponent } from '../dropdown/dropdown.component';
45

@@ -7,7 +8,7 @@ import { DropdownComponent } from '../dropdown/dropdown.component';
78
exportAs: 'cDropdownItem',
89
standalone: true
910
})
10-
export class DropdownItemDirective {
11+
export class DropdownItemDirective implements FocusableOption {
1112
/**
1213
* Set active state to a dropdown-item.
1314
* @type boolean
@@ -27,12 +28,22 @@ export class DropdownItemDirective {
2728
*/
2829
@Input() disabled?: boolean;
2930

31+
#elementRef: ElementRef = inject(ElementRef);
32+
3033
constructor(
3134
private dropdownService: DropdownService,
3235
@Optional() public dropdown?: DropdownComponent
3336
) {
3437
}
3538

39+
focus(origin?: FocusOrigin | undefined): void {
40+
this.#elementRef?.nativeElement?.focus();
41+
}
42+
43+
getLabel?(): string {
44+
return this.#elementRef?.nativeElement?.textContent.trim();
45+
}
46+
3647
@HostBinding('attr.aria-current')
3748
get ariaCurrent(): string | null {
3849
return this.active ? 'true' : null;
@@ -52,9 +63,11 @@ export class DropdownItemDirective {
5263
set tabIndex(value: string | number | null) {
5364
this._tabIndex = value;
5465
}
66+
5567
get tabIndex() {
5668
return this.disabled ? '-1' : this._tabIndex;
5769
}
70+
5871
private _tabIndex: string | number | null = null;
5972

6073
@HostBinding('attr.aria-disabled')
Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
1-
import { ElementRef } from '@angular/core';
1+
import { ElementRef, Renderer2 } from '@angular/core';
2+
import { TestBed } from '@angular/core/testing';
23
import { DropdownService } from '../dropdown.service';
34
import { DropdownMenuDirective } from './dropdown-menu.directive';
45

6+
class MockElementRef extends ElementRef {}
7+
58
describe('DropdownMenuDirective', () => {
6-
let elementRef: ElementRef;
9+
10+
beforeEach(() => {
11+
TestBed.configureTestingModule({
12+
providers: [{ provide: ElementRef, useClass: MockElementRef }, Renderer2, DropdownService]
13+
});
14+
});
15+
716
it('should create an instance', () => {
8-
const dropdownService = new DropdownService();
9-
const directive = new DropdownMenuDirective(elementRef, dropdownService);
10-
expect(directive).toBeTruthy();
17+
TestBed.runInInjectionContext(() => {
18+
const directive = new DropdownMenuDirective();
19+
expect(directive).toBeTruthy();
20+
});
21+
1122
});
1223
});
Lines changed: 85 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,37 @@
1-
import { booleanAttribute, Directive, ElementRef, HostBinding, Input, OnDestroy, OnInit } from '@angular/core';
2-
import { Subscription } from 'rxjs';
1+
import {
2+
AfterContentInit,
3+
ContentChildren,
4+
DestroyRef,
5+
Directive,
6+
ElementRef,
7+
forwardRef,
8+
HostBinding,
9+
HostListener,
10+
inject,
11+
Input,
12+
OnInit,
13+
QueryList
14+
} from '@angular/core';
15+
import { FocusKeyManager } from '@angular/cdk/a11y';
16+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
17+
import { tap } from 'rxjs/operators';
18+
19+
import { ThemeDirective } from '../../shared/theme.directive';
320
import { DropdownService } from '../dropdown.service';
21+
import { DropdownItemDirective } from '../dropdown-item/dropdown-item.directive';
422

523
@Directive({
624
selector: '[cDropdownMenu]',
725
exportAs: 'cDropdownMenu',
8-
standalone: true
26+
standalone: true,
27+
hostDirectives: [{ directive: ThemeDirective, inputs: ['dark'] }]
928
})
10-
export class DropdownMenuDirective implements OnInit, OnDestroy {
29+
export class DropdownMenuDirective implements OnInit, AfterContentInit {
1130

12-
constructor(
13-
public elementRef: ElementRef,
14-
private dropdownService: DropdownService
15-
) {}
31+
readonly #destroyRef: DestroyRef = inject(DestroyRef);
32+
public readonly elementRef: ElementRef = inject(ElementRef);
33+
readonly #dropdownService: DropdownService = inject(DropdownService);
34+
#focusKeyManager!: FocusKeyManager<DropdownItemDirective>;
1635

1736
/**
1837
* Set alignment of dropdown menu.
@@ -22,55 +41,81 @@ export class DropdownMenuDirective implements OnInit, OnDestroy {
2241

2342
/**
2443
* Toggle the visibility of dropdown menu component.
25-
*/
26-
@Input() visible = false;
27-
28-
/**
29-
* Sets a darker color scheme to match a dark navbar.
3044
* @type boolean
3145
*/
32-
@Input({ transform: booleanAttribute }) dark: string | boolean = false;
33-
34-
private dropdownStateSubscription!: Subscription;
46+
@Input() visible: boolean = false;
3547

36-
@HostBinding('class')
37-
get hostClasses(): any {
48+
@HostBinding('class') get hostClasses(): any {
3849
return {
39-
'dropdown-menu': true,
40-
'dropdown-menu-dark': this.dark,
41-
[`dropdown-menu-${this.alignment}`]: !!this.alignment,
42-
show: this.visible
50+
'dropdown-menu': true, [`dropdown-menu-${this.alignment}`]: !!this.alignment, show: this.visible
4351
};
4452
}
4553

46-
@HostBinding('style')
47-
get hostStyles() {
54+
@HostBinding('style') get hostStyles() {
4855
// workaround for popper position calculate (see also: dropdown.component)
4956
return {
50-
visibility: this.visible ? null : '',
51-
display: this.visible ? null : ''
57+
visibility: this.visible ? null : '', display: this.visible ? null : ''
5258
};
5359
}
5460

55-
ngOnInit(): void {
56-
this.dropdownStateSubscribe();
61+
@HostListener('keydown', ['$event']) onKeyDown($event: KeyboardEvent): void {
62+
if (!this.visible) {
63+
return;
64+
}
65+
if (['Space', 'ArrowDown'].includes($event.code)) {
66+
$event.preventDefault();
67+
}
68+
this.#focusKeyManager.onKeydown($event);
5769
}
5870

59-
ngOnDestroy(): void {
60-
this.dropdownStateSubscribe(false);
71+
@HostListener('keyup', ['$event']) onKeyUp($event: KeyboardEvent): void {
72+
if (!this.visible) {
73+
return;
74+
}
75+
if (['Tab'].includes($event.key)) {
76+
if (this.#focusKeyManager.activeItem) {
77+
$event.shiftKey ? this.#focusKeyManager.setPreviousItemActive() : this.#focusKeyManager.setNextItemActive();
78+
} else {
79+
this.#focusKeyManager.setFirstItemActive();
80+
}
81+
}
6182
}
6283

63-
private dropdownStateSubscribe(subscribe: boolean = true): void {
64-
if (subscribe) {
65-
this.dropdownStateSubscription =
66-
this.dropdownService.dropdownState$.subscribe((state) => {
84+
@ContentChildren(forwardRef(() => DropdownItemDirective), { descendants: true }) dropdownItemsContent!: QueryList<DropdownItemDirective>;
85+
86+
ngAfterContentInit(): void {
87+
this.focusKeyManagerInit();
88+
89+
this.dropdownItemsContent.changes
90+
.pipe(
91+
tap((change) => {
92+
this.focusKeyManagerInit();
93+
}),
94+
takeUntilDestroyed(this.#destroyRef)
95+
).subscribe();
96+
}
97+
98+
ngOnInit(): void {
99+
this.#dropdownService.dropdownState$
100+
.pipe(
101+
tap((state) => {
67102
if ('visible' in state) {
68-
this.visible =
69-
state.visible === 'toggle' ? !this.visible : state.visible;
103+
this.visible = state.visible === 'toggle' ? !this.visible : state.visible;
104+
if (!this.visible) {
105+
this.#focusKeyManager?.setActiveItem(-1);
106+
}
70107
}
71-
});
72-
} else {
73-
this.dropdownStateSubscription?.unsubscribe();
74-
}
108+
}),
109+
takeUntilDestroyed(this.#destroyRef)
110+
).subscribe();
111+
}
112+
113+
private focusKeyManagerInit(): void {
114+
this.#focusKeyManager = new FocusKeyManager(this.dropdownItemsContent)
115+
.withHomeAndEnd()
116+
.withPageUpDown()
117+
.withWrap()
118+
.skipPredicate((dropdownItem) => (dropdownItem.disabled === true));
75119
}
120+
76121
}

projects/coreui-angular/src/lib/dropdown/dropdown.service.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@ export class DropdownService {
1212
private dropdownState = new BehaviorSubject<any>({});
1313
dropdownState$ = this.dropdownState.asObservable();
1414

15-
constructor() {
16-
}
17-
1815
toggle(state: IDropdownState): void {
1916
this.dropdownState.next(state);
2017
}

projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.scss

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
:host-context(.dropdown, .dropup):not(.btn-group) {
44
display: block;
5-
min-width: fit-content;
65
}
76

87
:host-context(.dropstart, .dropend):not(.btn-group) {

0 commit comments

Comments
 (0)