{
providers: [
{ provide: ElementRef, useClass: MockElementRef },
DropdownService,
- DropdownComponent
- // Renderer2,
+ DropdownComponent,
+ { provide: Renderer2, useValue: { setAttribute: vi.fn() } }
// ChangeDetectorRef
]
});
@@ -164,7 +171,7 @@ describe('DropdownToggleDirective', () => {
elementRef.nativeElement.dispatchEvent(new MouseEvent('click'));
fixture.detectChanges();
expect(component.visible()).toBe(true);
- dropdownRef.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
+ dropdownRef.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true }));
fixture.detectChanges();
expect(component.visible()).toBe(false);
component.visible.set(true);
@@ -175,6 +182,30 @@ describe('DropdownToggleDirective', () => {
});
});
+@Component({
+ template: `
+
+
+
+
+ `,
+ imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective]
+})
+class InitiallyVisibleComponent {}
+
+describe('DropdownToggleDirective initially visible', () => {
+ it('should set aria-expanded on first render', () => {
+ const fixture = TestBed.createComponent(InitiallyVisibleComponent);
+ fixture.detectChanges();
+
+ const toggle = fixture.debugElement.query(By.directive(DropdownToggleDirective)).nativeElement;
+ const menu = fixture.debugElement.query(By.directive(DropdownMenuDirective)).nativeElement;
+
+ expect(menu.classList.contains('show')).toBe(true);
+ expect(toggle.getAttribute('aria-expanded')).toBe('true');
+ });
+});
+
@Component({
template: `
@@ -213,3 +244,535 @@ describe('DropdownComponent alignment scope', () => {
expect(second.classList.contains('dropdown-menu-end')).toBe(false);
});
});
+
+@Component({
+ template: `
+
+
+
+
+ `,
+ imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective]
+})
+class KeyboardTestComponent {
+ readonly visible = signal(false);
+}
+
+describe('Dropdown keyboard', () => {
+ let fixture: ComponentFixture;
+ let toggle: HTMLElement;
+ let items: HTMLElement[];
+
+ const keydown = async (target: HTMLElement, key: string) => {
+ const keyCode = { ArrowDown: 40, ArrowUp: 38, End: 35, Home: 36 }[key];
+ target.dispatchEvent(new KeyboardEvent('keydown', { key, keyCode, bubbles: true, cancelable: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ };
+
+ beforeEach(async () => {
+ fixture = TestBed.createComponent(KeyboardTestComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ toggle = fixture.nativeElement.querySelector('#toggle');
+ items = [...fixture.nativeElement.querySelectorAll('.dropdown-item')];
+ toggle.focus();
+ });
+
+ it('should open and focus the first item on ArrowDown on the toggle', async () => {
+ await keydown(toggle, 'ArrowDown');
+ expect(fixture.componentInstance.visible()).toBe(true);
+ expect(document.activeElement).toBe(items[0]);
+ });
+
+ it('should open and focus the last item on ArrowUp on the toggle', async () => {
+ await keydown(toggle, 'ArrowUp');
+ expect(fixture.componentInstance.visible()).toBe(true);
+ expect(document.activeElement).toBe(items[2]);
+ });
+
+ it('should focus the first item on ArrowDown on the toggle of an open menu', async () => {
+ fixture.componentInstance.visible.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ await keydown(toggle, 'ArrowDown');
+ expect(document.activeElement).toBe(items[0]);
+ });
+
+ it('should ignore ArrowDown from a field inside the toggle', async () => {
+ const field = fixture.nativeElement.querySelector('#field');
+ field.focus();
+ await keydown(field, 'ArrowDown');
+ expect(fixture.componentInstance.visible()).toBe(false);
+ expect(document.activeElement).toBe(field);
+ });
+
+ it('should continue arrow navigation from the item that received focus', async () => {
+ await keydown(toggle, 'ArrowDown');
+ items[2].focus();
+ await keydown(items[2], 'ArrowUp');
+ expect(document.activeElement).toBe(items[1]);
+ });
+
+ it('should wrap the arrows and jump with Home and End', async () => {
+ await keydown(toggle, 'ArrowUp');
+ expect(document.activeElement).toBe(items[2]);
+ await keydown(items[2], 'ArrowDown');
+ expect(document.activeElement).toBe(items[0]);
+ await keydown(items[0], 'End');
+ expect(document.activeElement).toBe(items[2]);
+ await keydown(items[2], 'Home');
+ expect(document.activeElement).toBe(items[0]);
+ });
+
+ it('should move focus to the first item only after the menu rendered', async () => {
+ const dropdownRef = fixture.debugElement.query(By.directive(DropdownComponent));
+ const service = dropdownRef.injector.get(DropdownService);
+ service.toggle({ visible: true, dropdown: dropdownRef.componentInstance, focus: 'first' });
+ expect(document.activeElement).not.toBe(items[0]);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(document.activeElement).toBe(items[0]);
+ });
+
+ it('should focus the toggle on click', async () => {
+ toggle.blur();
+ toggle.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.visible()).toBe(true);
+ expect(document.activeElement).toBe(toggle);
+ });
+
+ it('should keep focus on a field inside the toggle on click', async () => {
+ const field = fixture.nativeElement.querySelector('#field');
+ field.focus();
+ field.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(document.activeElement).toBe(field);
+ });
+
+ it('should close and focus the toggle on Escape from an item', async () => {
+ fixture.componentInstance.visible.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ items[0].focus();
+ items[0].dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.visible()).toBe(false);
+ expect(document.activeElement).toBe(toggle);
+ });
+});
+
+@Component({
+ template: `
+
+ Toggle
+
+
+
+ Link
+
+
+
+ Custom
+
+
+
+
+
+
+
+ Router
+
+
+
+ Search
+
+
+
+ Off
+
+
+ `,
+ imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective, RouterLink]
+})
+class AnchorToggleComponent {
+ readonly disabledVisible = signal(false);
+ readonly visible = signal(false);
+ readonly nestedVisible = signal(false);
+}
+
+describe('DropdownToggleDirective on anchor', () => {
+ let fixture: ComponentFixture;
+ const element = (id: string): HTMLElement => fixture.nativeElement.querySelector(`#${id}`);
+ const keydown = async (target: HTMLElement, key: string) => {
+ const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
+ target.dispatchEvent(event);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ return event.defaultPrevented;
+ };
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({ providers: [provideRouter([])] });
+ fixture = TestBed.createComponent(AnchorToggleComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ });
+
+ it('should expose an anchor toggle as a button and keep its tabindex and href', () => {
+ expect(element('toggle').getAttribute('role')).toBe('button');
+ expect(element('toggle').hasAttribute('tabindex')).toBe(false);
+ expect(element('link').getAttribute('role')).toBe('button');
+ expect(element('link').hasAttribute('tabindex')).toBe(false);
+ expect(element('custom').getAttribute('role')).toBe('tab');
+ expect(element('custom').getAttribute('tabindex')).toBe('-1');
+ expect(element('button').hasAttribute('role')).toBe(false);
+ expect(element('button').hasAttribute('tabindex')).toBe(false);
+ expect(element('router').getAttribute('href')).toBe('/route');
+ expect(element('router').getAttribute('role')).toBe('button');
+ expect(element('router').hasAttribute('tabindex')).toBe(false);
+ });
+
+ it('should toggle an anchor without href on Enter and Space', async () => {
+ const toggle = element('toggle');
+ expect(await keydown(toggle, 'Enter')).toBe(true);
+ expect(fixture.componentInstance.visible()).toBe(true);
+ expect(await keydown(toggle, ' ')).toBe(true);
+ expect(fixture.componentInstance.visible()).toBe(false);
+ });
+
+ it('should toggle once while Space is held on an anchor toggle', async () => {
+ const toggle = element('toggle');
+ await keydown(toggle, ' ');
+ toggle.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', repeat: true, bubbles: true, cancelable: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.visible()).toBe(true);
+ });
+
+ it('should expose an anchor toggle with interactive content as a button too', () => {
+ expect(element('nested').getAttribute('role')).toBe('button');
+ });
+
+ it('should not prevent Enter and Space keydown on a native control inside an anchor toggle', async () => {
+ const go = element('go');
+ go.focus();
+ expect(await keydown(go, 'Enter')).toBe(false);
+ expect(await keydown(go, ' ')).toBe(false);
+ });
+
+ it('should not expose aria-disabled from the directive input', () => {
+ expect(element('disabledAnchor').hasAttribute('aria-disabled')).toBe(false);
+ });
+
+ it('should ignore the arrows on a disabled anchor toggle', async () => {
+ await keydown(element('disabledAnchor'), 'ArrowDown');
+ expect(fixture.componentInstance.disabledVisible()).toBe(false);
+ });
+
+ it('should leave Space and Enter to a field inside an anchor toggle', async () => {
+ const field = element('nestedField');
+ field.focus();
+ expect(await keydown(field, ' ')).toBe(false);
+ expect(await keydown(field, 'Enter')).toBe(false);
+ expect(fixture.componentInstance.nestedVisible()).toBe(false);
+ });
+
+ it('should leave Enter on an anchor with href to the native click', async () => {
+ expect(await keydown(element('link'), 'Enter')).toBe(false);
+ });
+});
+
+@Component({
+ template: `
+
+
+
+
+ `,
+ imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective]
+})
+class ExternalToggleComponent {
+ readonly visible = signal(false);
+}
+
+describe('DropdownToggleDirective outside the dropdown', () => {
+ it('should stay open when change detection runs before the click reaches document', async () => {
+ const fixture = TestBed.createComponent(ExternalToggleComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const toggle: HTMLElement = fixture.nativeElement.querySelector('#external');
+ fixture.nativeElement.addEventListener('click', () => fixture.detectChanges());
+ toggle.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.visible()).toBe(true);
+ });
+
+ it('should close on Escape pressed on the external toggle and stay open on Tab from it', async () => {
+ const fixture = TestBed.createComponent(ExternalToggleComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const toggle: HTMLElement = fixture.nativeElement.querySelector('#external');
+ fixture.componentInstance.visible.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ toggle.focus();
+ toggle.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', bubbles: true }));
+ fixture.detectChanges();
+ expect(fixture.componentInstance.visible()).toBe(true);
+ toggle.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true }));
+ fixture.detectChanges();
+ expect(fixture.componentInstance.visible()).toBe(false);
+ });
+
+ it('should close on Escape and focus the external toggle', async () => {
+ const fixture = TestBed.createComponent(ExternalToggleComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const toggle: HTMLElement = fixture.nativeElement.querySelector('#external');
+ const item: HTMLElement = fixture.nativeElement.querySelector('#item');
+
+ toggle.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.visible()).toBe(true);
+
+ item.focus();
+ item.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.visible()).toBe(false);
+ expect(document.activeElement).toBe(toggle);
+ });
+});
+
+@Component({
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective]
+})
+class FieldToggleComponent {
+ readonly visible = signal(false);
+}
+
+describe('DropdownToggleDirective on a non-focusable host', () => {
+ it('should toggle on a click bubbling from a button inside the toggle', async () => {
+ const fixture = TestBed.createComponent(FieldToggleComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.nativeElement
+ .querySelector('#clear')
+ .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.visible()).toBe(true);
+ });
+
+ it('should return focus to the field inside the toggle on Escape', async () => {
+ const fixture = TestBed.createComponent(FieldToggleComponent);
+ fixture.componentInstance.visible.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const item: HTMLElement = fixture.nativeElement.querySelector('#item');
+ item.focus();
+ item.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.visible()).toBe(false);
+ expect(document.activeElement).toBe(fixture.nativeElement.querySelector('#field'));
+ });
+});
+
+@Component({
+ template: `
+
+ `,
+ imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective]
+})
+class NestedDropdownComponent {
+ readonly inner = signal(false);
+ readonly outer = signal(false);
+ panelClosed = false;
+}
+
+describe('DropdownComponent nested', () => {
+ it('should close only the inner dropdown on Escape and keep it from ancestors', async () => {
+ const fixture = TestBed.createComponent(NestedDropdownComponent);
+ const component = fixture.componentInstance;
+ component.outer.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ component.inner.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const innerItem: HTMLElement = fixture.nativeElement.querySelector('#innerItem');
+ innerItem.focus();
+ innerItem.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(component.inner()).toBe(false);
+ expect(component.outer()).toBe(true);
+ expect(component.panelClosed).toBe(false);
+ expect(document.activeElement).toBe(fixture.nativeElement.querySelector('#innerToggle'));
+ });
+});
+
+describe('DropdownComponent with shadow DOM', () => {
+ it('should keep the menu open on Tab inside a shadow root in the menu', async () => {
+ const fixture = TestBed.createComponent(KeyboardTestComponent);
+ fixture.componentInstance.visible.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const host = document.createElement('x-item');
+ fixture.nativeElement.querySelector('li').append(host);
+ const inner = document.createElement('button');
+ host.attachShadow({ mode: 'open' }).append(inner);
+ inner.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', bubbles: true, composed: true }));
+ fixture.detectChanges();
+ expect(fixture.componentInstance.visible()).toBe(true);
+ });
+
+ it('should close on Tab leaving a dropdown rendered inside a shadow root and return focus on Escape', async () => {
+ const fixture = TestBed.createComponent(KeyboardTestComponent);
+ const host = document.createElement('div');
+ document.body.append(host);
+ const root = host.attachShadow({ mode: 'open' });
+ root.append(fixture.nativeElement);
+ fixture.componentInstance.visible.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const toggle: HTMLElement = fixture.nativeElement.querySelector('#toggle');
+ const items: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.dropdown-item')];
+ items[0].focus();
+ items[0].dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', bubbles: true, composed: true }));
+ fixture.detectChanges();
+ expect(fixture.componentInstance.visible()).toBe(true);
+ items[0].dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true, composed: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.visible()).toBe(false);
+ expect(root.activeElement).toBe(toggle);
+ const outside = document.createElement('button');
+ root.append(outside);
+ fixture.componentInstance.visible.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ outside.focus();
+ outside.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', bubbles: true, composed: true }));
+ fixture.detectChanges();
+ expect(fixture.componentInstance.visible()).toBe(false);
+ host.remove();
+ });
+});
+
+@Component({
+ template: `
+
+
+
+
+
+ `,
+ imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective]
+})
+class NoAutoCloseComponent {
+ readonly visible = signal(true);
+}
+
+describe('DropdownComponent with autoClose false', () => {
+ it('should neither close nor move focus on Escape or Tab', async () => {
+ const fixture = TestBed.createComponent(NoAutoCloseComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const item: HTMLElement = fixture.nativeElement.querySelector('#item');
+ const outside: HTMLElement = fixture.nativeElement.querySelector('#outside');
+ item.focus();
+ item.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.visible()).toBe(true);
+ expect(document.activeElement).toBe(item);
+ outside.focus();
+ outside.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', bubbles: true }));
+ fixture.detectChanges();
+ expect(fixture.componentInstance.visible()).toBe(true);
+ });
+});
+
+const replayed = (type: string, init: KeyboardEventInit): KeyboardEvent => {
+ const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init });
+ Object.defineProperty(event, 'eventPhase', { value: 101 });
+ Object.defineProperty(event, 'preventDefault', {
+ value: () => {
+ throw new Error('preventDefault called during event replay');
+ }
+ });
+ Object.defineProperty(event, 'composedPath', {
+ value: () => {
+ throw new Error('composedPath called during event replay');
+ }
+ });
+ return event;
+};
+
+describe('DropdownComponent with replayed events', () => {
+ it('should ignore a replayed key on the toggle and in the menu without an error', async () => {
+ const fixture = TestBed.createComponent(KeyboardTestComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const errorSpy = vi.spyOn(TestBed.inject(ErrorHandler), 'handleError').mockImplementation(() => undefined);
+ const service = fixture.debugElement.query(By.directive(DropdownComponent)).injector.get(DropdownService);
+ const toggleSpy = vi.spyOn(service, 'toggle');
+ const toggle: HTMLElement = fixture.nativeElement.querySelector('#toggle');
+ toggle.dispatchEvent(replayed('keydown', { key: 'ArrowDown', keyCode: 40 }));
+ expect(toggleSpy).not.toHaveBeenCalled();
+ fixture.componentInstance.visible.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const items: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.dropdown-item')];
+ items[0].focus();
+ items[0].dispatchEvent(replayed('keydown', { key: 'ArrowDown', keyCode: 40 }));
+ expect(document.activeElement).toBe(items[0]);
+ expect(errorSpy).not.toHaveBeenCalled();
+ });
+});
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 c0e93111..24de147e 100644
--- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts
+++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts
@@ -31,6 +31,9 @@ import { DropdownAlignment } from '../../coreui.types';
import { ThemeDirective } from '../../shared';
import { DropdownMenuDirective } from '../dropdown-menu/dropdown-menu.directive';
import { DropdownService } from '../dropdown.service';
+import { clicksOnEnter, isEditableTarget, isReplayedEvent } from '../dropdown.utils';
+
+const FOCUSABLE_SELECTOR = 'a[href], button, input, select, textarea, [tabindex]';
// lightweight injection token
export abstract class DropdownToken {}
@@ -42,12 +45,13 @@ export abstract class DropdownToken {}
host: {
'[class]': 'hostClasses()',
'[aria-expanded]': 'ariaExpanded',
- '(click)': 'onClick($event)'
+ '(click)': 'onClick($event)',
+ '(keydown)': 'onKeyDown($event)'
}
})
export class DropdownToggleDirective implements AfterViewInit {
- readonly #destroyRef = inject(DestroyRef);
public readonly elementRef = inject(ElementRef);
+ readonly #renderer = inject(Renderer2);
#dropdownService = inject(DropdownService);
public dropdown = inject(DropdownToken, { optional: true });
@@ -88,7 +92,9 @@ export class DropdownToggleDirective implements AfterViewInit {
} as Record;
});
- readonly #ariaExpanded = signal(false);
+ readonly #ariaExpanded = computed(
+ () => (this.dropdownComponent() ?? (this.dropdown as DropdownComponent | null))?.visible() ?? false
+ );
get ariaExpanded() {
return this.#ariaExpanded();
@@ -96,7 +102,48 @@ export class DropdownToggleDirective implements AfterViewInit {
public onClick($event: MouseEvent): void {
$event.preventDefault();
- !this.disabled() && this.#dropdownService.toggle({ visible: 'toggle', dropdown: this.dropdown });
+ if (this.disabled()) {
+ return;
+ }
+ const element: HTMLElement = this.elementRef.nativeElement;
+ if (!element.contains(element.ownerDocument.activeElement)) {
+ element.focus();
+ }
+ this.#dropdownService.toggle({ visible: 'toggle', dropdown: this.dropdown });
+ }
+
+ onKeyDown($event: KeyboardEvent): void {
+ if (this.disabled() || isReplayedEvent($event) || isEditableTarget($event.target)) {
+ return;
+ }
+ const element: HTMLElement = this.elementRef.nativeElement;
+ if (
+ $event.target === element &&
+ element.tagName === 'A' &&
+ ($event.key === ' ' || ($event.key === 'Enter' && !clicksOnEnter(element)))
+ ) {
+ $event.preventDefault();
+ if (!$event.repeat) {
+ element.click();
+ }
+ return;
+ }
+ if (!['ArrowDown', 'ArrowUp'].includes($event.key)) {
+ return;
+ }
+ $event.preventDefault();
+ this.#dropdownService.toggle({
+ visible: true,
+ dropdown: this.dropdown,
+ focus: $event.key === 'ArrowDown' ? 'first' : 'last'
+ });
+ }
+
+ constructor() {
+ const element: HTMLElement | undefined = this.elementRef.nativeElement;
+ if (element?.tagName === 'A' && !element.hasAttribute('role')) {
+ this.#renderer.setAttribute(element, 'role', 'button');
+ }
}
ngAfterViewInit(): void {
@@ -104,17 +151,7 @@ export class DropdownToggleDirective implements AfterViewInit {
if (dropdownComponent) {
this.dropdown = dropdownComponent;
this.#dropdownService = dropdownComponent?.dropdownService;
- }
- if (this.dropdown) {
- const dropdown = this.dropdown;
- const subscription = dropdown?.visibleChange?.subscribe((visible) => {
- this.#ariaExpanded.set(visible);
- });
- if (subscription) {
- this.#destroyRef.onDestroy(() => {
- subscription.unsubscribe();
- });
- }
+ dropdownComponent.toggler = this;
}
}
}
@@ -283,6 +320,7 @@ export class DropdownComponent implements OnDestroy, OnInit {
dropdownContext = { $implicit: this.visible() };
readonly _toggler = contentChild(DropdownToggleDirective);
+ toggler?: DropdownToggleDirective;
readonly _menu = contentChild(DropdownMenuDirective);
readonly _menuElementRef = contentChild(DropdownMenuDirective, { read: ElementRef });
@@ -362,7 +400,7 @@ export class DropdownComponent implements OnDestroy, OnInit {
// todo: turn off popper in navbar-nav
createPopperInstance(): void {
- const _toggler = this._toggler();
+ const _toggler = this._toggler() ?? this.toggler;
const _menu = this._menu();
if (_toggler && _menu) {
this.#ngZone.runOutsideAngular(() => {
@@ -397,7 +435,7 @@ export class DropdownComponent implements OnDestroy, OnInit {
if (this._menuElementRef()?.nativeElement.contains(event.target)) {
this.clickedTarget = target;
}
- if (this._toggler()?.elementRef.nativeElement.contains(event.target)) {
+ if (this.#togglerElement()?.contains(event.target)) {
return;
}
const autoClose = this.autoClose();
@@ -415,29 +453,60 @@ export class DropdownComponent implements OnDestroy, OnInit {
}
})
);
+ this.listeners.push(this.#renderer.listen(this.#elementRef.nativeElement, 'keyup', this.#onEscape));
+ const toggler = this.#togglerElement();
+ if (toggler && !this.#elementRef.nativeElement.contains(toggler)) {
+ this.listeners.push(this.#renderer.listen(toggler, 'keyup', this.#onEscape));
+ }
this.listeners.push(
- this.#renderer.listen(this.#elementRef.nativeElement, 'keyup', (event) => {
- if (event.key === 'Escape' && this.autoClose() !== false) {
- event.stopPropagation();
- this.setVisibleState(false);
- return;
- }
- })
- );
- this.listeners.push(
- this.#renderer.listen(this.#document, 'keyup', (event) => {
+ this.#renderer.listen(this.#document, 'keyup', (event: KeyboardEvent) => {
+ const path = event.composedPath();
+ const toggler = this.#togglerElement();
if (
event.key === 'Tab' &&
this.autoClose() !== false &&
- !this.#elementRef.nativeElement.contains(event.target)
+ !path.includes(this.#elementRef.nativeElement) &&
+ !(toggler && path.includes(toggler))
) {
this.setVisibleState(false);
- return;
}
})
);
}
+ readonly #onEscape = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape' || this.autoClose() === false) {
+ return;
+ }
+ event.stopPropagation();
+ const focusInMenu = this._menuElementRef()?.nativeElement.contains(event.target as Node);
+ this.setVisibleState(false);
+ if (focusInMenu) {
+ this.#focusToggler();
+ }
+ };
+
+ #togglerElement(): HTMLElement | undefined {
+ return (this._toggler() ?? this.toggler)?.elementRef.nativeElement;
+ }
+
+ #focusToggler(): void {
+ const toggler = this.#togglerElement();
+ if (!toggler) {
+ return;
+ }
+ const root = toggler.getRootNode() as Document | ShadowRoot;
+ const candidates = [toggler, ...toggler.querySelectorAll(FOCUSABLE_SELECTOR)].filter(
+ (candidate) => candidate === toggler || (candidate.tabIndex >= 0 && !candidate.closest('[aria-hidden="true"]'))
+ );
+ for (const candidate of candidates) {
+ candidate.focus();
+ if (root.activeElement === candidate) {
+ return;
+ }
+ }
+ }
+
private clearListeners(): void {
this.listeners.forEach((unListen) => {
unListen();